Files
OpenFUT/fifa17-recon/tools/fut_coaches.py
T
funman300 9feb577c1c 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
2026-08-05 10:04:44 -07:00

243 lines
11 KiB
Python

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