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.
246 lines
13 KiB
Python
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()
|