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:
funman300
2026-08-05 10:04:21 -07:00
parent d8ef9d4c4f
commit 340c31f34e
3 changed files with 4945 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+245
View File
@@ -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()
+262
View File
@@ -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))