From 9feb577c1c53127400f58911bb5e3561882cee37 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 5 Aug 2026 10:04:44 -0700 Subject: [PATCH] fifa17-recon: the four coach families -- 411 real ids, and a miss that labels itself headcoachcards 124 rows 2000004..2000328, gkcoachcards 121 rows 9000001..9000324, physiocards 51 rows 4000002..4000259, fitnesscoachcards 115 rows 3000019..3000328. All MEASURED from data/tables/, dumped read-only from the running client; rowcount == rows_emitted == len(rows) on all four, which is what makes "this id is absent" a claim about a complete dump rather than about a truncated one. assetid == carddbid on every row; every id fits in 24 bits. WHY COACHES ARE THE CHEAPEST FAMILY TO TEST. Their four arms of FUN_180141660 (2,129 bytes, 214-line decompile read to its closing `return`) are the only merges in the game that label their own failure: on rowcount < 1 each writes firstname = lastname = "DB Error", rec+0xb4 = 0x32, rec+0x58 = 1 and a TABLE-UNIQUE assetid -- head 2000148, fitness 3000259, physio 4000146, gkcoach 9000258. Two independent facts make that a one-glance oracle, both verified by exhaustive scan of all 411 rows: no row in any of the four tables has value == 50, and no fitnesscoach row is (fieldpos 1, posbonus 7, amount 1). CORRECTION to docs/plan-2026-08-04-card-families.md: the miss-fill is NOT uniform. Only head coach and GK coach write 0xf into the attribute array at rec+0x98. Physio writes 0xf into a BYTE at rec+0xdd; fitness coach writes no 0xf at all -- rec+0xde = 0x107 and rec+0xdd = 1. So card_identity_probe's attrs column means something different per family, and its F_NAME_KNOWN=0xdd string read sits directly on top of physio's, fitness coach's and the manager's raw stat bytes. Use coach_probe.py. The key is RAW: all four staff branches pass *(u32*)(rec+0x18) unmasked into `WHERE carddbid == ?`. Players are the only family that masks with & 0xffffff, so a version byte in the top octet breaks every staff lookup -- silently on a manager, loudly on a coach. WHAT WE SEND: id, resourceId, cardsubtypeid, itemType, contract, itemState, owners, untradeable. Nothing else. rating/rareflag/assetId are overwritten by the merge; nation/leagueId/teamid would be INVENTED, because none of the four tables has such a column; preferredPosition (rec+0x146) and attributeList (rec+0x98..) SURVIVE the merge and are read by the generic view-model FUN_1800d7920, so sending them would hang a position label and six attribute numbers on a coach. Omission is safe; a scalar where an object is expected is not. The starter shelf is one card per (tier, rare) combination per family -- 24 cards -- with two exclusions: the four miss-fill assetids (three of which are REAL rows, so a hit and a miss would look identical on those cards), and any row whose own stat write is byte-identical to its family's miss-fill (head/GK attribute 0 amount 15). tier() is the binary's own tail, not our convention: the shared exit of FUN_180141660 writes rec+0x54 = 3 if rating >= 0x4b else 2 - (rating < 0x41), for every arm including the miss arms. Also lands the design round's read-only probe tooling: coach_probe.py (grades a live record HIT/MISS/WRONG-BRANCH/NO-MERGE against the on-disk rows) and coach_window.py (builds a mixed-control window; fires nothing). Not wired into the server in this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW --- fifa17-recon/data/coach_seeds.json | 416 +++++++++++++++++++++++++++++ fifa17-recon/tools/coach_probe.py | 294 ++++++++++++++++++++ fifa17-recon/tools/coach_window.py | 257 ++++++++++++++++++ fifa17-recon/tools/fut_coaches.py | 242 +++++++++++++++++ 4 files changed, 1209 insertions(+) create mode 100644 fifa17-recon/data/coach_seeds.json create mode 100644 fifa17-recon/tools/coach_probe.py create mode 100644 fifa17-recon/tools/coach_window.py create mode 100644 fifa17-recon/tools/fut_coaches.py diff --git a/fifa17-recon/data/coach_seeds.json b/fifa17-recon/data/coach_seeds.json new file mode 100644 index 0000000..b17c137 --- /dev/null +++ b/fifa17-recon/data/coach_seeds.json @@ -0,0 +1,416 @@ +{ + "_source": "data/tables/*.json dumped read-only from the running client by tools/db_dump.py", + "_key": "resourceId == carddbid, RAW u32 (the staff branches of FUN_180141660 do NOT mask, unlike players)", + "families": { + "headcoach": { + "cardsubtypeid": 5, + "table": "headcoachcards", + "record_4c": 3, + "rowcount": 124, + "carddbid_band": [ + 2000004, + 2000328 + ], + "absent_ids_in_band": 201, + "miss_fill_assetid": 2000148, + "seeds": [ + { + "carddbid": 2000004, + "assetid": 2000004, + "attribute": 0, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 2000008, + "assetid": 2000008, + "attribute": 2, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 2000016, + "assetid": 2000016, + "attribute": 5, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 2000024, + "assetid": 2000024, + "attribute": 4, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 2000032, + "assetid": 2000032, + "attribute": 5, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 2000044, + "assetid": 2000044, + "attribute": 1, + "value": 64, + "amount": 5, + "rare": 1 + }, + { + "carddbid": 2000064, + "assetid": 2000064, + "attribute": 1, + "value": 80, + "amount": 15, + "rare": 1 + }, + { + "carddbid": 2000084, + "assetid": 2000084, + "attribute": 3, + "value": 66, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 2000124, + "assetid": 2000124, + "attribute": 0, + "value": 70, + "amount": 10, + "rare": 1 + }, + { + "carddbid": 2000164, + "assetid": 2000164, + "attribute": 1, + "value": 77, + "amount": 10, + "rare": 0 + } + ], + "bad_controls": [ + 2000005, + 2000089, + 2000177, + 2000259 + ] + }, + "gkcoach": { + "cardsubtypeid": 6, + "table": "gkcoachcards", + "record_4c": 10, + "rowcount": 121, + "carddbid_band": [ + 9000001, + 9000324 + ], + "absent_ids_in_band": 203, + "miss_fill_assetid": 9000258, + "seeds": [ + { + "carddbid": 9000001, + "assetid": 9000001, + "attribute": 0, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 9000017, + "assetid": 9000017, + "attribute": 4, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 9000021, + "assetid": 9000021, + "attribute": 5, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 9000025, + "assetid": 9000025, + "attribute": 0, + "value": 80, + "amount": 15, + "rare": 1 + }, + { + "carddbid": 9000037, + "assetid": 9000037, + "attribute": 3, + "value": 64, + "amount": 5, + "rare": 1 + }, + { + "carddbid": 9000081, + "assetid": 9000081, + "attribute": 2, + "value": 66, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 9000117, + "assetid": 9000117, + "attribute": 5, + "value": 80, + "amount": 15, + "rare": 1 + }, + { + "carddbid": 9000121, + "assetid": 9000121, + "attribute": 0, + "value": 75, + "amount": 10, + "rare": 0 + }, + { + "carddbid": 9000125, + "assetid": 9000125, + "attribute": 1, + "value": 74, + "amount": 10, + "rare": 1 + }, + { + "carddbid": 9000308, + "assetid": 9000308, + "attribute": 5, + "value": 80, + "amount": 15, + "rare": 1 + } + ], + "bad_controls": [ + 9000002, + 9000086, + 9000174, + 9000280 + ] + }, + "physio": { + "cardsubtypeid": 7, + "table": "physiocards", + "record_4c": 5, + "rowcount": 51, + "carddbid_band": [ + 4000002, + 4000259 + ], + "absent_ids_in_band": 207, + "miss_fill_assetid": 4000146, + "seeds": [ + { + "carddbid": 4000002, + "assetid": 4000002, + "attribute": 5, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 4000018, + "assetid": 4000018, + "attribute": 6, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 4000022, + "assetid": 4000022, + "attribute": 0, + "value": 80, + "amount": 15, + "rare": 1 + }, + { + "carddbid": 4000026, + "assetid": 4000026, + "attribute": 2, + "value": 55, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 4000046, + "assetid": 4000046, + "attribute": 3, + "value": 64, + "amount": 5, + "rare": 1 + }, + { + "carddbid": 4000078, + "assetid": 4000078, + "attribute": 4, + "value": 66, + "amount": 5, + "rare": 0 + }, + { + "carddbid": 4000122, + "assetid": 4000122, + "attribute": 3, + "value": 74, + "amount": 10, + "rare": 1 + }, + { + "carddbid": 4000170, + "assetid": 4000170, + "attribute": 1, + "value": 75, + "amount": 10, + "rare": 0 + }, + { + "carddbid": 4000194, + "assetid": 4000194, + "attribute": 6, + "value": 75, + "amount": 10, + "rare": 0 + }, + { + "carddbid": 4000254, + "assetid": 4000254, + "attribute": 6, + "value": 80, + "amount": 15, + "rare": 1 + } + ], + "bad_controls": [ + 4000003, + 4000089, + 4000175, + 4000257 + ] + }, + "fitnesscoach": { + "cardsubtypeid": 8, + "table": "fitnesscoachcards", + "record_4c": 4, + "rowcount": 115, + "carddbid_band": [ + 3000019, + 3000328 + ], + "absent_ids_in_band": 195, + "miss_fill_assetid": 3000259, + "seeds": [ + { + "carddbid": 3000019, + "assetid": 3000019, + "value": 55, + "amount": 1, + "posbonus": 3, + "fieldpos": 1, + "rare": 0 + }, + { + "carddbid": 3000023, + "assetid": 3000023, + "value": 55, + "amount": 1, + "posbonus": 5, + "fieldpos": 1, + "rare": 0 + }, + { + "carddbid": 3000035, + "assetid": 3000035, + "value": 55, + "amount": 1, + "posbonus": 5, + "fieldpos": 0, + "rare": 0 + }, + { + "carddbid": 3000043, + "assetid": 3000043, + "value": 64, + "amount": 2, + "posbonus": 6, + "fieldpos": 2, + "rare": 1 + }, + { + "carddbid": 3000047, + "assetid": 3000047, + "value": 64, + "amount": 2, + "posbonus": 2, + "fieldpos": 1, + "rare": 1 + }, + { + "carddbid": 3000059, + "assetid": 3000059, + "value": 64, + "amount": 2, + "posbonus": 1, + "fieldpos": 0, + "rare": 1 + }, + { + "carddbid": 3000083, + "assetid": 3000083, + "value": 66, + "amount": 2, + "posbonus": 5, + "fieldpos": 0, + "rare": 0 + }, + { + "carddbid": 3000091, + "assetid": 3000091, + "value": 80, + "amount": 5, + "posbonus": 5, + "fieldpos": 2, + "rare": 1 + }, + { + "carddbid": 3000127, + "assetid": 3000127, + "value": 70, + "amount": 3, + "posbonus": 5, + "fieldpos": 1, + "rare": 1 + }, + { + "carddbid": 3000171, + "assetid": 3000171, + "value": 77, + "amount": 3, + "posbonus": 5, + "fieldpos": 3, + "rare": 0 + } + ], + "bad_controls": [ + 3000020, + 3000100, + 3000178, + 3000307 + ] + } + } +} \ No newline at end of file diff --git a/fifa17-recon/tools/coach_probe.py b/fifa17-recon/tools/coach_probe.py new file mode 100644 index 0000000..6805bf1 --- /dev/null +++ b/fifa17-recon/tools/coach_probe.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Read back what the client resolved for STAFF cards (head coach, GK coach, +physio, fitness coach) and grade every one HIT / MISS / WRONG-BRANCH / NO-MERGE. + +READ-ONLY. Uses card_identity_probe.nodes() and the same /proc/PID/mem reader; +there is no write path in this file. + +WHY A SEPARATE FILE FROM card_identity_probe +-------------------------------------------- +card_identity_probe is a PLAYER tool and is actively wrong for staff: + + * classify() returns "NO-MERGE" for anything with cardtype != 1, so all four + coach families come back NO-MERGE and the tool reports nothing. + * F_NAME_KNOWN = 0xdd is the player knownAs string. For physio and fitness + coach the merge writes RAW STAT BYTES into 0xdd..0xe3, and for managers + talkrating/negotiation land at 0xe2/0xe3. So `known` is garbage for every + non-player family and must never be read as a name there. + * F_ATTRS (0x98..0xac) is only meaningful for head coach and GK coach, and + even there the merge writes exactly ONE element. + +THE MECHANISM (FUN_180141660, cardsdll.dll, 2129 bytes / 214 decompiled lines, +read end to end) +------------------------------------------------------------------------------ +The merge switches on record+0x4c, which FUN_1800d8330 derives from the JSON +atom 0x6c cardsubtypeid alone: + + cardsubtypeid -> +0x4c -> table key column + 5 3 headcoachcards carddbid == *(u32*)(rec+0x18) + 8 4 fitnesscoachcards carddbid == *(u32*)(rec+0x18) + 7 5 physiocards carddbid == *(u32*)(rec+0x18) + 6 10 gkcoachcards carddbid == *(u32*)(rec+0x18) + 4 2 managercards carddbid == *(u32*)(rec+0x18) + 0..3 1 players playerid == rec+0x18 & 0xffffff + +record+0x18 is atom 0x287 resourceId. THE COACH BRANCHES DO NOT MASK IT: unlike +FUN_180135890 (players), which does `& 0xffffff` twice, the four staff branches +and the manager branch pass the raw u32 straight into the `==` predicate. So a +version nibble in the high byte of resourceId breaks every staff lookup silently. + +MISS FINGERPRINTS -- these are NOT uniform, contrary to earlier notes +-------------------------------------------------------------------- +All four write firstname/lastname "DB Error", rating(+0xb4) 0x32 and rare(+0x58) +1, plus a TABLE-UNIQUE fallback assetid at +0x20. Only head coach and GK coach +write 0xf into the attribute array; physio writes 0xf into a BYTE at +0xdd, and +fitness coach writes no 0xf at all -- it writes fieldpos 1 / posbonus 7 / +amount 1 into +0xdd/+0xde/+0xdf. + +The fallback assetid is what makes a negative interpretable: it names the branch +that ran. A head-coach id that comes back with assetId 3000259 means the FITNESS +branch ran, which is a different bug from "the id is wrong". + +Two facts checked against the on-disk dumps in data/tables/ and used as oracles: + * no row in any of the four tables has value == 50, so rating 0x32 is an + unambiguous MISS for all four families; + * no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), so + that byte triple is an unambiguous MISS for fitness coach on its own. + +Usage: + python3 coach_probe.py # graded table + tally + python3 coach_probe.py --json out.json + python3 coach_probe.py --raw # + hexdump of the first staff record +""" +import argparse +import json +import os +import struct +import sys + +import card_identity_probe as P +import watch_club_model as W + +TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "data", "tables") + +# cardsubtypeid -> (family, table file, +0x4c value, fallback assetid) +FAMILY = { + 5: ("headcoach", "headcoachcards.json", 3, 0x1E8514), # 2000148 + 8: ("fitnesscoach", "fitnesscoachcards.json", 4, 0x2DC7C3), # 3000259 + 7: ("physio", "physiocards.json", 5, 0x3D0992), # 4000146 + 6: ("gkcoach", "gkcoachcards.json", 10, 0x895542), # 9000258 + 4: ("manager", "managercards.json", 2, None), # NO miss-fill +} + +F_ATTRS_I32 = (0x98, 0x9C, 0xA0, 0xA4, 0xA8, 0xAC) # head/GK coach boost slot +F_STAT_BYTES = 0xDD # 0xdd..0xe3, 7 bytes +MISS_RATING = 0x32 +MISS_NAME = "DB Error" + + +def load_tables(): + """{cardsubtypeid: {carddbid: row}} from the read-only on-disk dumps.""" + out = {} + for sub, (_, fn, _, _) in FAMILY.items(): + path = os.path.join(TABLES, fn) + try: + with open(path) as f: + rows = json.load(f)["rows"] + except (IOError, OSError, ValueError): + continue + out[sub] = {r["carddbid"]: r for r in rows} + return out + + +def read_staff(mem, node): + buf = mem.read(node + P.REC, P.REC_LEN) + if buf is None or len(buf) < P.REC_LEN: + return None + return { + "node": node, + "id": P.u32(buf, P.F_ID), + "resourceId": P.u32(buf, P.F_RESOURCE), + "assetId": P.u32(buf, P.F_ASSET), + "cardtype": P.u32(buf, P.F_CARDTYPE), + "subtype": P.u32(buf, P.F_SUBTYPE), + "rating": P.u8(buf, P.F_RATING), + "rare": P.u32(buf, 0x58), + "tier": P.u32(buf, 0x54), + "first": P.cstr(buf, P.F_NAME_FIRST, 0x10), + "last": P.cstr(buf, P.F_NAME_LAST, 0x15), + # what OUR json set and the coach branches never touch: + "teamid": P.u32(buf, P.F_TEAM), + "position": P.u8(buf, P.F_POSITION), + "nation": P.u16(buf, P.F_NATION), + "league": P.u32(buf, P.F_LEAGUE), + # the two stat regions, read as raw numbers, never as a string: + "attrs": [P.u32(buf, o) for o in F_ATTRS_I32], + "stat_bytes": list(buf[F_STAT_BYTES:F_STAT_BYTES + 7]), + "_raw": buf, + } + + +def expected_tier(rating): + """The tail of FUN_180141660, which runs for EVERY family including miss.""" + if rating is None: + return None + return 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1) + + +def grade(c, tables): + """HIT / MISS / WRONG-BRANCH / NO-MERGE / UNEXPECTED, plus a reason.""" + sub = c["subtype"] + if sub not in FAMILY: + return "NOT-STAFF", "cardsubtypeid %s is not a staff family" % sub + name, _, want_ct, fallback = FAMILY[sub] + c["family"] = name + if c["cardtype"] != want_ct: + return "NO-MERGE", ("cardtype %s, expected %d -- FUN_1800d8330 did not " + "map this subtype, so no query ran" + % (c["cardtype"], want_ct)) + + # A miss anywhere names its own branch through the fallback assetid. + for osub, (oname, _, _, ofb) in FAMILY.items(): + if ofb is not None and c["assetId"] == ofb and c["rating"] == MISS_RATING: + if osub == sub: + row = tables.get(sub, {}).get(c["resourceId"]) + if row is not None: + return "UNEXPECTED", ("MISS, but carddbid %d IS in %s -- the " + "key or the field is wrong, not the id" + % (c["resourceId"], name)) + return "MISS", "id %d absent from %s (as designed)" % ( + c["resourceId"], name) + return "WRONG-BRANCH", ("fallback assetid %d belongs to %s, but we " + "sent cardsubtypeid %d (%s)" + % (c["assetId"], oname, sub, name)) + + if c["rating"] == MISS_RATING or MISS_NAME in (c["first"], c["last"]): + return "UNEXPECTED", ("miss fingerprint without a known fallback assetid " + "(assetId=%s)" % c["assetId"]) + + row = tables.get(sub, {}).get(c["resourceId"]) + if row is None: + if sub == 4: + return "SILENT", ("managercards writes NO miss-fill; a wrong id is " + "indistinguishable from a wrong mechanism") + return "UNEXPECTED", ("no miss fingerprint, but id %d is absent from %s" + % (c["resourceId"], name)) + + # HIT: every column we can see must agree with the on-disk row. + bad = [] + if c["rating"] != row["value"]: + bad.append("rating %s != value %s" % (c["rating"], row["value"])) + if c["assetId"] != row["assetid"]: + bad.append("assetId %s != assetid %s" % (c["assetId"], row["assetid"])) + if c["rare"] != (1 if row["rare"] == 1 else 0): + bad.append("rare %s != %s" % (c["rare"], row["rare"])) + if c["tier"] != expected_tier(c["rating"]): + bad.append("tier %s != %s" % (c["tier"], expected_tier(c["rating"]))) + if sub in (5, 6): # head coach / GK coach + got = c["attrs"][row["attribute"]] + if got != row["amount"]: + bad.append("attrs[%d] %s != amount %s" + % (row["attribute"], got, row["amount"])) + elif sub == 7: # physio, via FUN_180136270 + got = c["stat_bytes"][row["attribute"]] + if got != row["amount"]: + bad.append("+%#x %s != amount %s" + % (0xDD + row["attribute"], got, row["amount"])) + elif sub == 8: # fitness coach: three columns at once + for i, col in enumerate(("fieldpos", "posbonus", "amount")): + if c["stat_bytes"][i] != row[col]: + bad.append("+%#x %s != %s %s" + % (0xDD + i, c["stat_bytes"][i], col, row[col])) + if bad: + return "HIT-MISMATCH", "; ".join(bad) + return "HIT", "every visible column agrees with %s row %d" % ( + name, c["resourceId"]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--raw", action="store_true") + ap.add_argument("--json", metavar="PATH") + ap.add_argument("--all", action="store_true", + help="also list player/unknown cards instead of skipping them") + a = ap.parse_args() + + tables = load_tables() + if not tables: + print("no table dumps under %s -- run tools/db_dump.py first" % TABLES) + return 1 + + pid = W.find_pid() + if pid is None: + print("FIFA17.exe is not running.") + return 1 + base = W.dll_base(pid) + if base is None: + print("pid %d is up but %s is not mapped yet." % (pid, W.DLL)) + return 1 + mem = W.Mem(pid) + obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE)) + if not obj: + print("CardsDb singleton is NULL (no FUT session loaded).") + return 1 + + ns = P.nodes(mem, obj) + print("pid=%d CardsDb=%#x size(+0x160e8)=%s walked=%d" + % (pid, obj, mem.i32(obj + W.TREE_SIZE), len(ns))) + + cards = [] + for n in ns: + c = read_staff(mem, n) + if not c: + continue + c["verdict"], c["why"] = grade(c, tables) + cards.append(c) + if not a.all: + cards = [c for c in cards if c["verdict"] != "NOT-STAFF"] + cards.sort(key=lambda c: (c["subtype"] or 0, c["resourceId"] or 0)) + + print() + print("%-13s %-9s %-8s %-4s %-4s %-4s %-22s %-13s %s" + % ("family", "resource", "assetId", "rat", "tie", "rar", "name", + "verdict", "why")) + for c in cards: + nm = ("%s %s" % (c["first"], c["last"])).strip()[:22] + print("%-13s %-9s %-8s %-4s %-4s %-4s %-22s %-13s %s" + % (c.get("family", "?"), c["resourceId"], c["assetId"], + c["rating"], c["tier"], c["rare"], nm, c["verdict"], c["why"])) + + tally = {} + for c in cards: + tally[c["verdict"]] = tally.get(c["verdict"], 0) + 1 + print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items()))) + print("\nfields the coach branches NEVER write, i.e. OURS on screen if they " + "render at all:") + for c in cards[:8]: + print(" %-9s teamid=%-6s leagueId=%-6s nation=%-5s position=%s" + % (c["resourceId"], c["teamid"], c["league"], c["nation"], + c["position"])) + + if a.raw and cards: + b = cards[0]["_raw"] + print("\nrecord %#x:" % (cards[0]["node"] + P.REC)) + for off in range(0, P.REC_LEN, 16): + row = b[off:off + 16] + print(" +%03x %-47s %s" % ( + off, " ".join("%02x" % x for x in row), + "".join(chr(x) if 32 <= x < 127 else "." for x in row))) + + if a.json: + for c in cards: + c.pop("_raw", None) + with open(a.json, "w") as f: + json.dump(cards, f, indent=1) + print("\nwrote %s" % a.json) + + print("\nfailed reads=%d" % mem.fails) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/coach_window.py b/fifa17-recon/tools/coach_window.py new file mode 100644 index 0000000..13baa91 --- /dev/null +++ b/fifa17-recon/tools/coach_window.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Build the MIXED-CONTROL staff window: known-good coach ids interleaved with +known-bad ones, so a correct result and an incorrect one look different in the +same screenshot and in the same probe readback. + +THIS FILE FIRES NOTHING. It prints/writes JSON. The integrate agent wires it in; +nobody else touches utas_server.py, fut_cards.py or fut_store.py. + +WHY THIS SHAPE +-------------- +The four coach branches of FUN_180141660 write a LOUD miss-fill -- firstname and +lastname "DB Error", rating 0x32, rare 1, and a TABLE-UNIQUE fallback assetid. +That gives three separable outcomes instead of two: + + HIT real name, rating == the row's `value`, assetId == carddbid + MISS "DB Error", rating 50, assetId == this family's fallback + WRONG-BRANCH "DB Error", rating 50, assetId == ANOTHER family's fallback + NO-MERGE our sentinel rating survives, name empty -> the query never ran + +so every failure mode says which one it is. That is what makes the negative +interpretable, and it is the reason coaches are the cheapest family to prove. + +Two properties were checked against the on-disk dumps and both hold: + * no row in any of the four tables has value == 50, so rating 50 can only be a + miss -- it can never be a hit that happens to look like one; + * no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), the + fitness miss-fill triple at +0xdd/+0xde/+0xdf, so fitness has a second, + fully independent oracle that does not depend on reading a name at all. + +The known-bad ids are ids INSIDE each family's own carddbid band that are absent +from the table -- 195..207 such gaps exist per family, so a bad control is never +an out-of-range value the client might reject for an unrelated reason. + +THE SENTINEL. Every item is sent with rating SENTINEL (=1), which no coach row +carries and which the miss-fill never writes. If a card comes back still holding +rating 1, the merge did not run at all; that is the NO-MERGE arm and it is a +different bug from either a hit or a miss. + +DO NOT ROUTE THIS THROUGH FUT_ID_SWEEP. sweep_items() keeps itemType "player" +and varies only cardsubtypeid, and club_route answers a sweep BEFORE the ?type= +filter, so a sweep-borne staff experiment is confounded twice over. + +WHICH SCREEN FIRES IT. The client's own ?type= taxonomy (FUN_18012ec50, 29 arms ++ default) does contain headcoach / gkcoach / physio / fitnesscoach / staff -- +atoms 0x153 / 0x13f / 0x21d / 0x129 / 0x2dc. NONE of those five has ever been +seen on the wire. A grep of every capture and log in this repo finds exactly +three values: type=player (x14), type=manager (x2), type=custom (x1). So the +screen to aim this at is the STAFF tab, which sends type=manager, and the reason +these items reach it is that club_route already filters on cardsubtypeid -- not +on itemType and not on the type string -- keeping everything outside 0..3. + +Usage: + python3 coach_window.py # human-readable prediction table + python3 coach_window.py --json items.json + python3 coach_window.py --family headcoach --json one.json +""" +import argparse +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +SEEDS = os.path.join(HERE, "..", "data", "coach_seeds.json") + +# Item ids for the experiment. Far above fut_store's ITEM_ID_BASE range so a +# stray record in the CardsDb map can always be attributed. +ID_BASE = 950000000 +SENTINEL_RATING = 1 + + +# A DISTINCT, unmistakable nation + league per family. This is the SECOND +# question the same window answers, and it is free: the four coach branches of +# FUN_180141660 never write nation (+0x148), leagueId (+0x154), teamid (+0x94) or +# position (+0x146), and none of the four tables even HAS a nation/league/team +# column, so these values cannot change the hit/miss outcome. Whatever a coach +# card shows for country or league therefore came from US. +# +# Both the good and the bad ids of a family carry the same pair, so a "DB Error" +# card flying a Brazilian flag is itself direct proof that the miss-fill leaves +# our fields alone. +# +# nation 14 (England) is deliberately AVOIDED: 14 is what the PLAYER miss-fill +# writes, and a value that doubles as a known failure fingerprint is not a probe. +FACE = { # family -> (nationid, leagueid) + "headcoach": (54, 13), # Brazil, Premier League + "gkcoach": (45, 19), # Spain, Bundesliga + "physio": (27, 16), # Italy, Ligue 1 + "fitnesscoach": (21, 53), # Germany, LaLiga Santander +} + + +def _item(item_id, carddbid, cardsubtypeid, nation=0, league=0): + """One staff item. + + EXACTLY the field set fut_store._item() already builds and that this client + is live-proven to parse. The only changes are cardsubtypeid and the ids. No + new atom is introduced: the wire shape of a real staff item has NEVER been + observed, and inventing one -- a scalar where the parser wants an object -- + is the change class that busy-loops the client at 0x1801c7f1a. + + resourceId carries NO version nibble. The staff branches compare + `carddbid == *(u32*)(record+0x18)` on the RAW dword; players are the only + family that masks with & 0xffffff. A high byte here breaks every lookup and + does it silently. + + itemType stays "player" for the first run. The merge dispatches on + cardsubtypeid alone, and club_route's ?type= filter already keys on + cardsubtypeid (anything not in 0..3 survives a non-player type), so nothing + needs itemType to be changed in order for these to reach the STAFF tab. + Flipping it to the taxonomy name ("headcoach"/"gkcoach"/"physio"/ + "fitnesscoach", atoms 0x153/0x13f/0x21d/0x129) is a separate, later, + one-variable experiment. + """ + return { + "id": item_id, + "resourceId": carddbid, # == carddbid, raw, no version byte + "assetId": carddbid, + "cardassetid": carddbid, + "definitionId": carddbid, + "cardsubtypeid": cardsubtypeid, + "itemType": "player", + "rareflag": 1, # overwritten by the merge either way + "rating": SENTINEL_RATING, + "preferredPosition": "ST", + "nation": nation, + "teamid": 0, + "leagueId": league, + "playStyle": 250, + # zeros so that, for head coach and GK coach, the ONE slot the merge + # writes (attrs[row.attribute] = row.amount) stands out against five + # untouched zeros -- that single write verifies two columns at once. + "attributeList": [{"index": i, "value": 0} for i in range(6)], + "itemState": "free", + "owners": 1, + "untradeable": True, + "contract": 7, + "fitness": 99, + } + + +def build(families=None): + with open(SEEDS) as f: + spec = json.load(f) + fams = spec["families"] + order = [f for f in ("headcoach", "gkcoach", "physio", "fitnesscoach") + if families is None or f in families] + + items, predict, n = [], [], 0 + for fam in order: + d = fams[fam] + sub, fb = d["cardsubtypeid"], d["miss_fill_assetid"] + nat, lg = FACE[fam] + good = [r["carddbid"] for r in d["seeds"]] + bad = d["bad_controls"] + rows = {r["carddbid"]: r for r in d["seeds"]} + + # INTERLEAVE. The client pages the club (start=N&count=11 observed), so + # good and bad must alternate or a page can come back all-good/all-bad + # and prove nothing on its own screenshot. + mixed, gi, bi = [], 0, 0 + while gi < len(good) or bi < len(bad): + for _ in range(3): + if gi < len(good): + mixed.append((good[gi], True)); gi += 1 + if bi < len(bad): + mixed.append((bad[bi], False)); bi += 1 + + for cid, is_good in mixed: + iid = ID_BASE + n; n += 1 + items.append(_item(iid, cid, sub, nat, lg)) + if is_good: + r = rows[cid] + v = r["value"] + p = {"id": iid, "family": fam, "carddbid": cid, "expect": "HIT", + "rating": v, "tier": 3 if v >= 75 else (2 if v >= 65 else 1), + "rare": r["rare"], "assetId": cid, "name": "a real person", + "face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)} + if fam in ("headcoach", "gkcoach"): + p["attrs"] = "index %d == %d, other five == 0" % ( + r["attribute"], r["amount"]) + elif fam == "physio": + p["stat_byte"] = "+%#x == %d" % (0xDD + r["attribute"], + r["amount"]) + else: + p["stat_bytes"] = "+0xdd/+0xde/+0xdf == %d/%d/%d" % ( + r["fieldpos"], r["posbonus"], r["amount"]) + else: + p = {"id": iid, "family": fam, "carddbid": cid, "expect": "MISS", + "rating": 50, "tier": 1, "rare": 1, "assetId": fb, + "name": "DB Error DB Error", + "face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)} + if fam in ("headcoach", "gkcoach"): + p["attrs"] = "index 0 == 15, other five == 0" + elif fam == "physio": + p["stat_byte"] = "+0xdd == 15" + else: + p["stat_bytes"] = "+0xdd/+0xde/+0xdf == 1/7/1" + predict.append(p) + + # ONE deliberate cross-family item per family: this family's BEST-KNOWN + # good id sent under the NEXT family's cardsubtypeid. It must miss, and + # its fallback assetid must name the OTHER table. That is the only item + # in the window that can distinguish "the subtype picks the table" from + # "the id band picks the table", and it is interpretable in both + # directions: a real name here would refute the dispatch outright. + other = order[(order.index(fam) + 1) % len(order)] + if other != fam: + osub = fams[other]["cardsubtypeid"] + ofb = fams[other]["miss_fill_assetid"] + iid = ID_BASE + n; n += 1 + # the cross-family item keeps the SOURCE family's face pair, so if + # it ever renders it is visibly the head-coach flag on a gkcoach slot + items.append(_item(iid, good[0], osub, nat, lg)) + predict.append({"id": iid, "family": "%s-id/%s-subtype" % (fam, other), + "carddbid": good[0], "expect": "MISS (cross-family)", + "rating": 50, "tier": 1, "rare": 1, "assetId": ofb, + "name": "DB Error DB Error", + "refutes": "a real name here means cardsubtypeid does " + "NOT select the table"}) + return items, predict + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json", metavar="PATH", help="write the item array") + ap.add_argument("--family", action="append", + choices=["headcoach", "gkcoach", "physio", "fitnesscoach"]) + a = ap.parse_args() + + items, predict = build(a.family) + nfam = len({p["family"] for p in predict if "/" not in p["family"]}) + print("%d items across %d familie(s), including %d deliberate cross-family " + "controls. Every item carries rating=%d as the NO-MERGE sentinel.\n" + % (len(items), nfam, sum(1 for p in predict if "/" in p["family"]), + SENTINEL_RATING)) + print("%-11s %-24s %-9s %-20s %-5s %-4s %-8s %s" + % ("id", "family", "carddbid", "expect", "rat", "tier", "assetId", "extra")) + for p in predict: + extra = p.get("attrs") or p.get("stat_byte") or p.get("stat_bytes") or "" + print("%-11d %-24s %-9d %-20s %-5d %-4d %-8d %s" + % (p["id"], p["family"], p["carddbid"], p["expect"], p["rating"], + p["tier"], p["assetId"], extra)) + n_hit = sum(1 for p in predict if p["expect"] == "HIT") + print("\npredicted: HIT=%d MISS=%d (a run where all %d agree is the proof; " + "any single disagreement names its own failure mode)" + % (n_hit, len(predict) - n_hit, len(predict))) + + if a.json: + with open(a.json, "w") as f: + json.dump({"itemData": items, "predictions": predict}, f, indent=1) + print("\nwrote %s" % a.json) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/fut_coaches.py b/fifa17-recon/tools/fut_coaches.py new file mode 100644 index 0000000..5194c08 --- /dev/null +++ b/fifa17-recon/tools/fut_coaches.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""The four coach families for the FIFA 17 offline backend. + + cardsubtypeid 5 -> cardtype 3 headcoachcards 124 rows 2000004..2000328 + cardsubtypeid 6 -> cardtype 10 gkcoachcards 121 rows 9000001..9000324 + cardsubtypeid 7 -> cardtype 5 physiocards 51 rows 4000002..4000259 + cardsubtypeid 8 -> cardtype 4 fitnesscoachcards 115 rows 3000019..3000328 + +All four ids come out of data/tables/, dumped READ-ONLY from the running client, so +none of this needed the live game. rowcount == rows_emitted == len(rows) on all four, +which is what makes "this id is absent from the table" a claim about a COMPLETE dump. + +WHY COACHES ARE THE CHEAPEST FAMILY TO TEST +------------------------------------------- +Their merge arms are the only ones in the game that LABEL THEIR OWN FAILURE. All four +inline branches of FUN_180141660 (2,129 bytes, 214-line decompile read to its closing +`return`) do, on rowcount < 1: + + firstname = lastname = "DB Error" rec+0xb4 = 0x32 (rating 50) rec+0x58 = 1 + and a TABLE-UNIQUE assetid: head 2000148 fitness 3000259 physio 4000146 + gkcoach 9000258 + +so a wrong coach id puts the words DB ERROR on the screen instead of failing silently +the way a manager does. Two independent facts make that a legitimate one-glance oracle: +no row in ANY of the four tables has value == 50, and three of those four fallback +assetids are REAL rows in their own table (3000259 is not) -- so they are excluded +from everything this module ships. + +THE MISS-FILL IS NOT UNIFORM, contrary to the earlier note in +docs/plan-2026-08-04-card-families.md. Only head coach and GK coach write 0xf into the +attribute array at rec+0x98. Physio writes 0xf into a BYTE at rec+0xdd, and fitness +coach writes no 0xf at all -- it writes rec+0xde = 0x107 and rec+0xdd = 1, i.e. +fieldpos 1 / posbonus 7 / amount 1. + +THE KEY IS RAW. All four staff branches pass *(u32*)(rec+0x18) unmasked into +`WHERE carddbid == ?`. Players are the ONLY family that masks with & 0xffffff. A +version byte in the top octet therefore breaks every staff lookup, silently on a +manager and loudly on a coach. + +WHAT IS OURS AND WHAT IS THEIRS. The merge overwrites firstname, lastname, +assetId(+0x20), rating(+0xb4), rare(+0x58), the tier(+0x54) it derives from rating, +and the family stat block. It never writes teamid(+0x94), preferredPosition(+0x146), +nation(+0x148) or leagueId(+0x154) -- and none of the four tables even HAS a nation, +league or team column, so any value we put there would be INVENTED. We therefore send +none of them: omission leaves the memset zero, and rec+0x146/+0x98.. are read by the +generic view-model FUN_1800d7920, so sending them would hang a position label and six +attribute numbers on a coach card. +""" +import json, os + +HERE = os.path.dirname(os.path.abspath(__file__)) +TABLES = os.path.join(os.path.dirname(HERE), "data", "tables") + +# family -> (cardsubtypeid, record+0x4c cardtype, table, miss-fill assetid) +FAMILIES = { + "headcoach": (5, 3, "headcoachcards", 2000148), + "gkcoach": (6, 10, "gkcoachcards", 9000258), + "physio": (7, 5, "physiocards", 4000146), + "fitnesscoach": (8, 4, "fitnesscoachcards", 3000259), +} + +# The ?type= arms that ask for coaching staff. FUN_18012ec50 resolves its 29 explicit +# arms through the atom table to headcoach(3), fitnesscoach(4), physio(5), gkcoach(9) +# and staff(10) -- but NONE of those five strings has ever been seen on the wire from +# this client. Only player, manager and custom have. Hence FUT_COACHES=all, which also +# answers type=manager (the one staff request that HAS been observed, from the STAFF +# tab on 2026-08-04). +CLUB_TYPES = ("headcoach", "gkcoach", "physio", "fitnesscoach", "staff") + +COACH_ID_BASE = 950000000 # clear of the save (1e8), the sweep (9e8) and the + # consumable overlay (9.4e8) + + +def _rows(table): + with open(os.path.join(TABLES, table + ".json")) as f: + d = json.load(f) + assert d["rowcount"] == d["rows_emitted"] == len(d["rows"]), ( + "%s: partial dump -- every 'this id is absent' claim below would be void" + % table) + return d["rows"] + + +def _build(): + out = {} + for fam, (sub, ct, table, missfill) in FAMILIES.items(): + rows = [] + for r in _rows(table): + row = { + "family": fam, + "subtype": sub, + "cardtype": ct, + "carddbid": r["carddbid"], # == assetid on every row, all 4 tables + "rating": r["value"], # the client writes this itself + "rare": r["rare"], + # the family stat, for OUR predictions only -- never sent + "amount": r["amount"], + } + if fam == "fitnesscoach": + row["fieldpos"] = r["fieldpos"] + row["posbonus"] = r["posbonus"] + else: + row["attribute"] = r["attribute"] + rows.append(row) + out[fam] = rows + return out + + +COACHES = _build() +BY_ID = {(r["subtype"], r["carddbid"]): r for fam in COACHES for r in COACHES[fam]} + +# ids that must never be shipped: they ARE the miss-fill fingerprint. 2000148, 4000146 +# and 9000258 are genuine rows in their own tables, so a card carrying one of them is +# ambiguous -- a hit and a miss look identical. 3000259 is not a row at all. +MISS_FILL_IDS = {v[3] for v in FAMILIES.values()} + + +def tier(rating): + """The shared tail of FUN_180141660 writes rec+0x54 for EVERY arm including the + miss arms: 3 if rating >= 0x4b, else 2 - (rating < 0x41). Bronze/silver/gold.""" + return 3 if rating >= 75 else (2 if rating >= 65 else 1) + + +def coach_item(item_id, subtype, carddbid, contract=7, untradeable=True, rating=None): + """One coach item. Eight keys, and every one of them is already proven on the wire. + + id -> rec+0x08 our handle. NOTE FUN_180141660 opens with + `if (*(longlong *)(param_1 + 8) == 0) return;` -- an + item with id 0 or no id gets NO merge for ANY family: + no name, no rating, not even DB Error. + resourceId -> rec+0x18 THE merge key, compared RAW against carddbid. + cardsubtypeid -> rec+0x50 the ONLY family selector (FUN_1800d8330 -> rec+0x4c). + itemType inert (atom 0x173 never reaches the record); "staff" + is for our own readers, matching fut_staff. + contract -> rec+0x8c + itemState / owners / untradeable as on a player. + + NOT SENT, each for a reason: rating/rareflag/assetId (all overwritten by the + merge), nation/leagueId/teamid (no such column exists in any coach table, so any + value would be invented), preferredPosition/attributeList (they SURVIVE the merge + and are read by the generic view-model), definitionId (not an atom). + `rating` is exposed only so a probe can plant a sentinel. + """ + if subtype not in {v[0] for v in FAMILIES.values()}: + raise ValueError("cardsubtypeid %r is not a coach family (5, 6, 7 or 8)" + % (subtype,)) + if carddbid in MISS_FILL_IDS: + raise ValueError("carddbid %d is a miss-fill assetid: a hit and a miss would " + "look identical on that card" % carddbid) + it = { + "id": item_id, + "resourceId": carddbid, + "cardsubtypeid": subtype, + "itemType": "staff", + "contract": contract, + "itemState": "free", + "owners": 1, + "untradeable": untradeable, + } + if rating is not None: + it["rating"] = rating + return it + + +def _ambiguous(fam, r): + """True if this row's own stat write is byte-identical to its family's miss-fill. + + Head coach and GK coach both miss-fill with *(u32*)(rec+0x98) = 0xf, i.e. + attrs[0] = 15 -- so a genuine row with attribute 0 and amount 15 leaves the + attribute array indistinguishable from a miss. rating and the name still tell them + apart, but there is no reason to ship a card that needs a tie-break. + Fitness coach's miss triple (fieldpos 1, posbonus 7, amount 1) occurs on no row of + its table, and physio's rec+0xdd = 0xf collides only with attribute 0 amount 15.""" + if fam == "fitnesscoach": + return (r["fieldpos"], r["posbonus"], r["amount"]) == (1, 7, 1) + return r["attribute"] == 0 and r["amount"] == 15 + + +def _pick(fam): + """One real id per (tier, rare) combination that the family actually has -- so at + most six cards, spanning bronze/silver/gold and both rare flags, with the miss-fill + assetid and every miss-ambiguous row excluded.""" + rows = [r for r in COACHES[fam] + if r["carddbid"] not in MISS_FILL_IDS and not _ambiguous(fam, r)] + picked = [] + for want in ((3, 1), (3, 0), (2, 1), (2, 0), (1, 1), (1, 0)): + for r in rows: + if (tier(r["rating"]), r["rare"]) == want: + picked.append(r) + break + return picked + + +# A readable STAFF tab: up to six cards per family, every one a real row. +STARTER_COACHES = {fam: [r["carddbid"] for r in _pick(fam)] for fam in FAMILIES} + + +def items_for_type(kind, next_id=COACH_ID_BASE): + """The starter shelf filtered to one ?type= arm. + + `staff` (arm 10) means all four families; each family's own arm means only that + family. Ids stay stable per family regardless of which arm asked, so the same card + keeps the same item id across tabs.""" + all_items = starter_coaches(next_id) + if kind == "staff": + return all_items + fam = FAMILIES.get(kind) + if fam is None: + return [] + return [i for i in all_items if i["cardsubtypeid"] == fam[0]] + + +def starter_coaches(next_id=COACH_ID_BASE): + if isinstance(next_id, int): + base = [next_id] + alloc = lambda: (base.__setitem__(0, base[0] + 1), base[0] - 1)[1] + else: + alloc = next_id + out = [] + for fam in ("headcoach", "gkcoach", "physio", "fitnesscoach"): + sub = FAMILIES[fam][0] + for cid in STARTER_COACHES[fam]: + out.append(coach_item(alloc(), sub, cid)) + return out + + +if __name__ == "__main__": + for fam, (sub, ct, table, miss) in sorted(FAMILIES.items()): + rows = COACHES[fam] + print("%-13s subtype %d cardtype %2d %3d rows %d..%d miss-fill %d" + % (fam, sub, ct, len(rows), rows[0]["carddbid"], rows[-1]["carddbid"], + miss)) + for cid in STARTER_COACHES[fam]: + r = BY_ID[(sub, cid)] + print(" %7d rating %2d (%s) rare %d %s" + % (cid, r["rating"], "bronze silver gold".split()[tier(r["rating"]) - 1], + r["rare"], + "fieldpos %d posbonus %d amount %d" + % (r["fieldpos"], r["posbonus"], r["amount"]) + if fam == "fitnesscoach" + else "attrs[%d] = %d" % (r["attribute"], r["amount"]))) + print("starter_coaches(): %d item(s)" % len(starter_coaches()))