Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/coach_probe.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

295 lines
12 KiB
Python

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