70a64e3709
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.
292 lines
14 KiB
Python
292 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Consumable cards (cardtype 6) for the FIFA 17 offline backend.
|
|
|
|
THE CHEAPEST WHOLE FAMILY IN THE GAME: no id space has to be discovered, because a
|
|
consumable carries no identity at all. `FUN_18013f4d0` (8,354 chars, read to its
|
|
closing brace) has exactly two callees -- a range clamp and an enum map -- and never
|
|
touches a DB handle. Everything on the card comes from `cardsubtypeid` alone:
|
|
|
|
cardsubtypeid --FUN_1800d8330--> cardtype 6
|
|
--FUN_18013f4d0--> category -> record+0xb8
|
|
sub-sel -> record+0xbc (i16)
|
|
amount -> record+0xbf (i8) [or +0xbe playstyle]
|
|
single -> record+0xc0
|
|
|
|
and `FUN_1801bfac0` then renders category + those bytes into a FUT_CONSUMABLE_*
|
|
string and a hardcoded 5000xxx artwork constant. resourceId NEVER reaches the screen
|
|
for a consumable, which is why we can pick ids freely -- we still use EA's own
|
|
`fcc_*` carddbids so nothing drifts out of their space.
|
|
|
|
THE TWO THINGS WE MUST GET RIGHT
|
|
--------------------------------
|
|
1. `amount` (atom 0x1b) is MANDATORY for categories 0, 4, 5, 9, 10. The parser
|
|
initialises its temp to 0xffffffffffffffff, so OMITTING it stamps (byte)-1 into
|
|
record+0xbf -- and the accessors FUN_1801a8040/FUN_1801a8060 both do
|
|
`(int)*(char *)`, i.e. SIGNED, so the card reads "-1", not "255". Categories 2 and
|
|
3 (the two contract cards) take their number from a DIFFERENT atom, `contract`
|
|
(0xb8) -> record+0x8c, and IGNORE `amount` entirely.
|
|
2. rareflag must be 0 on subtype 219. See fut_store._SQUAD_FITNESS_TRAP: rareflag 1
|
|
silently converts a Player Fitness card into a Squad Fitness card.
|
|
|
|
A WRONG SUBTYPE IS SILENT *AND LOOKS PLAUSIBLE*. There is no "DB Error" analogue
|
|
here: a dead-zone subtype falls to the bottom default of FUN_18013f4d0 (category 0,
|
|
+0xbc = 0, +0xbf = 0) and FUN_1801bfac0 then renders it as a perfectly ordinary Squad
|
|
Training (Pace) card with amount 0. That is why every subtype we ship comes out of
|
|
data/consumables.json and `consumable_item` REFUSES a dead zone rather than trusting
|
|
the caller.
|
|
|
|
data/consumables.json is generated by tools/build_consumables.py from the three
|
|
decompiles above plus EA's own authored variants in fcc_trainingcards (143 rows),
|
|
fcc_healingcards (27) and fcc_contractcards (13).
|
|
"""
|
|
import json, os
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DATA = os.path.join(os.path.dirname(HERE), "data", "consumables.json")
|
|
|
|
with open(DATA) as _f:
|
|
_DOC = json.load(_f)
|
|
|
|
SUBTYPES = _DOC["subtypes"]
|
|
BY_SUBTYPE = {r["cardsubtypeid"]: r for r in SUBTYPES}
|
|
|
|
# ?type= -> the consumable CATEGORIES that arm asks for.
|
|
#
|
|
# The vocabulary is certain: FUN_18012ec50's 29 arms resolve through the atom table
|
|
# to healing=23, contract=24, training=25, development=6 (and there is NO fitness,
|
|
# position, formation, playstyle or managerLeague arm). The category grouping below
|
|
# is INFERRED from FUN_180048780's UI-bucket names, because the tab-to-arm binding
|
|
# has NEVER been observed on the wire -- only type=player, type=manager and
|
|
# type=custom have ever come from this client. Hence the flag, and hence the log line
|
|
# in utas_server's club_route that prints every ?type= it is asked for.
|
|
TYPE_CATEGORIES = {
|
|
"contract": {2, 3}, # player contract, manager contract
|
|
"training": {0}, # GK training + player training
|
|
"healing": {4, 5}, # healing, and fitness has no arm of its own
|
|
"development": {6, 7, 8, 9, 10}, # formation, position, playstyle, mgr league
|
|
}
|
|
|
|
# Families worth shipping: they have EA-authored variants and none needs a lookup the
|
|
# client cannot do from the subtype alone.
|
|
#
|
|
# THREE FAMILIES ARE DELIBERATELY EXCLUDED, each for a named reason:
|
|
# manager_formation_mod (71-86, category 6) vestigial AND a crash candidate:
|
|
# zero rows in the 143-row fcc_trainingcards (the carddbid run jumps 5003042 ->
|
|
# 5003059, exactly 16 ids), and FUN_1801bfac0 case 6 calls FUN_1801a0100 on the
|
|
# formations query result WITHOUT the `if (0 < rowcount)` guard its otherwise
|
|
# identical case 7 has.
|
|
# formation_mod (121-136, category 7) guarded, but its artwork constant is -1, so
|
|
# there is nothing to look at yet. Left for a later round.
|
|
# manager_league (300-341, category 10) FUN_1801bfac0 case 10 formats the label as
|
|
# literally "ML: %d" from record+0xbc -- a raw number, no league-name lookup --
|
|
# and one shipped amount (2118, subtype 337) is in neither leagues.json nor
|
|
# fcc_leagues.json.
|
|
CORE_KINDS = ("player_contract", "manager_contract", "healing",
|
|
"player_fitness", "squad_fitness", "gk_training", "player_training",
|
|
"position_mod", "player_playstyle", "gk_playstyle")
|
|
|
|
# carddbid -> cardassetid, the ART id, read from the game's own fcc_ tables.
|
|
#
|
|
# THE TWO IDS ARE NOT INTERCHANGEABLE and this cost a live debugging round. A card
|
|
# draws its artwork from cardassetid, which is a SMALL id (3 training, 7 contract,
|
|
# 10 healing, 45 misc), not the carddbid. Copying resourceId into cardassetid is
|
|
# right for players and wrong here: the client looked up art 5003001, found nothing,
|
|
# and drew external/ion_fut/artAssets/.../notfound.swf -- a green NOT FOUND box on
|
|
# every consumable card until 2026-08-05.
|
|
_ART_BY_CARDDBID = None
|
|
|
|
|
|
def art_id(carddbid, default=None):
|
|
global _ART_BY_CARDDBID
|
|
if _ART_BY_CARDDBID is None:
|
|
import glob
|
|
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
|
m = {}
|
|
for f in glob.glob(os.path.join(d, "fcc_*.json")):
|
|
try:
|
|
rows = json.load(open(f)).get("rows") or []
|
|
except Exception:
|
|
continue
|
|
for r in rows:
|
|
if "carddbid" in r and "cardassetid" in r:
|
|
m[r["carddbid"]] = r["cardassetid"]
|
|
_ART_BY_CARDDBID = m
|
|
return _ART_BY_CARDDBID.get(carddbid, default)
|
|
|
|
CONSUMABLE_ID_BASE = 940000000 # distinct from the save (1e8), the sweep (9e8)
|
|
# and the staff overlay (9.5e8)
|
|
|
|
|
|
def variants(subtype):
|
|
"""EA's own authored (carddbid, amount, rating, weightrare) rows for a subtype."""
|
|
return BY_SUBTYPE[subtype].get("ea_variants", [])
|
|
|
|
|
|
def consumable_item(item_id, subtype, amount=None, contract=None, rating=None,
|
|
resource_id=None, rareflag=0, untradeable=True):
|
|
"""Build one consumable item.
|
|
|
|
Eleven common keys plus at most one class key. Every one of the eleven is already
|
|
proven on the wire by the live player path, so this introduces NO new wire shape
|
|
-- which matters, because a scalar where the parser wants an object busy-loops the
|
|
client at 0x1801c7f1a.
|
|
|
|
id -> rec+0x08 our handle
|
|
resourceId -> rec+0x18 written by FUN_18013f4d0 from its param_2; never
|
|
rendered for a consumable (artwork is a constant), so
|
|
this is bookkeeping only. Defaults to EA's carddbid.
|
|
assetId/cardassetid same value, for our own readers
|
|
cardsubtypeid -> rec+0x50 THE ONLY selector. Category, artwork, name and both
|
|
stat bytes all derive from it.
|
|
itemType "player": the ONLY value this client has ever been
|
|
sent. cardtype is derived from cardsubtypeid alone
|
|
(FUN_18013fe00 line 713), so the string cannot affect
|
|
the render. If the consumables tab comes back empty
|
|
this is the first thing to vary; the candidates from
|
|
the atom table are "training"/"contract"/"healing".
|
|
rareflag -> rec+0x58 0 by default. Read unconditionally into the card's
|
|
rare/backing art AND, in category 5 only, as the
|
|
squad-fitness selector -- see the 219 guard below.
|
|
rating -> rec+0xb4 drives level (rec+0x54: <65 bronze, <75 silver, else
|
|
gold) and therefore the fcc_discardcoins price.
|
|
itemState / owners / untradeable same meaning as on a player.
|
|
|
|
amount -> rec+0xbf (or +0xbe for playstyle). MANDATORY where `needs`
|
|
says so: omitting it stamps -1, not 0.
|
|
contract -> rec+0x8c categories 2 and 3 only.
|
|
|
|
DELIBERATELY ABSENT: preferredPosition, nation, teamid, leagueId, playStyle,
|
|
attributeList, fitness (all player-only), definitionId (not an atom at all -- the
|
|
parser has always been skipping it), and discardValue (the client computes it from
|
|
fcc_discardcoins on (cardtype 6, level, rare), and real rows exist for both rare
|
|
values, so omission is safe).
|
|
"""
|
|
r = BY_SUBTYPE.get(subtype)
|
|
if r is None:
|
|
raise ValueError("cardsubtypeid %r is not a cardtype-6 subtype" % (subtype,))
|
|
if r["kind"] == "DEAD_ZONE":
|
|
raise ValueError(
|
|
"cardsubtypeid %d is a DEAD ZONE: it renders as a plausible Squad "
|
|
"Training (Pace) card with amount 0 and gives no hint anything is wrong"
|
|
% subtype)
|
|
needs = set(r.get("needs", ()))
|
|
if "amount" in needs and amount is None:
|
|
raise ValueError("cardsubtypeid %d needs `amount`; omitting it reads back as "
|
|
"-1 on screen, not 0" % subtype)
|
|
if "contract" in needs and contract is None:
|
|
raise ValueError("cardsubtypeid %d needs `contract` (atom 0xb8)" % subtype)
|
|
if subtype == 219 and rareflag:
|
|
# Same fact as fut_store._SQUAD_FITNESS_TRAP, enforced at the other end so a
|
|
# caller cannot reintroduce it by passing rareflag through.
|
|
raise ValueError("rareflag must be 0 on subtype 219: FUN_1801bfac0 case 5 "
|
|
"renders a rare Player Fitness card as a SQUAD Fitness card")
|
|
|
|
ev = variants(subtype)
|
|
if resource_id is None:
|
|
resource_id = ev[0]["carddbid"] if ev else 5000000 + subtype
|
|
if rating is None:
|
|
rating = ev[0]["rating"] if ev else 55
|
|
|
|
it = {
|
|
"id": item_id,
|
|
"resourceId": resource_id,
|
|
"assetId": resource_id,
|
|
# THE ART ID, not a copy of resourceId -- see art_id() above.
|
|
"cardassetid": art_id(resource_id, resource_id),
|
|
"cardsubtypeid": subtype,
|
|
"itemType": "player",
|
|
"rareflag": rareflag,
|
|
"rating": rating,
|
|
"itemState": "free",
|
|
"owners": 1,
|
|
"untradeable": untradeable,
|
|
}
|
|
if amount is not None:
|
|
it["amount"] = int(amount)
|
|
if contract is not None:
|
|
it["contract"] = int(contract)
|
|
return it
|
|
|
|
|
|
def item_from_variant(item_id, subtype, variant):
|
|
"""Build the item EA itself authored: their carddbid, their amount, their rating."""
|
|
r = BY_SUBTYPE[subtype]
|
|
kw = dict(resource_id=variant["carddbid"], rating=variant["rating"])
|
|
if "amount" in r.get("needs", ()):
|
|
kw["amount"] = variant["amount"]
|
|
if "contract" in r.get("needs", ()):
|
|
# fcc_contractcards has NO amount column at all (schema: carddbid, cardsubtype,
|
|
# weightrare, cardassetid, gold, rating, bronze, silver), so the games count is
|
|
# NOT in the shipped data. 7 is INVENTED. Live test 3 is exactly the test that
|
|
# makes that safe: the client displays whatever `contract` we send.
|
|
kw["contract"] = variant.get("contract", 7)
|
|
return consumable_item(item_id, subtype, **kw)
|
|
|
|
|
|
# The starter shelf: every core family, every EA variant of it, in subtype order.
|
|
# Contracts, healing, fitness and training are what a club actually spends.
|
|
def starter_consumables(next_id):
|
|
"""[(item)] for one of each EA-authored variant of every CORE_KIND.
|
|
|
|
`next_id` is a zero-argument allocator (or an int base). Ids come from
|
|
CONSUMABLE_ID_BASE by default because these are served as an OVERLAY -- they are
|
|
not written into the save, so they must not consume the save's id space.
|
|
"""
|
|
if isinstance(next_id, int):
|
|
base = [next_id]
|
|
alloc = lambda: (base.__setitem__(0, base[0] + 1), base[0] - 1)[1]
|
|
else:
|
|
alloc = next_id
|
|
out = []
|
|
for r in SUBTYPES:
|
|
if r["kind"] not in CORE_KINDS:
|
|
continue
|
|
for v in r.get("ea_variants", []):
|
|
out.append(item_from_variant(alloc(), r["cardsubtypeid"], v))
|
|
return out
|
|
|
|
|
|
def items_for_type(kind, next_id=CONSUMABLE_ID_BASE):
|
|
"""The starter shelf filtered to one ?type= arm. [] if the arm is not ours."""
|
|
cats = TYPE_CATEGORIES.get(kind)
|
|
if cats is None:
|
|
return []
|
|
return [i for i in starter_consumables(next_id)
|
|
if BY_SUBTYPE[i["cardsubtypeid"]]["category"] in cats]
|
|
|
|
|
|
# resourceId -> cardsubtypeid, for the item-DEFINITION route (ut/%s/item/resource).
|
|
# Without this a def request for a consumable is answered with cardsubtypeid 0, which
|
|
# makes it cardtype 0 -- a player with no merge, i.e. plausible garbage.
|
|
DEF_BY_RESOURCE = {}
|
|
for _r in SUBTYPES:
|
|
for _v in _r.get("ea_variants", []):
|
|
DEF_BY_RESOURCE[_v["carddbid"]] = (_r["cardsubtypeid"], _v)
|
|
|
|
|
|
def def_for(rid):
|
|
"""The item-definition body for a consumable resourceId, or None."""
|
|
hit = DEF_BY_RESOURCE.get(rid)
|
|
if hit is None:
|
|
return None
|
|
subtype, v = hit
|
|
return item_from_variant(rid, subtype, v)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
if "--json" in sys.argv:
|
|
print(json.dumps({"itemData": starter_consumables(CONSUMABLE_ID_BASE)}, indent=1))
|
|
else:
|
|
live = [r for r in SUBTYPES if r["kind"] != "DEAD_ZONE"]
|
|
print("%d cardtype-6 subtypes (%d live, %d dead zones)"
|
|
% (len(SUBTYPES), len(live), len(SUBTYPES) - len(live)))
|
|
shelf = starter_consumables(CONSUMABLE_ID_BASE)
|
|
print("starter shelf: %d items across %d subtypes"
|
|
% (len(shelf), len({i["cardsubtypeid"] for i in shelf})))
|
|
for k, cats in sorted(TYPE_CATEGORIES.items()):
|
|
n = len(items_for_type(k))
|
|
print(" type=%-12s categories %-18s -> %2d item(s)"
|
|
% (k, sorted(cats), n))
|