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:
@@ -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())
|
||||
Reference in New Issue
Block a user