From 9b2243542130966c5e840b425b1480d336459048 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 5 Aug 2026 10:05:05 -0700 Subject: [PATCH] fifa17-recon: manager cards -- 417 ids, real names, and a nation/league slot that is ours alone managercards: 417 rows, carddbid 1000001..1001552, assetid == carddbid on all 417, value 57..88 (120 rows at exactly 57), rare 282/135 and NOT a rating threshold. talkrating and formationid are 0 on ALL 417 rows -- both columns are dead in FIFA 17. MEASURED from data/tables/, rowcount == rows_emitted. Names come out after all, and by the CLIENT's own rule rather than an inference: FUN_1801bb060 (879 bytes, read in full) does SELECT firstname,surname,... FROM manager WHERE managerid == (*(u32*)(rec+0x18) & 0xffffff) - 1000000. Joining data/tables/ manager.json on that rule gives 1000509 Luis Enrique 88, 1000089 Wenger 86, 1000417 Guardiola 87, 1000414 Klopp 84 -- the ratings match the men, which a shifted column could not produce. This supersedes the note that we get ids and not names: true of managercards, false once you join `manager`. Fixed here: `manager` has 747 rows but 746 distinct managerids -- managerid 107 is duplicated with an empty-name row, and a plain dict comprehension kept the wrong one, silently giving carddbid 1000107 a blank name and teamid 1357 instead of Slutskiy and 315. 296 -> 297 usable names. THE KEY FACT, verified at instruction level: the parser routes the JSON `nation` to a DIFFERENT record offset for a manager. At 0x180140e0b FUN_1800d8330's result is DEC'd twice -- cardtype 1 stores nation to rec+0x148, cardtype 2 to rec+0xde, everything else DISCARDS it. leagueId (atom 0x18a) lands unconditionally at rec+0xe0. The manager merge FUN_1801356c0 (452 bytes, 1,587 chars, read in full) writes only firstname, lastname, assetid, rating, talkrating, negotiation and rare -- it never touches rec+0xde/+0xe0. So for a manager, WE are the only source of nation and league, and they are read: FUN_1801a8580/FUN_1801a8540 feed FUN_1800e5940 ("ManagerCardBio"), which publishes NATIONALITY, NATIONALITY_ASSET_ID and LEAGUE_ID. That merge has NO else-branch, so unlike a coach a wrong manager id is COMPLETELY SILENT. resourceId must equal carddbid exactly -- the manager arm reads the key raw, with no & 0xffffff. Also corrected against the design round's own draft: talkrating and negotiation are NOT unread. FUN_1800e5940 publishes them as ATTRIB_TEAM_TALKS (rec+0xe2) and ATTRIB_CONTRACT_NEGOTIATION (rec+0xe3). They come from the DB, not from us, and since talkrating is 0 on all 417 rows TEAM TALKS reads 0 on every manager card in the game. Not wired into the server in this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW --- fifa17-recon/tools/fut_staff.py | 280 ++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 fifa17-recon/tools/fut_staff.py diff --git a/fifa17-recon/tools/fut_staff.py b/fifa17-recon/tools/fut_staff.py new file mode 100644 index 0000000..0c374bf --- /dev/null +++ b/fifa17-recon/tools/fut_staff.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Manager (and, later, coach) cards for the FIFA 17 offline backend. + +HAND-OVER MODULE. Nothing here is imported by utas_server / fut_store / fut_cards +yet -- those three have a single writer. This file is the data plus the exact item +shape that the integrator should wire in. See the spec at the bottom. + +WHY A MANAGER ITEM LOOKS NOTHING LIKE A PLAYER ITEM +--------------------------------------------------- +Both go through the SAME deserializer, `FUN_18013fe00` (1234 instructions, fully +enumerated -- record base is RBP+0x160, the record is memset to zero at +0x180140020 and spans rec+0x00..0x157). At the tail it calls the merge dispatcher +`FUN_180141660`, which switches on rec+0x4c (the cardtype that `FUN_1800d8330` +derives from `cardsubtypeid` alone) and, for cardtype 2, calls `FUN_1801356c0`: + + SELECT firstname,lastname,assetid,value,talkrating,negotiation,rare + FROM managercards WHERE carddbid == *(u32*)(rec+0x18) <-- RAW, no & 0xffffff + + on rowcount > 0: rec+0xb8 firstname(16) rec+0xc8 lastname(21) + rec+0x20 assetid rec+0xb4 rating ( = `value` ) + rec+0xe2 talkrating rec+0xe3 negotiation + rec+0x58 (rare == 1) + on rowcount < 1: NOTHING. There is no else-branch. A wrong manager id is silent. + +So every field above is the CLIENT's to fill and ours to leave out. What the merge +does NOT write is ours alone, and the parser reserves two slots specifically for a +manager (verified at instruction level, 0x180140e1c..0x180140e37): + + MOV [RBP+0x1ac],EAX ; rec+0x4c = cardtype + DEC EAX / JZ ...e32 ; cardtype == 1 -> player + DEC EAX / JNZ ...e3e ; cardtype != 2 -> the JSON `nation` is DISCARDED + MOVZX EAX,word [RSP+0x38] + MOV word [RBP+0x23e],AX ; rec+0xde <-- MANAGER nation + ... + MOV word [RBP+0x2a8],AX ; rec+0x148 <-- PLAYER nation + +`leagueId` (atom 0x18a) is stored unconditionally as a u16 at rec+0xe0 +(0x180140739). For a player the merge immediately overwrites rec+0xdd.. with the +knownAs string, so it only survives on a card whose merge does not write there -- +i.e. a manager. rec+0xde nation, rec+0xe0 league, rec+0xe2 talkrating, +rec+0xe3 negotiation is one contiguous manager block, and it is exactly the +league+nation pair that manager chemistry is built on. + +CONCLUSION, and it inverts the player rule: for a PLAYER we send zero and let the +client fill in; for a MANAGER the client fills in name/rating/rare/assetid and +WE are the only source of nation and league. +""" +import json, os + +HERE = os.path.dirname(os.path.abspath(__file__)) +TABLES = os.path.join(os.path.dirname(HERE), "data", "tables") + + +def _table(name): + with open(os.path.join(TABLES, name + ".json")) as f: + return json.load(f)["rows"] + + +def _fix(s): + """db_dump wrote UTF-8 bytes through a latin-1 decode; undo it.""" + try: + return s.encode("latin-1").decode("utf-8") + except Exception: + return s + + +def _build(): + """managercards x manager x leagueteamlinks -> one row per manager card. + + `managercards` (417 rows, carddbid 1000001..1001552, assetid == carddbid for + all 417) has firstname/lastname as 32-bit string-pool offsets whose pool was + never located, so it gives us no names. The `manager` table (747 rows, + managerid 1..1631) has INLINE names, and the client itself joins the two: + `FUN_1801bb060` does + + SELECT firstname,surname,headid,suittypeid,skintonecode,... FROM manager + WHERE managerid == (*(u32*)(rec+0x18) & 0xffffff) - 1000000 + + to build the in-match manager. carddbid - 1000000 == managerid, confirmed by + the names it produces: 1000089 Wenger 86, 1000509 Luis Enrique 88, 1000417 + Guardiola 87, 1000113 Zidane 88. 297 of the 417 join; the 120 that do not are + the 1001432+ tail, all rated 57, with no career appearance row. + + Names are for OUR logs only. We never put a name on the wire -- the client + writes its own into rec+0xb8/+0xc8 from managercards. + """ + # `manager` has 747 rows but only 746 distinct managerids: managerid 107 appears + # twice, once as Leonid Slutskiy (teamid 315) and once as an EMPTY-name row + # (teamid 1357). A plain dict comprehension keeps the last, which silently gave + # carddbid 1000107 a blank name and the wrong club. Prefer the row that has a name. + mgr = {} + for r in _table("manager"): + prev = mgr.get(r["managerid"]) + if prev is None or (not (prev["firstname"] or prev["surname"]) + and (r["firstname"] or r["surname"])): + mgr[r["managerid"]] = r + league_of = {} + for r in _table("leagueteamlinks"): + league_of.setdefault(r["teamid"], r["leagueid"]) + out = [] + for r in _table("managercards"): + m = mgr.get(r["carddbid"] - 1000000) + team = m["teamid"] if m else 0 + out.append({ + "carddbid": r["carddbid"], # the merge key; also the artwork key + "rating": r["value"], # the client writes this itself + "rare": r["rare"], # the client writes this itself + "nation": r["nation"], # OURS: rec+0xde + "leagueId": league_of.get(team, 0), # OURS: rec+0xe0 + "teamid": team, # OURS: rec+0x94 + "name": _fix(m["firstname"] + " " + m["surname"]) if m else "", + }) + return out + + +MANAGERS = _build() +BY_ID = {m["carddbid"]: m for m in MANAGERS} + +# Ten real carddbids spread across the whole 57..88 rating band, every one of them +# joined to a `manager` row (so the portrait resolves) and to a league (so the +# league logo and the league half of chemistry resolve). +STARTER_MANAGERS = [ + 1000509, # Luis Enrique 88 Spain(45) LaLiga(53) Barcelona + 1000183, # Antonio Conte 87 Italy(27) Premier(13) Chelsea + 1000089, # Arsene Wenger 86 France(18) Premier(13) Arsenal + 1000414, # Juergen Klopp 84 Germany(21) Premier(13) Liverpool + 1000096, # Ronald Koeman 82 Netherlands(34) Premier(13) Everton + 1000052, # Alan Pardew 80 England(14) Premier(13) Crystal Palace + 1000034, # Eusebio Di Francesco 78 Italy(27) Serie A(31) Sassuolo + 1000090, # Peter Maes 75 Belgium(7) Pro League(4) Genk + 1000019, # Dariusz Wdowczyk 70 Poland(37) Ekstraklasa(66) Wisla Krakow + 1000505, # Martin Canning 65 Scotland(42) Scottish Prem(50) Hamilton +] + +MANAGER_SUBTYPE = 4 # cardsubtypeid 4 -> FUN_1800d8330 -> cardtype 2 + +# The four staff families that DO write a loud miss-fill, for use as positive +# controls: firstname/lastname "DB Error", rating 0x32, rare 1, and a table-unique +# assetid. subtype -> (cardtype, table, a deliberately INVALID carddbid). +STAFF_CONTROLS = { + 5: (3, "headcoachcards", 2999999), + 6: (10, "gkcoachcards", 9999999), + 7: (5, "physiocards", 4999999), + 8: (4, "fitnesscoachcards", 3999999), +} + + +def manager_item(item_id, carddbid, rating=None, contract=7, untradeable=True): + """The manager item JSON. Every key is justified; nothing else is sent. + + id -> rec+0x08 our item handle, needed for move/quick-sell + resourceId -> rec+0x18 THE merge key, read RAW as a u32. It must equal + carddbid exactly: no version byte, because + FUN_1801356c0 does not mask. The same field, masked + to &0xffffff, is what the view-model (FUN_1800d7920, + p2[1]) and FUN_1801bb060 use as the artwork key. + cardsubtypeid -> rec+0x50 4. This alone selects the managercards merge. + itemType -> DISCARDED atom 0x173 is parsed into a stack std::string at + RBP+0xc8 (0x180140262..0x180140279) and freed at the + end of the function. It is not written into the + record: all 1234 instructions of FUN_18013fe00 were + enumerated and every store and every LEA into + RBP+0x160..0x2b7 accounted for; none comes from that + string. "staff" is therefore inert on the wire, and + is used only so OUR OWN readers can see at a glance + that this is not a footballer. Nothing may depend on + it -- see the spec note about the three filters in + utas_server that currently key on itemType. + nation -> rec+0xde MANAGER-ONLY SLOT. Not touched by the merge, so this + is the only source. Nation half of chemistry + flag. + leagueId -> rec+0xe0 Same: ours alone. League half of chemistry + logo. + teamid -> rec+0x94 read by the view-model (p2[2]). The manager's real + club, from the `manager` table. + contract -> rec+0x8c staff cards consume contracts like players do. + itemState -> rec+0x5c "free" == not listed; same meaning as for a player. + owners -> rec+0x48 + untradeable -> rec+0x49 + + DELIBERATELY ABSENT, each because it provably does nothing on a manager card: + assetId / cardassetid rec+0x20 is overwritten by the merge with the DB + assetid (which equals carddbid for all 417 rows). + definitionId not one of the 52 atoms this parser handles -> routed + to the value-SKIP handler FUN_180135ff0. + rating rec+0xb4 is overwritten by the merge with `value`. + Pass rating= ONLY for the live probe below. + rareflag rec+0x58 is overwritten with (rare == 1). This is also + why fut_store._item()'s hardcoded "rareflag": 1 -- the + trap that silently converts a Player Fitness consumable + into a Squad Fitness one -- cannot hurt a manager. + preferredPosition rec+0x146 survives the merge and is read by the + view-model, so sending it would hang a position label + on a manager. + attributeList rec+0x98.. likewise survives and is likewise read. + playStyle / fitness player-only. + """ + it = { + "id": item_id, + "resourceId": carddbid, + "cardsubtypeid": MANAGER_SUBTYPE, + "itemType": "staff", + "nation": BY_ID[carddbid]["nation"], + "leagueId": BY_ID[carddbid]["leagueId"], + "teamid": BY_ID[carddbid]["teamid"], + "contract": contract, + "itemState": "free", + "owners": 1, + "untradeable": untradeable, + } + if rating is not None: + it["rating"] = rating + return it + + +def staff_control_item(item_id, subtype, carddbid=None): + """A self-labelling positive control. The four coach arms of FUN_180141660 all + write, on rowcount < 1: firstname = lastname = "DB Error", rec+0xb4 = 0x32, + rec+0x58 = 1, and a table-unique assetid. A deliberately invalid id therefore + puts the words DB ERROR on screen, which proves the whole chain ran.""" + ct, table, bad = STAFF_CONTROLS[subtype] + return { + "id": item_id, + "resourceId": carddbid if carddbid is not None else bad, + "cardsubtypeid": subtype, + "itemType": "staff", + "itemState": "free", + "owners": 1, + "untradeable": True, + } + + +OVERLAY_ID_BASE = 960000000 # the club overlay's ids: clear of the save (1e8), the + # sweep (9e8), the consumable shelf (9.4e8), the coach + # shelf (9.5e8) and the probe below (9.1e8) +PROBE_ID_BASE = 910000000 # distinct from the save (1e8) and the sweep (9e8) +PROBE_SENTINEL_RATING = 7 # nowhere near the 57..88 band `value` can produce + + +def probe_items(carddbid=1000509): + """THE live test. Four items, one club fetch, every outcome interpretable. + + A real manager, sentinel rating 7 + B the same manager with a version byte in the top octet of resourceId + C a headcoach id that cannot exist -> must render "DB Error" + D a manager id that cannot exist -> the silent-miss reference + """ + n = PROBE_ID_BASE + m = BY_ID[carddbid] + a = manager_item(n + 1, carddbid, rating=PROBE_SENTINEL_RATING) + b = manager_item(n + 2, carddbid, rating=PROBE_SENTINEL_RATING) + b["resourceId"] = (1 << 24) | carddbid + c = staff_control_item(n + 3, 5) + d = manager_item(n + 4, carddbid, rating=PROBE_SENTINEL_RATING) + d["resourceId"] = 1009999 + d["nation"] = m["nation"] + d["leagueId"] = m["leagueId"] + d["teamid"] = m["teamid"] + return [a, b, c, d] + + +if __name__ == "__main__": + import sys + if "--probe" in sys.argv: + print(json.dumps({"itemData": probe_items()}, indent=1)) + elif "--starter" in sys.argv: + print(json.dumps([manager_item(100900000 + i, c) + for i, c in enumerate(STARTER_MANAGERS)], indent=1)) + else: + print("%d manager cards, carddbid %d..%d, rating %d..%d, %d rare, " + "%d with a `manager` row" + % (len(MANAGERS), MANAGERS[0]["carddbid"], MANAGERS[-1]["carddbid"], + min(m["rating"] for m in MANAGERS), + max(m["rating"] for m in MANAGERS), + sum(m["rare"] for m in MANAGERS), + sum(1 for m in MANAGERS if m["name"]))) + for c in STARTER_MANAGERS: + m = BY_ID[c] + print(" %d %-24s rating %2d rare %d nation %3d league %3d team %6d" + % (c, m["name"], m["rating"], m["rare"], m["nation"], + m["leagueId"], m["teamid"]))