a3fd51692f
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.
REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.
REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.
AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
-> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.
The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.
Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40 vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.
WHAT IS ACTUALLY SHIPPED HERE, all default off:
* FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
in _item(), because the save holds 246 items minted before the flag existed and the
club route serves them straight from the save. That gap was caught by reading the
served JSON, not by unit-testing the factory.
* FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
only as a record of the refutation, with the reasoning inline so nobody retries it.
* fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
preferred could never have answered this for three of five families.
* Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
(which was never bisected; value is the established suspect and that response also
carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
never copied into the record.
Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
197 lines
9.5 KiB
Python
197 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Club items: balls, stadia, badges, kits and league logos.
|
|
|
|
Built from the game's OWN tables, dumped read-only into data/tables/ by db_dump.py:
|
|
|
|
fcc_balls 42 rows carddbid 8120194+ cardassetid 37
|
|
fcc_stadium 78 rows carddbid 6200000+ cardassetid 36
|
|
fcc_badgecards 656 rows carddbid 6000000+ cardassetid 39
|
|
fcc_kitcards 1482 rows carddbid 6300000+ cardassetid 35
|
|
fcc_leaguelogos 44 rows carddbid 8010000+ cardassetid 40
|
|
|
|
TWO ID COLUMNS, AND THEY ARE NOT INTERCHANGEABLE. Every fcc_ row carries BOTH
|
|
carddbid and cardassetid. carddbid is the database key the merge would use;
|
|
cardassetid is the ART id the card draws from. fut_store._item copies resourceId
|
|
into cardassetid, which is right for players and wrong for every other family --
|
|
that is exactly what produced the green "NOT FOUND" placeholder on consumables
|
|
(external/ion_fut/artAssets/.../notfound.swf) until it was fixed on 2026-08-05.
|
|
So this module sets both explicitly and never lets one default to the other.
|
|
|
|
WHAT IS NOT KNOWN YET, AND IS NOT GUESSED HERE
|
|
----------------------------------------------
|
|
cardtype 9 (the club-item family) has NO arm in the merge FUN_180141660: no table
|
|
query and no miss-fill. So unlike a player or a coach, a club item's identity does
|
|
NOT come from the local card DB, and a wrong id cannot announce itself. The
|
|
cardsubtypeid values that reach cardtype 9 are the eight-value set
|
|
{30, 31, 145, 146, 147, 148, 149, 150}, and WHICH of those means ball versus
|
|
stadium versus badge is assigned nowhere in the 149 dumped tables.
|
|
|
|
Rather than guess, SUBTYPE is a per-family constant below with an explicit
|
|
"unverified" marker, and probe_shelf() serves one item per candidate subtype so the
|
|
screen itself can say which is which. The counts do not need any of this: a count is
|
|
just a number, which is why counts come first.
|
|
|
|
THE COUNT IS THE GATE. Proven on consumables the same day: the client does not ask
|
|
for an item list until club/stats reports a non-zero count for that family. The
|
|
CLUB tab reads global stat ids 1 (players), 0x1e (balls), 0x28 (kits), 0x14
|
|
(stadia), 0x0a (staff) and 0x32 (trophies), so making those non-zero is what makes
|
|
the client reveal the item route it uses. Nothing here should be believed to work
|
|
until that route is observed in the log.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
|
|
|
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
|
|
|
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
|
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
|
|
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
|
|
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
|
|
# families were pointed at the trophy range.
|
|
#
|
|
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
|
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
|
|
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
|
|
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
|
|
# club-item id "cannot announce itself", and for these three that is false. A wrong
|
|
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
|
FAMILIES = [
|
|
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
|
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
|
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
|
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
|
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
|
]
|
|
|
|
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
|
|
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
|
|
# probe route the docs preferred would have spent a launch and returned nothing for
|
|
# three of the five families.
|
|
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
|
|
|
# How many of each family the starter club owns. Small on purpose: the point is to
|
|
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
|
STARTER_N = {"balls": 6, "stadia": 4, "badges": 8, "kits": 8, "leaguelogos": 4}
|
|
|
|
|
|
def _rows(fname):
|
|
try:
|
|
with open(os.path.join(_DATA, fname)) as f:
|
|
return json.load(f).get("rows") or []
|
|
except (IOError, ValueError):
|
|
return []
|
|
|
|
|
|
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
|
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
|
no nation, no league. A club item has none of those, and sending a field the
|
|
family does not have is how a wrong shape gets accepted and does nothing."""
|
|
it = {
|
|
"id": item_id,
|
|
"resourceId": carddbid,
|
|
"assetId": carddbid,
|
|
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
|
"cardsubtypeid": subtype,
|
|
"itemState": "free",
|
|
"owners": 1,
|
|
"untradeable": False,
|
|
}
|
|
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
|
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
|
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
|
|
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
|
|
# record +0x94: an established scalar field, not a new shape.
|
|
#
|
|
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
|
# response that crashed the client, and it was never bisected. `value` is the
|
|
# established suspect, because it is an OBJECT member elsewhere and a scalar where
|
|
# an object is expected is the 0x1801c7f1a busy loop, and that response also
|
|
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
|
|
# ONE family, with the subtypes now corrected. That is the narrow test the crash
|
|
# denied us, and it is why families are served one at a time.
|
|
if teamid is not None and subtype in (9, 11):
|
|
it["teamid"] = teamid
|
|
if extra:
|
|
it.update(extra)
|
|
return it
|
|
|
|
|
|
def shelf(next_id=CLUBITEM_ID_BASE, families=None):
|
|
"""The starter club-item shelf, {family: [item]}.
|
|
|
|
`families` limits which are built. The combined `equippables` view is what
|
|
crashed the client: 30 items across FIVE unverified subtypes in one response is
|
|
the widest possible blast radius for a wrong shape. One family at a time is the
|
|
only way to learn which subtype is wrong.
|
|
"""
|
|
out, nid = {}, next_id
|
|
for name, table, art, _sid, _sname, subtype in FAMILIES:
|
|
if families is not None and name not in families:
|
|
out[name] = []
|
|
continue
|
|
rows = _rows(table)
|
|
picked = []
|
|
for r in rows[:STARTER_N.get(name, 4)]:
|
|
cid = r.get("carddbid")
|
|
if not cid:
|
|
continue
|
|
# NO EXTRAS. An earlier version copied teamid/leagueid/value straight
|
|
# out of the fcc row and the game hung and then CRASHED on the first
|
|
# equippables fetch (2026-08-05). `value` is the prime suspect: it
|
|
# appears elsewhere as an OBJECT member (displayGroup {"value": ...}),
|
|
# and a scalar where an object is expected is the type-desync busy loop
|
|
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
|
# and then dies. Omission is safe; an unestablished field is not. None of
|
|
# the three was needed to draw a card.
|
|
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
|
|
# which are the two families whose caption is <name> + TeamName_Abbr15_
|
|
# <teamid>. It is the one field from the fcc row being reintroduced after
|
|
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
|
|
# the note in _item(). value and leagueid stay omitted.
|
|
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
|
|
teamid=r.get("teamid")))
|
|
nid += 1
|
|
out[name] = picked
|
|
return out
|
|
|
|
|
|
def counts(next_id=CLUBITEM_ID_BASE):
|
|
"""[(stat name, count)] for the club panel."""
|
|
s = shelf(next_id)
|
|
return [(sname, len(s.get(name, [])))
|
|
for name, _t, _a, _sid, sname, _st in FAMILIES]
|
|
|
|
|
|
def probe_shelf(family, next_id=CLUBITEM_ID_BASE):
|
|
"""One item per CANDIDATE cardsubtypeid, same carddbid, for the live oracle.
|
|
|
|
Which of {30,31,145..150} means which family is unknown and unguessable from the
|
|
dumped tables. Serving all eight and looking at the screen is the cheapest way to
|
|
find out, and unlike a sweep it is READABLE: the family that draws real artwork
|
|
names its own subtype.
|
|
"""
|
|
entry = next((f for f in FAMILIES if f[0] == family), None)
|
|
if entry is None:
|
|
return []
|
|
_n, table, art, _sid, _sname, _st = entry
|
|
rows = _rows(table)
|
|
if not rows:
|
|
return []
|
|
r = rows[0]
|
|
return [_item(next_id + i, r.get("carddbid"), r.get("cardassetid", art), st)
|
|
for i, st in enumerate(CARDTYPE9_SUBTYPES)]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
s = shelf()
|
|
for name, items in s.items():
|
|
print("%-12s %2d item(s)" % (name, len(items)))
|
|
if items:
|
|
i = items[0]
|
|
print(" resourceId=%-9s cardassetid=%-4s subtype=%s"
|
|
% (i["resourceId"], i["cardassetid"], i["cardsubtypeid"]))
|
|
print("\ncounts:", counts())
|