70a64e3709
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.
243 lines
11 KiB
Python
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()))
|