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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-05 10:04:44 -07:00
parent 340c31f34e
commit 9feb577c1c
4 changed files with 1209 additions and 0 deletions
+416
View File
@@ -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
]
}
}
}
+294
View File
@@ -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())
+257
View File
@@ -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())
+242
View File
@@ -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()))