Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/build_consumables.py
T
root 70a64e3709 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.
2026-08-10 23:54:04 +00:00

246 lines
13 KiB
Python

#!/usr/bin/env python3
"""Generate data/consumables.json -- the complete FIFA 17 consumable (cardtype 6)
enum, derived from the binary and cross-checked against the client's own tables.
SOURCES, all verified 2026-08-05 (slug `consumables`):
* FUN_1800d8330 (714 chars, full) cardsubtypeid -> cardtype
* FUN_18013f4d0 (8354 chars, full) cardsubtypeid -> category(+0xb8), +0xbc, +0xbe,
+0xbf, +0xc0. Two callees only:
FUN_180136480 (playstyle index) and
FUN_1800d7ad0 (int16 clamp). No DB handle.
* FUN_1801bfac0 (42813 chars, full) category -> FUT_CONSUMABLE_* loc keys + the
fixed 5000xxx artwork ids.
* FUN_1801aa230 (6, category) -> fcc_myclubscategories id
* FUN_180048780 (3455 chars, full) that id -> the UI bucket name
* data/tables/fcc_trainingcards.json (143 rows) and fcc_healingcards.json (27),
fcc_contractcards.json (13): EA's own authored amount/rating per subtype.
NOTHING here needs the live game. Run it any time; it only reads files.
"""
import json, os, collections
HERE = os.path.dirname(os.path.abspath(__file__))
TABLES = os.path.join(HERE, "..", "data", "tables")
OUT = os.path.join(HERE, "..", "data", "consumables.json")
POS = ["GK","SW","RWB","RB","RCB","CB","LCB","LB","LWB","RDM","CDM","LDM","RM","RCM",
"CM","LCM","LM","RAM","CAM","LAM","RF","CF","LF","RW","RS","ST","LS","LW"]
# FUN_18013f4d0, arm by arm. value = (category, bc, bf, c0_is_single)
# bf/bc of None means "taken from the `amount` atom at parse time".
GK_ATTR = ["FUT_UC_DIVING","FUT_UC_HANDLING","FUT_UC_KICKING","FUT_UC_REFLEXES",
"FUT_UC_SPEED","FUT_UC_POSITIONING","FUT_FITNESS_UC"]
PL_ATTR = ["FUT_MC_PACE","FUT_MC_SHOOTING","FUT_MC_PASSING","FUT_MC_DRIBBLING",
"FUT_MC_DEFENDING","FUT_MC_HEADING","FUT_FITNESS_MC"]
HEAL_AREA = ["Injury_Head","Injury_UpperBody","Injury_Arm","Injury_Back","Injury_Knee",
"Injury_Leg","Injury_Foot","Injury_All"]
HEAL_LOC = ["FUT_HEAD_HEALING","FUT_UPPERBODY_HEALING","FUT_ARM_HEALING",
"FUT_BACK_HEALING","FUT_KNEE_HEALING","FUT_LEG_HEALING",
"FUT_FOOT_HEALING","FUT_PLAYER_HEALING"]
GK_TRAIN = {51:0, 52:1, 53:2, 54:4, 55:5, 56:3, 57:6} # 0x33..0x39
PL_TRAIN = {61:0, 62:1, 63:2, 64:3, 65:5, 66:4, 67:6} # 0x3d..0x43
FORMATION = [23,24,25,27,14,3,13,6,7,8,19,16,21,29,30,31] # identical in both arms
POSMOD = { # 0x5b..0x6e (bc=from, bf=to)
91:(8,7), 92:(7,8), 93:(2,3), 94:(3,2), 95:(16,27), 96:(12,23),
97:(27,16), 98:(23,12), 99:(27,22), 100:(23,20),101:(22,27),102:(20,23),
103:(14,18),104:(18,14),105:(10,14),106:(14,10),107:(18,21),108:(21,18),
109:(21,25),110:(25,21),
}
CAT_LOC = { # category -> (name loc key, desc loc key, artwork base, myclub cat, ui bucket)
0: ("FUT_CONSUMABLE_NAME_PLAYERTRAINING", "FUT_CONSUMABLE_PLAYERTRAINING", "5000000_<idx>", 0, "training"),
2: ("FUT_CONSUMABLE_NAME_PLAYERCONTRACT", "FUT_CONSUMABLE_PLAYERCONTRACT", "5000007", 1, "contracts"),
3: ("FUT_CONSUMABLE_NAME_MANAGERCONTRACT","FUT_CONSUMABLE_MANAGERCONTRACT","5000008", 2, "contracts"),
4: ("FUT_CONSUMABLE_NAME_PLAYERHEALING", "FUT_CONSUMABLE_PLAYERHEALING", "5000009_<idx>", 3, "healing"),
5: ("FUT_CONSUMABLE_NAME_PLAYERFITNESS", "FUT_CONSUMABLE_PLAYERFITNESS", "5000010", 4, "fitness"),
6: ("FUT_CONSUMABLE_NAME_FORMATIONMOD", "FUT_CONSUMABLE_FORMATIONMOD", "-1", 15, "training"),
7: ("FUT_CONSUMABLE_NAME_FORMATIONMOD", "FUT_CONSUMABLE_FORMATIONMOD", "-1", 16, "formation"),
8: ("FUT_CONSUMABLE_NAME_POSITIONMOD", "FUT_CONSUMABLE_POSITIONMOD", "5000013", 17, "position"),
9: ("FUT_CONSUMABLE_NAME_PLAYERSTYLE", "FUT_CONSUMABLE_PLAYERSTYLE", "5000015_<amt>", 23, "playStyle"),
10: ("FUT_CONSUMABLE_NAME_MANAGERLEAGUE", "FUT_CONSUMABLE_MANAGERLEAGUE", "5000016", 24, "managerLeagueModifier"),
}
def cardtype(s):
"""FUN_1800d8330, verbatim."""
if 0 <= s <= 3: return 1
if s == 4: return 2
if s == 5: return 3
if s == 6: return 10
if s == 7: return 5
if s == 8: return 4
if s in (9, 10, 11): return 7
if s in (30, 31, 145,146,147,148,149,150, 231,232,233, 236): return 9
if 0 <= s - 0x33 <= 0x55: return 6 # 51..136
if 0 <= s - 0xc9 <= 0x13: return 6 # 201..220
if 0 <= s - 0xfa <= 0x17: return 6 # 250..273
if 0 <= s - 300 < 0x2a: return 6 # 300..341
return 0
def rows():
out = []
for s in range(0, 400):
if cardtype(s) != 6:
continue
r = {"cardsubtypeid": s}
if s in GK_TRAIN:
i = GK_TRAIN[s]
r.update(kind="gk_training", category=0, bc=i, bf="amount",
c0=int(s != 57), detail=GK_ATTR[i],
loc_name="FUT_CONSUMABLE_NAME_KEEPERTRAINING",
loc_desc="FUT_CONSUMABLE_KEEPERTRAINING",
artwork="5000001_%d" % i, needs=["amount"])
elif s in PL_TRAIN:
i = PL_TRAIN[s]
r.update(kind="player_training", category=0, bc=i, bf="amount",
c0=int(s != 67), detail=PL_ATTR[i],
loc_name="FUT_CONSUMABLE_NAME_PLAYERTRAINING",
loc_desc="FUT_CONSUMABLE_PLAYERTRAINING",
artwork="5000000_%d" % i, needs=["amount"])
elif 71 <= s <= 86:
f = FORMATION[s - 71]
r.update(kind="manager_formation_mod", category=6, bc=f, bf=0, c0=None,
detail="formationid=%d" % f, artwork="-1", needs=[])
elif s in POSMOD:
a, b = POSMOD[s]
r.update(kind="position_mod", category=8, bc=a, bf=b, c0=None,
detail="%s >> %s" % (POS[a], POS[b]),
artwork="5000013", needs=[])
elif 121 <= s <= 136:
f = FORMATION[s - 121]
r.update(kind="formation_mod", category=7, bc=f, bf=0, c0=None,
detail="formationid=%d" % f, artwork="-1", needs=[])
elif s == 201:
r.update(kind="player_contract", category=2, bc=None, bf=None, c0=None,
detail="games from atom `contract` (0xb8) -> record+0x8c, unit fcc_matches",
artwork="5000007", needs=["contract"])
elif s == 202:
r.update(kind="manager_contract", category=3, bc=None, bf=None, c0=None,
detail="games from atom `contract` (0xb8) -> record+0x8c, unit fcc_matches",
artwork="5000008", needs=["contract"])
elif 211 <= s <= 218:
i = s - 211
r.update(kind="healing", category=4, bc=i, bf="amount", c0=int(s != 218),
detail=HEAL_AREA[i], loc_detail=HEAL_LOC[i],
artwork="5000009_%d" % i, needs=["amount"])
elif s in (219, 220):
r.update(kind="player_fitness" if s == 219 else "squad_fitness",
category=5, bc=0, bf="amount", c0=int(s != 220),
detail="RAREFLAG TRAP: rareflag==1 renders 219 as SQUAD fitness"
if s == 219 else "always squad fitness",
artwork="5000010" if s == 219 else "5000011", needs=["amount"])
if s == 220:
# 0xdc == 220 is the FIRST half of the squad-fitness test in
# FUN_1801bfac0 case 5, so 220 ALWAYS takes that branch -- and that
# branch writes FUT_CONSUMABLE_NAME_SQUADTRAINING. There is no
# FUT_CONSUMABLE_NAME_SQUADFITNESS string in the binary at all
# (24-string census); squad fitness reuses the squad-training name.
r.update(loc_name="FUT_CONSUMABLE_NAME_SQUADTRAINING",
loc_desc="FUT_CONSUMABLE_SQUADTRAINING")
elif 250 <= s <= 273:
i = s - 250
r.update(kind="gk_playstyle" if s >= 269 else "player_playstyle",
category=9, bc=i, be="amount", bf=None, c0=None,
detail="FUT_PLAYSTYLE_%d" % i,
loc_name=("FUT_CONSUMABLE_NAME_GK_PLAYERSTYLE" if s >= 269
else "FUT_CONSUMABLE_NAME_PLAYERSTYLE"),
artwork="5000015_<amount>", needs=["amount"])
elif 300 <= s <= 341:
r.update(kind="manager_league", category=10, bc="clamp_i16(amount)",
bf=None, c0=None, detail="ML: <leagueid from amount>",
artwork="5000016", needs=["amount"])
else:
# FUN_1801bfac0 case 0 tests `subtype - 0x33 < 7` (GK training) then
# `subtype - 0x3d < 7` (player training) and otherwise falls to the
# SQUAD-training branch. No dead-zone subtype satisfies either test, so
# the name is SQUADTRAINING, not PLAYERTRAINING (which is what CAT_LOC[0]
# would otherwise have stamped here).
r.update(kind="DEAD_ZONE", category=0, bc=0, bf=0, c0=None,
detail="falls through to the bottom default of FUN_18013f4d0; "
"renders as SQUAD TRAINING / attribute index 0, amount 0. "
"NOT a loud error -- do not ship these.",
loc_name="FUT_CONSUMABLE_NAME_SQUADTRAINING",
loc_desc="FUT_CONSUMABLE_SQUADTRAINING",
artwork="5000000_0", needs=[])
c = r["category"]
if "loc_name" not in r: r["loc_name"] = CAT_LOC[c][0]
if "loc_desc" not in r: r["loc_desc"] = CAT_LOC[c][1]
r["myclub_category"] = CAT_LOC[c][3]
r["ui_bucket"] = CAT_LOC[c][4]
out.append(r)
return out
def merge_ea():
"""EA's authored (amount, rating) per subtype, straight out of the resident DB."""
ea = collections.defaultdict(list)
for f in ("fcc_trainingcards", "fcc_healingcards"):
d = json.load(open(os.path.join(TABLES, f + ".json")))
for row in d["rows"]:
ea[row["cardsubtype"]].append(
{"carddbid": row["carddbid"], "amount": row["amount"],
"rating": row["rating"], "weightrare": row["weightrare"],
"table": f})
d = json.load(open(os.path.join(TABLES, "fcc_contractcards.json")))
for row in d["rows"]:
ea[row["cardsubtype"]].append(
{"carddbid": row["carddbid"], "rating": row["rating"],
"weightrare": row["weightrare"], "table": "fcc_contractcards",
"gold": row["gold"], "silver": row["silver"], "bronze": row["bronze"]})
return ea
def main():
rs = rows()
ea = merge_ea()
for r in rs:
r["ea_variants"] = ea.get(r["cardsubtypeid"], [])
doc = {
"generated_by": "tools/build_consumables.py",
"derived_from": ["FUN_1800d8330", "FUN_18013f4d0", "FUN_1801bfac0",
"FUN_1801aa230", "FUN_180048780",
"data/tables/fcc_trainingcards.json",
"data/tables/fcc_healingcards.json",
"data/tables/fcc_contractcards.json"],
"record_offsets": {
"0x4c": "cardtype (FUN_1800d8330(cardsubtypeid))",
"0x50": "cardsubtypeid (atom 0x6c). default 342 -> cardtype 0",
"0x54": "level: 1 if rating<65, 2 if <75, else 3 (shared tail of FUN_180141660)",
"0x58": "rareflag (atom 0x271, u32). THE TRAP: ==1 flips subtype 219 to squad fitness",
"0x18": "resourceId (atom 0x287) -- overwritten by FUN_18013f4d0 param_2",
"0x88": "playStyle (atom 0x23f, mapped 250..273 -> 0..23)",
"0x8c": "contract (atom 0xb8) -- the contract card's games count",
"0xb4": "rating (atom 0x274)",
"0xb8": "consumable CATEGORY (written by FUN_18013f4d0)",
"0xbc": "int16 sub-selector: attribute / injury area / formationid / from-position / playstyle index / leagueid",
"0xbe": "int8 playstyle artwork variant = (byte)amount",
"0xbf": "int8 amount, or to-position for position mods",
"0xc0": "bool single-target",
},
"club_type_values_for_consumables": {
"contract": "FUN_18012ec50 arm 24 -> atom 0xb8",
"training": "arm 25 -> atom 0x337",
"healing": "arm 23 -> atom 0x156",
"development": "arm 6 -> atom 0xd3 (best candidate for position/formation/playStyle/managerLeague)",
"STATUS": "ALL FOUR UNOBSERVED ON THE WIRE. Only type=player, type=manager and "
"type=custom have ever been seen from this client.",
},
"subtypes": rs,
}
with open(OUT, "w") as f:
json.dump(doc, f, indent=1)
live = [r for r in rs if r["kind"] != "DEAD_ZONE"]
dead = [r for r in rs if r["kind"] == "DEAD_ZONE"]
print("cardtype-6 subtypes: %d (live %d, dead-zone %d)" % (len(rs), len(live), len(dead)))
print("dead zones:", sorted(r["cardsubtypeid"] for r in dead))
print("with EA variants:", len([r for r in rs if r["ea_variants"]]))
print("live but NO EA variant:",
sorted(r["cardsubtypeid"] for r in live if not r["ea_variants"]))
print("wrote", os.path.normpath(OUT))
if __name__ == "__main__":
main()