9b22435421
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
281 lines
14 KiB
Python
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"]))
|