Files
OpenFUT/fifa17-recon/tools/fut_staff.py
T
funman300 9b22435421 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 10:05:05 -07:00

281 lines
14 KiB
Python

#!/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=<sentinel> 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"]))