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,291 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user