fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
This commit is contained in:
@@ -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=<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"]))
|
||||
Reference in New Issue
Block a user