Add MTGA Assistant: native Linux deck and game tracker

PySide6 app that follows MTG Arena's Player.log under Steam/Proton and shows
my remaining library with draw odds, my zones, and everything revealed about
the opponent (board, graveyard, exile, known hand/library, seen this match).

- Auto-detects the Steam library, Proton prefix log and MTGA install
- Card names/costs/types from MTGA's local SQLite card database
- Tracks card identity across zone changes, shuffles and Bo3 games
- Scryfall card images with a lazy, size-capped LRU disk cache
- Live log following with restart detection, plus --replay mode
- Flags log gaps where MTGA omits oversized game-state messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 09:56:12 -04:00
1 parent 734336168c
commit 7dc2ce0da8
21 files changed
+2736

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
+62
View File
@@ -1,2 +1,64 @@
# mtga-assistant # mtga-assistant
A native Linux deck and game tracker for **Magic: The Gathering Arena** running under Steam/Proton.
It follows MTGA's `Player.log`, much like Untapped.gg and other trackers do, and shows:
- **My deck**: remaining library with draw odds, hand, battlefield, graveyard, exile, sideboard.
Between matches, it shows the last deck you submitted.
- **Opponent**: battlefield, stack, graveyard, exile, cards *known* to be in their hand or library
(cards you saw that went back into a hidden zone, or were revealed), and every card seen this match.
- **Card preview**: hover a card to see its image from [Scryfall](https://scryfall.com).
Card names, mana costs and types come from MTGA's own local card database, so they work offline.
## Requirements
- MTGA installed through Steam (Proton). The log, install and card DB are auto-detected from your
Steam library folders.
- **Detailed logs enabled** in MTGA: *Options → Account → Detailed Logs (Plugin Support)*.
- [uv](https://docs.astral.sh/uv/) (`pacman -S uv`).
## Running
```sh
uv run mtga-assistant # follow the live Player.log
uv run mtga-assistant --replay path/to/Player-prev.log --rate 40 # replay a saved log
uv run pytest # tests
```
Start it before or after MTGA: on startup it reads the existing log to catch up on a match in
progress, then follows new lines. When MTGA restarts and replaces the log, the tracker resets.
On Wayland the window's app id is `mtga-assistant`. For example, to keep it floating on another
workspace in Hyprland:
```
windowrule = float, class:^(mtga-assistant)$
windowrule = workspace 2, class:^(mtga-assistant)$
```
## Configuration
Optional: `~/.config/mtga-assistant/config.toml`
```toml
log_path = "/path/to/Player.log" # override auto-detection
mtga_dir = "/path/to/steamapps/common/MTGA"
cache_max_mb = 500 # image cache cap
```
## Image cache
Images are downloaded only for cards that actually appear, at Scryfall's `normal` size (about
60–100 KB each), and stored in `~/.cache/mtga-assistant/images`. When the cache passes
`cache_max_mb`, the least recently viewed images are deleted. *Cache → Clear image cache* empties it.
## Known limitations
- MTGA leaves oversized game-state messages out of the log ("Message summarized because one or
more GameStateMessages exceeded the 50 GameObject or 50 Annotation limit"), typically during board
wipes. Some zones can then be stale until they next change. The app detects this and shows a
warning.
- After a library shuffle, a known card's position in the opponent's library is lost. It stays listed
under "Library … known" until it shows up again. If it is drawn without being revealed, it may
stay listed.
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "mtga-assistant"
version = "0.1.0"
description = "Native Linux deck and game tracker for MTG Arena, driven by Player.log"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"PySide6-Essentials>=6.8",
]
[project.scripts]
mtga-assistant = "mtga_assistant.app:main"
[dependency-groups]
dev = ["pytest>=8"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mtga_assistant"]
[tool.pytest.ini_options]
testpaths = ["tests"]
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
import sys
from .app import main
sys.exit(main())
+50
View File
@@ -0,0 +1,50 @@
"""Entry point: mtga-assistant [--log PATH] [--replay PATH [--rate N]]."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from . import paths
from .carddb import CardDB
from .images import ImageCache
from .ui.main_window import MainWindow
def main() -> int:
ap = argparse.ArgumentParser(description="MTG Arena deck and game tracker")
ap.add_argument("--log", type=Path, help="Player.log to follow (default: auto-detect)")
ap.add_argument("--replay", type=Path, help="replay a saved log instead of following the live one")
ap.add_argument("--rate", type=float, default=40.0, help="replay speed in log events per second")
ap.add_argument("--mtga-dir", type=Path, help="MTGA install directory (default: auto-detect)")
args, qt_args = ap.parse_known_args()
cfg = paths.load_config()
if args.log:
cfg.log_path = args.log
if args.mtga_dir:
cfg.mtga_dir = args.mtga_dir
app = QApplication([sys.argv[0], *qt_args])
app.setApplicationName("MTGA Assistant")
app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules
mtga_dir = paths.find_mtga_dir(cfg)
db_path = paths.find_card_db(mtga_dir) if mtga_dir else None
cards = CardDB(db_path)
images = ImageCache(paths.CACHE_DIR, cards, cfg.cache_max_mb * 1024 * 1024)
if args.replay:
win = MainWindow(cards, images, args.replay, replay_rate=args.rate)
else:
win = MainWindow(cards, images, paths.find_log_path(cfg))
app.aboutToQuit.connect(win.shutdown)
win.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+131
View File
@@ -0,0 +1,131 @@
"""Read-only access to MTGA's local SQLite card database (Raw_CardDatabase_*.mtga)."""
from __future__ import annotations
import re
import sqlite3
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
CARD_TYPES = {
1: "Artifact", 2: "Creature", 3: "Enchantment", 4: "Instant", 5: "Land",
6: "Phenomenon", 7: "Plane", 8: "Planeswalker", 9: "Scheme", 10: "Sorcery",
11: "Kindred", 12: "Vanguard", 13: "Dungeon", 14: "Battle", 15: "Conspiracy",
}
RARITIES = {0: "token", 1: "land", 2: "common", 3: "uncommon", 4: "rare", 5: "mythic"}
# Cards.LinkedFaceType values whose first LinkedFaceGrpId is the "base" card: DFC back
# faces, split/room halves, adventure and omen sides, meld results, specializations...
# (Observed: odd types are the derived face, except omens where 20 is the derived side.)
DERIVED_FACE_TYPES = {1, 3, 5, 7, 9, 11, 13, 15, 17, 20}
_MANA_TOKEN = re.compile(r"o(\([^)]*\)|[^o(]+)")
def parse_mana(text: str) -> list[str]:
"""'o2o(W/U)oU' -> ['2', 'W/U', 'U']."""
return [m.group(1).strip("()") for m in _MANA_TOKEN.finditer(text or "")]
def mana_value(symbols: list[str]) -> int:
total = 0
for s in symbols:
if s.isdigit():
total += int(s)
elif s.startswith("2/"):
total += 2
elif s != "X":
total += 1
return total
@dataclass(frozen=True)
class CardInfo:
grp_id: int
name: str
mana: tuple[str, ...] = ()
types: tuple[str, ...] = ()
set_code: str = ""
collector_number: str = ""
rarity: str = ""
is_token: bool = False
power: str = ""
toughness: str = ""
linked_faces: tuple[int, ...] = field(default=())
linked_face_type: int = 0
@property
def mana_value(self) -> int:
return mana_value(list(self.mana))
@property
def is_land(self) -> bool:
return "Land" in self.types
@property
def type_line(self) -> str:
return " ".join(self.types)
def unknown_card(grp_id: int) -> CardInfo:
return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}")
class CardDB:
def __init__(self, path: Path | None):
self.path = path
self._conn: sqlite3.Connection | None = None
if path is not None:
# immutable=1: MTGA may rewrite the file on update; never take locks on it.
uri = f"file:{path}?mode=ro&immutable=1"
self._conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
@property
def available(self) -> bool:
return self._conn is not None
@lru_cache(maxsize=None)
def get(self, grp_id: int) -> CardInfo:
if self._conn is None:
return unknown_card(grp_id)
row = self._conn.execute(
"""
SELECT c.GrpId,
(SELECT Loc FROM Localizations_enUS l WHERE l.LocId = c.TitleId
ORDER BY l.Formatted = 1 DESC, l.Formatted LIMIT 1),
c.OldSchoolManaText, c.Types, c.ExpansionCode, c.CollectorNumber,
c.Rarity, c.IsToken, c.Power, c.Toughness, c.LinkedFaceGrpIds, c.LinkedFaceType
FROM Cards c WHERE c.GrpId = ?
""",
(grp_id,),
).fetchone()
if row is None:
return unknown_card(grp_id)
(gid, name, mana, types, set_code, cn, rarity, is_token, power, tough, linked, ltype) = row
return CardInfo(
grp_id=gid,
name=_strip_tags(name) if name else f"Card #{gid}",
mana=tuple(parse_mana(mana)),
types=tuple(CARD_TYPES.get(int(t), "?") for t in (types or "").split(",") if t),
set_code=set_code or "",
collector_number=cn or "",
rarity=RARITIES.get(rarity, ""),
is_token=bool(is_token),
power=power or "",
toughness=tough or "",
linked_faces=tuple(int(x) for x in (linked or "").split(",") if x),
linked_face_type=int(ltype or 0),
)
def base_grp(self, grp_id: int) -> int:
"""Canonical grpId for a physical card, e.g. a DFC's back face -> its front face."""
info = self.get(grp_id)
if info.linked_face_type in DERIVED_FACE_TYPES and info.linked_faces:
return info.linked_faces[0]
return grp_id
def _strip_tags(s: str) -> str:
# Some titles carry markup such as <nobr> or <sprite=...>.
return re.sub(r"<[^>]+>", "", s)
+366
View File
@@ -0,0 +1,366 @@
"""Game/match state reconstructed from GRE messages. Pure Python, no Qt.
Model notes (derived from MTGA's GRE protocol as it appears in Player.log):
* Each zone message carries the zone's *complete* ``objectInstanceIds`` list, so
zones are replaced wholesale on every update.
* Game objects are only sent for cards the local player can see. Hidden cards
(libraries, the opponent's hand) exist only as instance ids in a zone list.
* When a card changes zone it usually gets a new instance id, announced by an
``ObjectIdChanged`` annotation (orig_id -> new_id). We follow these chains so
that an opponent's card we have seen keeps its identity when it goes back into
a hidden zone (bounced to hand, put back into library, ...).
* ``Shuffle`` annotations renumber a library (OldIds -> NewIds) in random order,
so known cards in a shuffled library lose their position but are still there.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
from .logparse import ClientDeck, DeckSubmitted, Event, GreMessages, MatchRoom
HIDDEN_ZONES = {"ZoneType_Library", "ZoneType_Hand"}
CARD_OBJECT_TYPES = {"GameObjectType_Card"}
# Zones whose cards are "out of the library" for my remaining-library maths.
MY_TRACKED_ZONES = (
"ZoneType_Hand",
"ZoneType_Stack",
"ZoneType_Battlefield",
"ZoneType_Graveyard",
"ZoneType_Exile",
"ZoneType_Command",
)
@dataclass
class Zone:
zone_id: int
type: str
owner: int | None
ids: list[int]
def _details(annotation: dict[str, Any]) -> dict[str, list[Any]]:
out: dict[str, list[Any]] = {}
for d in annotation.get("details") or []:
vals = d.get("valueInt32") or d.get("valueString") or d.get("valueUint32") or []
out[d.get("key", "")] = vals
return out
@dataclass
class GameState:
"""State of one game (one game of a best-of-three)."""
my_seat: int
deck: Counter[int]
sideboard: Counter[int]
game_number: int = 1
zones: dict[int, Zone] = field(default_factory=dict)
objects: dict[int, dict[str, Any]] = field(default_factory=dict)
life: dict[int, int] = field(default_factory=dict)
turn: int = 0
active_player: int = 0
phase: str = ""
complete: bool = False
applied_state_ids: set[int] = field(default_factory=set)
# MTGA omits oversized GRE messages from the log ("Message summarized..."); after such a
# gap some zones may be stale until the server next sends them.
desynced: bool = False
# Opponent knowledge.
parent: dict[int, int] = field(default_factory=dict) # new instance id -> previous id
known: dict[int, int] = field(default_factory=dict) # opp instance id -> grpId, survives hiding
opp_library_unplaced: Counter[int] = field(default_factory=Counter) # known cards shuffled away
# grpId -> instance ids seen. Resolved to physical cards (chain roots) only when counting,
# because an object can be logged a message before the ObjectIdChanged that links it.
opp_seen: dict[int, set[int]] = field(default_factory=dict)
@property
def opp_seat(self) -> int:
return 2 if self.my_seat == 1 else 1
# -- applying messages -------------------------------------------------
def apply(self, gsm: dict[str, Any]) -> None:
state_id = gsm.get("gameStateId")
if state_id is not None:
if state_id in self.applied_state_ids:
return
self.applied_state_ids.add(state_id)
if gsm.get("type") == "GameStateType_Full":
self.zones.clear()
self.objects.clear()
self.desynced = False
elif (prev := gsm.get("prevGameStateId")) and self.applied_state_ids - {state_id} \
and prev not in self.applied_state_ids:
self.desynced = True
if info := gsm.get("gameInfo"):
self.game_number = info.get("gameNumber", self.game_number)
if info.get("stage") == "GameStage_GameOver" or info.get("matchState") in (
"MatchState_GameComplete",
"MatchState_MatchComplete",
):
self.complete = True
for p in gsm.get("players") or []:
seat = p.get("systemSeatNumber")
if seat is not None and "lifeTotal" in p:
self.life[seat] = p["lifeTotal"]
if ti := gsm.get("turnInfo"):
self.turn = ti.get("turnNumber", self.turn)
self.active_player = ti.get("activePlayer", self.active_player)
self.phase = ti.get("phase", self.phase)
for ann in gsm.get("annotations") or []:
self._apply_annotation(ann)
for z in gsm.get("zones") or []:
zid = z["zoneId"]
self.zones[zid] = Zone(zid, z.get("type", ""), z.get("ownerSeatId"), list(z.get("objectInstanceIds") or []))
for obj in gsm.get("gameObjects") or []:
self._apply_object(obj)
for iid in gsm.get("diffDeletedInstanceIds") or []:
self.objects.pop(iid, None)
def _apply_annotation(self, ann: dict[str, Any]) -> None:
types = ann.get("type") or []
if "AnnotationType_ObjectIdChanged" in types:
d = _details(ann)
if d.get("orig_id") and d.get("new_id"):
orig, new = d["orig_id"][0], d["new_id"][0]
self.parent[new] = orig
if orig in self.known:
self.known[new] = self.known[orig]
if "AnnotationType_Shuffle" in types:
d = _details(ann)
for old in d.get("OldIds") or []:
if old in self.known:
self.opp_library_unplaced[self.known.pop(old)] += 1
def _apply_object(self, obj: dict[str, Any]) -> None:
iid = obj["instanceId"]
self.objects[iid] = obj
grp = obj.get("grpId") or 0
if not grp or obj.get("ownerSeatId") != self.opp_seat:
return
otype = obj.get("type")
if otype in CARD_OBJECT_TYPES:
newly_visible = iid not in self.known
self.known[iid] = grp
ids = self.opp_seen.setdefault(grp, set())
if newly_visible and self.opp_library_unplaced[grp] > 0 \
and self.root_of(iid) not in {self.root_of(i) for i in ids}:
# Possibly the known card we lost track of in a shuffle resurfacing.
self.opp_library_unplaced[grp] -= 1
ids.add(iid)
elif otype == "GameObjectType_RevealedCard":
self.opp_seen.setdefault(grp, set()).add(-iid) # distinct key space for reveals
def root_of(self, iid: int) -> int:
"""First instance id of the chain this id belongs to (the physical card)."""
seen = set()
while iid in self.parent and iid not in seen:
seen.add(iid)
iid = self.parent[iid]
return iid
# -- queries ------------------------------------------------------------
def zone_of_type(self, ztype: str, owner: int | None) -> Zone | None:
for z in self.zones.values():
if z.type == ztype and (owner is None or z.owner == owner):
return z
return None
def cards_in(self, ztype: str, *, owner: int | None = None, controller: int | None = None,
include_tokens: bool = True) -> Counter[int]:
"""grpId counts of visible objects in zones of the given type."""
out: Counter[int] = Counter()
for z in self.zones.values():
if z.type != ztype:
continue
for iid in z.ids:
obj = self.objects.get(iid)
if not obj or not obj.get("grpId"):
continue
otype = obj.get("type")
if otype not in CARD_OBJECT_TYPES and not (include_tokens and otype == "GameObjectType_Token"):
continue
if owner is not None and obj.get("ownerSeatId") != owner:
continue
if controller is not None and obj.get("controllerSeatId", obj.get("ownerSeatId")) != controller:
continue
out[obj["grpId"]] += 1
return out
def zone_size(self, ztype: str, owner: int) -> int:
z = self.zone_of_type(ztype, owner)
return len(z.ids) if z else 0
def opp_known_in(self, ztype: str) -> Counter[int]:
"""Opponent cards we know are in a hidden zone (by tracking identity)."""
out: Counter[int] = Counter()
z = self.zone_of_type(ztype, self.opp_seat)
if z:
for iid in z.ids:
if iid in self.known:
out[self.known[iid]] += 1
# Cards the opponent revealed from that zone (e.g. "reveal your hand").
if z:
for obj in self.objects.values():
if (obj.get("type") == "GameObjectType_RevealedCard" and obj.get("zoneId") == z.zone_id
and obj.get("grpId")):
out[obj["grpId"]] += 1
if ztype == "ZoneType_Library":
out += self.opp_library_unplaced
return out
def opp_seen_counts(self, normalize=lambda g: g) -> Counter[int]:
merged: dict[int, set[int]] = {}
for g, ids in self.opp_seen.items():
# Negative ids are reveal copies, which have no chain.
merged.setdefault(normalize(g), set()).update(i if i < 0 else self.root_of(i) for i in ids)
return Counter({g: len(r) for g, r in merged.items() if r})
def my_outside_library(self, normalize) -> Counter[int]:
"""My non-token cards outside the library, keyed by deck grpId."""
out: Counter[int] = Counter()
for ztype in MY_TRACKED_ZONES:
for g, n in self.cards_in(ztype, owner=self.my_seat, include_tokens=False).items():
out[normalize(g)] += n
return out
@dataclass
class MatchTracker:
"""Everything the UI needs, across the games of a match."""
card_lookup: Any = None # CardDB-like with .get(grpId) -> CardInfo; optional
match_id: str = ""
event_id: str = ""
players: dict[int, str] = field(default_factory=dict)
my_seat: int | None = None
game: GameState | None = None
in_match: bool = False
game_results: list[str] = field(default_factory=list) # "Won"/"Lost" per finished game
last_result: str = ""
next_deck: ClientDeck | None = None
# Opponent cards seen in earlier games of this match (max count in any one game).
opp_seen_prev_games: Counter[int] = field(default_factory=Counter)
deck_name: str = ""
submitted_deck: DeckSubmitted | None = None
version: int = 0 # bumped on every change, for cheap UI refresh checks
def handle(self, ev: Event) -> None:
if isinstance(ev, GreMessages):
for m in ev.messages:
self._handle_gre(m)
elif isinstance(ev, MatchRoom):
self._handle_room(ev)
elif isinstance(ev, DeckSubmitted):
self.submitted_deck = ev
self.deck_name = ev.name
elif isinstance(ev, ClientDeck):
self.next_deck = ev
self.version += 1
def _start_match(self) -> None:
self.opp_seen_prev_games.clear()
self.game_results.clear()
self.last_result = ""
self.players.clear()
self.in_match = True
def _handle_room(self, ev: MatchRoom) -> None:
if ev.state == "MatchGameRoomStateType_Playing":
# ConnectResp (which starts the match on our side) may arrive before or after this.
if not self.in_match:
self._start_match()
self.match_id = ev.match_id
for p in ev.players:
self.players[p.get("systemSeatId")] = p.get("playerName", "")
self.event_id = p.get("eventId", self.event_id)
elif ev.state == "MatchGameRoomStateType_MatchCompleted":
self.in_match = False
res = ev.final_result or {}
for r in res.get("resultList") or []:
if r.get("scope") == "MatchScope_Match" and self.my_seat is not None:
won = r.get("winningTeamId") == self.my_seat
self.last_result = "Won" if won else "Lost"
if self.game:
self.game.complete = True
def _handle_gre(self, m: dict[str, Any]) -> None:
mtype = m.get("type")
if mtype == "GREMessageType_ConnectResp":
seats = m.get("systemSeatIds") or [1]
self.my_seat = seats[0]
deck_msg = (m.get("connectResp") or {}).get("deckMessage") or {}
if not self.in_match:
self._start_match()
self.next_deck = None
self.game = GameState(
my_seat=self.my_seat,
deck=Counter(deck_msg.get("deckCards") or []),
sideboard=Counter(deck_msg.get("sideboardCards") or []),
)
return
gsm = m.get("gameStateMessage")
if gsm is None or self.game is None:
return
game_no = (gsm.get("gameInfo") or {}).get("gameNumber")
if gsm.get("type") == "GameStateType_Full" and game_no and game_no != self.game.game_number:
# Next game of the match: no new ConnectResp, just a fresh Full state.
prev = self.game
self.opp_seen_prev_games = self.opp_seen_match
deck = self.next_deck or ClientDeck(prev.deck, prev.sideboard)
self.next_deck = None
self.game = GameState(my_seat=prev.my_seat, deck=Counter(deck.main),
sideboard=Counter(deck.sideboard), game_number=game_no)
self.game.apply(gsm)
results = (gsm.get("gameInfo") or {}).get("results") or []
game_results = [r for r in results if r.get("scope") == "MatchScope_Game"]
if len(game_results) > len(self.game_results) and self.my_seat is not None:
self.game_results = [
"Won" if r.get("winningTeamId") == self.my_seat else "Lost" for r in game_results
]
self.game.complete = True
# -- derived views ------------------------------------------------------
@property
def opp_seen_match(self) -> Counter[int]:
"""Opponent cards seen this match: for each card, the most copies seen in any one game."""
out = self.opp_seen_prev_games.copy()
if self.game is not None:
for g, n in self.game.opp_seen_counts(self.normalize).items():
out[g] = max(out[g], n)
return out
def normalize(self, grp: int) -> int:
"""Map a game object's grpId to its card's canonical grpId (e.g. DFC back -> front)."""
g = self.game
if (g is not None and grp in g.deck) or self.card_lookup is None:
return grp
return self.card_lookup.base_grp(grp)
def my_library_remaining(self) -> Counter[int]:
g = self.game
if g is None:
return Counter()
remaining = g.deck.copy()
remaining.subtract(g.my_outside_library(self.normalize))
return +remaining # drop zero/negative entries
@property
def opponent_name(self) -> str:
if self.my_seat is None:
return ""
return self.players.get(2 if self.my_seat == 1 else 1, "")
+173
View File
@@ -0,0 +1,173 @@
"""Card images from Scryfall with a size-capped on-disk LRU cache.
Images are fetched lazily, only for cards that actually show up, and stored as
``<cache>/images/<grpId>.jpg``. A file's mtime is bumped whenever it is used, and
the least recently used files are evicted when the cache exceeds its cap.
"""
from __future__ import annotations
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QPixmap
from .carddb import CardDB
USER_AGENT = "mtga-assistant/0.1 (personal desktop tracker)"
SCRYFALL = "https://api.scryfall.com"
MIN_REQUEST_INTERVAL = 0.11 # Scryfall asks for <= 10 requests/second
class ImageCache(QObject):
image_ready = Signal(int) # grpId
size_changed = Signal(int) # bytes on disk
_downloaded = Signal(int)
def __init__(self, cache_dir: Path, cards: CardDB, max_bytes: int):
super().__init__()
self.dir = cache_dir / "images"
self.dir.mkdir(parents=True, exist_ok=True)
self.cards = cards
self.max_bytes = max_bytes
self._pool = ThreadPoolExecutor(max_workers=3, thread_name_prefix="scryfall")
self._lock = threading.Lock()
self._inflight: set[int] = set()
self._failed: set[int] = set()
self._last_request = 0.0
self._pixmaps: dict[int, QPixmap] = {}
# Emitted from worker threads; Qt queues delivery onto this object's (GUI) thread.
self._downloaded.connect(self._on_downloaded)
self.evict()
def path_for(self, grp_id: int) -> Path:
return self.dir / f"{grp_id}.jpg"
def pixmap(self, grp_id: int) -> QPixmap | None:
"""Return the image if cached; otherwise start a download and return None."""
if pm := self._pixmaps.get(grp_id):
return pm
path = self.path_for(grp_id)
if path.exists():
try:
path.touch()
except OSError:
pass
pm = QPixmap(str(path))
if not pm.isNull():
if len(self._pixmaps) > 200:
self._pixmaps.clear()
self._pixmaps[grp_id] = pm
return pm
self.request(grp_id)
return None
def request(self, grp_id: int) -> None:
with self._lock:
if grp_id in self._inflight or grp_id in self._failed:
return
self._inflight.add(grp_id)
self._pool.submit(self._download, grp_id)
def is_failed(self, grp_id: int) -> bool:
return grp_id in self._failed
# -- worker side ---------------------------------------------------------
def _throttle(self) -> None:
with self._lock:
wait = self._last_request + MIN_REQUEST_INTERVAL - time.monotonic()
self._last_request = max(time.monotonic(), self._last_request + MIN_REQUEST_INTERVAL)
if wait > 0:
time.sleep(wait)
def _candidate_urls(self, grp_id: int) -> list[str]:
q = "format=image&version=normal"
urls = [f"{SCRYFALL}/cards/arena/{grp_id}?{q}"]
info = self.cards.get(grp_id)
if info.set_code and info.collector_number:
set_code = urllib.parse.quote(info.set_code.lower())
cn = urllib.parse.quote(info.collector_number)
urls.append(f"{SCRYFALL}/cards/{set_code}/{cn}?{q}")
if info.name and not info.name.startswith(("Unknown card", "Card #")):
exact = urllib.parse.quote(info.name.split(" // ")[0])
urls.append(f"{SCRYFALL}/cards/named?exact={exact}&{q}")
return urls
def _download(self, grp_id: int) -> None:
ok = False
try:
for url in self._candidate_urls(grp_id):
self._throttle()
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = resp.read()
except urllib.error.HTTPError as e:
if e.code == 404:
continue
break
except (urllib.error.URLError, TimeoutError, OSError):
break
tmp = self.path_for(grp_id).with_suffix(".part")
tmp.write_bytes(data)
tmp.replace(self.path_for(grp_id))
ok = True
break
finally:
with self._lock:
self._inflight.discard(grp_id)
if not ok:
self._failed.add(grp_id)
if ok:
self._downloaded.emit(grp_id)
def _on_downloaded(self, grp_id: int) -> None:
self.evict()
self.image_ready.emit(grp_id)
# -- cache management ------------------------------------------------------
def disk_usage(self) -> int:
return sum(p.stat().st_size for p in self.dir.glob("*.jpg"))
def evict(self) -> None:
files = []
for p in self.dir.glob("*.jpg"):
try:
st = p.stat()
except OSError:
continue
files.append((st.st_mtime, st.st_size, p))
total = sum(f[1] for f in files)
if total > self.max_bytes:
for _, size, p in sorted(files):
try:
p.unlink()
except OSError:
continue
total -= size
if p.stem.isdigit():
self._pixmaps.pop(int(p.stem), None)
if total <= self.max_bytes * 0.9:
break
self.size_changed.emit(total)
def clear(self) -> None:
for p in self.dir.glob("*"):
try:
p.unlink()
except OSError:
pass
self._pixmaps.clear()
self._failed.clear()
self.size_changed.emit(0)
def shutdown(self) -> None:
self._pool.shutdown(wait=False, cancel_futures=True)
+137
View File
@@ -0,0 +1,137 @@
"""Turn Player.log lines into typed events.
The log interleaves plain text with JSON payloads. The payloads we care about are:
* a line starting with ``{`` that holds ``greToClientEvent`` (in-game state) or
``matchGameRoomStateChangedEvent`` (match start / end), preceded by a header line;
* ``[UnityCrossThreadLogger]==> EventSetDeckV3 {...}`` requests, whose ``request``
field is itself a JSON string carrying the deck submitted to an event.
This module has no Qt dependency so it can be unit-tested and replayed headlessly.
"""
from __future__ import annotations
import json
import re
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
MAX_PENDING_LINES = 5000
@dataclass
class GreMessages:
messages: list[dict[str, Any]]
@dataclass
class MatchRoom:
state: str
match_id: str
players: list[dict[str, Any]]
final_result: dict[str, Any] | None = None
@dataclass
class DeckSubmitted:
name: str
event: str
main: Counter[int] = field(default_factory=Counter)
sideboard: Counter[int] = field(default_factory=Counter)
@dataclass
class ClientDeck:
"""Deck the client submitted for the next game of a match (after sideboarding)."""
main: Counter[int]
sideboard: Counter[int]
Event = GreMessages | MatchRoom | DeckSubmitted | ClientDeck
_REQUEST_RE = re.compile(r"==> (EventSetDeck\w*) (\{.*)$")
class LogParser:
"""Incremental line parser. Feed complete lines; get events back."""
def __init__(self) -> None:
self._pending: list[str] = []
def reset(self) -> None:
self._pending.clear()
def feed(self, line: str) -> list[Event]:
line = line.rstrip("\r\n")
if self._pending:
# Continuing a multi-line JSON body.
if line.startswith("[") and not line.startswith("[{"):
self._pending.clear() # a new log entry began; drop the fragment
else:
self._pending.append(line)
# Pretty-printed bodies close with a '}' at column 0; only reparse then.
return self._try_pending() if line.startswith("}") else []
if line.startswith("{"):
self._pending = [line]
return self._try_pending()
if "==> EventSetDeck" in line:
m = _REQUEST_RE.search(line)
if m:
try:
return _deck_submitted(json.loads(m.group(2)))
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
return []
return []
def _try_pending(self) -> list[Event]:
text = "\n".join(self._pending)
try:
obj = json.loads(text)
except json.JSONDecodeError:
if len(self._pending) > MAX_PENDING_LINES:
self._pending.clear()
return []
self._pending.clear()
return _classify(obj)
def _classify(obj: Any) -> list[Event]:
if not isinstance(obj, dict):
return []
if gre := obj.get("greToClientEvent"):
msgs = gre.get("greToClientMessages") or []
return [GreMessages(msgs)] if msgs else []
if room := obj.get("matchGameRoomStateChangedEvent"):
info = room.get("gameRoomInfo") or {}
cfg = info.get("gameRoomConfig") or {}
return [
MatchRoom(
state=info.get("stateType", ""),
match_id=cfg.get("matchId", "") or (info.get("finalMatchResult") or {}).get("matchId", ""),
players=cfg.get("reservedPlayers") or [],
final_result=info.get("finalMatchResult"),
)
]
payload = obj.get("payload")
if isinstance(payload, dict) and payload.get("type") == "ClientMessageType_SubmitDeckResp":
deck = (payload.get("submitDeckResp") or {}).get("deck") or {}
return [ClientDeck(Counter(deck.get("deckCards") or []), Counter(deck.get("sideboardCards") or []))]
return []
def _deck_submitted(payload: dict[str, Any]) -> list[Event]:
req = json.loads(payload["request"])
deck = req.get("Deck") or {}
main: Counter[int] = Counter()
side: Counter[int] = Counter()
for c in deck.get("MainDeck") or []:
main[int(c["cardId"])] += int(c["quantity"])
for c in deck.get("Sideboard") or []:
side[int(c["cardId"])] += int(c["quantity"])
summary = req.get("Summary") or {}
return [DeckSubmitted(name=summary.get("Name", ""), event=req.get("EventName", ""), main=main, sideboard=side)]
+103
View File
@@ -0,0 +1,103 @@
"""Background thread that follows Player.log (or replays a saved log) and emits events."""
from __future__ import annotations
import os
import time
from pathlib import Path
from PySide6.QtCore import QThread, Signal
from .logparse import LogParser
POLL_INTERVAL = 0.25
BATCH_MAX = 200
class LogFollower(QThread):
events = Signal(list) # list[Event]
status = Signal(str)
reset = Signal() # log was replaced (MTGA restarted): state must be rebuilt
def __init__(self, path: Path, replay_rate: float | None = None):
"""replay_rate: if set, read the file once at that many events/second, then stop."""
super().__init__()
self.path = path
self.replay_rate = replay_rate
self._stop = False
def stop(self) -> None:
self._stop = True
def run(self) -> None:
if self.replay_rate:
self._replay()
else:
self._follow()
def _replay(self) -> None:
parser = LogParser()
delay = 1.0 / self.replay_rate
self.status.emit(f"Replaying {self.path.name}")
with open(self.path, encoding="utf-8", errors="replace") as f:
for line in f:
if self._stop:
return
evs = parser.feed(line)
if evs:
self.events.emit(evs)
time.sleep(delay)
self.status.emit(f"Replay of {self.path.name} finished")
def _follow(self) -> None:
parser = LogParser()
f = None
inode = None
pos = 0
buf = b""
waiting_reported = False
following = False
while not self._stop:
try:
st = os.stat(self.path)
except FileNotFoundError:
if not waiting_reported:
self.status.emit(f"Waiting for {self.path}")
waiting_reported = True
time.sleep(1.0)
continue
waiting_reported = False
if f is None or st.st_ino != inode or st.st_size < pos:
# First open, or MTGA restarted and replaced/truncated the log.
if f is not None:
f.close()
self.reset.emit()
f = open(self.path, "rb")
inode, pos, buf = st.st_ino, 0, b""
parser.reset()
self.status.emit(f"Reading {self.path.name}")
following = False
chunk = f.read()
if not chunk:
if not following:
self.status.emit(f"Following {self.path.name}")
following = True
time.sleep(POLL_INTERVAL)
continue
pos = f.tell()
buf += chunk
lines = buf.split(b"\n")
buf = lines.pop() # keep a trailing partial line for the next read
batch = []
for line in lines:
batch.extend(parser.feed(line.decode("utf-8", errors="replace")))
if len(batch) >= BATCH_MAX:
self.events.emit(batch)
batch = []
if batch:
self.events.emit(batch)
if f is not None:
f.close()
+104
View File
@@ -0,0 +1,104 @@
"""Locate MTGA's Player.log and install directory inside a Steam/Proton setup."""
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
MTGA_APP_ID = "2141910"
LOG_SUBPATH = Path(
"pfx/drive_c/users/steamuser/AppData/LocalLow/Wizards Of The Coast/MTGA"
)
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "mtga-assistant"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mtga-assistant"
CONFIG_FILE = CONFIG_DIR / "config.toml"
STEAM_ROOTS = [
Path.home() / ".local/share/Steam",
Path.home() / ".steam/steam",
Path.home() / ".steam/root",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
]
@dataclass
class Config:
log_path: Path | None = None
mtga_dir: Path | None = None
cache_max_mb: int = 500
def load_config() -> Config:
cfg = Config()
try:
data = tomllib.loads(CONFIG_FILE.read_text())
except (FileNotFoundError, tomllib.TOMLDecodeError):
return cfg
if p := data.get("log_path"):
cfg.log_path = Path(p).expanduser()
if p := data.get("mtga_dir"):
cfg.mtga_dir = Path(p).expanduser()
if (mb := data.get("cache_max_mb")) is not None:
cfg.cache_max_mb = int(mb)
return cfg
def steam_libraries() -> list[Path]:
"""All Steam library folders listed in any libraryfolders.vdf we can find."""
libs: list[Path] = []
for root in STEAM_ROOTS:
vdf = root / "steamapps/libraryfolders.vdf"
try:
text = vdf.read_text(errors="replace")
except OSError:
continue
for m in re.finditer(r'"path"\s+"([^"]+)"', text):
p = Path(m.group(1).replace("\\\\", "\\"))
if p not in libs:
libs.append(p)
if root not in libs and (root / "steamapps").is_dir():
libs.append(root)
return libs
def find_mtga_library() -> Path | None:
for lib in steam_libraries():
if (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").exists():
return lib
return None
def find_log_path(cfg: Config) -> Path | None:
if cfg.log_path:
return cfg.log_path
libs = steam_libraries()
mtga_lib = find_mtga_library()
# The prefix normally lives in the same library as the game, but check all of them.
if mtga_lib:
libs = [mtga_lib] + [l for l in libs if l != mtga_lib]
for lib in libs:
d = lib / "steamapps/compatdata" / MTGA_APP_ID / LOG_SUBPATH
if d.is_dir():
return d / "Player.log"
return None
def find_mtga_dir(cfg: Config) -> Path | None:
if cfg.mtga_dir:
return cfg.mtga_dir
lib = find_mtga_library()
if lib:
d = lib / "steamapps/common/MTGA"
if d.is_dir():
return d
return None
def find_card_db(mtga_dir: Path) -> Path | None:
raw = mtga_dir / "MTGA_Data/Downloads/Raw"
dbs = sorted(raw.glob("Raw_CardDatabase_*.mtga"), key=lambda p: p.stat().st_mtime)
return dbs[-1] if dbs else None
View File
Whitespace-only changes.
+184
View File
@@ -0,0 +1,184 @@
"""Sectioned card list: count, mana cost, name, note. Emits the hovered card's grpId."""
from __future__ import annotations
from dataclasses import dataclass, field
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractItemView,
QHeaderView,
QStyledItemDelegate,
QStyleOptionViewItem,
QTreeWidget,
QTreeWidgetItem,
)
from ..carddb import CardInfo
GRP_ROLE = Qt.ItemDataRole.UserRole + 1
MANA_ROLE = Qt.ItemDataRole.UserRole + 2
COL_COUNT, COL_MANA, COL_NAME, COL_NOTE = range(4)
MANA_COLORS = {
"W": "#f8f6d8",
"U": "#c1d7e9",
"B": "#bab1ab",
"R": "#e49977",
"G": "#a3c095",
"C": "#cac5c0",
}
GENERIC_COLOR = "#cac5c0"
@dataclass
class Row:
info: CardInfo
count: int = 1
note: str = ""
dim: bool = False
@dataclass
class Section:
key: str # stable id used to remember expanded/collapsed state
title: str
rows: list[Row] = field(default_factory=list)
collapsed_by_default: bool = False
def sort_key(row: Row) -> tuple:
i = row.info
return (i.is_land, i.mana_value, i.name)
class ManaDelegate(QStyledItemDelegate):
PIP = 16
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
symbols = index.data(MANA_ROLE)
if not symbols:
super().paint(painter, option, index)
return
self.initStyleOption(option, index)
style = option.widget.style() if option.widget else None
if style:
# Draw selection/hover background without text.
option.text = ""
style.drawControl(style.ControlElement.CE_ItemViewItem, option, painter, option.widget)
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
size = min(self.PIP, option.rect.height() - 4)
x = option.rect.left() + 2
y = option.rect.top() + (option.rect.height() - size) // 2
font = QFont(option.font)
font.setPixelSize(int(size * 0.7))
font.setBold(True)
painter.setFont(font)
for sym in symbols:
if x + size > option.rect.right():
break
rect = QRectF(x, y, size, size)
parts = [p for p in sym.split("/") if p]
phyrexian = "P" in parts
parts = [p for p in parts if p != "P"] or ["C"]
fills = [MANA_COLORS.get(p, GENERIC_COLOR) for p in parts]
painter.setPen(QPen(QColor("#555"), 0.8))
if len(fills) >= 2: # hybrid: two half circles
painter.setBrush(QBrush(QColor(fills[0])))
painter.drawPie(rect, 90 * 16, 180 * 16)
painter.setBrush(QBrush(QColor(fills[1])))
painter.drawPie(rect, 270 * 16, 180 * 16)
else:
painter.setBrush(QBrush(QColor(fills[0])))
painter.drawEllipse(rect)
if phyrexian:
label = "φ"
elif sym == "C":
label = "◇"
elif parts[0] not in MANA_COLORS:
label = parts[0] # generic number, X, or the "2" of a 2/C hybrid
else:
label = ""
if label:
painter.setPen(QColor("#111"))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, label)
x += size + 2
painter.restore()
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
symbols = index.data(MANA_ROLE) or []
return QSize(len(symbols) * (self.PIP + 2) + 4, self.PIP + 6)
class CardList(QTreeWidget):
hovered = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self.setColumnCount(4)
self.setHeaderHidden(True)
self.setRootIsDecorated(True)
self.setIndentation(12)
self.setUniformRowHeights(True)
self.setMouseTracking(True)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setItemDelegateForColumn(COL_MANA, ManaDelegate(self))
hdr = self.header()
hdr.setStretchLastSection(False)
hdr.setSectionResizeMode(COL_COUNT, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(COL_MANA, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(COL_NAME, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(COL_NOTE, QHeaderView.ResizeMode.ResizeToContents)
self.itemEntered.connect(self._on_entered)
self.currentItemChanged.connect(lambda cur, _prev: self._on_entered(cur, 0))
self._expanded: dict[str, bool] = {}
self._last_render: list | None = None
def _on_entered(self, item: QTreeWidgetItem | None, _col: int) -> None:
if item is not None and (grp := item.data(0, GRP_ROLE)):
self.hovered.emit(int(grp))
def set_sections(self, sections: list[Section]) -> None:
# Skip rebuilding (and losing scroll position) when nothing changed.
signature = [(s.key, s.title, [(r.info.grp_id, r.count, r.note, r.dim) for r in s.rows]) for s in sections]
if signature == self._last_render:
return
self._last_render = signature
for i in range(self.topLevelItemCount()):
top = self.topLevelItem(i)
self._expanded[top.data(0, GRP_ROLE + 10)] = top.isExpanded()
scroll = self.verticalScrollBar().value()
self.clear()
bold = QFont(self.font())
bold.setBold(True)
dim_brush = QBrush(self.palette().color(self.palette().ColorRole.PlaceholderText))
for sec in sections:
top = QTreeWidgetItem([sec.title, "", "", ""])
top.setFirstColumnSpanned(True)
top.setFont(0, bold)
top.setData(0, GRP_ROLE + 10, sec.key)
top.setFlags(Qt.ItemFlag.ItemIsEnabled)
self.addTopLevelItem(top)
top.setFirstColumnSpanned(True)
for row in sorted(sec.rows, key=sort_key):
it = QTreeWidgetItem([f"{row.count}×" if row.count else "", "", row.info.name, row.note])
it.setData(0, GRP_ROLE, row.info.grp_id)
it.setData(COL_MANA, MANA_ROLE, list(row.info.mana))
it.setToolTip(COL_NAME, f"{row.info.name}\n{row.info.type_line}")
it.setTextAlignment(COL_COUNT, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
it.setTextAlignment(COL_NOTE, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
if row.dim:
for c in range(4):
it.setForeground(c, dim_brush)
top.addChild(it)
top.setExpanded(self._expanded.get(sec.key, not sec.collapsed_by_default))
self.verticalScrollBar().setValue(scroll)
def sizeHint(self) -> QSize:
return QSize(340, 600)
+290
View File
@@ -0,0 +1,290 @@
"""Main window: my deck | opponent | card preview."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QFileDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QSplitter,
QVBoxLayout,
QWidget,
)
from ..carddb import CardDB
from ..gamestate import GameState, MatchTracker
from ..images import ImageCache
from ..logtail import LogFollower
from .card_list import CardList, Row, Section
from .preview import CardPreview
REFRESH_MS = 150
def _panel(title: str, body: QWidget) -> QWidget:
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(4, 4, 4, 4)
label = QLabel(f"<b>{title}</b>")
lay.addWidget(label)
lay.addWidget(body, 1)
return w
class MainWindow(QMainWindow):
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
replay_rate: float | None = None):
super().__init__()
self.setWindowTitle("MTGA Assistant")
self.cards = cards
self.images = images
self.tracker = MatchTracker(card_lookup=cards)
self.follower: LogFollower | None = None
self._rendered_version = -1
# Header
self.header = QLabel("Waiting for a match…")
self.header.setTextFormat(Qt.TextFormat.RichText)
self.warning = QLabel()
self.warning.setStyleSheet("color: #d08770;")
self.warning.setWordWrap(True)
self.warning.hide()
# Panels
self.mine = CardList()
self.opp = CardList()
self.preview = CardPreview(cards, images)
self.mine.hovered.connect(self.preview.show_card)
self.opp.hovered.connect(self.preview.show_card)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(_panel("My deck", self.mine))
split.addWidget(_panel("Opponent", self.opp))
split.addWidget(self.preview)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 3)
split.setStretchFactor(2, 2)
central = QWidget()
lay = QVBoxLayout(central)
top = QHBoxLayout()
top.addWidget(self.header, 1)
lay.addLayout(top)
lay.addWidget(self.warning)
lay.addWidget(split, 1)
self.setCentralWidget(central)
# Status bar
self.status_log = QLabel()
self.status_cache = QLabel()
self.statusBar().addWidget(self.status_log, 1)
self.statusBar().addPermanentWidget(self.status_cache)
images.size_changed.connect(self._on_cache_size)
self._on_cache_size(images.disk_usage())
if not cards.available:
self.warning.setText("MTGA card database not found: card names are unavailable. "
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
self.warning.show()
self._build_menu()
self.refresh_timer = QTimer(self)
self.refresh_timer.setInterval(REFRESH_MS)
self.refresh_timer.timeout.connect(self.refresh)
self.refresh_timer.start()
self.resize(1280, 800)
if log_path is not None:
self.start_log(log_path, replay_rate)
else:
self.status_log.setText("Player.log not found: use File → Open log…")
# -- menu ----------------------------------------------------------------
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("&File")
open_act = QAction("&Open log…", self)
open_act.triggered.connect(lambda: self._choose_log(None))
replay_act = QAction("&Replay log…", self)
replay_act.triggered.connect(lambda: self._choose_log(40.0))
quit_act = QAction("&Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
file_menu.addActions([open_act, replay_act])
file_menu.addSeparator()
file_menu.addAction(quit_act)
cache_menu = self.menuBar().addMenu("&Cache")
clear_act = QAction("&Clear image cache", self)
clear_act.triggered.connect(self._clear_cache)
cache_menu.addAction(clear_act)
def _choose_log(self, replay_rate: float | None) -> None:
start = str(self.follower.path.parent) if self.follower else str(Path.home())
path, _ = QFileDialog.getOpenFileName(self, "Choose an MTGA log", start, "Logs (*.log);;All files (*)")
if path:
self.start_log(Path(path), replay_rate)
def _clear_cache(self) -> None:
if QMessageBox.question(self, "Clear image cache",
f"Delete all cached card images in {self.images.dir}?") == QMessageBox.StandardButton.Yes:
self.images.clear()
def _on_cache_size(self, size: int) -> None:
self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
# -- log -----------------------------------------------------------------
def start_log(self, path: Path, replay_rate: float | None = None) -> None:
if self.follower:
self.follower.stop()
self.follower.wait(2000)
self._reset_tracker()
self.follower = LogFollower(path, replay_rate)
self.follower.events.connect(self._on_events)
self.follower.status.connect(self.status_log.setText)
self.follower.reset.connect(self._reset_tracker)
self.follower.start()
def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards)
self._rendered_version = -1
def _on_events(self, events: list) -> None:
for ev in events:
self.tracker.handle(ev)
def shutdown(self) -> None:
if self.follower:
self.follower.stop()
self.follower.wait(2000)
self.follower = None
self.images.shutdown()
def closeEvent(self, event) -> None:
self.shutdown()
super().closeEvent(event)
# -- rendering -------------------------------------------------------------
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
rows = []
for grp, n in counts.items():
if n <= 0:
continue
info = self.cards.get(grp)
rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim))
return rows
def refresh(self) -> None:
t = self.tracker
if t.version == self._rendered_version:
return
self._rendered_version = t.version
g = t.game
self._render_header(t, g)
if g is None:
self._render_idle(t)
return
self._render_mine(t, g)
self._render_opp(t, g)
def _render_header(self, t: MatchTracker, g: GameState | None) -> None:
if g is None:
self.header.setText("Waiting for a match…")
self.warning.setVisible(not self.cards.available)
return
opp = t.opponent_name or "opponent"
parts = [f"<b>vs {opp}</b>"]
if t.event_id:
parts.append(t.event_id.replace("_", " "))
parts.append(f"Game {g.game_number}")
if g.turn:
whose = "your" if g.active_player == g.my_seat else "their"
parts.append(f"Turn {g.turn} ({whose})")
if g.life:
parts.append(f"Life: you {g.life.get(g.my_seat, '?')} · them {g.life.get(g.opp_seat, '?')}")
if t.game_results:
w = t.game_results.count("Won")
parts.append(f"Games {w}-{len(t.game_results) - w}")
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
unaccounted = sum(t.my_library_remaining().values()) - lib_size if g.zones else 0
if g.desynced or unaccounted:
self.warning.setText(
"Part of the game log was skipped by MTGA (oversized message), so some zones may be "
"out of date until they next change."
+ (f" {unaccounted} card(s) of yours are unaccounted for." if unaccounted > 0 else "")
)
self.warning.show()
else:
self.warning.setVisible(not self.cards.available)
def _render_idle(self, t: MatchTracker) -> None:
sections = []
if d := t.submitted_deck:
sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})",
self._rows(d.main)))
if d.sideboard:
sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})",
self._rows(d.sideboard), collapsed_by_default=True))
self.mine.set_sections(sections)
self.opp.set_sections([])
def _render_mine(self, t: MatchTracker, g: GameState) -> None:
me = g.my_seat
lib_size = g.zone_size("ZoneType_Library", me)
remaining = t.my_library_remaining()
denom = lib_size or sum(remaining.values()) or 1
def pct(_grp: int, n: int) -> str:
return f"{100 * n / denom:.0f}%"
# Every card in the deck; ones with no copies left stay listed, greyed out.
lib_rows = [
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n)
for grp in g.deck
for n in [remaining.get(grp, 0)]
]
sections = [
Section("library", f"Library ({lib_size})", lib_rows),
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))),
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))),
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
collapsed_by_default=True),
]
self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")])
def _render_opp(self, t: MatchTracker, g: GameState) -> None:
opp = g.opp_seat
hand_size = g.zone_size("ZoneType_Hand", opp)
lib_size = g.zone_size("ZoneType_Library", opp)
known_hand = g.opp_known_in("ZoneType_Hand")
known_lib = g.opp_known_in("ZoneType_Library")
seen = t.opp_seen_match
sections = [
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))),
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))),
Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))),
Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)),
Section("seen", f"Seen this match ({sum(seen.values())} cards)",
[r for r in self._rows(seen) if not r.info.is_token]),
]
self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")])
+80
View File
@@ -0,0 +1,80 @@
"""Large image of the hovered card, with a text fallback while it downloads."""
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QPixmap, QResizeEvent
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
from ..carddb import CardDB
from ..images import ImageCache
class CardPreview(QWidget):
def __init__(self, cards: CardDB, images: ImageCache, parent=None):
super().__init__(parent)
self.cards = cards
self.images = images
self.grp_id: int | None = None
self._pixmap: QPixmap | None = None
self.image = QLabel()
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
self.image.setMinimumSize(200, 280)
self.caption = QLabel()
self.caption.setWordWrap(True)
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay = QVBoxLayout(self)
lay.setContentsMargins(4, 4, 4, 4)
lay.addWidget(self.image, 1)
lay.addWidget(self.caption)
images.image_ready.connect(self._on_image_ready)
self._show_placeholder("Hover a card to preview it")
def show_card(self, grp_id: int) -> None:
if grp_id == self.grp_id:
return
self.grp_id = grp_id
info = self.cards.get(grp_id)
details = [info.type_line]
if info.power or info.toughness:
details.append(f"{info.power}/{info.toughness}")
if info.set_code:
details.append(f"{info.set_code} #{info.collector_number}")
self.caption.setText(f"<b>{info.name}</b><br>{' · '.join(d for d in details if d)}")
pm = self.images.pixmap(grp_id)
if pm is not None:
self._pixmap = pm
self._rescale()
elif self.images.is_failed(grp_id):
self._show_placeholder("No image available")
else:
self._show_placeholder("Loading image…")
def _show_placeholder(self, text: str) -> None:
self._pixmap = None
self.image.setPixmap(QPixmap())
self.image.setText(text)
def _on_image_ready(self, grp_id: int) -> None:
if grp_id == self.grp_id:
self.grp_id = None
self.show_card(grp_id)
def _rescale(self) -> None:
if self._pixmap is None:
return
self.image.setPixmap(
self._pixmap.scaled(self.image.size(), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
)
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._rescale()
def sizeHint(self) -> QSize:
return QSize(320, 600)
+601
View File
@@ -0,0 +1,601 @@
{"MatchesV3":[]}
[UnityCrossThreadLogger]==> EventJoin {"id":"53b1b1d2-ef72-461f-a4c6-93218bbffe0c","request":"{\"EventName\":\"Traditional_Historic_Play\",\"EntryCurrencyType\":\"None\",\"EntryCurrencyPaid\":0,\"CustomTokenId\":null,\"EventChoice\":\"\",\"DebugIgnoreEntryLimits\":false}"}
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
<== EventJoin(53b1b1d2-ef72-461f-a4c6-93218bbffe0c)
{"Course":{"CourseId":"9f838f46-df35-409e-919c-50298839beb1","InternalEventName":"Traditional_Historic_Play","CurrentModule":"DeckSelect","ModulePayload":"","CourseDeckSummary":{"Attributes":[],"PreferredCosmetics":{"Avatar":"","Sleeve":"","Pet":"","Title":"","Emotes":[]}},"CardPool":[],"CardStyles":[]},"InventoryInfo":{"SeqId":4,"Changes":[],"Gems":2800,"Gold":4395,"TotalVaultProgress":965,"wcTrackPosition":7,"WildCardCommons":173,"WildCardUnCommons":153,"WildCardRares":5,"CustomTokens":{"HotPocketsProgressiveRewards":1,"PlayInToken":5,"DraftToken":5,"Token_JumpIn":2,"StarterBundle_Gate_1":1,"SealedToken":2,"BonusPackProgress":7},"Boosters":[],"VouchersV2":{},"PrizeWallsUnlocked":[],"Cosmetics":{"ArtStyles":[{"Type":"ArtStyle","Id":"416780.DA","ArtId":416780,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"415990.DA","ArtId":415990,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"406523.SG","ArtId":406523,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"404135.DA","ArtId":404135,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"406559.SG","ArtId":406559,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"406626.SG","ArtId":406626,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"404488.DA","ArtId":404488,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"409514.SG","ArtId":409514,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"402959.DA","ArtId":402959,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"400332.DA","ArtId":400332,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"402057.DA","ArtId":402057,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"406664.SG","ArtId":406664,"Variant":"SG","ExplicitGrpIds":[],"AcquisitionFlags":"CodeRedemption"},{"Type":"ArtStyle","Id":"413908.MP","ArtId":413908,"Variant":"MP","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416644.SH","ArtId":416644,"Variant":"SH","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416688.DA","ArtId":416688,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419665.DA","ArtId":419665,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"411692.DA","ArtId":411692,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416781.DA","ArtId":416781,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419492.DA","ArtId":419492,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"410632.DA","ArtId":410632,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"410641.DA","ArtId":410641,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"410635.DA","ArtId":410635,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419672.DA","ArtId":419672,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416724.DA","ArtId":416724,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"416568.DA","ArtId":416568,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419467.DA","ArtId":419467,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"419630.DA","ArtId":419630,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"419514.DA","ArtId":419514,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421172.DA","ArtId":421172,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"417870.JP","ArtId":417870,"Variant":"JP","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421184.DA","ArtId":421184,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421370.FF","ArtId":421370,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421371.FF","ArtId":421371,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421372.FF","ArtId":421372,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421373.FF","ArtId":421373,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421374.FF","ArtId":421374,"Variant":"FF","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"419511.DA","ArtId":419511,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"419475.DA","ArtId":419475,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"421185.DA","ArtId":421185,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421198.DA","ArtId":421198,"Variant":"DA","ExplicitGrpIds":[]},{"Type":"ArtStyle","Id":"421180.DA","ArtId":421180,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"421284.DA","ArtId":421284,"Variant":"DA","ExplicitGrpIds":[],"AcquisitionFlags":"SeasonReward"},{"Type":"ArtStyle","Id":"417357.DA","ArtId":417357,"Variant":"Line truncated
[UnityCrossThreadLogger]==> DeckUpsertDeckV3 {"id":"7668648d-95b7-4564-8077-91d1e069f937","request":"{\"Summary\":{\"DeckId\":\"ad644e5b-5720-44ab-8d98-022c13d54788\",\"Mana\":\"\",\"Name\":\"Cruel Tutelage\",\"Attributes\":[{\"name\":\"Version\",\"value\":\"11\"},{\"name\":\"TileID\",\"value\":\"71860\"},{\"name\":\"LastPlayed\",\"value\":\"\\\"2026-10-16T00:34:31.4451208+00:00\\\"\"},{\"name\":\"LastUpdated\",\"value\":\"\\\"2026-10-14T15:39:44.5372957+00:00\\\"\"},{\"name\":\"IsFavorite\",\"value\":\"false\"},{\"name\":\"Format\",\"value\":\"TraditionalHistoric\"}],\"Description\":\"\",\"DeckTileId\":71860,\"DeckArtId\":0,\"IsCompanionValid\":false,\"PreferredCosmetics\":{\"Avatar\":\"\",\"Sleeve\":\"\",\"Pet\":\"\",\"Title\":\"\",\"Emotes\":[]},\"NetDeckFolderId\":null,\"IsNetDeck\":false,\"checkInbox\":false},\"Deck\":{\"MainDeck\":[{\"cardId\":71860,\"quantity\":4},{\"cardId\":97170,\"quantity\":1},{\"cardId\":93986,\"quantity\":4},{\"cardId\":77498,\"quantity\":4},{\"cardId\":94278,\"quantity\":4},{\"cardId\":73266,\"quantity\":4},{\"cardId\":102507,\"quantity\":4},{\"cardId\":75662,\"quantity\":4},{\"cardId\":97169,\"quantity\":6},{\"cardId\":95614,\"quantity\":2},{\"cardId\":91788,\"quantity\":4},{\"cardId\":83950,\"quantity\":4},{\"cardId\":68740,\"quantity\":4},{\"cardId\":94351,\"quantity\":1},{\"cardId\":87448,\"quantity\":1},{\"cardId\":73474,\"quantity\":1},{\"cardId\":106251,\"quantity\":4},{\"cardId\":70335,\"quantity\":4}],\"Sideboard\":[{\"cardId\":95614,\"quantity\":1},{\"cardId\":82853,\"quantity\":3},{\"cardId\":75118,\"quantity\":1},{\"cardId\":102775,\"quantity\":1},{\"cardId\":70205,\"quantity\":2},{\"cardId\":90718,\"quantity\":1},{\"cardId\":94877,\"quantity\":2},{\"cardId\":92204,\"quantity\":2},{\"cardId\":92407,\"quantity\":1},{\"cardId\":88998,\"quantity\":1}],\"CommandZone\":[],\"Companions\":[],\"CardSkins\":[]},\"ActionType\":\"Updated\"}"}
[UnityCrossThreadLogger]==> EventSetDeckV3 {"id":"ddbd8dd5-7f9f-4156-9a57-f8337cc06ae5","request":"{\"EventName\":\"Traditional_Historic_Play\",\"Summary\":{\"DeckId\":\"ad644e5b-5720-44ab-8d98-022c13d54788\",\"Mana\":\"\",\"Name\":\"Cruel Tutelage\",\"Attributes\":[{\"name\":\"Version\",\"value\":\"11\"},{\"name\":\"TileID\",\"value\":\"71860\"},{\"name\":\"LastPlayed\",\"value\":\"\\\"2026-10-16T00:34:31.4451208+00:00\\\"\"},{\"name\":\"LastUpdated\",\"value\":\"\\\"2026-10-14T15:39:44.5372957+00:00\\\"\"},{\"name\":\"IsFavorite\",\"value\":\"false\"},{\"name\":\"Format\",\"value\":\"TraditionalHistoric\"}],\"Description\":\"\",\"DeckTileId\":71860,\"DeckArtId\":0,\"IsCompanionValid\":false,\"PreferredCosmetics\":{\"Avatar\":\"\",\"Sleeve\":\"\",\"Pet\":\"\",\"Title\":\"\",\"Emotes\":[]},\"NetDeckFolderId\":null,\"IsNetDeck\":false,\"checkInbox\":false},\"Deck\":{\"MainDeck\":[{\"cardId\":71860,\"quantity\":4},{\"cardId\":97170,\"quantity\":1},{\"cardId\":93986,\"quantity\":4},{\"cardId\":77498,\"quantity\":4},{\"cardId\":94278,\"quantity\":4},{\"cardId\":73266,\"quantity\":4},{\"cardId\":102507,\"quantity\":4},{\"cardId\":75662,\"quantity\":4},{\"cardId\":97169,\"quantity\":6},{\"cardId\":95614,\"quantity\":2},{\"cardId\":91788,\"quantity\":4},{\"cardId\":83950,\"quantity\":4},{\"cardId\":68740,\"quantity\":4},{\"cardId\":94351,\"quantity\":1},{\"cardId\":87448,\"quantity\":1},{\"cardId\":73474,\"quantity\":1},{\"cardId\":106251,\"quantity\":4},{\"cardId\":70335,\"quantity\":4}],\"Sideboard\":[{\"cardId\":95614,\"quantity\":1},{\"cardId\":82853,\"quantity\":3},{\"cardId\":75118,\"quantity\":1},{\"cardId\":102775,\"quantity\":1},{\"cardId\":70205,\"quantity\":2},{\"cardId\":90718,\"quantity\":1},{\"cardId\":94877,\"quantity\":2},{\"cardId\":92204,\"quantity\":2},{\"cardId\":92407,\"quantity\":1},{\"cardId\":88998,\"quantity\":1}],\"CommandZone\":[],\"Companions\":[],\"CardSkins\":[]}}"}
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
<== DeckUpsertDeckV3(7668648d-95b7-4564-8077-91d1e069f937)
{"DeckId":"ad644e5b-5720-44ab-8d98-022c13d54788","Name":"Cruel Tutelage","Attributes":[{"name":"Version","value":"11"},{"name":"TileID","value":"71860"},{"name":"LastPlayed","value":"\"2026-10-16T00:34:31.4451208+00:00\""},{"name":"LastUpdated","value":"\"2026-10-14T15:39:44.5372957+00:00\""},{"name":"IsFavorite","value":"false"},{"name":"Format","value":"TraditionalHistoric"}],"Description":"","DeckTileId":71860,"DeckArtId":0,"PreferredCosmetics":{"Avatar":"","Sleeve":"","Pet":"","Title":"","Emotes":[]}}
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
<== EventSetDeckV3(ddbd8dd5-7f9f-4156-9a57-f8337cc06ae5)
{"CourseId":"9f838f46-df35-409e-919c-50298839beb1","InternalEventName":"Traditional_Historic_Play","CurrentModule":"CreateMatch","ModulePayload":"","CourseDeckSummary":{"DeckId":"ad644e5b-5720-44ab-8d98-022c13d54788","Name":"Cruel Tutelage","Attributes":[{"name":"Version","value":"11"},{"name":"TileID","value":"71860"},{"name":"LastPlayed","value":"\"2026-10-16T00:34:31.4451208+00:00\""},{"name":"LastUpdated","value":"\"2026-10-14T15:39:44.5372957+00:00\""},{"name":"IsFavorite","value":"false"},{"name":"Format","value":"TraditionalHistoric"}],"Description":"","DeckTileId":71860,"DeckArtId":0,"PreferredCosmetics":{"Avatar":"","Sleeve":"","Pet":"","Title":"","Emotes":[]}},"CourseDeck":{"MainDeck":[{"cardId":71860,"quantity":4},{"cardId":97170,"quantity":1},{"cardId":93986,"quantity":4},{"cardId":77498,"quantity":4},{"cardId":94278,"quantity":4},{"cardId":73266,"quantity":4},{"cardId":102507,"quantity":4},{"cardId":75662,"quantity":4},{"cardId":97169,"quantity":6},{"cardId":95614,"quantity":2},{"cardId":91788,"quantity":4},{"cardId":83950,"quantity":4},{"cardId":68740,"quantity":4},{"cardId":94351,"quantity":1},{"cardId":87448,"quantity":1},{"cardId":73474,"quantity":1},{"cardId":106251,"quantity":4},{"cardId":70335,"quantity":4}],"Sideboard":[{"cardId":95614,"quantity":1},{"cardId":82853,"quantity":3},{"cardId":75118,"quantity":1},{"cardId":102775,"quantity":1},{"cardId":70205,"quantity":2},{"cardId":90718,"quantity":1},{"cardId":94877,"quantity":2},{"cardId":92204,"quantity":2},{"cardId":92407,"quantity":1},{"cardId":88998,"quantity":1}],"CommandZone":[],"Companions":[],"CardSkins":[]},"CardPool":[],"CardStyles":[]}
[UnityCrossThreadLogger]==> EventEnterPairing {"id":"180f5e47-4e44-4798-9a30-df046b1d285c","request":"{\"EventName\":\"Traditional_Historic_Play\",\"EventCode\":null}"}
[UnityCrossThreadLogger]10/16/2026 12:34:31 AM
<== EventEnterPairing(180f5e47-4e44-4798-9a30-df046b1d285c)
{"CurrentModule":"CreateMatch","Payload":"Success"}
Unloading 4 Unused Serialized files (Serialized files now loaded: 299)
Unloading 11767 unused Assets to reduce memory usage. Loaded Objects now: 26584.
Total: 61.613200 ms (FindLiveObjects: 1.378800 ms CreateObjectMapping: 1.154200 ms MarkObjects: 54.909400 ms DeleteObjects: 4.170200 ms)
OnSceneLoaded for MatchScene
There can be only one active Event System.
OnSceneLoaded for PreGameScene
OnSceneUnloaded for NavBar
cannot find async scene unload for NavBar
OnSceneUnloaded for MainNavigation
cannot find async scene unload for MainNavigation
Caches cleared
Unloading 1 Unused Serialized files (Serialized files now loaded: 255)
Unloading 10110 unused Assets to reduce memory usage. Loaded Objects now: 12270.
Total: 71.226500 ms (FindLiveObjects: 0.839700 ms CreateObjectMapping: 0.876100 ms MarkObjects: 65.183800 ms DeleteObjects: 4.326200 ms)
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
Reference Assets/Core/Art/Meta/Cosmetics/Pets/Images/Icon_Pet_AFR_Dragon_U.png named AFR_DragonSkin3 not cleaned up properly.
[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to LOCALPLAYERID0000000000000: AuthenticateResponse
{ "transactionId": "c63e3b86-1930-44bd-bcf5-a7662be2fb42", "requestId": 1, "timestamp": "639277076914576915", "authenticateResponse": { "clientId": "LOCALPLAYERID0000000000000", "sessionId": "00000000-0000-0000-0000-000000000000", "screenName":"Local Player" } }
[UnityCrossThreadLogger]STATE CHANGED {"old":"None","new":"ConnectedToMatchDoor"}
[UnityCrossThreadLogger]Connecting to matchId c0a18506-9d87-4d0c-a2d3-e10bef8659d3
[UnityCrossThreadLogger]STATE CHANGED {"old":"ConnectedToMatchDoor","new":"ConnectedToMatchDoor_ConnectingToGRE"}
[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to LOCALPLAYERID0000000000000: MatchGameRoomStateChangedEvent
{ "transactionId": "2f0123f2-3c0b-417a-a5c4-2258f3d25569", "requestId": 2, "timestamp": "1792110891750", "matchGameRoomStateChangedEvent": { "gameRoomInfo": { "gameRoomConfig": { "reservedPlayers": [ { "userId": "LOCALPLAYERID0000000000000", "playerName":"Local Player", "systemSeatId": 1, "teamId": 1, "courseId": "Avatar_Basic_BilboRiddles_HOB", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "SteamWindows", "eventId": "Traditional_Historic_Play" }, { "userId": "OPPONENTID000000", "playerName":"Opponent Player", "systemSeatId": 2, "teamId": 2, "courseId": "Avatar_Basic_Adventurer", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "Windows", "eventId": "Traditional_Historic_Play" } ], "matchId": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3" }, "stateType": "MatchGameRoomStateType_Playing", "players": [ { "userId": "LOCALPLAYERID0000000000000", "playerName":"Local Player", "systemSeatId": 1, "teamId": 1 }, { "userId": "OPPONENTID000000", "playerName":"Opponent Player", "systemSeatId": 2, "teamId": 2 } ] } } }
[UnityCrossThreadLogger]STATE CHANGED {"old":"ConnectedToMatchDoor_ConnectingToGRE","new":"Playing"}
[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "2f0123f2-3c0b-417a-a5c4-2258f3d25569", "requestId": 2, "timestamp": "1792110891791", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_ConnectResp", "systemSeatIds": [ 1 ], "msgId": 2, "connectResp": { "status": "ConnectionStatus_Success", "protoVer": "ProtoVersion_PersistentAnnotations", "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_ResolveMyStackEffects", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SLine truncated
OnSceneLoaded for Battlefield_VOW
OnSceneLoaded for DuelScene
[UnityCrossThreadLogger]10/16/2026 12:34:53 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 3,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277076930443443",
"transactionId": "547bd1dd-afcd-4073-bc42-5564a1f55936",
"payload": {
"type": "ClientMessageType_SetSettingsReq",
"setSettingsReq": {
"settings": {
"autoPassOption": "AutoPassOption_ResolveMyStackEffects",
"manaSelectionType": "ManaSelectionType_Auto",
"defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects",
"autoSelectReplacementSetting": "Setting_Enable"
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:34:53 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "547bd1dd-afcd-4073-bc42-5564a1f55936", "requestId": 3, "timestamp": "1792110893078", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 7, "gameStateId": 1, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_ResolveMyStackEffects", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "Line truncated
OnSceneUnloaded for PreGameScene
[UnityCrossThreadLogger]10/16/2026 12:35:02 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 4,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077020272410",
"transactionId": "70cf4fe7-eed6-46b4-a124-e5e7836a4f3b",
"payload": {
"type": "ClientMessageType_ChooseStartingPlayerResp",
"gameStateId": 1,
"respId": 5,
"chooseStartingPlayerResp": {
"teamType": "TeamType_Individual",
"systemSeatId": 1,
"teamId": 1
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:02 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "70cf4fe7-eed6-46b4-a124-e5e7836a4f3b", "requestId": 4, "timestamp": "1792110902076", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 8, "gameStateId": 2, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 2, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_MulliganResp", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_MulliganResp", "startingLifeTotal": 20 } ], "turnInfo": { "activePlayer": 1, "decisionPlayer": 1 }, "zones": [ { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "objectInstanceIds": [ 170, 169, 168, 167, 166, 165, 164 ], "viewers": [ 1 ] }, { "zoneId": 32, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 1, "objectInstanceIds": [ 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223 ] }, { "zoneId": 35, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 2, "objectInstanceIds": [ 230, 229, 228, 227, 226, 225, 224 ], "viewers": [ 2 ] }, { "zoneId": 36, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 2, "objectInstanceIds": [ 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283 ] } ], "gameObjects": [ { "instanceId": 164, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "viewers": [ 1 ], "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 54, "grpId": 1002 } ] }, { "instanceId": 165, "grpId": 94278, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Instant" ], "color": [ "CardColor_Blue" ], "viewers": [ 1 ], "name": 529845, "overlayGrpId": 94278, "uniqueAbilities": [ { "id": 29, "grpId": 269 }, { "id": 30, "grpId": 25848 } ] }, { "instanceId": 166, "grpId": 71860, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Enchantment" ], "color": [ "CardColor_Blue" ], "viewers": [ 1 ], "name": 433755, "overlayGrpId": 71860, "uniqueAbilities": [ { "id": 6, "grpId": 92280 }, { "id": 7, "grpId": 137881 } ] }, { "instanceId": 167, "grpId": 91788, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Land" ], "viewers": [ 1 ], "name": 414451, "overlayGrpId": 91788, "uniqueAbilities": [ { "id": 60, "grpId": 136225 } ] }, { "instanceId": 168, "grpId": 87448, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Land" ], "viewers": [ 1 ], "name": 738112, "overlayGrpId": 87448, "uniqueAbilities": [ { "id": 82, "grpId": 180111 }, { "id": 83, "grpId": 1167 }, { "id": 84, "grpId": 169785 }, { "id": 85, "grpId": 169786 } ] }, { "instanceId": 169, "grpId": 73266, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Creature" ], "subtypes": [ "SubType_Crab" ], "color": [ "CardColor_Blue" ], "power": { }, "toughness": { "value": 3 }, "viewers": [ 1 ], "name": 437203, "overlayGrpId": 73266, "uniqueAbilities": [ { "id": 40, "grpId": 138408 } ] }, { "instanceId": 170, "grpId": 68740, "type": "GameObjectType_Card", "zoneId": 31, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island", "SubType_Swamp" ], "viewers": [ 1 ], "name": 30133, "overlayGrpId": 68740, "uniqueAbilities": [ { "id": 78, "grpId": 1003 }, { "id": 79, "grpId": 1002 }, { "id": 80, "grpId": 90846 } ] } ], "annotations": [ { "id": 76, "affectorId": 1, "affectedIds": [ 1 ], "type": [ "AnnotationType_NewTurnStarted" ] } ], "pendingMessageCount": 1, "prevGameStateId": 1, "timers": [ { "timerId": 1, "type": "TimerType_ProLine truncated
[UnityCrossThreadLogger]10/16/2026 12:35:04 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 5,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077041146547",
"transactionId": "211ff842-4b99-4b40-9500-eeda87188dd5",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:04 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 6,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077044826475",
"transactionId": "2612646a-cad1-45e0-9bd1-e8ea6b7861f4",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:05 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 7,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077056599346",
"transactionId": "964f666c-3a18-401f-a657-2ba3d42a4d04",
"payload": {
"type": "ClientMessageType_MulliganResp",
"gameStateId": 2,
"respId": 9,
"mulliganResp": {
"decision": "MulliganOption_AcceptHand"
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:05 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "964f666c-3a18-401f-a657-2ba3d42a4d04", "requestId": 7, "timestamp": "1792110905707", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 10, "gameStateId": 3, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 3, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 } ], "turnInfo": { "activePlayer": 1, "decisionPlayer": 2 }, "pendingMessageCount": 1, "prevGameStateId": 2, "timers": [ { "timerId": 1, "type": "TimerType_Prologue", "durationSec": 120, "elapsedSec": 13, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 13914 }, { "timerId": 6, "type": "TimerType_Inactivity", "durationSec": 150, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30 }, { "timerId": 7, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 13, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 13914 }, { "timerId": 8, "type": "TimerType_Prologue", "durationSec": 120, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 14, "type": "TimerType_MatchClock", "durationSec": 1800, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 169, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 164 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } } ] } }, { "type": "GREMessageType_PromptReq", "systemSeatIds": [ 1, 2 ], "msgId": 11, "gameStateId": 3, "prompt": { "promptId": 37, "parameters": [ { "parameterName": "PlayerId", "type": "ParameterType_Reference", "reference": { "type": "ReferenceType_PlayerSeatId", "id": 1 } } ] } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 8,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077061142090",
"transactionId": "d1aa0e84-c106-44be-b840-acd3b97f4c84",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {
"objectId": 165
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 9,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077061315553",
"transactionId": "06c95a02-f598-42a4-bc78-a217e3abe6d7",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 10,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077061404856",
"transactionId": "4eeb8bf7-e5f4-4b60-b8b5-647447d12006",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {
"objectId": 170
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "06085585-8ac8-4c74-9db9-08ee6bdba377", "requestId": 4, "timestamp": "1792110906631", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onHover": { } } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:06 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "8ac8d6d3-a810-4e92-afb9-2cb4f37fdc26", "requestId": 5, "timestamp": "1792110906823", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onHover": { } } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:07 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "c4344ffc-4182-471b-aeba-50567f8adc6c", "requestId": 6, "timestamp": "1792110907812", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onHover": { } } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:08 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 11,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077082945543",
"transactionId": "dd785371-e392-4e8f-a513-eada3c52a626",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {
"objectId": 164
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "f10f5093-bdfd-4b61-9eb5-3df8d014fc77", "requestId": 7, "timestamp": "1792110909129", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 14, "gameStateId": 3, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_Clear", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StoLine truncated
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 12,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077097327477",
"transactionId": "7b4a270a-d961-444d-afd7-493b93362c08",
"payload": {
"type": "ClientMessageType_PerformActionResp",
"gameStateId": 6,
"respId": 19,
"performActionResp": {
"actions": [
{
"actionType": "ActionType_Play",
"grpId": 97169,
"instanceId": 164,
"facetId": 164,
"shouldStop": true
}
],
"autoPassPriority": "AutoPassPriority_Yes"
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 13,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077098083989",
"transactionId": "a3f6d8ca-9dfc-4ea8-a29d-6d0bab8a43d5",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "7b4a270a-d961-444d-afd7-493b93362c08", "requestId": 12, "timestamp": "1792110909786", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 20, "gameStateId": 7, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 7, "zones": [ { "zoneId": 28, "type": "ZoneType_Battlefield", "visibility": "Visibility_Public", "objectInstanceIds": [ 284 ] }, { "zoneId": 30, "type": "ZoneType_Limbo", "visibility": "Visibility_Public", "objectInstanceIds": [ 164 ] }, { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "objectInstanceIds": [ 170, 169, 168, 167, 166, 165 ], "viewers": [ 1 ] } ], "gameObjects": [ { "instanceId": 164, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 30, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "viewers": [ 1 ], "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 54, "grpId": 1002 } ] }, { "instanceId": 284, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 28, "visibility": "Visibility_Public", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 235, "grpId": 1002 } ] } ], "annotations": [ { "id": 80, "affectedIds": [ 164 ], "type": [ "AnnotationType_ObjectIdChanged" ], "details": [ { "key": "orig_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 164 ] }, { "key": "new_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 284 ] } ] }, { "id": 81, "affectedIds": [ 284 ], "type": [ "AnnotationType_ZoneTransfer" ], "details": [ { "key": "zone_src", "type": "KeyValuePairValueType_int32", "valueInt32": [ 31 ] }, { "key": "zone_dest", "type": "KeyValuePairValueType_int32", "valueInt32": [ 28 ] }, { "key": "category", "type": "KeyValuePairValueType_string", "valueString": [ "PlayLand" ] } ] }, { "id": 82, "affectorId": 1, "affectedIds": [ 284 ], "type": [ "AnnotationType_UserActionTaken" ], "details": [ { "key": "actionType", "type": "KeyValuePairValueType_int32", "valueInt32": [ 3 ] }, { "key": "abilityGrpId", "type": "KeyValuePairValueType_int32", "valueInt32": [ 0 ] } ] } ], "pendingMessageCount": 1, "prevGameStateId": 6, "timers": [ { "timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 68, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 5, "type": "TimerType_Delay", "durationSec": 2, "running": true, "behavior": "TimerBehavior_StartDelayedTimer", "elapsedMs": 649 }, { "timerId": 7, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 14, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 14564 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 169, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ], "persistentAnnotations": [ { "id": 5, "affectorId": 28, "affectedIds": [ 284 ], "type": [ "AnnotationType_EnteredZoneThisTurn" ] }, { "id": 84, "affectorId": 284, "affectedIds": [ 284 ], "type": [ "AnnotationType_ColorProduction" ], "details": [ { "key": "colors", "type": "KeyValuePairValueType_int32", "valueInt32": [ 2 ] } ] } ] } }, { "type": "GREMessageType_ActionsAvailableReq", "systemSeatIds": [ 1 ], "msgId": 21, "gameStateId": 7, "prompt": { "promptId": 2 }, "actionsAvailableReq": { "actions": [ { "actionType": "ActionType_Cast", "grpId": 94278, "instanceId": 165, "facetId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ], "shouldStop": true, "autoTapSolution": { "autoTapActions": [ { "instanceId": 284, "abilityGrpId": 1002, "manaPaymentOption": { "mana": [ { "manaId": 1, "color": "ManaColor_Blue", "srcInstanceId": 284, "specs": [ { "type": "ManaSpecType_Predictive" } ], "abilityGrpId": 1002, "count": 1 } ] } } ] } }, { "actionType": "ActionType_Cast", "grpId": 71860, "instanceId": 166, "facetId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count"Line truncated
[UnityCrossThreadLogger]10/16/2026 12:35:09 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 14,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077099127670",
"transactionId": "50fdb5af-7c78-4d45-8d62-e04f26ed0d7a",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {
"objectId": 169
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:10 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 15,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077102439495",
"transactionId": "a3633719-ff1a-4b3d-9f45-48580a4285ba",
"payload": {
"type": "ClientMessageType_PerformActionResp",
"gameStateId": 7,
"respId": 21,
"performActionResp": {
"actions": [
{
"actionType": "ActionType_Cast",
"grpId": 73266,
"instanceId": 169,
"facetId": 169,
"manaCost": [
{
"color": [
"ManaColor_Blue"
],
"count": 1
}
],
"shouldStop": true,
"autoTapSolution": {
"autoTapActions": [
{
"instanceId": 284,
"abilityGrpId": 1002,
"manaPaymentOption": {
"mana": [
{
"manaId": 1,
"color": "ManaColor_Blue",
"srcInstanceId": 284,
"specs": [
{
"type": "ManaSpecType_Predictive"
}
],
"abilityGrpId": 1002,
"count": 1
}
]
}
}
]
}
}
],
"autoPassPriority": "AutoPassPriority_Yes"
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:10 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 16,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077103568649",
"transactionId": "30282bac-1512-42ea-874f-0cd320e6f609",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:10 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "a3633719-ff1a-4b3d-9f45-48580a4285ba", "requestId": 15, "timestamp": "1792110910338", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 22, "gameStateId": 8, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 8, "zones": [ { "zoneId": 27, "type": "ZoneType_Stack", "visibility": "Visibility_Public", "objectInstanceIds": [ 285 ] }, { "zoneId": 30, "type": "ZoneType_Limbo", "visibility": "Visibility_Public", "objectInstanceIds": [ 169, 164 ] }, { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "objectInstanceIds": [ 170, 168, 167, 166, 165 ], "viewers": [ 1 ] } ], "gameObjects": [ { "instanceId": 169, "grpId": 73266, "type": "GameObjectType_Card", "zoneId": 30, "visibility": "Visibility_Private", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Creature" ], "subtypes": [ "SubType_Crab" ], "color": [ "CardColor_Blue" ], "power": { }, "toughness": { "value": 3 }, "viewers": [ 1 ], "name": 437203, "overlayGrpId": 73266, "uniqueAbilities": [ { "id": 40, "grpId": 138408 } ] }, { "instanceId": 284, "grpId": 97169, "type": "GameObjectType_Card", "zoneId": 28, "visibility": "Visibility_Public", "ownerSeatId": 1, "controllerSeatId": 1, "superTypes": [ "SuperType_Basic" ], "cardTypes": [ "CardType_Land" ], "subtypes": [ "SubType_Island" ], "isTapped": true, "name": 652, "overlayGrpId": 97169, "uniqueAbilities": [ { "id": 235, "grpId": 1002 } ] }, { "instanceId": 285, "grpId": 73266, "type": "GameObjectType_Card", "zoneId": 27, "visibility": "Visibility_Public", "ownerSeatId": 1, "controllerSeatId": 1, "cardTypes": [ "CardType_Creature" ], "subtypes": [ "SubType_Crab" ], "color": [ "CardColor_Blue" ], "power": { }, "toughness": { "value": 3 }, "name": 437203, "overlayGrpId": 73266, "uniqueAbilities": [ { "id": 236, "grpId": 138408 } ] } ], "annotations": [ { "id": 85, "affectedIds": [ 169 ], "type": [ "AnnotationType_ObjectIdChanged" ], "details": [ { "key": "orig_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 169 ] }, { "key": "new_id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 285 ] } ] }, { "id": 86, "affectedIds": [ 285 ], "type": [ "AnnotationType_ZoneTransfer" ], "details": [ { "key": "zone_src", "type": "KeyValuePairValueType_int32", "valueInt32": [ 31 ] }, { "key": "zone_dest", "type": "KeyValuePairValueType_int32", "valueInt32": [ 27 ] }, { "key": "category", "type": "KeyValuePairValueType_string", "valueString": [ "CastSpell" ] } ] }, { "id": 87, "affectorId": 284, "affectedIds": [ 286 ], "type": [ "AnnotationType_AbilityInstanceCreated" ], "details": [ { "key": "source_zone", "type": "KeyValuePairValueType_int32", "valueInt32": [ 28 ] } ] }, { "id": 88, "affectorId": 286, "affectedIds": [ 284 ], "type": [ "AnnotationType_TappedUntappedPermanent" ], "details": [ { "key": "tapped", "type": "KeyValuePairValueType_int32", "valueInt32": [ 1 ] } ] }, { "id": 89, "affectorId": 1, "affectedIds": [ 286 ], "type": [ "AnnotationType_UserActionTaken" ], "details": [ { "key": "actionType", "type": "KeyValuePairValueType_int32", "valueInt32": [ 4 ] }, { "key": "abilityGrpId", "type": "KeyValuePairValueType_int32", "valueInt32": [ 1002 ] } ] }, { "id": 90, "affectorId": 284, "affectedIds": [ 285 ], "type": [ "AnnotationType_ManaPaid" ], "details": [ { "key": "id", "type": "KeyValuePairValueType_int32", "valueInt32": [ 3 ] }, { "key": "color", "type": "KeyValuePairValueType_int32", "valueInt32": [ 2 ] } ] }, { "id": 91, "affectorId": 284, "affectedIds": [ 286 ], "type": [ "AnnotationType_AbilityInstanceDeleted" ] }, { "id": 92, "affectorId": 1, "affectedIds": [ 285 ], "type": [ "AnnotationType_UserActionTaken" ], "details": [ { "key": "actionType", "type": "KeyValuePairValueType_int32", "valueInt32": [ 1 ] }, { "key": "abilityGrpId", "type": "KeyValuePairValueType_int32", "valueInt32": [ 0 ] } ] } ], "diffDeletedInstanceIds": [ 286 ], "prevGameStateId": 7, "timers": [ { "timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 75, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 5, "type": "TimerType_Delay", "durationSec": 2, "elapsedSec": 1, "behavior": "TimerBehavior_StartDelayedTimer", "elapsedMs": 1155 }, { "timerId": 7, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 15, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 15069 } ], "update": "GameStateUpdate_SendHiFi", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 169, "manaCost": [ { "color": [ "ManaColor_Blue" ], "Line truncated
[UnityCrossThreadLogger]10/16/2026 12:35:11 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "6a6cc617-24ab-4d80-ad8b-de6ebdde78b2", "requestId": 8, "timestamp": "1792110911594", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage", "systemSeatIds": [ 1 ], "uiMessage": { "seatIds": [ 2 ], "onChat": { "text": "Phrase_Basic_Hello" } } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 17,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077121691790",
"transactionId": "a056a52c-9892-4aeb-8a19-2695cc8cfe64",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {
"objectId": 285
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 18,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077121764773",
"transactionId": "c4e289e4-ec09-47e9-afd9-f6e84f11d0f9",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "timestamp": "1792110912338", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_TimerStateMessage", "systemSeatIds": [ 1, 2 ], "msgId": 42, "gameStateId": 26, "timerStateMessage": { "seatId": 2, "timers": [ { "timerId": 8, "type": "TimerType_Prologue", "durationSec": 120, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 10, "type": "TimerType_ActivePlayer", "durationSec": 61, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 11, "type": "TimerType_NonActivePlayer", "durationSec": 45, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 12, "type": "TimerType_Delay", "durationSec": 2, "behavior": "TimerBehavior_StartDelayedTimer" }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30 }, { "timerId": 14, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 5, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 5417 } ] } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 19,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077125998520",
"transactionId": "f0daff02-794f-4a27-8709-76fa68cbfa9e",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {
"objectId": 285
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:12 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 20,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077126278570",
"transactionId": "864a802d-e833-4b8e-b714-993f9ebc61de",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onHover": {}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:14 AM: LOCALPLAYERID0000000000000 to Match: ClientToGreuimessage
{
"requestId": 21,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREUIMessage",
"timestamp": "639277077140142120",
"transactionId": "943a2900-e1de-4961-9e09-9002e36d7728",
"payload": {
"type": "ClientMessageType_UIMessage",
"systemSeatId": 1,
"uiMessage": {
"seatIds": [
2
],
"onChat": {
"text": "Phrase_OTJ_Howdy"
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:15 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "e6f344f9-5e8f-4d56-92a9-9b2a83d750f6", "requestId": 9, "timestamp": "1792110915443", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 43, "gameStateId": 27, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 27, "gameInfo": { "matchID": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3", "gameNumber": 1, "stage": "GameStage_GameOver", "type": "GameType_Duel", "variant": "GameVariant_Normal", "matchState": "MatchState_GameComplete", "matchWinCondition": "MatchWinCondition_Best2of3", "maxTimeoutCount": 4, "maxPipCount": 3, "timeoutDurationSec": 30, "results": [ { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" } ], "superFormat": "SuperFormat_Constructed", "mulliganType": "MulliganType_London", "deckConstraintInfo": { "minDeckSize": 60, "maxDeckSize": 250, "maxSideboardSize": 15 } }, "teams": [ { "id": 2, "playerIds": [ 2 ], "status": "TeamStatus_PendingLoss" } ], "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "turnNumber": 1, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_PendingLoss", "maxHandSize": 7, "turnNumber": 1, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 } ], "prevGameStateId": 26, "timers": [ { "timerId": 10, "type": "TimerType_ActivePlayer", "durationSec": 61, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "elapsedSec": 3, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 3103 }, { "timerId": 14, "type": "TimerType_MatchClock", "durationSec": 1800, "elapsedSec": 8, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 8519 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ] } }, { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 44, "gameStateId": 28, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 28, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "turnNumber": 1, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_EnterSideboardingReq", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_PendingLoss", "maxHandSize": 7, "turnNumber": 1, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_EnterSideboardingReq", "startingLifeTotal": 20 } ], "pendingMessageCount": 1, "prevGameStateId": 27, "timers": [ { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 6, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 1 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "elapsedSec": 3, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 3105 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_PlaLine truncated
[UnityCrossThreadLogger]10/16/2026 12:35:18 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 22,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077180236913",
"transactionId": "8505a700-0acd-406d-9835-99e061658467",
"payload": {
"type": "ClientMessageType_EnterSideboardingReq",
"gameStateId": 28,
"respId": 45
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:18 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "8505a700-0acd-406d-9835-99e061658467", "requestId": 22, "timestamp": "1792110918060", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 47, "gameStateId": 29, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 29, "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "turnNumber": 1, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_SubmitDeckResp", "startingLifeTotal": 20 } ], "turnInfo": { "phase": "Phase_Main1", "turnNumber": 2, "activePlayer": 2, "priorityPlayer": 2, "decisionPlayer": 1, "nextPhase": "Phase_Combat", "nextStep": "Step_BeginCombat" }, "pendingMessageCount": 1, "prevGameStateId": 28, "timers": [ { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2618 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2618 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "elapsedSec": 5, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 5722 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ] } }, { "type": "GREMessageType_SubmitDeckReq", "systemSeatIds": [ 1 ], "msgId": 48, "gameStateId": 29, "submitDeckReq": { "deck": { "deckCards": [ 71860, 71860, 71860, 71860, 97170, 93986, 93986, 93986, 93986, 77498, 77498, 77498, 77498, 94278, 94278, 94278, 94278, 73266, 73266, 73266, 73266, 102507, 102507, 102507, 102507, 75662, 75662, 75662, 75662, 97169, 97169, 97169, 97169, 97169, 97169, 95614, 95614, 91788, 91788, 91788, 91788, 83950, 83950, 83950, 83950, 68740, 68740, 68740, 68740, 94351, 87448, 73474, 106251, 106251, 106251, 106251, 70335, 70335, 70335, 70335 ], "sideboardCards": [ 95614, 82853, 82853, 82853, 75118, 102775, 70205, 70205, 90718, 94877, 94877, 92204, 92204, 92407, 88998 ] } } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:19 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "44b53100-71b3-4ebd-8d05-ac474a01b6cb", "requestId": 10, "timestamp": "1792110918164", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 49, "gameStateId": 30, "gameStateMessage": { "type": "GameStateType_Diff", "gameStateId": 30, "players": [ { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_PendingLoss", "maxHandSize": 7, "turnNumber": 1, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_SubmitDeckResp", "startingLifeTotal": 20 } ], "turnInfo": { "phase": "Phase_Main1", "turnNumber": 2, "activePlayer": 2, "priorityPlayer": 2, "decisionPlayer": 2, "nextPhase": "Phase_Combat", "nextStep": "Step_BeginCombat" }, "prevGameStateId": 29, "timers": [ { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2722 }, { "timerId": 6, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 105 }, { "timerId": 9, "type": "TimerType_Epilogue", "durationSec": 145, "elapsedSec": 2, "running": true, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30, "elapsedMs": 2722 }, { "timerId": 13, "type": "TimerType_Inactivity", "durationSec": 150, "running": true, "behavior": "TimerBehavior_Timeout", "warningThresholdSec": 30, "elapsedMs": 1 } ], "update": "GameStateUpdate_SendAndRecord", "actions": [ { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 166, "manaCost": [ { "color": [ "ManaColor_Generic" ], "count": 2 }, { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Cast", "instanceId": 165, "manaCost": [ { "color": [ "ManaColor_Blue" ], "count": 1 } ] } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 167 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 170 } }, { "seatId": 1, "action": { "actionType": "ActionType_Play", "instanceId": 168 } }, { "seatId": 1, "action": { "actionType": "ActionType_Activate_Mana", "instanceId": 284, "abilityGrpId": 1002 } } ] } } ] } }
[UnityCrossThreadLogger]10/16/2026 12:35:24 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 23,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077242110190",
"transactionId": "b27f4de8-4b27-4a59-b324-482531ceef70",
"payload": {
"type": "ClientMessageType_SubmitDeckResp",
"gameStateId": 29,
"respId": 48,
"submitDeckResp": {
"deck": {
"deckCards": [
71860,
71860,
71860,
71860,
97170,
93986,
93986,
93986,
93986,
77498,
77498,
77498,
77498,
94278,
94278,
94278,
94278,
73266,
73266,
73266,
73266,
102507,
102507,
102507,
102507,
75662,
75662,
75662,
75662,
97169,
97169,
97169,
97169,
97169,
97169,
95614,
95614,
91788,
91788,
91788,
91788,
83950,
83950,
83950,
83950,
68740,
68740,
68740,
68740,
94351,
87448,
73474,
106251,
106251,
106251,
106251,
70335,
70335,
70335,
70335
],
"sideboardCards": [
95614,
82853,
82853,
82853,
75118,
102775,
70205,
70205,
90718,
94877,
94877,
92204,
92204,
92407,
88998
]
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:24 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "b27f4de8-4b27-4a59-b324-482531ceef70", "requestId": 23, "timestamp": "1792110924266", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SubmitDeckConfirmation", "systemSeatIds": [ 1 ], "msgId": 52, "gameStateId": 30 }, { "type": "GREMessageType_GameStateMessage", "systemSeatIds": [ 1 ], "msgId": 1, "gameStateId": 1, "gameStateMessage": { "type": "GameStateType_Full", "gameStateId": 1, "gameInfo": { "matchID": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3", "gameNumber": 2, "stage": "GameStage_Start", "type": "GameType_Duel", "variant": "GameVariant_Normal", "matchState": "MatchState_GameInProgress", "matchWinCondition": "MatchWinCondition_Best2of3", "maxTimeoutCount": 4, "maxPipCount": 3, "timeoutDurationSec": 30, "results": [ { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" } ], "superFormat": "SuperFormat_Constructed", "mulliganType": "MulliganType_London", "deckConstraintInfo": { "minDeckSize": 60, "maxDeckSize": 250, "maxSideboardSize": 15 } }, "teams": [ { "id": 1, "playerIds": [ 1 ], "status": "TeamStatus_InGame" }, { "id": 2, "playerIds": [ 2 ], "status": "TeamStatus_InGame" } ], "players": [ { "lifeTotal": 20, "systemSeatNumber": 1, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 1, "timerIds": [ 1, 2, 3, 4, 5, 6, 7 ], "controllerSeatId": 1, "controllerType": "ControllerType_Player", "startingLifeTotal": 20 }, { "lifeTotal": 20, "systemSeatNumber": 2, "status": "PlayerStatus_InGame", "maxHandSize": 7, "teamId": 2, "timerIds": [ 8, 9, 10, 11, 12, 13, 14 ], "controllerSeatId": 2, "controllerType": "ControllerType_Player", "pendingMessageType": "ClientMessageType_ChooseStartingPlayerResp", "startingLifeTotal": 20 } ], "turnInfo": { "decisionPlayer": 2 }, "zones": [ { "zoneId": 18, "type": "ZoneType_Revealed", "visibility": "Visibility_Public", "ownerSeatId": 1 }, { "zoneId": 19, "type": "ZoneType_Revealed", "visibility": "Visibility_Public", "ownerSeatId": 2 }, { "zoneId": 24, "type": "ZoneType_Suppressed", "visibility": "Visibility_Public" }, { "zoneId": 25, "type": "ZoneType_Pending", "visibility": "Visibility_Public" }, { "zoneId": 26, "type": "ZoneType_Command", "visibility": "Visibility_Public" }, { "zoneId": 27, "type": "ZoneType_Stack", "visibility": "Visibility_Public" }, { "zoneId": 28, "type": "ZoneType_Battlefield", "visibility": "Visibility_Public" }, { "zoneId": 29, "type": "ZoneType_Exile", "visibility": "Visibility_Public" }, { "zoneId": 30, "type": "ZoneType_Limbo", "visibility": "Visibility_Public" }, { "zoneId": 31, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 1, "viewers": [ 1 ] }, { "zoneId": 32, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 1, "objectInstanceIds": [ 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223 ] }, { "zoneId": 33, "type": "ZoneType_Graveyard", "visibility": "Visibility_Public", "ownerSeatId": 1 }, { "zoneId": 34, "type": "ZoneType_Sideboard", "visibility": "Visibility_Private", "ownerSeatId": 1, "viewers": [ 1 ] }, { "zoneId": 35, "type": "ZoneType_Hand", "visibility": "Visibility_Private", "ownerSeatId": 2, "viewers": [ 2 ] }, { "zoneId": 36, "type": "ZoneType_Library", "visibility": "Visibility_Hidden", "ownerSeatId": 2, "objectInstanceIds": [ 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283 ] }, { "zoneId": 37, "type": "ZoneType_Graveyard", "visibility": "Visibility_Public", "ownerSeatId": 2 }, { "zoneId": 38, "type": "ZoneType_Sideboard", "visibility": "Visibility_Private", "ownerSeatId": 2, "viewers": [ 2 ] } ], "diffDeletedInstanceIds": [ 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 141, 143, 145, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163 ], "timers": [ { "timerId": 1, "type": "TimerType_Prologue", "durationSec": 120, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 2, "type": "TimerType_Epilogue", "durationSec": 145, "behavior": "TimerBehavior_TakeControl", "warningThresholdSec": 30 }, { "timerId": 3, "type": "TimerType_ActivePLine truncated
Can't add component to object that is being destroyed.
Can't add component to object that is being destroyed.
OnSceneUnloaded for DuelScene
OnSceneLoaded for PreGameScene
OnSceneLoaded for DuelScene
[UnityCrossThreadLogger]10/16/2026 12:35:26 AM: LOCALPLAYERID0000000000000 to Match: ClientToGremessage
{
"requestId": 24,
"clientToMatchServiceMessageType": "ClientToMatchServiceMessageType_ClientToGREMessage",
"timestamp": "639277077265180717",
"transactionId": "b1b6802c-df3c-46c6-a884-ca2ca85417d1",
"payload": {
"type": "ClientMessageType_SetSettingsReq",
"setSettingsReq": {
"settings": {
"autoPassOption": "AutoPassOption_ResolveMyStackEffects",
"manaSelectionType": "ManaSelectionType_Auto",
"defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects",
"autoSelectReplacementSetting": "Setting_Enable"
}
}
}
}
[UnityCrossThreadLogger]10/16/2026 12:35:26 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "b1b6802c-df3c-46c6-a884-ca2ca85417d1", "requestId": 24, "timestamp": "1792110926563", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 4, "gameStateId": 1, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_ResolveMyStackEffects", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { Line truncated
[UnityCrossThreadLogger]10/16/2026 12:35:28 AM: Match to LOCALPLAYERID0000000000000: GreToClientEvent
{ "transactionId": "84c23c13-3d3a-4800-98f0-86c88a243951", "requestId": 13, "timestamp": "1792110928334", "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_SetSettingsResp", "systemSeatIds": [ 1 ], "msgId": 5, "gameStateId": 1, "setSettingsResp": { "settings": { "stops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_PostcombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Set" }, { "stopType": "StopType_FirstStrikeDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Set" } ], "autoPassOption": "AutoPassOption_Clear", "graveyardOrder": "OrderingType_OrderArbitraryAlways", "manaSelectionType": "ManaSelectionType_Auto", "defaultAutoPassOption": "AutoPassOption_ResolveMyStackEffects", "smartStopsSetting": "SmartStopsSetting_Enable", "autoTapStopsSetting": "AutoTapStopsSetting_Enable", "autoOptionalPaymentCancellationSetting": "Setting_Enable", "transientStops": [ { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_UpkeepStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DrawStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_PrecombatMainPhase", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_BeginCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareAttackersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_DeclareBlockersStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_CombatDamageStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Team", "status": "SettingStatus_Clear" }, { "stopType": "StopType_EndCombatStep", "appliesTo": "SettingScope_Opponents", "status": "SettingStatus_Clear" }, { "stopType": "StoLine truncated
[UnityCrossThreadLogger]10/16/2026 12:35:28 AM: Match to LOCALPLAYERID0000000000000: MatchGameRoomStateChangedEvent
{ "transactionId": "84c23c13-3d3a-4800-98f0-86c88a243951", "requestId": 13, "timestamp": "1792110928334", "matchGameRoomStateChangedEvent": { "gameRoomInfo": { "gameRoomConfig": { "reservedPlayers": [ { "userId": "LOCALPLAYERID0000000000000", "playerName":"Local Player", "systemSeatId": 1, "teamId": 1, "courseId": "Avatar_Basic_BilboRiddles_HOB", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "SteamWindows", "eventId": "Traditional_Historic_Play" }, { "userId": "OPPONENTID000000", "playerName":"Opponent Player", "systemSeatId": 2, "teamId": 2, "courseId": "Avatar_Basic_Adventurer", "sessionId": "00000000-0000-0000-0000-000000000000", "platformId": "Windows", "eventId": "Traditional_Historic_Play" } ], "matchId": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3" }, "stateType": "MatchGameRoomStateType_MatchCompleted", "finalMatchResult": { "matchId": "c0a18506-9d87-4d0c-a2d3-e10bef8659d3", "matchCompletedReason": "MatchCompletedReasonType_Success", "resultList": [ { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" }, { "scope": "MatchScope_Game", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" }, { "scope": "MatchScope_Match", "result": "ResultType_WinLoss", "winningTeamId": 1, "reason": "ResultReason_Concede" } ] } } } }
[UnityCrossThreadLogger]STATE CHANGED {"old":"Playing","new":"MatchCompleted"}
OnSceneUnloaded for PreGameScene
Unloading 2 Unused Serialized files (Serialized files now loaded: 528)
+50
View File
@@ -0,0 +1,50 @@
import pytest
from mtga_assistant import paths
from mtga_assistant.carddb import CardDB, mana_value, parse_mana
@pytest.mark.parametrize(
"text,symbols,mv",
[
("o2oU", ["2", "U"], 3),
("oW", ["W"], 1),
("", [], 0),
("o1o(R/G)", ["1", "R/G"], 2),
("o1o(B/P)o(B/P)", ["1", "B/P", "B/P"], 3),
("o(2/B)o(2/R)", ["2/B", "2/R"], 4),
("oXoXoR", ["X", "X", "R"], 1),
("o10", ["10"], 10),
],
)
def test_parse_mana(text, symbols, mv):
assert parse_mana(text) == symbols
assert mana_value(symbols) == mv
def test_missing_db_gives_placeholder():
db = CardDB(None)
assert not db.available
assert db.get(123).name == "Unknown card #123"
assert db.base_grp(123) == 123
@pytest.fixture(scope="module")
def real_db():
mtga = paths.find_mtga_dir(paths.load_config())
path = paths.find_card_db(mtga) if mtga else None
if path is None:
pytest.skip("MTGA is not installed on this machine")
return CardDB(path)
def test_real_db_lookup(real_db):
island = real_db.get(97169)
assert island.name == "Island"
assert island.is_land
def test_real_db_split_card_half_maps_to_whole(real_db):
# Desist (half) -> Cease // Desist
assert real_db.base_grp(89160) == 89158
assert real_db.base_grp(89158) == 89158
+196
View File
@@ -0,0 +1,196 @@
from pathlib import Path
from mtga_assistant.carddb import CardInfo
from mtga_assistant.gamestate import MatchTracker
from mtga_assistant.logparse import GreMessages, LogParser
FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log"
ME, OPP = 1, 2
# Zone ids as MTGA assigns them for a two-player game.
BATTLEFIELD, EXILE, LIMBO = 28, 29, 30
MY_HAND, MY_LIB, MY_GY = 31, 32, 33
OPP_HAND, OPP_LIB, OPP_GY = 35, 36, 37
def replay(path: Path, tracker: MatchTracker, check=None) -> None:
p = LogParser()
with open(path, encoding="utf-8") as f:
for line in f:
for ev in p.feed(line):
tracker.handle(ev)
if check and isinstance(ev, GreMessages):
check(tracker)
def test_fixture_match_lifecycle():
t = MatchTracker()
replay(FIXTURE, t)
assert t.my_seat == ME
assert t.opponent_name == "Opponent Player"
assert t.game is not None and t.game.game_number == 2
assert t.game_results == ["Won", "Won"]
assert t.last_result == "Won"
assert not t.in_match
assert t.deck_name == "Cruel Tutelage"
def test_fixture_library_count_matches_library_zone():
checked = 0
def check(t: MatchTracker) -> None:
nonlocal checked
g = t.game
if g is None or not g.zones or g.desynced:
return
assert sum(t.my_library_remaining().values()) == g.zone_size("ZoneType_Library", ME)
checked += 1
replay(FIXTURE, MatchTracker(), check)
assert checked >= 15
# -- synthetic GRE messages for opponent knowledge ------------------------------
def gsm(state_id: int, *, zones=(), objects=(), annotations=(), deleted=(), full=False) -> dict:
msg = {
"type": "GameStateType_Full" if full else "GameStateType_Diff",
"gameStateId": state_id,
"zones": list(zones),
"gameObjects": list(objects),
"annotations": list(annotations),
"diffDeletedInstanceIds": list(deleted),
}
if not full:
msg["prevGameStateId"] = state_id - 1
return {"type": "GREMessageType_GameStateMessage", "gameStateMessage": msg}
def zone(zid: int, ztype: str, ids, owner=None) -> dict:
z = {"zoneId": zid, "type": f"ZoneType_{ztype}", "objectInstanceIds": list(ids)}
if owner is not None:
z["ownerSeatId"] = owner
return z
def card(iid: int, grp: int, zid: int, owner=OPP, otype="Card") -> dict:
return {"instanceId": iid, "grpId": grp, "type": f"GameObjectType_{otype}", "zoneId": zid,
"ownerSeatId": owner, "controllerSeatId": owner}
def id_changed(orig: int, new: int) -> dict:
return {"type": ["AnnotationType_ObjectIdChanged"], "details": [
{"key": "orig_id", "valueInt32": [orig]}, {"key": "new_id", "valueInt32": [new]}]}
def shuffle(old: list[int], new: list[int]) -> dict:
return {"type": ["AnnotationType_Shuffle"], "details": [
{"key": "OldIds", "valueInt32": old}, {"key": "NewIds", "valueInt32": new}]}
def start_game(t: MatchTracker, deck=(1001,) * 4) -> None:
t.handle(GreMessages([{
"type": "GREMessageType_ConnectResp", "systemSeatIds": [ME],
"connectResp": {"deckMessage": {"deckCards": list(deck), "sideboardCards": []}},
}]))
t.handle(GreMessages([gsm(1, full=True, zones=[
zone(BATTLEFIELD, "Battlefield", []), zone(LIMBO, "Limbo", []),
zone(MY_HAND, "Hand", [], ME), zone(MY_LIB, "Library", range(100, 100 + len(deck)), ME),
zone(OPP_HAND, "Hand", [200, 201], OPP), zone(OPP_LIB, "Library", [300, 301, 302], OPP),
zone(OPP_GY, "Graveyard", [], OPP),
])]))
def test_bounced_card_is_known_in_opponent_hand():
t = MatchTracker()
start_game(t)
# Opponent casts card 200 -> it resolves onto the battlefield as instance 210.
t.handle(GreMessages([gsm(2, annotations=[id_changed(200, 210)], zones=[
zone(OPP_HAND, "Hand", [201], OPP), zone(BATTLEFIELD, "Battlefield", [210]),
], objects=[card(210, 5005, BATTLEFIELD)])]))
g = t.game
assert g.cards_in("ZoneType_Battlefield", controller=OPP) == {5005: 1}
# It gets bounced: new id 220, back in the hidden hand, no object sent for it.
t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[
zone(OPP_HAND, "Hand", [201, 220], OPP), zone(BATTLEFIELD, "Battlefield", []),
], deleted=[210])]))
assert g.opp_known_in("ZoneType_Hand") == {5005: 1}
assert not g.cards_in("ZoneType_Battlefield", controller=OPP)
assert t.opp_seen_match == {5005: 1} # same physical card, counted once
def test_known_card_survives_library_shuffle():
t = MatchTracker()
start_game(t)
g = t.game
# A card is revealed on top of their library (e.g. by a "reveal the top card" effect).
t.handle(GreMessages([gsm(2, objects=[card(300, 6006, OPP_LIB)])]))
assert g.opp_known_in("ZoneType_Library") == {6006: 1}
t.handle(GreMessages([gsm(3, annotations=[shuffle([300, 301, 302], [310, 311, 312])],
zones=[zone(OPP_LIB, "Library", [310, 311, 312], OPP)], deleted=[300])]))
assert g.opp_known_in("ZoneType_Library") == {6006: 1}
# Later it is drawn and played: no longer "known in library".
t.handle(GreMessages([gsm(4, zones=[zone(OPP_LIB, "Library", [310, 311], OPP),
zone(BATTLEFIELD, "Battlefield", [320])],
objects=[card(320, 6006, BATTLEFIELD)])]))
assert g.opp_known_in("ZoneType_Library") == {}
def test_revealed_card_in_hand():
t = MatchTracker()
start_game(t)
t.handle(GreMessages([gsm(2, objects=[card(400, 7007, OPP_HAND, otype="RevealedCard")])]))
assert t.game.opp_known_in("ZoneType_Hand") == {7007: 1}
assert t.opp_seen_match == {7007: 1}
def test_tokens_do_not_count_as_my_cards():
t = MatchTracker()
start_game(t, deck=(1001,) * 4)
t.handle(GreMessages([gsm(2, annotations=[id_changed(100, 150)], zones=[
zone(MY_LIB, "Library", [101, 102, 103], ME), zone(BATTLEFIELD, "Battlefield", [150, 151]),
], objects=[card(150, 1001, BATTLEFIELD, owner=ME), card(151, 9999, BATTLEFIELD, owner=ME, otype="Token")])]))
assert t.my_library_remaining() == {1001: 3}
class FakeCards:
def __init__(self, base: dict[int, int]):
self.base = base
def get(self, grp):
return CardInfo(grp_id=grp, name=str(grp))
def base_grp(self, grp):
return self.base.get(grp, grp)
def test_transformed_card_maps_back_to_deck_entry():
t = MatchTracker(card_lookup=FakeCards({1002: 1001})) # 1002 is 1001's back face
start_game(t, deck=(1001,) * 2)
t.handle(GreMessages([gsm(2, annotations=[id_changed(100, 150)], zones=[
zone(MY_LIB, "Library", [101], ME), zone(BATTLEFIELD, "Battlefield", [150]),
], objects=[card(150, 1002, BATTLEFIELD, owner=ME)])]))
assert t.my_library_remaining() == {1001: 1}
def test_gap_in_state_ids_marks_desync():
t = MatchTracker()
start_game(t)
t.handle(GreMessages([gsm(2)]))
assert not t.game.desynced
t.handle(GreMessages([gsm(5)])) # prevGameStateId 4 never seen
assert t.game.desynced
def test_object_logged_before_its_id_change_is_one_card():
t = MatchTracker()
start_game(t)
t.handle(GreMessages([gsm(2, objects=[card(210, 5005, BATTLEFIELD)])]))
# The new object shows up one message before the annotation linking it to its old id.
t.handle(GreMessages([gsm(3, objects=[card(220, 5005, BATTLEFIELD)])]))
t.handle(GreMessages([gsm(4, annotations=[id_changed(210, 220)])]))
assert t.opp_seen_match == {5005: 1}
+65
View File
@@ -0,0 +1,65 @@
from collections import Counter
from pathlib import Path
from mtga_assistant.logparse import ClientDeck, DeckSubmitted, GreMessages, LogParser, MatchRoom
FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log"
def parse_file(path: Path) -> list:
p = LogParser()
out = []
with open(path, encoding="utf-8") as f:
for line in f:
out.extend(p.feed(line))
return out
def test_fixture_event_kinds():
events = parse_file(FIXTURE)
kinds = Counter(type(e).__name__ for e in events)
assert kinds["MatchRoom"] == 2
assert kinds["DeckSubmitted"] == 1
assert kinds["ClientDeck"] == 1
assert kinds["GreMessages"] >= 15
rooms = [e for e in events if isinstance(e, MatchRoom)]
assert rooms[0].state == "MatchGameRoomStateType_Playing"
assert rooms[1].state == "MatchGameRoomStateType_MatchCompleted"
assert {p["playerName"] for p in rooms[0].players} == {"Local Player", "Opponent Player"}
def test_deck_submitted():
deck = next(e for e in parse_file(FIXTURE) if isinstance(e, DeckSubmitted))
assert deck.name == "Cruel Tutelage"
assert sum(deck.main.values()) == 60
assert sum(deck.sideboard.values()) == 15
def test_client_deck_from_pretty_printed_body():
deck = next(e for e in parse_file(FIXTURE) if isinstance(e, ClientDeck))
assert sum(deck.main.values()) == 60
assert sum(deck.sideboard.values()) == 15
def test_single_line_gre_event():
p = LogParser()
assert p.feed("[UnityCrossThreadLogger]10/16/2026 12:34:51 AM: Match to X: GreToClientEvent\n") == []
evs = p.feed('{ "greToClientEvent": { "greToClientMessages": [ { "type": "GREMessageType_UIMessage" } ] } }\n')
assert len(evs) == 1 and isinstance(evs[0], GreMessages)
def test_multiline_body_and_abandoned_fragment():
p = LogParser()
assert p.feed("{\n") == []
assert p.feed(' "payload": { "type": "Other" },\n') == []
# A new log entry starts before the body closed: the fragment is dropped.
assert p.feed("[UnityCrossThreadLogger]something\n") == []
evs = p.feed('{ "greToClientEvent": { "greToClientMessages": [ { "type": "T" } ] } }\n')
assert len(evs) == 1
def test_non_json_lines_ignored():
p = LogParser()
for line in ["OnSceneLoaded for DuelScene", "[UnityCrossThreadLogger]STATE CHANGED {\"old\":1}", ""]:
assert p.feed(line) == []
Generated
+110
View File
@@ -0,0 +1,110 @@
version = 1
revision = 5
requires-python = ">=3.12"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "mtga-assistant"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pyside6-essentials" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "pyside6-essentials", specifier = ">=6.8" }]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "shiboken6"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" },
{ url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" },
]