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