Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/fut_coaches.py
T
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
  (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
  required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
  (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
  verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
  restore instructions and rebuild-equivalence procedure

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00: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()))