#!/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_", 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_", 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_", 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_", needs=["amount"]) elif 300 <= s <= 341: r.update(kind="manager_league", category=10, bc="clamp_i16(amount)", bf=None, c0=None, detail="ML: ", 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()