340c31f34e
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
263 lines
13 KiB
Python
263 lines
13 KiB
Python
#!/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))
|