#!/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()))