3b58b29094
Correcting an overstatement first: I said every card family works. Balls, stadia,
badges and kits do not, and this is the start of that, not the finish.
CLUB ITEMS (FUT_CLUBITEMS=1, default off). tools/fut_clubitems.py builds shelves from
the game's own tables: fcc_balls 42, fcc_stadium 78, fcc_badgecards 656,
fcc_kitcards 1482, fcc_leaguelogos 44, each with its real carddbid AND its real
cardassetid. Counts are wired into the club stat set, replacing the honest zeros
rather than appending a second row per id (the deserializer does
store[ctx][statId] = value, so two rows for one id is a coin toss).
Counts FIRST and on purpose. The consumables round proved the count gates the fetch:
the client does not ask for an item list until club/stats reports a non-zero count,
and the CLUB tab reads 0x1e balls, 0x28 kits, 0x14 stadia. Arming the counters is what
makes the client name the item route, which is the one thing static reading has never
produced for this family -- cardtype 9 has NO arm in the merge, so nothing about it is
discoverable from the card DB.
What is deliberately NOT guessed: which cardsubtypeid means ball versus stadium. The
eight values that reach cardtype 9 are {30,31,145..150} and the assignment appears in
none of the 149 dumped tables. probe_shelf() serves one item per candidate so the
screen can say which is which, rather than shipping a guess into someone's club.
PACKS (FUT_PACK_MIX=1, default on). A pack is no longer eleven footballers: roughly a
quarter of each pack is now consumables and occasionally staff, as a ratio so it
scales from a 5-card bronze to an 11-card premium. Club items are excluded until their
subtypes are verified -- a pack is the worst place to discover a wrong subtype,
because the card lands in the save and has to be cleaned out by hand.
Also fixes the art id AT THE SOURCE. fut_consumables now sets cardassetid from the
fcc_ tables, so a pack-granted consumable renders correctly too. The earlier fix only
corrected the copy served by the club route, which meant packs would have dealt cards
with the green NOT FOUND box again.
Pack tests run against a COPY of the profile, after an earlier test persisted 15 cards
into the live save and had to be undone by hand.
439 + 414 checks green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
292 lines
14 KiB
Python
292 lines
14 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")
|
|
|
|
# carddbid -> cardassetid, the ART id, read from the game's own fcc_ tables.
|
|
#
|
|
# THE TWO IDS ARE NOT INTERCHANGEABLE and this cost a live debugging round. A card
|
|
# draws its artwork from cardassetid, which is a SMALL id (3 training, 7 contract,
|
|
# 10 healing, 45 misc), not the carddbid. Copying resourceId into cardassetid is
|
|
# right for players and wrong here: the client looked up art 5003001, found nothing,
|
|
# and drew external/ion_fut/artAssets/.../notfound.swf -- a green NOT FOUND box on
|
|
# every consumable card until 2026-08-05.
|
|
_ART_BY_CARDDBID = None
|
|
|
|
|
|
def art_id(carddbid, default=None):
|
|
global _ART_BY_CARDDBID
|
|
if _ART_BY_CARDDBID is None:
|
|
import glob
|
|
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
|
m = {}
|
|
for f in glob.glob(os.path.join(d, "fcc_*.json")):
|
|
try:
|
|
rows = json.load(open(f)).get("rows") or []
|
|
except Exception:
|
|
continue
|
|
for r in rows:
|
|
if "carddbid" in r and "cardassetid" in r:
|
|
m[r["carddbid"]] = r["cardassetid"]
|
|
_ART_BY_CARDDBID = m
|
|
return _ART_BY_CARDDBID.get(carddbid, default)
|
|
|
|
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,
|
|
# THE ART ID, not a copy of resourceId -- see art_id() above.
|
|
"cardassetid": art_id(resource_id, 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))
|