fifa17-recon: consumables -- the whole family, and it needs no id space at all
144 live cardtype-6 subtypes and 28 dead zones, all derived from the binary rather
than guessed, plus EA's own authored variants out of the dumped fcc_* tables.
MEASURED (decompiles read to their closing brace, lengths stated):
FUN_1800d8330 (714 chars) cardsubtypeid -> cardtype. The cardtype-6 space is
{51..136} u {201..220} u {250..273} u {300..341} = 172.
FUN_18013f4d0 (8,354 chars) subtype -> category(rec+0xb8), sub-sel(rec+0xbc i16),
amount(rec+0xbf i8), single(rec+0xc0). Two callees, a
range clamp and an enum map; no DB handle is touched,
which is why a consumable has no identity to look up.
FUN_1801bfac0 (42,813 chars) category -> FUT_CONSUMABLE_* string + a HARDCODED
5000xxx artwork constant. resourceId never reaches the
screen for a consumable.
fcc_trainingcards 143 rows / fcc_healingcards 27 / fcc_contractcards 13, each with
rowcount == rows_emitted == len(rows), so absences below are from a COMPLETE dump.
Two things the family will not forgive, both enforced in the builder rather than
documented and hoped for:
* `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, and
the accessors FUN_1801a8040/FUN_1801a8060 are `(int)*(char *)` -- SIGNED. The
card reads "-1", not 0. consumable_item() raises instead.
* A DEAD-ZONE subtype does not self-label. It falls to the bottom default of
FUN_18013f4d0 and renders as an ordinary Squad Training (Pace) card with amount
0. There is no "DB Error" analogue here, so every subtype we ship comes from
data/consumables.json and the builder refuses the other 28.
Two corrections to the generated data, both from re-reading FUN_1801bfac0 case 5 and
case 0 rather than from the category table:
* subtype 220 is named FUT_CONSUMABLE_NAME_SQUADTRAINING, not ..._PLAYERFITNESS.
0xdc == 220 is the FIRST half of the squad-fitness test, so 220 always takes that
branch, and there is no ..._SQUADFITNESS string in the binary at all.
* all 28 dead zones are SQUADTRAINING, not PLAYERTRAINING: case 0 tests
`subtype - 0x33 < 7` then `subtype - 0x3d < 7` and no dead zone satisfies either.
Exactly 29 of 172 rows changed; nothing else moved.
INFERRED, and flagged as such in the module: the ?type= grouping. The vocabulary is
certain (FUN_18012ec50 arms healing=23, contract=24, training=25, development=6), but
the tab-to-arm binding has NEVER been observed -- only type=player, type=manager and
type=custom have ever come from this client.
Three families deliberately NOT shipped: manager_formation_mod (71-86) has zero rows
in the 143-row table AND FUN_1801bfac0 case 6 calls FUN_1801a0100 on the formations
result without the rowcount guard its twin case 7 has -- a crash candidate;
formation_mod (121-136) has artwork -1; manager_league (300-341) renders literally
"ML: %d" from a raw number and one shipped amount (2118) is in no league table.
Not wired into the server in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/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")
|
||||
|
||||
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,
|
||||
"cardassetid": 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