diff --git a/fifa17-recon/tools/autopatch.py b/fifa17-recon/tools/autopatch.py old mode 100644 new mode 100755 index 9bb8b16..49dcf62 --- a/fifa17-recon/tools/autopatch.py +++ b/fifa17-recon/tools/autopatch.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches.""" -import glob, time, struct +import glob, time, struct, sys # Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches import glob, time, os @@ -24,7 +24,7 @@ STORE_PATCHES = { 0x1800175aa: NOP2, } -LOG="/tmp/autopatch.log" +LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log") def log(m): line=f"[{time.strftime('%H:%M:%S')}] {m}" @@ -55,8 +55,16 @@ def wr(pid,va,b): patched=set() store_patched=set() +launcher_pid = None +if "--launcher-pid" in sys.argv: + try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1]) + except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid") + log("=== AUTOPATCH watching for FIFA17.exe ===") while True: + if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"): + log(f"launcher pid {launcher_pid} exited; stopping autopatch") + break for pid in find_pids(): if pid not in patched: try: diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py index c4b012a..610a6f0 100644 --- a/fifa17-recon/tools/blaze_responder_v3b.py +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -526,6 +526,7 @@ OSDK_TICKER = [] # Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject # http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK. ROSTER_HOST = "127.0.0.1:8081" +POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080") OSDK_ROSTER = [ ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), ("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0 @@ -562,7 +563,6 @@ IDENTITY_PARAMS = [ # FUT_POW=1 ./openfut-fut.sh restart # and read /tmp/pow_server.log. FUT_POW=off is the instant fallback. POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094") -POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080") _POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes") OSDK_POW = [ ("FIFA_POW_URL", "http://%s/" % POW_HOST), @@ -571,6 +571,14 @@ OSDK_POW = [ ("POW_IS_ON", "1"), ] if _POW_ON else [] +# CardsDLL's shared web-file downloader also reads this key for FUT-owned content. +# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions..xml +# after /sbs/sets succeeds. Keep the content base available even while the unrelated +# POW API remains opt-in through FUT_POW/POW_IS_ON. +FUT_CONTENT_CONFIG = [ + ("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST), +] + CLIENT_CONFIGS = { "BlazeSDK": None, # built dynamically, see below "netres": OSDK_NETRES, # CFID (verified @0x143962be0) @@ -741,8 +749,9 @@ def client_config_for(cfid: str) -> list: # this is a no-op by default. (Putting the keys ONLY under a hypothetical # "OSDK_POW" CFID would be dead code -- nothing is known to request that name.) if cfid == "BlazeSDK": - return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW) - return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW) + return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW) + return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + + FUT_CONTENT_CONFIG + OSDK_POW) def fetch_config_response_fields(cfid: str) -> "OrderedDict": diff --git a/fifa17-recon/tools/fifa17-hook-m1.sh b/fifa17-recon/tools/fifa17-hook-m1.sh index 914fe00..ab57506 100755 --- a/fifa17-recon/tools/fifa17-hook-m1.sh +++ b/fifa17-recon/tools/fifa17-hook-m1.sh @@ -158,6 +158,7 @@ launch() { local trace_enabled=0 local request_trace_enabled=0 local notifier_trace_enabled=0 + local commit_enabled=0 case "$mode" in baseline) [[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] || @@ -176,6 +177,15 @@ launch() { request_trace_enabled=1 notifier_trace_enabled=1 ;; + commit) + [[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] || + die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE" + hook_enabled=1 + trace_enabled=1 + request_trace_enabled=1 + notifier_trace_enabled=1 + commit_enabled=1 + ;; *) die "unknown launch mode: $mode" ;; esac need_file "$deployed_dll" @@ -192,12 +202,12 @@ launch() { [[ "$(sha256 "$deployed_dll")" == "$recorded" ]] || die "deployed version.dll does not match the staged M1 artifact" command -v umu-run >/dev/null || die "umu-run is required" - for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_COMMIT OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do + for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do [[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch" done mkdir -p "${wine_prefix}/dosdevices" ln -sfn /mnt "${wine_prefix}/dosdevices/w:" - note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; every mutation feature disabled); log=/tmp/fifa17-hook-m1-launch.log" + note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log" cd "$game_dir" env \ GAMEID=fifa17 \ @@ -209,7 +219,7 @@ launch() { OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \ OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \ OPENFUT_SBC_DISPATCH=0 \ - OPENFUT_SBC_COMMIT=0 \ + OPENFUT_SBC_COMMIT="$commit_enabled" \ OPENFUT_SBC_ARM_ONLY=0 \ OPENFUT_SBC_POPULATE=0 \ umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log @@ -217,7 +227,7 @@ launch() { usage() { cat <<'EOF' -Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace] +Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit] inspect Read-only PE/hash/export preflight (default). build Cross-build the inert FIFA17 hook, then run inspect. @@ -232,6 +242,9 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc launch-trace Start the single M3 passive factory/deserializer trace; requires: OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE + launch-commit + Trace and arm the SBC cache only after a validated native parse; requires: + OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE Optional path overrides: OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR, @@ -247,6 +260,7 @@ case "${1:-inspect}" in launch) launch baseline ;; launch-resolve) launch resolve ;; launch-trace) launch trace ;; + launch-commit) launch commit ;; -h|--help|help) usage ;; *) usage >&2; die "unknown command: $1" ;; esac diff --git a/fifa17-recon/tools/fut_accounts.py b/fifa17-recon/tools/fut_accounts.py new file mode 100644 index 0000000..317990f --- /dev/null +++ b/fifa17-recon/tools/fut_accounts.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Launcher-to-server active-account selection for the single-player stack.""" +import json +import os + +from fut_account import ACCOUNT +from fut_store import STORE, profile_path_for + + +def _existing_identity(persona_id): + path = profile_path_for(persona_id) + try: + with open(path) as f: + profile = json.load(f) + except (OSError, ValueError): + return {} + if not isinstance(profile, dict): + return {} + return { + "club_name": profile.get("clubName"), + "club_abbr": profile.get("clubAbbr"), + "established": profile.get("established"), + "pow_level": profile.get("powLevel"), + "pow_exp": profile.get("powExp"), + "pow_exp_max": profile.get("powExpMax"), + "pow_funds": profile.get("powFunds"), + "pow_funds_cap": profile.get("powFundsCap"), + } + + +def activate(payload): + """Select/create one persistent profile and publish it to all responders.""" + if not isinstance(payload, dict): + raise ValueError("account payload must be an object") + try: + persona_id = int(payload.get("personaId")) + except (TypeError, ValueError): + raise ValueError("personaId must be a positive integer") from None + persona_name = payload.get("personaName") + if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip(): + raise ValueError("personaId must be positive and personaName must not be empty") + + values = _existing_identity(persona_id) + values.update(persona_id=persona_id, persona_name=persona_name.strip()) + for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"), + ("established", "established"), ("squadName", "squad_name"), + ("level", "pow_level"), ("experience", "pow_exp"), + ("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"), + ("accountFundsCap", "pow_funds_cap")): + if payload.get(wire) not in (None, ""): + values[field] = payload[wire] + + ACCOUNT.replace(values) + ACCOUNT.set_online_profile() + ACCOUNT.save() + profile = STORE.select_account(persona_id) + STORE.ensure_security_question() + return { + "personaId": ACCOUNT.persona_id, + "personaName": ACCOUNT.persona_name, + "clubName": ACCOUNT.club_name, + "clubAbbr": ACCOUNT.club_abbr, + "level": ACCOUNT.pow_level, + "experience": ACCOUNT.pow_exp, + "experienceMax": ACCOUNT.pow_exp_max, + "accountFunds": ACCOUNT.pow_funds, + "accountFundsCap": ACCOUNT.pow_funds_cap, + "profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)), + "coins": profile.get("coins", 0), + "unopenedPacks": len(profile.get("unopenedPackIds", [])), + } \ No newline at end of file diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index 3aac2c5..5437603 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -226,6 +226,56 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00 # the club showing different numbers for the same card. +SPECIAL_CARD_TYPES = { + # name: (rareflag, revision byte, rating/attribute boost, selection weight) + # rareflag names come from FIFA 17's ItemRareType enum. Revisions are local, + # stable identities; the client resolves the footballer from the low 24 bits. + "TOTW": (3, 1, 2, 34), + "PURPLE": (4, 2, 3, 7), + "TOTY": (5, 3, 6, 3), + "RECORD_BREAKER": (6, 4, 5, 2), + "TOTS": (11, 5, 5, 7), + "OTW": (21, 6, 2, 14), + "HALLOWEEN": (22, 7, 3, 8), + "MOVEMBER": (23, 8, 3, 8), + "SBC": (24, 9, 4, 17), +} + + +def choose_special_type(player, rng=None): + """Choose a rating-appropriate FIFA 17 promo family for one pool row.""" + import random + rng = rng or random + rating = player[1] + eligible = [] + for name, spec in SPECIAL_CARD_TYPES.items(): + if name in ("TOTY", "RECORD_BREAKER") and rating < 85: + continue + if name == "TOTS" and rating < 75: + continue + eligible.append((name, spec[3])) + names, weights = zip(*eligible) + return rng.choices(names, weights=weights, k=1)[0] + + +def player_item(item_id, player, special=False): + """Build a base or named FIFA 17 special revision from a pool row. + + `special=True` remains supported and chooses a weighted eligible family; + callers and tests may also pass an explicit name such as ``"TOTY"``. + """ + asset, rating, pos, nation, league, team, attrs = player + if special: + special_name = choose_special_type(player) if special is True else special + rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name] + rating = min(99, rating + boost) + attrs = [min(99, value + boost) for value in attrs] + else: + rareflag, version = 1, 0 + return _item(item_id, asset, rating, pos, nation, league, team, attrs, + version=version, rareflag=rareflag) + + # FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS # the same number the server pays. # @@ -293,6 +343,10 @@ def _new_profile(): "purchased": [], # unassigned/pending items from opened packs "squads": [], # saved squads (raw squad objects from PUT /squad) "packsOpened": 0, + # Owned reward packs are separate from purchased items. Pack 70 is a + # one-time migration grant used to bring the retail My Packs flow online. + "unopenedPackIds": [70], + "unopenedSeeded": True, } @@ -311,6 +365,10 @@ class Store: self._p = _new_profile() self._sync_identity() self._save() + if not self._p.get("unopenedSeeded"): + self._p.setdefault("unopenedPackIds", []).append(70) + self._p["unopenedSeeded"] = True + self._save() self._sync_identity() return self._p @@ -513,6 +571,32 @@ class Store: sq = self.load()["squads"] return sq[0] if sq else None + def unopened_packs(self): + """Owned reward-pack template IDs, including repeated grants.""" + return list(self.load().get("unopenedPackIds", [])) + + def consume_unopened_pack(self, pack_id): + """Atomically consume one owned instance of a reward pack.""" + with _LOCK: + p = self.load() + owned = p.setdefault("unopenedPackIds", []) + try: + owned.remove(pack_id) + except ValueError: + return False + self._save() + return True + + def grant_unopened_pack(self, pack_id): + """Persist one additional owned reward-pack instance.""" + if pack_by_id(pack_id) is None: + return False + with _LOCK: + p = self.load() + p.setdefault("unopenedPackIds", []).append(pack_id) + self._save() + return True + def reconstruct_squad(self, squad): """FIFA's updateActiveSquad PUT stores each slot as itemData={id:} (a reference). Re-embed the FULL club item by id so the squad reloads with @@ -557,7 +641,8 @@ class Store: return i - def open_pack(self, price, count, gold=True, tiers=None): + def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0, + players_only=False): """Deduct `price` coins, generate `count` player items from the pool, and place them in the PENDING purchased pile (unassigned). They are NOT owned club items until moved there via FutMoveCard (PUT /item). Returns None if @@ -579,19 +664,31 @@ class Store: # fixed number so it scales from a 5-card bronze to an 11-card premium. n_extra = 0 extras = [] - if PACK_MIX and count >= 5: + if PACK_MIX and not players_only and count >= 5: n_extra = max(1, count // 4) extras = _pack_extras(n_extra, self) n_extra = len(extras) n_players = max(1, count - n_extra) if tiers: - picks = [random.choice(fut_cards.pool_for(random.choice(tiers))) - for _ in range(n_players)] + # Draw each tier independently but reject duplicate asset IDs inside + # one pack. The real pool is large enough that this normally succeeds + # on the first attempt; the cap makes malformed tiny test pools safe. + picks = [] + used_assets = set() + for _ in range(n_players): + tier_pool = fut_cards.pool_for(random.choice(tiers)) + available = [p for p in tier_pool if p[0] not in used_assets] + pick = random.choice(available or tier_pool) + picks.append(pick) + used_assets.add(pick[0]) else: pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL - picks = [random.choice(pool) for _ in range(n_players)] - items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at) - for (a, r, p, n, lg, tm, at) in picks] + picks = random.sample(pool, min(n_players, len(pool))) + while len(picks) < n_players: + picks.append(random.choice(pool)) + items = [player_item(self.new_item_id(), pick, + special=random.random() < special_chance) + for pick in picks] items += extras random.shuffle(items) with _LOCK: @@ -677,11 +774,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [ # no silver or bronze players at all, so all three packs were identical in practice. PACK_CATALOG = [ {"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False, - "tiers": ["bronze"] * 8 + ["silver"] * 2}, + "tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005}, {"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True, - "tiers": ["gold"] * 6 + ["silver"] * 4}, + "tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03}, {"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True, - "tiers": ["gold"] * 9 + ["silver"] * 1}, + "tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08}, + {"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11, + "gold": True, "tiers": ["gold"], "specialChance": 1.0, + "playersOnly": True}, + {"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11, + "gold": True, "tiers": ["gold"], "specialChance": 1.0, + "playersOnly": True, "ownedOnly": True}, ] diff --git a/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_1.py b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_1.py new file mode 100644 index 0000000..0b827d9 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_1.py @@ -0,0 +1,25 @@ +"""Trace FutPurchaseDraftModeServerResponse beyond its known seven-int parser.""" + +cls = "FutPurchaseDraftModeServerResponse" +print("CLASS", cls, class_deser(cls)) + +seen = set() +for deser, vt, factory in class_deser(cls): + print("\nVTABLE", hex(vt), "FACTORY", hex(factory), "DESER", hex(deser)) + print(vtable(vt, 32)) + for target in [factory, deser] + [t for _, t, name in vtable(vt, 32) if name]: + if target in seen: + continue + seen.add(target) + print("\n===", hex(target), fname(target), "===") + print(dec(target, 300)) + print("XREFS", xrefs_to(target)[:100]) + +for target in (0x18014C090, 0x18014C260, 0x18014C820, 0x18014C8A0): + if target in seen: + continue + print("\n=== CANDIDATE", hex(target), fname(target), "===") + print(dec(target, 300)) + print("XREFS", xrefs_to(target)[:100]) + +print("QUERY_DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_2.py b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_2.py new file mode 100644 index 0000000..154b76a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_2.py @@ -0,0 +1,22 @@ +"""Bind the live draft-purchase URI builder to one of its two response factories.""" + +for literal in ( + "purchase/mode/", + "/purchase/mode/", + "draft", + "ut/%s/draft/mode", + "FutPurchaseDraftModeServerResponse", +): + print("\nLITERAL", repr(literal)) + for hit in find_all(literal.encode() + b"\x00"): + print(hex(hit), rd_str(hit), xrefs_to(hit)[:100]) + +for target in (0x180224EF8, 0x1802262F0, 0x18014C090, 0x180150260): + print("\nTARGET", hex(target), fname(target)) + print("XREFS", xrefs_to(target)[:200]) + for frm, typ, fn, ent in xrefs_to(target): + if ent: + print("\nOWNER", hex(ent), fn) + print(dec(ent, 300)) + +print("QUERY_DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_3.py b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_3.py new file mode 100644 index 0000000..b2ab677 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_3.py @@ -0,0 +1,16 @@ +"""Dump both request vtables sharing FutPurchaseDraftModeServerResponse.""" + +for vt in (0x180226300, 0x180224F08): + print("\nREQUEST_VTABLE", hex(vt)) + rows = vtable(vt, 40) + print(rows) + seen = set() + for off, target, name in rows: + if not name or target in seen: + continue + seen.add(target) + print("\n=== SLOT", hex(off), hex(target), name, "===") + print(dec(target, 300)) + print("XREFS", xrefs_to(target)[:100]) + +print("QUERY_DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_4.py b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_4.py new file mode 100644 index 0000000..0a9dbd1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_draft_purchase_4.py @@ -0,0 +1,23 @@ +"""Recover the JSON element shape consumed by the array-root draft response.""" + +targets = ( + 0x180138BD0, # helper called once per array element + 0x180150310, # array-root FutPurchaseDraftModeServerResponse parser +) + +seen = set() +for target in targets: + print("\n=== TARGET", hex(target), fname(target), "===") + print(dec(target, 500)) + print("XREFS", xrefs_to(target)[:150]) + + # Include direct callees so small string/value accessors used by the helper + # are visible without broad, noisy whole-program searching. + for callee, name in callees(target): + if callee in seen: + continue + seen.add(callee) + print("\n--- CALLEE", hex(callee), name, "---") + print(dec(callee, 250)) + +print("QUERY_DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_md_draft_state_1.py b/fifa17-recon/tools/ghidra_queries/q_md_draft_state_1.py new file mode 100644 index 0000000..512738b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_draft_state_1.py @@ -0,0 +1,17 @@ +"""Trace active draft-state enum literals and the current-state response consumers.""" + +for literal in ("DRAFTSQUAD_ON", "DRAFTSQUAD_OFF", "DRAFT_SQUAD", "squadState", + "stateParam1", "stateParam2", "roundsInfo"): + print("\n=== LITERAL", literal, "===") + for hit in find_all(literal.encode() + b"\x00", (".rdata", ".data")): + print("HIT", hex(hit), "XREFS", xrefs_to(hit)[:100]) + for _frm, _typ, _name, entry in xrefs_to(hit): + print("\n--- XREF FUNCTION", hex(entry), fname(entry), "---") + print(dec(entry, 500)) + +for target in (0x180147070,): + print("\n=== STATE DESERIALIZER", hex(target), fname(target), "===") + print(dec(target, 500)) + print("CALLERS", callers(target)) + +print("QUERY_DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_md_draft_state_2.py b/fifa17-recon/tools/ghidra_queries/q_md_draft_state_2.py new file mode 100644 index 0000000..f7957f8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_draft_state_2.py @@ -0,0 +1,21 @@ +"""Resolve draft-state atom IDs to their authoritative wire strings.""" + +ATOM_TABLE = 0x1802D2760 + +def atom_name(index): + pointer = qword(ATOM_TABLE + index * 8) + return rd_str(pointer, 96) + +groups = { + "squadState values": (0x1AC, 0x6A, 0x9C, 0x12C, 0x169, 0x225, 0x23E, 0x277, 0x278), + "stateParam1 values": (0x169, 0x1AA, 0x22D), + "entranceCriteria keys": (0x96, 0xDF, 0x241), + "top-level keys": (0x108, 0x13B, 0x293, 0x2CD, 0x2D5, 0x2EE, 0x2EF), +} + +for group, indices in groups.items(): + print("\n===", group, "===") + for index in indices: + print(hex(index), repr(atom_name(index))) + +print("QUERY_DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_10.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_10.py new file mode 100644 index 0000000..11db389 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_10.py @@ -0,0 +1,50 @@ +"""Resolve the concrete owner behind request+0x08 for the SBC category request. + +q_md_sbc_9 proved generic slot +0x88 (0x1801631e0) invokes: + owner = *(request + 8) + owner.vtable[+0x18](owner, parsed_response, 0) + +Work backwards from the category request constructor and its callers to identify who +supplies request+8, then map candidate owner vtables and their +0x18 consumers. +""" +import traceback + +try: + def show(a, label): + f = func(a) + print("\n=== %s %#x %s ===" % (label, a, f.getName() if f else "?")) + print(dec(a)) + + ctor = 0x18017a7c0 + show(ctor, "category request constructor") + print("\n=== ctor callers ===") + for ent, name in callers(ctor): + print(" %#x %s" % (ent, name)) + show(ent, "ctor caller") + print("\n=== ctor xrefs ===") + for frm, typ, name, ent in xrefs_to(ctor): + print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent)) + + # The request base constructor is usually visible as the first direct call in + # the category constructor. Dump every direct callee so request+8 initialization + # can be distinguished from URI/tag setup. + print("\n=== constructor direct callees ===") + for target, name in callees(ctor): + print(" %#x %s" % (target, name)) + show(target, "ctor callee") + + # Ghidra did not create a function at the traced +0x90 thunk. Print its raw + # instructions and nearby containing-function identity without assuming a body. + print("\n=== raw callback thunk at 0x180154830 ===") + ad = addr(0x180154830) + for _ in range(48): + ins = listing.getInstructionAt(ad) + if ins is None: + print(" %s " % ad) + ad = ad.add(1) + continue + print(" %s %s" % (ad, ins)) + ad = ins.getNext().getAddress() if ins.getNext() else ad.add(ins.getLength()) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_11.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_11.py new file mode 100644 index 0000000..1d8b5db --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_11.py @@ -0,0 +1,34 @@ +"""Trace the FUT-root constructor's third argument, inherited by every request at +8. + +The category request lives at FUT root +0x4140 (qword index 0x828). Its base ctor +stores the root constructor's param_3 at request+8, making that object the receiver +of owner.vtable[+0x18](owner, parsed_response, 0). +""" +import traceback + +try: + root_ctor = 0x18010cdc0 + print("=== root ctor callers ===") + for ent, name in callers(root_ctor): + print("\n--- %#x %s ---" % (ent, name)) + print(dec(ent)) + + print("\n=== root ctor xrefs ===") + for frm, typ, name, ent in xrefs_to(root_ctor): + print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent)) + if ent: + print(dec(ent)) + + # Static singleton slot and root vtables provide adjacent factory/type metadata. + for site in (0x1802e6398, 0x18021c2a0, 0x18021cda8, 0x18021cdb8): + print("\n=== qwords around %#x ===" % site) + for i in range(-8, 16): + p = site + i * 8 + try: + value = qword(p) + except Exception: + continue + print(" [%#x] = %#x %s" % (p, value, fname(value))) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_12.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_12.py new file mode 100644 index 0000000..35598c7 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_12.py @@ -0,0 +1,29 @@ +"""Map the category success notifier already instrumented at 0x18017aa80. + +The checkpoint hook can passively record ctx+0x88 and the +0x58..+0x60 handler +vector. Establish where this notifier sits relative to request ownership transfer and +whether it is the concrete receiver-side publication path we need to observe live. +""" +import traceback + +try: + target = 0x18017aa80 + print("=== notifier 0x18017aa80 ===") + print(dec(target)) + print("\n=== notifier callers ===") + for ent, name in callers(target): + print(" %#x %s" % (ent, name)) + print(dec(ent)) + print("\n=== notifier xrefs ===") + for frm, typ, name, ent in xrefs_to(target): + print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent)) + + # Adjacent category request methods often expose the notifier through a vtable + # or callback descriptor; inspect nearby functions and data references. + for a in (0x18017aa80, 0x18017aaf0, 0x18017ab80, 0x18017b1c0): + f = func(a) + print("\n=== %#x %s ===" % (a, f.getName() if f else "?")) + print(dec(a)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_13.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_13.py new file mode 100644 index 0000000..de24360 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_13.py @@ -0,0 +1,31 @@ +"""Map the sole live category-notifier listener into CardsDLL. + +Live capture 2026-08-07: + listener object 0x4216ca48 + listener vtable 0x6ffffc20d6d0 + vtable +0x08 0x6ffffc1e577a + CardsDLL slide 0x6ffe7c020000 + static method 0x1801c577a +""" + +TARGET = 0x1801C577A +VTABLE = 0x1801ED6D0 + +print("=== live notifier listener method ===") +target_function = func(TARGET) +if target_function is None: + print("no Ghidra function at %#x" % TARGET) + print("raw PE decoding: jmp [0x1801e5200], imported CRT _purecall") +else: + print("containing function:", target_function.getName(), + hex(int(target_function.getEntryPoint().getOffset()))) + print(dec(TARGET)) + +print("\n=== listener vtable ===") +for slot, target, name in vtable(VTABLE, 12): + print("%+#04x %#x %s" % (slot, target, name)) + +print("\n=== method callers/xrefs ===") +print("callers:", callers(TARGET) if target_function is not None else []) +for row in xrefs_to(TARGET): + print(row) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_14.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_14.py new file mode 100644 index 0000000..e5b0d32 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_14.py @@ -0,0 +1,29 @@ +"""Find concrete siblings of the live notifier listener's abstract vtable.""" + +import struct + +VTABLE = 0x1801ED6D0 +DTOR = 0x180018EF0 +PURECALL_THUNK = 0x1801C577A + +print("=== exact vtable references ===") +for row in xrefs_to(VTABLE): + print(row) + +print("\n=== vtables sharing the live listener destructor ===") +for hit in find_all(struct.pack(" %#x %s" % (off, target, fname(target))) + if 0x180001000 <= target < 0x180200000: + print(dec(target, 180)) + print("callers", callers(target)[:30]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_18.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_18.py new file mode 100644 index 0000000..0fcbc75 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_18.py @@ -0,0 +1,7 @@ +"""Follow the SBC category-completion continuation registered by event 0x753c.""" + +for target in (0x1800B8950, 0x1800B89D0, 0x1800B8C30, 0x1800BA460, 0x1800B7090): + print("\n=== %#x %s ===" % (target, fname(target))) + print(dec(target, 300)) + print("callers", callers(target)[:50]) + print("xrefs", xrefs_to(target)[:50]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_19.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_19.py new file mode 100644 index 0000000..216cc40 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_19.py @@ -0,0 +1,10 @@ +"""Resolve manager +0xe0 used to schedule the ServerErrSets continuation.""" + +for target in (0x180009C80, 0x1800D7170, 0x180154830, 0x1801631E0): + print("\n=== %#x %s ===" % (target, fname(target))) + print(dec(target, 300)) + print("xrefs", xrefs_to(target)[:80]) + +print("\n=== candidate manager vtables referencing category request callbacks ===") +for target in (0x1800B8950, 0x18017AA80, 0x18017B2B0): + print(hex(target), xrefs_to(target)[:100]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_20.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_20.py new file mode 100644 index 0000000..8b7ccf8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_20.py @@ -0,0 +1,21 @@ +"""Find completion callbacks that test the same status field at response+0x1c.""" + +patterns = ( + bytes.fromhex("83 7a 1c 00"), # cmp dword ptr [rdx+1c],0 + bytes.fromhex("83 79 1c 00"), # cmp dword ptr [rcx+1c],0 + bytes.fromhex("83 78 1c 00"), # cmp dword ptr [rax+1c],0 +) + +seen = set() +for pattern in patterns: + print("\npattern", pattern.hex()) + for hit in find_all(pattern): + f = func(hit) + if f is None: + continue + entry = int(f.getEntryPoint().getOffset()) + if entry in seen: + continue + seen.add(entry) + print("\n=== hit %#x function %#x %s ===" % (hit, entry, f.getName())) + print(dec(f, 180)[:5000]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_21.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_21.py new file mode 100644 index 0000000..25dd4d3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_21.py @@ -0,0 +1,14 @@ +"""Map the live category response object's vtable and status-bearing base class.""" + +VTABLE = 0x18022E5B0 +print("=== live category response vtable ===") +print("vtable xrefs", xrefs_to(VTABLE)[:100]) +for off in range(0, 0x100, 8): + target = qword(VTABLE + off) + print("slot +%#x -> %#x %s" % (off, target, fname(target))) + if 0x180001000 <= target < 0x180200000 and off < 0x60: + print(dec(target, 120)[:3000]) + +print("\n=== direct references to vtable entries/address ===") +for a in range(VTABLE - 0x20, VTABLE + 0x20, 8): + print(hex(a), xrefs_to(a)[:40]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_22.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_22.py new file mode 100644 index 0000000..ba4f9c2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_22.py @@ -0,0 +1,22 @@ +"""Find static assignments/usages of completion status 999 (0x3e7).""" + +patterns = [] +for modrm in (0x40, 0x41, 0x42, 0x43, 0x46, 0x47, 0x80, 0x81, 0x82, 0x83, 0x86, 0x87): + patterns.append(bytes((0xC7, modrm, 0x1C, 0xE7, 0x03, 0x00, 0x00))) +patterns.extend((bytes.fromhex("b8 e7 03 00 00"), bytes.fromhex("b9 e7 03 00 00"), + bytes.fromhex("ba e7 03 00 00"), bytes.fromhex("41 b8 e7 03 00 00"))) + +seen = set() +for pattern in patterns: + for hit in find_all(pattern): + f = func(hit) + entry = int(f.getEntryPoint().getOffset()) if f else 0 + key = (entry, hit) + if key in seen: + continue + seen.add(key) + print("\n=== pattern %s hit %#x function %#x %s ===" % + (pattern.hex(), hit, entry, f.getName() if f else "?")) + if f: + print(dec(f, 240)[:10000]) + print("callers", callers(f)[:80]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_23.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_23.py new file mode 100644 index 0000000..8b034f0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_23.py @@ -0,0 +1,8 @@ +"""Trace callers of the HTTP/FUT status mapper returning 999.""" + +for target in (0x1801844C0, 0x180163120, 0x180165050, 0x180165CC0, + 0x18016C060, 0x180184A90): + print("\n=== %#x %s ===" % (target, fname(target))) + print(dec(target, 300)[:18000]) + print("callers", callers(target)[:100]) + print("xrefs", xrefs_to(target)[:100]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_24.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_24.py new file mode 100644 index 0000000..e0b4154 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_24.py @@ -0,0 +1,26 @@ +"""Decompile the transport-result conversion and SBC response base methods.""" + + +TARGETS = ( + 0x180184420, + 0x1801844C0, + 0x180184A90, + 0x180163120, + 0x1801631E0, + 0x180165050, + 0x180165CC0, + 0x18016C060, + 0x18016C110, + 0x18016C950, + 0x18016CA40, + 0x18016CAC0, + 0x18016CB20, + 0x18016CB90, + 0x18016CBE0, + 0x18016CCA0, + 0x18016D230, +) + +for address in TARGETS: + print("\n===== %#x %s =====" % (address, fname(address))) + print(dec(address, 60)) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_25.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_25.py new file mode 100644 index 0000000..14c9cb0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_25.py @@ -0,0 +1,31 @@ +"""Enumerate CardsDLL instructions that write a dword-like value to object +0x1c. + +This is intentionally a read-only listing query. It finds explicit memory writes whose +rendered destination operand contains displacement 0x1c, then groups them by function. +""" + +listing = prog.getListing() +seen = set() + +for insn in listing.getInstructions(True): + text = insn.toString().lower() + if "0x1c" not in text and "+1ch" not in text: + continue + refs = insn.getReferencesFrom() + has_write = any(ref.getReferenceType().isWrite() for ref in refs) + # Register-relative memory writes do not always produce a Ghidra reference, so retain + # the common write mnemonics and require the first rendered operand to contain +0x1c. + mnemonic = insn.getMnemonicString().lower() + dst = insn.getDefaultOperandRepresentation(0).lower() + if "0x1c" not in dst and "+1ch" not in dst: + continue + if not has_write and mnemonic not in ("mov", "movzx", "and", "or", "xor", "inc", "dec"): + continue + owner = func(int(insn.getAddress().getOffset())) + entry = int(owner.getEntryPoint().getOffset()) if owner else 0 + key = (entry, int(insn.getAddress().getOffset())) + if key in seen: + continue + seen.add(key) + print("%#x function=%#x %s :: %s" % + (key[1], entry, owner.getName() if owner else "?", insn.toString())) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_26.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_26.py new file mode 100644 index 0000000..a8a1ae3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_26.py @@ -0,0 +1,7 @@ +"""Inspect the two additional CardsDLL functions with explicit dword writes to +0x1c.""" + +for target in (0x180171970, 0x1801790A0): + print("\n===== %#x %s =====" % (target, fname(target))) + print(dec(target, 180)) + print("callers", callers(target)[:100]) + print("xrefs", xrefs_to(target)[:100]) diff --git a/fifa17-recon/tools/ghidra_queries/q_md_sbc_9.py b/fifa17-recon/tools/ghidra_queries/q_md_sbc_9.py new file mode 100644 index 0000000..c894120 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_sbc_9.py @@ -0,0 +1,61 @@ +"""Continue the SBC response handoff analysis after the 2026-08-07 passive trace. + +Proven live boundary: + request +0x80 factory -> response 0x18022e5b0 + response +0x08 -> 0x18017b2b0 returns true + request +0x90 -> parsed response callback returns normally + request +0x88 -> ownership transfer returns normally + +The next unknown is the receiving owner's virtual +0x18 consumer called by +0x1801631e0. Recover the concrete receiver, its vtable, and downstream publication. +""" +import traceback + +try: + def dump_function(a, label): + f = func(a) + print("\n=== %s @%#x (%s) ===" % (label, a, f.getName() if f else "?")) + if f: + print("entry=%s body=%s" % (f.getEntryPoint(), f.getBody())) + print(dec(a)) + + def dump_instructions(a, before=0, count=80): + f = func(a) + print("\n=== instructions around %#x ===" % a) + if not f: + return + rows = [] + for ad in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins: + rows.append(ins) + pivot = next((i for i, ins in enumerate(rows) + if int(ins.getAddress().getOffset()) >= a), 0) + for ins in rows[max(0, pivot-before):pivot+count]: + print(" %s %s" % (ins.getAddress(), ins)) + + dump_function(0x1801631e0, "post-request ownership handoff / owner consumer") + dump_instructions(0x1801631e0, count=120) + + print("\n=== callers/xrefs of 0x1801631e0 ===") + for ent, name in callers(0x1801631e0): + print(" caller %#x %s" % (ent, name)) + print(dec(ent)) + for frm, typ, name, ent in xrefs_to(0x1801631e0): + print(" xref from=%#x type=%s fn=%s entry=%#x" % + (frm, typ, name, ent)) + + request_vtable = 0x18022e5c0 + print("\n=== category request vtable %#x ===" % request_vtable) + for off, target, name in vtable(request_vtable, 40): + print(" +%#04x -> %#x %s" % (off, target, name)) + + for slot, label in ((0x80, "typed factory"), + (0x88, "ownership transfer"), + (0x90, "completion callback")): + target = qword(request_vtable + slot) + dump_function(target, "request %s slot +%#x" % (label, slot)) + dump_instructions(target, count=100) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_md_scheduler_e0.py b/fifa17-recon/tools/ghidra_queries/q_md_scheduler_e0.py new file mode 100644 index 0000000..3e5048d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_md_scheduler_e0.py @@ -0,0 +1,30 @@ +"""Find indirect calls to service-interface slot +0xe0 and compare contracts.""" + +PATTERNS = ( + bytes.fromhex("ff 90 e0 00 00 00"), + bytes.fromhex("ff 91 e0 00 00 00"), + bytes.fromhex("ff 92 e0 00 00 00"), + bytes.fromhex("ff 93 e0 00 00 00"), + bytes.fromhex("ff 96 e0 00 00 00"), + bytes.fromhex("ff 97 e0 00 00 00"), + bytes.fromhex("41 ff 90 e0 00 00 00"), + bytes.fromhex("41 ff 91 e0 00 00 00"), + bytes.fromhex("41 ff 92 e0 00 00 00"), + bytes.fromhex("41 ff 93 e0 00 00 00"), +) + +seen = set() +for pattern in PATTERNS: + for hit in find_all(pattern, blocks=(".text",)): + owner = func(hit) + if owner is None: + continue + entry = int(owner.getEntryPoint().getOffset()) + if entry in seen: + continue + seen.add(entry) + print("\n===== call %#x function %#x %s =====" % + (hit, entry, owner.getName())) + print(dec(owner, 120)[:12000]) + print("callees", callees(owner)[:80]) + diff --git a/fifa17-recon/tools/ghidra_queries/q_runtime_response_registry.py b/fifa17-recon/tools/ghidra_queries/q_runtime_response_registry.py new file mode 100644 index 0000000..081e26b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_runtime_response_registry.py @@ -0,0 +1,27 @@ +"""Map the FIFA response-registry primitives surrounding state-3 completion.""" + +TARGETS = ( + 0x145336C50, + 0x145336E60, + 0x1453370B0, + 0x1453371B0, + 0x145337B20, + 0x1453388A0, + 0x145338950, + 0x145339650, + 0x1453396A0, + 0x145339B10, + 0x145374E10, + 0x145375070, + 0x145376200, + 0x145376270, + 0x1453762E0, + 0x145376360, +) + +for target in TARGETS: + print("\n===== %#x %s =====" % (target, fname(target))) + print(dec(target, 180)[:16000]) + print("callers", callers(target)[:120]) + print("callees", callees(target)[:120]) + diff --git a/fifa17-recon/tools/ghidra_queries/q_runtime_sbc_completion.py b/fifa17-recon/tools/ghidra_queries/q_runtime_sbc_completion.py new file mode 100644 index 0000000..1ba6d68 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_runtime_sbc_completion.py @@ -0,0 +1,16 @@ +"""Analyze the unpacked FIFA17 SBC completion route offline.""" + +TARGETS = ( + (0x146B805B0, "SBC request owner thunk"), + (0x145374E80, "generic request-state dispatcher"), + (0x145376270, "state-3 completion handler"), + (0x1453388A0, "response registry lookup (manager mode 1)"), + (0x145338950, "response registry lookup (manager mode 2)"), + (0x146162F50, "completion broadcast invoked on lookup miss"), +) + +for target, label in TARGETS: + print("\n=== %s %#x %s ===" % (label, target, fname(target))) + print(dec(target, 300)) + print("callers", callers(target)[:100]) + print("xrefs", xrefs_to(target)[:100]) diff --git a/fifa17-recon/tools/ghidra_queries/q_runtime_sbc_owner_table.py b/fifa17-recon/tools/ghidra_queries/q_runtime_sbc_owner_table.py new file mode 100644 index 0000000..df9fc01 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_runtime_sbc_owner_table.py @@ -0,0 +1,17 @@ +"""Resolve the two data tables that reference the SBC request-owner thunk.""" + +THUNK = 0x146B805B0 +REFERENCES = (0x14366D3E0, 0x14381B5D0) + +print("thunk bytes", read_bytes(THUNK, 32).hex(" ")) + +for reference in REFERENCES: + print("\n=== reference %#x ===" % reference) + print("raw", read_bytes(reference - 0x40, 0x90).hex(" ")) + for slot in range(reference - 0x40, reference + 0x48, 8): + target = qword(slot) + print("%#x rel=%+#x -> %#x %s xrefs=%s" % + (slot, slot - reference, target, fname(target), xrefs_to(slot)[:8])) + if func(target) is not None: + print(dec(target, 60)[:5000]) + diff --git a/fifa17-recon/tools/lsx_responder_v2.py b/fifa17-recon/tools/lsx_responder_v2.py old mode 100644 new mode 100755 index 64d8390..1e8be4f --- a/fifa17-recon/tools/lsx_responder_v2.py +++ b/fifa17-recon/tools/lsx_responder_v2.py @@ -162,6 +162,22 @@ def log(*a): print("[lsx]", *a, flush=True) +_SECRET_ATTR_RE = re.compile( + r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"') +_AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"') +_CHALLENGE_ATTR_RE = re.compile(r'(?i)\b(response)="[^"]*"') + + +def safe_xml_for_log(xml): + """Redact credential-bearing LSX attributes from ordinary diagnostics.""" + safe = _SECRET_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), xml) + if "> {reply}") + log(">>", safe_xml_for_log(reply)) conn.send_enc(reply) why = PUSH_AFTER.get(name) @@ -591,7 +605,12 @@ def selftest(): # 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0) # actually reads; Code=/Return= are legacy padding. assert '') + assert "secret" not in redacted and redacted.count("[REDACTED]") == 3, redacted + status = safe_xml_for_log('') + assert 'Code="0"' in status, status + print("[ok] GetAuthCode response shape and log redaction") print("[ok] selftest passed") diff --git a/fifa17-recon/tools/pow_server.py b/fifa17-recon/tools/pow_server.py index 3b7cd59..069740b 100644 --- a/fifa17-recon/tools/pow_server.py +++ b/fifa17-recon/tools/pow_server.py @@ -65,6 +65,18 @@ MODE = os.environ.get("POW_MODE", "serve") API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094") CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080") +# CardsDLL's store-description localizer accepts an empty translation catalogue; +# transport/XML success is the gate. It discovers individual records +# when present, so keep a standards-shaped empty XLIFF document rather than invent +# labels for server content we do not yet expose. +STOREPACK_DESCRIPTIONS_XML = b""" + + + + + +""" + def _split(hostport, default_port): host, _, port = hostport.partition(":") @@ -296,6 +308,17 @@ class _Handler(http.server.BaseHTTPRequestHandler): log(" body: %s" % body[:65536].decode("utf-8", "replace")) if self.kind == "content": + content_path = self.path.split("?", 1)[0] + if content_path.rstrip("/") == "/fut/packs/loc/storepackdescriptions.en_us.xml": + raw = STOREPACK_DESCRIPTIONS_XML + self.send_response(200) + self.send_header("Content-Type", "application/xml; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(raw) + log(" -> 200 storepack descriptions XML (%d bytes)" % len(raw)) + return # Art assets (.dds/.png). We have none; 404 is the honest answer and is # what a missing-asset CDN would return. Logged so we learn what art the # client wants before deciding to synthesise any. diff --git a/fifa17-recon/tools/test_account_profiles.py b/fifa17-recon/tools/test_account_profiles.py new file mode 100644 index 0000000..9d3d243 --- /dev/null +++ b/fifa17-recon/tools/test_account_profiles.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Regression tests for launcher-selected persistent FIFA 17 accounts.""" +import importlib +import json +import os +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + + +def main(): + with tempfile.TemporaryDirectory() as state: + os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json") + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + + import fut_account + import fut_store + import fut_accounts + import utas_server + importlib.reload(fut_account) + importlib.reload(fut_store) + importlib.reload(fut_accounts) + importlib.reload(utas_server) + + a = fut_accounts.activate({ + "personaId": 111001, "personaName": "TEST_A", + "level": 12, "experience": 345, "experienceMax": 1000, + "accountFunds": 50, "accountFundsCap": 100000, + }) + assert a["personaId"] == 111001 + assert a["level"] == 12 + assert fut_store.STORE.coins() == 15000 + assert fut_store.STORE.unopened_packs() == [70] + fut_store.STORE.spend(400) + fut_store.STORE.consume_unopened_pack(70) + + b = fut_accounts.activate({"personaId": 222002, "personaName": "TEST_B"}) + assert b["personaId"] == 222002 + assert fut_store.STORE.coins() == 15000 + assert fut_store.STORE.unopened_packs() == [70] + + a2 = fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"}) + assert a2["personaId"] == 111001 + assert fut_store.STORE.coins() == 14600 + assert fut_store.STORE.unopened_packs() == [] + assert a2["level"] == 12 + assert a2["accountFunds"] == 50 + + active = json.load(open(os.environ["FUT_ACCOUNT_PATH"])) + assert active["persona_id"] == 111001 + assert active["persona_name"] == "TEST_A" + + # A second process-like Account instance must observe an atomic active + # account file replacement rather than retaining its first loaded value. + observer = fut_account.Account(os.environ["FUT_ACCOUNT_PATH"]) + assert observer.persona_id == 111001 + fut_accounts.activate({"personaId": 222002, "personaName": "TEST_B"}) + assert observer.persona_id == 222002 + + class Purchase: + command = "POST" + _body = b'{"packId":6,"useCredits":1,"usePreOrder":0,"currency":"COINS"}' + + utas_server._OPENED_PACK_GRACE.clear() + status, _ = utas_server.purchased_items(Purchase()) + assert status == 200 + assert utas_server._OPENED_PACK_GRACE == [6] + status, catalog = utas_server.store_catalog(None) + assert status == 200 + grace = [p for p in catalog["purchase"] + if p.get("id") == 6 and p.get("unopened")] + assert len(grace) == 1 + assert grace[0]["state"] == "active" + assert grace[0]["displayGroup"]["value"] == "mypacks" + + print("account profile isolation: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fifa17-recon/tools/test_autopatch_logging.py b/fifa17-recon/tools/test_autopatch_logging.py new file mode 100644 index 0000000..56601bd --- /dev/null +++ b/fifa17-recon/tools/test_autopatch_logging.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Regression: autopatch logging is per-launcher/user writable. + +The watcher is run with a definitely-absent launcher PID, so it writes its +startup/ownership-exit diagnostics and terminates without touching FIFA. +""" +import os +import pathlib +import subprocess +import sys +import tempfile + + +def main(): + script = pathlib.Path(__file__).with_name("autopatch.py") + with tempfile.TemporaryDirectory(prefix="openfut-autopatch-test-") as root: + log_path = pathlib.Path(root) / "autopatch.log" + env = os.environ.copy() + env["OPENFUT_AUTOPATCH_LOG"] = str(log_path) + result = subprocess.run( + [sys.executable, str(script), "--launcher-pid", "999999999"], + env=env, + text=True, + capture_output=True, + timeout=5, + ) + assert result.returncode == 0, result.stderr or result.stdout + assert log_path.is_file(), "OPENFUT_AUTOPATCH_LOG was ignored" + text = log_path.read_text() + assert "watching for FIFA17.exe" in text + assert "launcher pid 999999999 exited" in text + print("autopatch writable-log override: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py index dff91cc..ce8a050 100644 --- a/fifa17-recon/tools/test_fut_contract.py +++ b/fifa17-recon/tools/test_fut_contract.py @@ -104,11 +104,17 @@ def test_store_catalog(): check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase")))) for p in d.get("purchase", []): check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId"))) - check("pack.currencies is array (coin price)", is_arr(p.get("currencies"))) check("pack.packContentInfo is object", is_obj(p.get("packContentInfo"))) - check("pack.extPrice is object", is_obj(p.get("extPrice"))) - ep = p.get("extPrice", {}) - check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice"))) + if not p.get("unopened"): + check("store pack currencies is array (coin price)", + is_arr(p.get("currencies"))) + check("store pack extPrice is object", is_obj(p.get("extPrice"))) + ep = p.get("extPrice", {}) + check("store extPrice.finalPrice is object", + is_obj(ep.get("finalPrice"))) + else: + check("owned pack omits purchase currencies", "currencies" not in p) + check("owned pack omits external purchase price", "extPrice" not in p) def test_market_bodies(): diff --git a/fifa17-recon/tools/test_hub_offline_season_contract.py b/fifa17-recon/tools/test_hub_offline_season_contract.py new file mode 100644 index 0000000..548ae58 --- /dev/null +++ b/fifa17-recon/tools/test_hub_offline_season_contract.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Regression for the FIFA 17 FUT hub offline-Seasons summary. + +CardsDLL's hub parser at 0x180139610 recognizes offlineSeason (atom 0x1ec) +and passes its object to 0x18013c3a0. That nested parser recognizes the +string-valued divisionId, gamesPlayed, points, totalGames, and +progressDataVersion fields. Without offlineSeason, the Single Player Season +UI rejects the otherwise-successful GetHubData response before /season is sent. +""" +import os +import pathlib +import sys +import tempfile + +TOOLS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS)) + + +def main(): + with tempfile.TemporaryDirectory(prefix="openfut-hub-season-test-") as state: + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + os.environ["FUT_MODES"] = "1" + + import utas_server + + body = utas_server.hub_data() + assert isinstance(body, dict), "hub response root must be an object" + summary = body.get("offlineSeason") + assert isinstance(summary, dict), "hub.offlineSeason must be an object" + + expected = {"divisionId", "gamesPlayed", "points", "totalGames", + "progressDataVersion"} + assert set(summary) == expected, repr(summary) + for key in expected: + assert isinstance(summary[key], str), "%s must be a string: %r" % (key, summary[key]) + + assert summary["divisionId"] == "10", repr(summary) + assert summary["gamesPlayed"] == "0", repr(summary) + assert summary["points"] == "0", repr(summary) + + +if __name__ == "__main__": + main() + print("PASS: FUT hub includes the recovered offline-Seasons summary") diff --git a/fifa17-recon/tools/test_match_lifecycle.py b/fifa17-recon/tools/test_match_lifecycle.py new file mode 100644 index 0000000..4452fad --- /dev/null +++ b/fifa17-recon/tools/test_match_lifecycle.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Isolated account-scoped regression for the FUT match HTTP lifecycle. + +Drives CREATE -> READY -> PLAY -> END through match_route using a temporary +profile root. No live profile or server is touched. +""" +import importlib +import json +import os +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + + +class Request: + def __init__(self, path, body, command="POST"): + self.path = path + self.command = command + self._body = json.dumps(body).encode("utf-8") + + +def main(): + with tempfile.TemporaryDirectory() as state: + os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json") + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + + import fut_account + import fut_store + import fut_accounts + import utas_server + importlib.reload(fut_account) + importlib.reload(fut_store) + importlib.reload(fut_accounts) + importlib.reload(utas_server) + + persona_id = 909001 + fut_accounts.activate({"personaId": persona_id, "personaName": "MATCH_TEST"}) + initial = fut_store.STORE.load() + initial_coins = initial["coins"] + initial_next_id = initial["nextItemId"] + + status, created = utas_server.match_route( + Request("/ut/game/fifa17/match", {})) + assert status == 200 + match_id = created["id"] + assert created["reportIdEnabled"] is False + assert fut_store.STORE.load()["nextItemId"] == initial_next_id + 1 + + status, ready = utas_server.match_route( + Request("/ut/game/fifa17/match/ready", {"matchId": match_id})) + assert status == 200 + assert ready == {"matchId": match_id, "opponentPersonaId": 0} + + next_id_before_play = fut_store.STORE.load()["nextItemId"] + status, played = utas_server.match_route( + Request("/ut/game/fifa17/match", {"matchId": match_id})) + assert status == 200 + assert played == {} + assert fut_store.STORE.load()["nextItemId"] == next_id_before_play + + status, ended = utas_server.match_route(Request( + "/ut/game/fifa17/match/end", + {"matchId": match_id, "endReason": "WIN", + "myMatchStats": {"goals": 2}, + "opponentMatchStats": {"goals": 1}}, + )) + assert status == 200 + expected_reward = (utas_server.MATCH_COINS["won"] + + utas_server.MATCH_PARTICIPATION) + assert ended["allCoins"] == initial_coins + expected_reward + + profile_path = os.path.join(state, "accounts", str(persona_id), + "fifa17_profile.json") + persisted = json.load(open(profile_path, encoding="utf-8")) + assert persisted["coins"] == initial_coins + expected_reward + assert persisted["record"] == {"won": 1, "draw": 0, "loss": 0} + assert persisted["matchesPlayed"] == 1 + + print("match lifecycle persistence: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fifa17-recon/tools/test_security_question.py b/fifa17-recon/tools/test_security_question.py new file mode 100644 index 0000000..1c055bb --- /dev/null +++ b/fifa17-recon/tools/test_security_question.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Regression tests for FIFA 17's account-scoped phishing/security gate.""" +import importlib +import json +import os +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +DEVICE_ID = "1" * 32 +TRANSFORMED_ANSWER = "a" * 32 # sanitized replay value, not a real answer + + +class Request: + def __init__(self, method, path, sid=None): + self.command = method + self.path = path + self.headers = {"X-UT-SID": sid} if sid is not None else {} + self._body = b"" + + +def request(utas_server, method, suffix, sid=None): + sid = utas_server.SID if sid is None else sid + h = Request(method, "/ut/game/fifa17/phishing/" + suffix, sid) + return utas_server.security_question_route(h) + + +def profile(state, persona_id): + path = os.path.join(state, "accounts", str(persona_id), "fifa17_profile.json") + with open(path) as f: + return json.load(f) + + +def main(): + with tempfile.TemporaryDirectory() as state: + os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json") + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + + import fut_account + import fut_store + import fut_accounts + import utas_server + importlib.reload(fut_account) + importlib.reload(fut_store) + importlib.reload(fut_accounts) + importlib.reload(utas_server) + + # New/missing state: launcher account selection initializes one account only. + fut_accounts.activate({"personaId": 771001, "personaName": "SEC_A"}) + p = profile(state, 771001) + assert p["securityQuestion"] == {"version": 1, "verified": True} + + # Actual trusted-device response fields parsed by CardsDLL 0x18012a170. + code, body = request( + utas_server, "GET", "trusteddevice?deviceId=" + DEVICE_ID) + assert code == 200 + assert body == { + "changed": False, + "exists": True, + "locked": False, + "trusted": True, + } + + # Existing initialized state survives a fresh Store instance/process view. + reopened = fut_store.Store(fut_store.profile_path_for(771001)) + assert reopened.profile()["securityQuestion"] == { + "version": 1, "verified": True} + + # FIFA's observed repeat-session request: POST, empty body, opaque 32-hex + # deviceId and transformed answer in the query string. The answer is accepted + # for OpenFUT compatibility but never persisted. + code, body = request( + utas_server, + "POST", + "validate?deviceId=%s&answer=%s" % (DEVICE_ID, TRANSFORMED_ANSWER), + ) + assert (code, body) == (200, {}) + saved = profile(state, 771001) + assert TRANSFORMED_ANSWER not in json.dumps(saved) + + # Question lookup uses the three fields parsed by CardsDLL 0x180129850. + code, body = request( + utas_server, "GET", "question?deviceId=" + DEVICE_ID) + assert code == 200 + assert set(body) == {"question", "attempts", "recoverAttempts"} + assert all(isinstance(body[k], int) for k in body) + + # Malformed values/methods and missing sessions fail explicitly. + code, _ = request(utas_server, "POST", "validate?deviceId=bad&answer=bad") + assert code == 400 + code, _ = request( + utas_server, "DELETE", "trusteddevice?deviceId=" + DEVICE_ID) + assert code == 405 + h = Request( + "GET", "/ut/game/fifa17/phishing/trusteddevice?deviceId=" + DEVICE_ID) + code, _ = utas_server.security_question_route(h) + assert code == 400 + + # Ordinary request logging must redact answer query values. + raw_path = "/ut/game/fifa17/phishing/validate?deviceId=%s&answer=%s" % ( + DEVICE_ID, TRANSFORMED_ANSWER) + safe_path = utas_server.safe_request_path(raw_path) + assert TRANSFORMED_ANSWER not in safe_path + assert "answer=%5BREDACTED%5D" in safe_path + + # Multiple profiles receive independent persisted state; selecting B must not + # alter A's initialized record. + fut_accounts.activate({"personaId": 771002, "personaName": "SEC_B"}) + assert profile(state, 771002)["securityQuestion"] == { + "version": 1, "verified": True} + assert profile(state, 771001)["securityQuestion"] == { + "version": 1, "verified": True} + + # Legacy profile with the field removed is repaired once and persisted. + b_path = os.path.join(state, "accounts", "771002", "fifa17_profile.json") + b = profile(state, 771002) + b.pop("securityQuestion") + with open(b_path, "w") as f: + json.dump(b, f) + fut_store.STORE._p = None + code, body = request( + utas_server, "GET", "trusteddevice?deviceId=" + DEVICE_ID) + assert code == 200 and body["exists"] and body["trusted"] + assert profile(state, 771002)["securityQuestion"]["verified"] is True + + print("security-question compatibility: PASS") + + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/test_tournament_contract.py b/fifa17-recon/tools/test_tournament_contract.py new file mode 100644 index 0000000..c31ebb0 --- /dev/null +++ b/fifa17-recon/tools/test_tournament_contract.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Regression for the FIFA 17 tournament-list response wrapper. + +CardsDLL's endpoint response parser at 0x18016b220 accepts an OBJECT root, +recognizes only tournament (atom 0x328), then opens its ARRAY and invokes the +element parser at 0x180169ef0. A bare array therefore parses as no tournament +list at all. +""" +import os +import pathlib +import sys +import tempfile + +TOOLS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TOOLS)) + + +def main(): + with tempfile.TemporaryDirectory(prefix="openfut-tournament-test-") as state: + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + + import utas_server + + body = utas_server.tournament_list() + assert isinstance(body, dict), "tournament response root must be an object" + body_dict = dict(body) + assert set(body_dict) == {"tournament"}, repr(body_dict) + tournaments = body_dict["tournament"] + assert isinstance(tournaments, list), "tournament must be an array" + assert tournaments, "the offline tournament catalog must not be empty" + assert all(isinstance(entry, dict) for entry in tournaments) + + +if __name__ == "__main__": + main() + print("PASS: tournament response uses the recovered object/array wrapper") diff --git a/fifa17-recon/tools/test_utas_log_redaction.py b/fifa17-recon/tools/test_utas_log_redaction.py new file mode 100644 index 0000000..4a18802 --- /dev/null +++ b/fifa17-recon/tools/test_utas_log_redaction.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Regression: ordinary UTAS diagnostics never expose session credentials.""" +import importlib +import os +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +CANARY = "OPENFUT_UTAS_CANARY_SECRET" + + +def main(): + with tempfile.TemporaryDirectory() as state: + os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json") + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ["FUT_LOG"] = os.path.join(state, "utas.log") + os.environ.pop("FUT_PROFILE", None) + + import utas_server + importlib.reload(utas_server) + + for name in ("X-UT-SID", "Authorization", "Cookie", "Set-Cookie"): + rendered = utas_server.safe_header_for_log(name, CANARY) + assert rendered == "[REDACTED]", (name, rendered) + assert CANARY not in rendered + + assert utas_server.safe_header_for_log("Content-Type", "application/json") == "application/json" + assert utas_server.safe_header_for_log("X-Request-Id", "status-0") == "status-0" + + print("UTAS header redaction: PASS") + + +if __name__ == "__main__": + main() diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 8dad976..163298c 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -11,11 +11,13 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0): * body must parse as JSON (else err 0x3E6); 204 + empty body is accepted. * [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET. """ -import datetime, json, os, re, sys, http.server +import copy, datetime, json, os, random, re, sys, http.server sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room) -from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs +from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_item +import fut_cards +import fut_staff from fut_account import ACCOUNT, validate_club # identity + club, single source # FUT_PORT exists so a second, THROWAWAY instance can be started without touching the @@ -37,6 +39,16 @@ SID = "OPENFUT-SID-0000000000000001" # Flip to True once you want to exercise the create-club path instead. NEW_USER = False +# An owned pack is consumed persistently when opened, but FIFA's reveal controller +# still returns to the My Packs group after all items are assigned/sold. Keep the +# just-opened catalogue record visible until the next hub request so that group is +# not deleted underneath a live UI controller. +_OPENED_PACK_GRACE = [] + + +def visible_unopened_packs(): + return STORE.unopened_packs() + list(_OPENED_PACK_GRACE) + def now(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -285,10 +297,16 @@ def user_info(): "actives": (current_squad().get("actives") or [])[:5], }) # ---- the two side-effecting members, off by default (see _UI above) -------- - if _UI in ("packs", "full"): + # Ownership counters must reflect persistent inventory only. The catalogue + # grace row prevents StoreFront from deleting a group beneath its live reveal + # controller, but the pack was already consumed and must not remain in FIFA's + # cached unopened-pack count. + unopened_count = len(STORE.unopened_packs()) + if unopened_count or _UI in ("packs", "full"): # unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks # (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered). - info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0} + info["unopenedPacks"] = {"preOrderPacks": 0, + "recoveredPacks": unopened_count} if _UI in ("roster", "full"): # squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it # fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen). @@ -936,13 +954,30 @@ def quick_sell_route(h): except Exception: body = {} ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)] + # Live FIFA 17 bulk serializer FUN_180126f40 emits the singular atom + # `itemId` containing an array of int64 handles. Keep itemIds as a tolerant + # alias for old replay fixtures, but never rely on it for the retail client. + if not ids and isinstance(body.get("itemId"), list): + ids = body["itemId"] if not ids and isinstance(body.get("itemIds"), list): ids = body["itemIds"] + ids = [int(i) for i in ids if isinstance(i, int) and i > 0] + sellable_before = {it.get("id") for it in STORE.purchased() + STORE.items()} sold, coins = STORE.quick_sell(ids) if sold: log(" QUICKSELL: sold %d card(s) for %d coins (total %d)" % (sold, coins, STORE.coins())) - return 200, {} + else: + log(" QUICKSELL: no requested ids were found; balance unchanged at %d" + % STORE.coins()) + # FutDiscardCardServerResponse. `totalCredits` is the absolute post-sale + # wallet balance, not the sale delta. Only echo accounted-for IDs; duplicate + # or stale request handles must not be removed from the client model twice. + sold_ids = list(dict.fromkeys(iid for iid in ids if iid in sellable_before)) + return 200, { + "items": [{"id": iid} for iid in sold_ids], + "totalCredits": STORE.coins(), + } def _move_ack(req, moved): @@ -1116,6 +1151,19 @@ ROUTES = [ # is composed by appending a suffix, so it is invisible to the request-template # table, and the generic /squad route below was swallowing it. MUST precede it. (re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)), + # Live-composed Draft URL, likewise invisible in the static request templates. + # It must precede generic /squad or squad_route answers {"id":0}, leaving the + # formation carousel empty even though FORMATION_DRAFT was accepted. + (re.compile(G + r"/squad/mode/\d+/draft/choices/formation"), + lambda m, h: draft_formation_choices_route(h)), + (re.compile(G + r"/squad/mode/\d+/draft/choices/captain"), + lambda m, h: draft_captain_choices_route(h)), + (re.compile(G + r"/squad/mode/\d+/draft/choices/player"), + lambda m, h: draft_player_choices_route(h)), + (re.compile(G + r"/squad/mode/\d+/draft/choices/manager"), + lambda m, h: draft_manager_choices_route(h)), + (re.compile(G + r"/squad/mode/\d+/draft/choose"), + lambda m, h: draft_choose_route(h)), (re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), @@ -1286,6 +1334,10 @@ def hub_data(): showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active listings are 'selling'; none are 'sold'. count == selling == number of listings.""" + if _OPENED_PACK_GRACE: + log(" STORE: retiring %d opened-pack grace entry at hub" + % len(_OPENED_PACK_GRACE)) + _OPENED_PACK_GRACE.clear() if not HUBDATA: return {} players = len([i for i in STORE.items() if _is_player(i)]) @@ -2522,25 +2574,214 @@ def sbc_tag_route(h): # Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if # this turns out to be wrong. DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1" +# Modes that successfully passed the entry-purchase response in this server process. +# Keep this volatile until the complete draft lifecycle (including abandon/rewards) +# is implemented; persisting a half-built draft would make recovery harder. +_DRAFT_SESSIONS = {} + + +def _draft_squad(session): + """A Draft-owned squad model, separate from STORE's regular active squad.""" + squad = copy.deepcopy(SQUAD) + squad.update({ + "id": 0, + "personaId": ACCOUNT.persona_id, + "squadName": "My Draft", + "formation": session.get("formation", "f442"), + "squadType": "DRAFT_SQUAD", + "chemistry": 0, + "starRating": 0, + "captain": 0, + "manager": ([{ + "id": session["manager"].get("id", 0), + "itemData": copy.deepcopy(session["manager"]), + "dream": False, + }] if session.get("manager") else []), + }) + # SQUAD is currently the empty, schema-proven seed, but explicitly stripping + # itemData prevents a future seed-mode change from leaking the regular XI here. + selected = session.get("selected", {}) + squad["players"] = [] + for index in range(23): + player = {"index": index, "kitNumber": 0} + if index in selected: + player["itemData"] = copy.deepcopy(selected[index]) + squad["players"].append(player) + captain_slot = session.get("captain_slot") + if captain_slot in selected: + squad["captain"] = selected[captain_slot].get("id", 0) + return squad def draft_state_route(h): if not DRAFT_STATE: return squad_route(h) + m = re.search(r"[?&]mode=([^&]+)", h.path) + mode = m.group(1) if m else "" + session = _DRAFT_SESSIONS.get(mode) + purchased = session is not None return 200, [{ - "squadState": "INVALID", # 0x2d5 STRING enum + # Atom-table-backed enum consumed by FUN_180147070. After a successful + # entry purchase FIFA's next legitimate stage is formation selection. + "squadState": session.get("stage", "FORMATION_DRAFT") if purchased else "INVALID", "stateParam1": "INVALID", # STRING "stateParam2": "0", # STRING (the int getter also accepts it) "gamesWonCurrentMatch": 0, # INT "roundsInfo": [], # array of the 7-scalar element; empty is safe + **({"squad": _draft_squad(session)} if purchased else {}), # entranceCriteria: OMITTED. Shape known, not needed, skip-safe. }] +def draft_formation_choices_route(h): + """Return the first Draft round: a formation carousel. + + FutGetDraftChoicesServerResponse (FUN_18014f2d0) consumes an object root with + choices[] records. Formation records use only index + formation; itemData is + reserved for later player/manager rounds. + """ + log(" DRAFT: serving formation choices") + return 200, { + "positionid": 0, + "tier": 1, + "choices": [ + {"index": 0, "formation": "f442"}, + {"index": 1, "formation": "f433"}, + ], + } + + +def draft_captain_choices_route(h): + """Offer five known-good player cards for the captain round.""" + candidates = [player for player in fut_cards.POOL if player[1] >= 84] + cards = [player_item(800000000 + index, player, special=random.random() < 0.20) + for index, player in enumerate(random.sample(candidates, 5))] + m = re.search(r"/squad/mode/(\d+)/draft/", h.path) + mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE" + session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "CAPTAIN_DRAFT"}) + session["pending_choices"] = cards + log(" DRAFT: serving %d captain choices" % len(cards)) + return 200, { + "positionid": 0, + "tier": 1, + "choices": [ + {"index": index, "itemData": card} + for index, card in enumerate(cards) + ], + } + + +def draft_player_choices_route(h): + """Offer a player round for the slot requested by the Draft UI.""" + try: + body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + position_id = int(body.get("positionId", body.get("positionid", 0))) + m = re.search(r"/squad/mode/(\d+)/draft/", h.path) + mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE" + session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "PLAYER_DRAFT"}) + selected_assets = { + card.get("assetId") for card in session.get("selected", {}).values() + } + + +def draft_manager_choices_route(h): + """Offer five verified FIFA 17 managercards for the final Draft round.""" + m = re.search(r"/squad/mode/(\d+)/draft/", h.path) + mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE" + session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "MANAGER_DRAFT"}) + manager_ids = random.sample(fut_staff.STARTER_MANAGERS, 5) + cards = [fut_staff.manager_item(800200000 + index, carddbid) + for index, carddbid in enumerate(manager_ids)] + session["stage"] = "MANAGER_DRAFT" + session["pending_choices"] = cards + session["pending_position"] = 23 + log(" DRAFT: serving %d manager choices" % len(cards)) + return 200, { + "positionid": 23, + "tier": 1, + "choices": [ + {"index": index, "itemData": card} + for index, card in enumerate(cards) + ], + } + # Prefer the requested slot's broad position family. If FIFA sends only a + # numeric squad slot (as observed), the client still enforces chemistry/fit; + # offering varied high-quality players is safer than inventing a slot map for + # every formation. Five unique assets are guaranteed per carousel. + candidates = [player for player in fut_cards.POOL + if player[1] >= 75 and player[0] not in selected_assets] + picks = random.sample(candidates, 5) + draft_seq = session.get("draft_item_seq", 0) + cards = [player_item(800100000 + draft_seq + index, player, + special=random.random() < 0.12) + for index, player in enumerate(picks)] + session["draft_item_seq"] = draft_seq + len(cards) + session["pending_choices"] = cards + session["pending_position"] = position_id + log(" DRAFT: serving %d player choices for slot %d" + % (len(cards), position_id)) + return 200, { + "positionid": position_id, + "tier": 1, + "choices": [ + {"index": index, "itemData": card} + for index, card in enumerate(cards) + ], + } + + +def draft_choose_route(h): + """Acknowledge a pick and advance the volatile Draft state machine.""" + try: + body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {} + except Exception: + body = {} + m = re.search(r"/squad/mode/(\d+)/draft/choose", h.path) + mode_id = int(m.group(1)) if m else 0 + mode = "SINGLE_PLAYER" if mode_id == 1 else "ONLINE" + session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "FORMATION_DRAFT"}) + if session.get("stage") == "FORMATION_DRAFT": + formations = ("f442", "f433") + choice = body.get("choiceIndex", 0) + session["formation"] = formations[choice] if choice in range(len(formations)) else "f442" + session["stage"] = "CAPTAIN_DRAFT" + log(" DRAFT: chose formation %s; advancing to captain" + % session["formation"]) + elif session.get("stage") in ("CAPTAIN_DRAFT", "PLAYER_DRAFT", "MANAGER_DRAFT"): + choice_index = int(body.get("choiceIndex", 0)) + position_id = int(body.get("positionId", session.get("pending_position", 0))) + choices = session.get("pending_choices", []) + if 0 <= choice_index < len(choices): + session.setdefault("selected", {})[position_id] = copy.deepcopy( + choices[choice_index] + ) + if session.get("stage") == "CAPTAIN_DRAFT": + session["captain_slot"] = position_id + session["stage"] = "PLAYER_DRAFT" + log(" DRAFT: chose captain for slot %d; advancing to players" + % position_id) + elif session.get("stage") == "MANAGER_DRAFT": + session["manager"] = copy.deepcopy(choices[choice_index]) + session["stage"] = "COMPLETED_DRAFT" + log(" DRAFT: chose manager; draft squad is complete") + else: + log(" DRAFT: chose player for slot %d" % position_id) + else: + log(" DRAFT: rejected out-of-range choice %d at slot %d" + % (choice_index, position_id)) + else: + log(" DRAFT: acknowledged pick at stage %s body=%s" + % (session.get("stage"), json.dumps(body))) + # FutPickDraftChoiceServerResponse uses the generic no-field response parser. + return 200, {} + + # ---- Draft entry purchase ---------------------------------------------------- -# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0} -# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns -# the fresh draft session summary. +# POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0} +# -> FutPurchaseDraftModeServerResponse. The path component is the draft mode +# (1 for SINGLE_PLAYER), not the entry price. # # LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the # client CRASHED immediately after. Sequence, from the log: @@ -2552,33 +2793,22 @@ def draft_state_route(h): # unimplemented call, which is the outcome a correct fix is supposed to have. # # WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two -# structures reference the class name. Resolved this session, and the doc's note about -# the second one is wrong: +# structures reference the class name. Live behaviour plus the request vtable resolves +# the POST to the second variant: # 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root -# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly -# the seven scalar ints below. THIS IS THE RESPONSE PARSER. +# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven +# scalar ints. This is a distinct response using the same class name. # 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root -# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a -# response root at all. It parses ENTRANCE CRITERIA: each element's -# name is strcmp'd against the literals "COINS", "POINTS" and -# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name -# string because it is the fee sub-object, not an alternate envelope. +# (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is +# parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared +# with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable +# 0x180226300 selects factory 0x180150260 for the live purchase POST. # -# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An -# object-root parser handed {} parses benignly and leaves defaults; an array-root -# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it -# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran, -# and the failure is downstream of an empty-but-valid parse. That is consistent with -# 0x18014c260 and inconsistent with 0x180150310. -# -# All seven members are scalar ints and the skip handler is present, so unknown keys -# are inert and there is no freeze surface here. -# -# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the -# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state, -# so there is no fee to charge yet. Charging a guessed amount would be inventing an -# economy rule; when entranceCriteria is served the price becomes real and this is the -# place to take it. +# LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP +# 200), issue no follow-up request, and spin at high CPU. That is the array parser's +# exact EOF-loop signature. The response below therefore uses the required array root. +# No coins are deducted yet: the emulator has not served a verified entrance price, +# and the path's mode id must not be mistaken for a price. # # DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a # confirmed crash, so there is no working state being protected. @@ -2593,18 +2823,18 @@ def draft_purchase_route(h): # calling .group() on the first argument. Doing that raised AttributeError, # which killed the connection outright -- strictly worse than the {} it replaced. m = re.search(r"/purchase/mode/(\d+)/draft", h.path) - price = int(m.group(1)) if m else 0 - log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria " - "served yet, so the client posted its own price)" % price) - return 200, { - "championEventId": 0, - "expectedTierLevel": 1, - "gamesPlayed": 0, - "gamesRemaining": 4, # a draft run is 4 rounds - "rank": 0, - "score": 0, - "tierLevel": 1, - } + mode = int(m.group(1)) if m else 0 + mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE" + _DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"} + coins = STORE.coins() + points = STORE.profile().get("points", 0) + log(" DRAFT: purchase entry, mode=%d; returning array-root currency result " + "(entry fee not deducted until entrance criteria are verified)" % mode) + return 200, [ + {"name": "COINS", "funds": coins, "finalFunds": coins}, + {"name": "POINTS", "funds": points, "finalFunds": points}, + {"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0}, + ] def champion_route(h): @@ -2866,7 +3096,7 @@ def _probe_final_funds(p): return p["price"] -def _pack_body(p, idx): +def _pack_body(p, idx, owned=False): """One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30). THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field @@ -2922,10 +3152,20 @@ def _pack_body(p, idx): "goldQuantity": p["count"] if gold else 0, "rareQuantity": p["count"] if gold else 0, "itemQuantity": p["count"], - "unopened": False, }, + # This is a top-level BOOL in the 0x158-byte pack record. Nesting it in + # packContentInfo (the old code) is skip-safe but completely inert. + "unopened": bool(owned), } - if STORE_DISPLAYGROUP: + if owned: + # Reward packs are opened with usePreOrder=1 and have no purchase path. + # Leaving zero-value coin/mtx objects attached makes the My Packs tile + # format an unavailable payment label as the literal "undefined". + body.pop("currencies", None) + body.pop("extPrice", None) + if owned: + body["displayGroup"] = {"value": "mypacks", "priority": idx} + elif STORE_DISPLAYGROUP: # THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT -- # it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the # recursion the old notes assumed does not exist and there was never anything @@ -2955,7 +3195,19 @@ def _pack_body(p, idx): # than a caption, the store goes from ugly-but-working to unusable. Default OFF # for exactly that reason, and the live test buys a pack to prove the buy path # still works. - body["displayGroup"] = {"value": p["name"]} + # FIFA 17's StoreFront does not treat this value as an arbitrary caption. + # It resolves exactly six hard-coded category tokens: mypacks, points, + # bronze, silver, gold and special (FUN_180014580/FUN_180014df0). Pack + # titles here create unsupported pseudo-categories and make GOTO_STORE_MYPACK + # initially land on the all-groups screen. Keep ordinary packs in the + # client's canonical categories; `description` remains the per-pack title. + if p.get("specialChance", 0.0) >= 1.0: + category = "special" + elif gold: + category = "gold" + else: + category = "bronze" + body["displayGroup"] = {"value": category} # FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05: # sending displayGroup did switch the store to a grouped render path, all three # packs collapsed into ONE group, and drilling into any of the three group tiles @@ -2997,7 +3249,32 @@ def store_catalog(h): consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC 0x1801c7f1a that was observed live. Not a mystery freeze; a traced one. """ - packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)] + normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")] + packs = [_pack_body(p, idx) for idx, p in enumerate(normal, start=1)] + owned_ids = visible_unopened_packs() + for idx, pack_id in enumerate(owned_ids, start=1): + owned = pack_by_id(pack_id) + if owned: + packs.append(_pack_body(owned, idx, owned=True)) + if not owned_ids: + # GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it + # renders rows. If the group is absent FIFA falls back to Bronze and + # shows the empty-category dialog over the wrong tab. Retain an inactive + # zero-item sentinel so the destination resolves, while state != active + # keeps it out of the visible row list. Its id is deliberately absent + # from PACK_CATALOG, so both purchase/open handlers reject it as well. + sentinel = { + "id": 65534, + "name": "", + "price": 0, + "count": 0, + "gold": True, + "specialChance": 0.0, + } + empty = _pack_body(sentinel, 1, owned=True) + empty["state"] = "inactive" + empty["unopened"] = False + packs.append(empty) return 200, {"purchase": packs, "timestamp": 1596326400} @@ -3015,10 +3292,11 @@ def store_buy(h): if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int): return 200, {} # not a confirmed buy pack = pack_by_id(pid) - if not pack: + if not pack or pack.get("ownedOnly"): return 200, {} items = STORE.open_pack(pack["price"], pack["count"], pack["gold"], - pack.get("tiers")) + pack.get("tiers"), pack.get("specialChance", 0.0), + pack.get("playersOnly", False)) if items is None: return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins())) @@ -3047,8 +3325,14 @@ def purchased_items(h): pack = pack_by_id(pid) if isinstance(pid, int) else None if pack is None: return 200, {"itemData": STORE.last_pack()} + if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid): + log(" STORE: rejected unopened pack %s; no owned instance" % pid) + return 200, {"itemData": STORE.last_pack()} + if pack.get("ownedOnly"): + _OPENED_PACK_GRACE.append(pid) items = STORE.open_pack(pack["price"], pack["count"], pack["gold"], - pack.get("tiers")) + pack.get("tiers"), pack.get("specialChance", 0.0), + pack.get("playersOnly", False)) if items is None: return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d" @@ -3072,13 +3356,20 @@ def credits_route(h): # The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50, # atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b. c = STORE.coins() - return 200, { + body = { "credits": c, "currencies": [ {"name": "coins", "funds": c, "finalFunds": c}, {"name": "points", "funds": 0, "finalFunds": 0}, ], } + # Do not count _OPENED_PACK_GRACE here: it is a UI-lifetime catalogue shim, + # not an owned pack. Reporting it would leave the My Packs badge stuck at 1. + unopened_count = len(STORE.unopened_packs()) + if unopened_count: + body["unopenedPacks"] = {"preOrderPacks": 0, + "recoveredPacks": unopened_count} + return 200, body # ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market ยง) ----------------