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