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,416 @@
|
||||
{
|
||||
"_source": "data/tables/*.json dumped read-only from the running client by tools/db_dump.py",
|
||||
"_key": "resourceId == carddbid, RAW u32 (the staff branches of FUN_180141660 do NOT mask, unlike players)",
|
||||
"families": {
|
||||
"headcoach": {
|
||||
"cardsubtypeid": 5,
|
||||
"table": "headcoachcards",
|
||||
"record_4c": 3,
|
||||
"rowcount": 124,
|
||||
"carddbid_band": [
|
||||
2000004,
|
||||
2000328
|
||||
],
|
||||
"absent_ids_in_band": 201,
|
||||
"miss_fill_assetid": 2000148,
|
||||
"seeds": [
|
||||
{
|
||||
"carddbid": 2000004,
|
||||
"assetid": 2000004,
|
||||
"attribute": 0,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 2000008,
|
||||
"assetid": 2000008,
|
||||
"attribute": 2,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 2000016,
|
||||
"assetid": 2000016,
|
||||
"attribute": 5,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 2000024,
|
||||
"assetid": 2000024,
|
||||
"attribute": 4,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 2000032,
|
||||
"assetid": 2000032,
|
||||
"attribute": 5,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 2000044,
|
||||
"assetid": 2000044,
|
||||
"attribute": 1,
|
||||
"value": 64,
|
||||
"amount": 5,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 2000064,
|
||||
"assetid": 2000064,
|
||||
"attribute": 1,
|
||||
"value": 80,
|
||||
"amount": 15,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 2000084,
|
||||
"assetid": 2000084,
|
||||
"attribute": 3,
|
||||
"value": 66,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 2000124,
|
||||
"assetid": 2000124,
|
||||
"attribute": 0,
|
||||
"value": 70,
|
||||
"amount": 10,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 2000164,
|
||||
"assetid": 2000164,
|
||||
"attribute": 1,
|
||||
"value": 77,
|
||||
"amount": 10,
|
||||
"rare": 0
|
||||
}
|
||||
],
|
||||
"bad_controls": [
|
||||
2000005,
|
||||
2000089,
|
||||
2000177,
|
||||
2000259
|
||||
]
|
||||
},
|
||||
"gkcoach": {
|
||||
"cardsubtypeid": 6,
|
||||
"table": "gkcoachcards",
|
||||
"record_4c": 10,
|
||||
"rowcount": 121,
|
||||
"carddbid_band": [
|
||||
9000001,
|
||||
9000324
|
||||
],
|
||||
"absent_ids_in_band": 203,
|
||||
"miss_fill_assetid": 9000258,
|
||||
"seeds": [
|
||||
{
|
||||
"carddbid": 9000001,
|
||||
"assetid": 9000001,
|
||||
"attribute": 0,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 9000017,
|
||||
"assetid": 9000017,
|
||||
"attribute": 4,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 9000021,
|
||||
"assetid": 9000021,
|
||||
"attribute": 5,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 9000025,
|
||||
"assetid": 9000025,
|
||||
"attribute": 0,
|
||||
"value": 80,
|
||||
"amount": 15,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 9000037,
|
||||
"assetid": 9000037,
|
||||
"attribute": 3,
|
||||
"value": 64,
|
||||
"amount": 5,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 9000081,
|
||||
"assetid": 9000081,
|
||||
"attribute": 2,
|
||||
"value": 66,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 9000117,
|
||||
"assetid": 9000117,
|
||||
"attribute": 5,
|
||||
"value": 80,
|
||||
"amount": 15,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 9000121,
|
||||
"assetid": 9000121,
|
||||
"attribute": 0,
|
||||
"value": 75,
|
||||
"amount": 10,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 9000125,
|
||||
"assetid": 9000125,
|
||||
"attribute": 1,
|
||||
"value": 74,
|
||||
"amount": 10,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 9000308,
|
||||
"assetid": 9000308,
|
||||
"attribute": 5,
|
||||
"value": 80,
|
||||
"amount": 15,
|
||||
"rare": 1
|
||||
}
|
||||
],
|
||||
"bad_controls": [
|
||||
9000002,
|
||||
9000086,
|
||||
9000174,
|
||||
9000280
|
||||
]
|
||||
},
|
||||
"physio": {
|
||||
"cardsubtypeid": 7,
|
||||
"table": "physiocards",
|
||||
"record_4c": 5,
|
||||
"rowcount": 51,
|
||||
"carddbid_band": [
|
||||
4000002,
|
||||
4000259
|
||||
],
|
||||
"absent_ids_in_band": 207,
|
||||
"miss_fill_assetid": 4000146,
|
||||
"seeds": [
|
||||
{
|
||||
"carddbid": 4000002,
|
||||
"assetid": 4000002,
|
||||
"attribute": 5,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 4000018,
|
||||
"assetid": 4000018,
|
||||
"attribute": 6,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 4000022,
|
||||
"assetid": 4000022,
|
||||
"attribute": 0,
|
||||
"value": 80,
|
||||
"amount": 15,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 4000026,
|
||||
"assetid": 4000026,
|
||||
"attribute": 2,
|
||||
"value": 55,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 4000046,
|
||||
"assetid": 4000046,
|
||||
"attribute": 3,
|
||||
"value": 64,
|
||||
"amount": 5,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 4000078,
|
||||
"assetid": 4000078,
|
||||
"attribute": 4,
|
||||
"value": 66,
|
||||
"amount": 5,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 4000122,
|
||||
"assetid": 4000122,
|
||||
"attribute": 3,
|
||||
"value": 74,
|
||||
"amount": 10,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 4000170,
|
||||
"assetid": 4000170,
|
||||
"attribute": 1,
|
||||
"value": 75,
|
||||
"amount": 10,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 4000194,
|
||||
"assetid": 4000194,
|
||||
"attribute": 6,
|
||||
"value": 75,
|
||||
"amount": 10,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 4000254,
|
||||
"assetid": 4000254,
|
||||
"attribute": 6,
|
||||
"value": 80,
|
||||
"amount": 15,
|
||||
"rare": 1
|
||||
}
|
||||
],
|
||||
"bad_controls": [
|
||||
4000003,
|
||||
4000089,
|
||||
4000175,
|
||||
4000257
|
||||
]
|
||||
},
|
||||
"fitnesscoach": {
|
||||
"cardsubtypeid": 8,
|
||||
"table": "fitnesscoachcards",
|
||||
"record_4c": 4,
|
||||
"rowcount": 115,
|
||||
"carddbid_band": [
|
||||
3000019,
|
||||
3000328
|
||||
],
|
||||
"absent_ids_in_band": 195,
|
||||
"miss_fill_assetid": 3000259,
|
||||
"seeds": [
|
||||
{
|
||||
"carddbid": 3000019,
|
||||
"assetid": 3000019,
|
||||
"value": 55,
|
||||
"amount": 1,
|
||||
"posbonus": 3,
|
||||
"fieldpos": 1,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 3000023,
|
||||
"assetid": 3000023,
|
||||
"value": 55,
|
||||
"amount": 1,
|
||||
"posbonus": 5,
|
||||
"fieldpos": 1,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 3000035,
|
||||
"assetid": 3000035,
|
||||
"value": 55,
|
||||
"amount": 1,
|
||||
"posbonus": 5,
|
||||
"fieldpos": 0,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 3000043,
|
||||
"assetid": 3000043,
|
||||
"value": 64,
|
||||
"amount": 2,
|
||||
"posbonus": 6,
|
||||
"fieldpos": 2,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 3000047,
|
||||
"assetid": 3000047,
|
||||
"value": 64,
|
||||
"amount": 2,
|
||||
"posbonus": 2,
|
||||
"fieldpos": 1,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 3000059,
|
||||
"assetid": 3000059,
|
||||
"value": 64,
|
||||
"amount": 2,
|
||||
"posbonus": 1,
|
||||
"fieldpos": 0,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 3000083,
|
||||
"assetid": 3000083,
|
||||
"value": 66,
|
||||
"amount": 2,
|
||||
"posbonus": 5,
|
||||
"fieldpos": 0,
|
||||
"rare": 0
|
||||
},
|
||||
{
|
||||
"carddbid": 3000091,
|
||||
"assetid": 3000091,
|
||||
"value": 80,
|
||||
"amount": 5,
|
||||
"posbonus": 5,
|
||||
"fieldpos": 2,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 3000127,
|
||||
"assetid": 3000127,
|
||||
"value": 70,
|
||||
"amount": 3,
|
||||
"posbonus": 5,
|
||||
"fieldpos": 1,
|
||||
"rare": 1
|
||||
},
|
||||
{
|
||||
"carddbid": 3000171,
|
||||
"assetid": 3000171,
|
||||
"value": 77,
|
||||
"amount": 3,
|
||||
"posbonus": 5,
|
||||
"fieldpos": 3,
|
||||
"rare": 0
|
||||
}
|
||||
],
|
||||
"bad_controls": [
|
||||
3000020,
|
||||
3000100,
|
||||
3000178,
|
||||
3000307
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Build the MIXED-CONTROL staff window: known-good coach ids interleaved with
|
||||
known-bad ones, so a correct result and an incorrect one look different in the
|
||||
same screenshot and in the same probe readback.
|
||||
|
||||
THIS FILE FIRES NOTHING. It prints/writes JSON. The integrate agent wires it in;
|
||||
nobody else touches utas_server.py, fut_cards.py or fut_store.py.
|
||||
|
||||
WHY THIS SHAPE
|
||||
--------------
|
||||
The four coach branches of FUN_180141660 write a LOUD miss-fill -- firstname and
|
||||
lastname "DB Error", rating 0x32, rare 1, and a TABLE-UNIQUE fallback assetid.
|
||||
That gives three separable outcomes instead of two:
|
||||
|
||||
HIT real name, rating == the row's `value`, assetId == carddbid
|
||||
MISS "DB Error", rating 50, assetId == this family's fallback
|
||||
WRONG-BRANCH "DB Error", rating 50, assetId == ANOTHER family's fallback
|
||||
NO-MERGE our sentinel rating survives, name empty -> the query never ran
|
||||
|
||||
so every failure mode says which one it is. That is what makes the negative
|
||||
interpretable, and it is the reason coaches are the cheapest family to prove.
|
||||
|
||||
Two properties were checked against the on-disk dumps and both hold:
|
||||
* no row in any of the four tables has value == 50, so rating 50 can only be a
|
||||
miss -- it can never be a hit that happens to look like one;
|
||||
* no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), the
|
||||
fitness miss-fill triple at +0xdd/+0xde/+0xdf, so fitness has a second,
|
||||
fully independent oracle that does not depend on reading a name at all.
|
||||
|
||||
The known-bad ids are ids INSIDE each family's own carddbid band that are absent
|
||||
from the table -- 195..207 such gaps exist per family, so a bad control is never
|
||||
an out-of-range value the client might reject for an unrelated reason.
|
||||
|
||||
THE SENTINEL. Every item is sent with rating SENTINEL (=1), which no coach row
|
||||
carries and which the miss-fill never writes. If a card comes back still holding
|
||||
rating 1, the merge did not run at all; that is the NO-MERGE arm and it is a
|
||||
different bug from either a hit or a miss.
|
||||
|
||||
DO NOT ROUTE THIS THROUGH FUT_ID_SWEEP. sweep_items() keeps itemType "player"
|
||||
and varies only cardsubtypeid, and club_route answers a sweep BEFORE the ?type=
|
||||
filter, so a sweep-borne staff experiment is confounded twice over.
|
||||
|
||||
WHICH SCREEN FIRES IT. The client's own ?type= taxonomy (FUN_18012ec50, 29 arms
|
||||
+ default) does contain headcoach / gkcoach / physio / fitnesscoach / staff --
|
||||
atoms 0x153 / 0x13f / 0x21d / 0x129 / 0x2dc. NONE of those five has ever been
|
||||
seen on the wire. A grep of every capture and log in this repo finds exactly
|
||||
three values: type=player (x14), type=manager (x2), type=custom (x1). So the
|
||||
screen to aim this at is the STAFF tab, which sends type=manager, and the reason
|
||||
these items reach it is that club_route already filters on cardsubtypeid -- not
|
||||
on itemType and not on the type string -- keeping everything outside 0..3.
|
||||
|
||||
Usage:
|
||||
python3 coach_window.py # human-readable prediction table
|
||||
python3 coach_window.py --json items.json
|
||||
python3 coach_window.py --family headcoach --json one.json
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SEEDS = os.path.join(HERE, "..", "data", "coach_seeds.json")
|
||||
|
||||
# Item ids for the experiment. Far above fut_store's ITEM_ID_BASE range so a
|
||||
# stray record in the CardsDb map can always be attributed.
|
||||
ID_BASE = 950000000
|
||||
SENTINEL_RATING = 1
|
||||
|
||||
|
||||
# A DISTINCT, unmistakable nation + league per family. This is the SECOND
|
||||
# question the same window answers, and it is free: the four coach branches of
|
||||
# FUN_180141660 never write nation (+0x148), leagueId (+0x154), teamid (+0x94) or
|
||||
# position (+0x146), and none of the four tables even HAS a nation/league/team
|
||||
# column, so these values cannot change the hit/miss outcome. Whatever a coach
|
||||
# card shows for country or league therefore came from US.
|
||||
#
|
||||
# Both the good and the bad ids of a family carry the same pair, so a "DB Error"
|
||||
# card flying a Brazilian flag is itself direct proof that the miss-fill leaves
|
||||
# our fields alone.
|
||||
#
|
||||
# nation 14 (England) is deliberately AVOIDED: 14 is what the PLAYER miss-fill
|
||||
# writes, and a value that doubles as a known failure fingerprint is not a probe.
|
||||
FACE = { # family -> (nationid, leagueid)
|
||||
"headcoach": (54, 13), # Brazil, Premier League
|
||||
"gkcoach": (45, 19), # Spain, Bundesliga
|
||||
"physio": (27, 16), # Italy, Ligue 1
|
||||
"fitnesscoach": (21, 53), # Germany, LaLiga Santander
|
||||
}
|
||||
|
||||
|
||||
def _item(item_id, carddbid, cardsubtypeid, nation=0, league=0):
|
||||
"""One staff item.
|
||||
|
||||
EXACTLY the field set fut_store._item() already builds and that this client
|
||||
is live-proven to parse. The only changes are cardsubtypeid and the ids. No
|
||||
new atom is introduced: the wire shape of a real staff item has NEVER been
|
||||
observed, and inventing one -- a scalar where the parser wants an object --
|
||||
is the change class that busy-loops the client at 0x1801c7f1a.
|
||||
|
||||
resourceId carries NO version nibble. The staff branches compare
|
||||
`carddbid == *(u32*)(record+0x18)` on the RAW dword; players are the only
|
||||
family that masks with & 0xffffff. A high byte here breaks every lookup and
|
||||
does it silently.
|
||||
|
||||
itemType stays "player" for the first run. The merge dispatches on
|
||||
cardsubtypeid alone, and club_route's ?type= filter already keys on
|
||||
cardsubtypeid (anything not in 0..3 survives a non-player type), so nothing
|
||||
needs itemType to be changed in order for these to reach the STAFF tab.
|
||||
Flipping it to the taxonomy name ("headcoach"/"gkcoach"/"physio"/
|
||||
"fitnesscoach", atoms 0x153/0x13f/0x21d/0x129) is a separate, later,
|
||||
one-variable experiment.
|
||||
"""
|
||||
return {
|
||||
"id": item_id,
|
||||
"resourceId": carddbid, # == carddbid, raw, no version byte
|
||||
"assetId": carddbid,
|
||||
"cardassetid": carddbid,
|
||||
"definitionId": carddbid,
|
||||
"cardsubtypeid": cardsubtypeid,
|
||||
"itemType": "player",
|
||||
"rareflag": 1, # overwritten by the merge either way
|
||||
"rating": SENTINEL_RATING,
|
||||
"preferredPosition": "ST",
|
||||
"nation": nation,
|
||||
"teamid": 0,
|
||||
"leagueId": league,
|
||||
"playStyle": 250,
|
||||
# zeros so that, for head coach and GK coach, the ONE slot the merge
|
||||
# writes (attrs[row.attribute] = row.amount) stands out against five
|
||||
# untouched zeros -- that single write verifies two columns at once.
|
||||
"attributeList": [{"index": i, "value": 0} for i in range(6)],
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": True,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
}
|
||||
|
||||
|
||||
def build(families=None):
|
||||
with open(SEEDS) as f:
|
||||
spec = json.load(f)
|
||||
fams = spec["families"]
|
||||
order = [f for f in ("headcoach", "gkcoach", "physio", "fitnesscoach")
|
||||
if families is None or f in families]
|
||||
|
||||
items, predict, n = [], [], 0
|
||||
for fam in order:
|
||||
d = fams[fam]
|
||||
sub, fb = d["cardsubtypeid"], d["miss_fill_assetid"]
|
||||
nat, lg = FACE[fam]
|
||||
good = [r["carddbid"] for r in d["seeds"]]
|
||||
bad = d["bad_controls"]
|
||||
rows = {r["carddbid"]: r for r in d["seeds"]}
|
||||
|
||||
# INTERLEAVE. The client pages the club (start=N&count=11 observed), so
|
||||
# good and bad must alternate or a page can come back all-good/all-bad
|
||||
# and prove nothing on its own screenshot.
|
||||
mixed, gi, bi = [], 0, 0
|
||||
while gi < len(good) or bi < len(bad):
|
||||
for _ in range(3):
|
||||
if gi < len(good):
|
||||
mixed.append((good[gi], True)); gi += 1
|
||||
if bi < len(bad):
|
||||
mixed.append((bad[bi], False)); bi += 1
|
||||
|
||||
for cid, is_good in mixed:
|
||||
iid = ID_BASE + n; n += 1
|
||||
items.append(_item(iid, cid, sub, nat, lg))
|
||||
if is_good:
|
||||
r = rows[cid]
|
||||
v = r["value"]
|
||||
p = {"id": iid, "family": fam, "carddbid": cid, "expect": "HIT",
|
||||
"rating": v, "tier": 3 if v >= 75 else (2 if v >= 65 else 1),
|
||||
"rare": r["rare"], "assetId": cid, "name": "a real person",
|
||||
"face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
|
||||
if fam in ("headcoach", "gkcoach"):
|
||||
p["attrs"] = "index %d == %d, other five == 0" % (
|
||||
r["attribute"], r["amount"])
|
||||
elif fam == "physio":
|
||||
p["stat_byte"] = "+%#x == %d" % (0xDD + r["attribute"],
|
||||
r["amount"])
|
||||
else:
|
||||
p["stat_bytes"] = "+0xdd/+0xde/+0xdf == %d/%d/%d" % (
|
||||
r["fieldpos"], r["posbonus"], r["amount"])
|
||||
else:
|
||||
p = {"id": iid, "family": fam, "carddbid": cid, "expect": "MISS",
|
||||
"rating": 50, "tier": 1, "rare": 1, "assetId": fb,
|
||||
"name": "DB Error DB Error",
|
||||
"face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
|
||||
if fam in ("headcoach", "gkcoach"):
|
||||
p["attrs"] = "index 0 == 15, other five == 0"
|
||||
elif fam == "physio":
|
||||
p["stat_byte"] = "+0xdd == 15"
|
||||
else:
|
||||
p["stat_bytes"] = "+0xdd/+0xde/+0xdf == 1/7/1"
|
||||
predict.append(p)
|
||||
|
||||
# ONE deliberate cross-family item per family: this family's BEST-KNOWN
|
||||
# good id sent under the NEXT family's cardsubtypeid. It must miss, and
|
||||
# its fallback assetid must name the OTHER table. That is the only item
|
||||
# in the window that can distinguish "the subtype picks the table" from
|
||||
# "the id band picks the table", and it is interpretable in both
|
||||
# directions: a real name here would refute the dispatch outright.
|
||||
other = order[(order.index(fam) + 1) % len(order)]
|
||||
if other != fam:
|
||||
osub = fams[other]["cardsubtypeid"]
|
||||
ofb = fams[other]["miss_fill_assetid"]
|
||||
iid = ID_BASE + n; n += 1
|
||||
# the cross-family item keeps the SOURCE family's face pair, so if
|
||||
# it ever renders it is visibly the head-coach flag on a gkcoach slot
|
||||
items.append(_item(iid, good[0], osub, nat, lg))
|
||||
predict.append({"id": iid, "family": "%s-id/%s-subtype" % (fam, other),
|
||||
"carddbid": good[0], "expect": "MISS (cross-family)",
|
||||
"rating": 50, "tier": 1, "rare": 1, "assetId": ofb,
|
||||
"name": "DB Error DB Error",
|
||||
"refutes": "a real name here means cardsubtypeid does "
|
||||
"NOT select the table"})
|
||||
return items, predict
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--json", metavar="PATH", help="write the item array")
|
||||
ap.add_argument("--family", action="append",
|
||||
choices=["headcoach", "gkcoach", "physio", "fitnesscoach"])
|
||||
a = ap.parse_args()
|
||||
|
||||
items, predict = build(a.family)
|
||||
nfam = len({p["family"] for p in predict if "/" not in p["family"]})
|
||||
print("%d items across %d familie(s), including %d deliberate cross-family "
|
||||
"controls. Every item carries rating=%d as the NO-MERGE sentinel.\n"
|
||||
% (len(items), nfam, sum(1 for p in predict if "/" in p["family"]),
|
||||
SENTINEL_RATING))
|
||||
print("%-11s %-24s %-9s %-20s %-5s %-4s %-8s %s"
|
||||
% ("id", "family", "carddbid", "expect", "rat", "tier", "assetId", "extra"))
|
||||
for p in predict:
|
||||
extra = p.get("attrs") or p.get("stat_byte") or p.get("stat_bytes") or ""
|
||||
print("%-11d %-24s %-9d %-20s %-5d %-4d %-8d %s"
|
||||
% (p["id"], p["family"], p["carddbid"], p["expect"], p["rating"],
|
||||
p["tier"], p["assetId"], extra))
|
||||
n_hit = sum(1 for p in predict if p["expect"] == "HIT")
|
||||
print("\npredicted: HIT=%d MISS=%d (a run where all %d agree is the proof; "
|
||||
"any single disagreement names its own failure mode)"
|
||||
% (n_hit, len(predict) - n_hit, len(predict)))
|
||||
|
||||
if a.json:
|
||||
with open(a.json, "w") as f:
|
||||
json.dump({"itemData": items, "predictions": predict}, f, indent=1)
|
||||
print("\nwrote %s" % a.json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/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()))
|
||||
Reference in New Issue
Block a user