fifa17-recon: FUT_CLUBSTATS -- serve the club-stat set (CLUB STATS panel, maybe the tile)

Live 2026-08-04: the CLUB STATS panel shows eight zeros (Rare Players, Players, Staff
Employed, Stadia Owned, Trophies Won, Kits, Badges, Balls Earned) while the client
fetches /club/stats/{staff,year,consumables} and we answer {} to all three. Those
zeros are ours. Every row name maps to a type string in the recovered map.

Wire schema, fully verified from deser 0x180130150 (7,870 chars, read end to end):
  {"stat":[{contextId:int, contextValue:int, type:string, typeValue:int}]}
Unknown keys route to FUN_180135ff0 at BOTH levels, so extras are inert.

FIVE THINGS THAT DECIDE WHETHER IT WORKS:

1. EVERY RESPONSE WIPES THE WHOLE MAP FIRST. Nothing accumulates, so a good body on
   one mode followed by a thin one on another ERASES the first and request ordering
   decides what survives. Handled by serving the SAME COMPLETE SET on every Stats2
   mode: whichever lands last leaves the map correct. (One investigator reported this
   factory does not wipe; a reviewer re-read it and refuted that. The wipe is real,
   and this is the second negative claim from that batch to fail.)
2. /club/stats/staff IS A DIFFERENT CLASS: FutStaffBonus, {"bonus":[{type,value}]},
   not Stats2. Its type strings are undecoded so it keeps {}, which is safe and also
   means it does not disturb the Stats2 map.
3. ELEMENT-LOCAL VARIABLES ARE NOT RESET BETWEEN ELEMENTS -- the clears sit before
   the array loop, not inside it -- so omitting a key in element N inherits element
   N-1's value. All four keys are emitted in every element.
4. The storage key is contextValue ALONE; contextId is only a guard (1, or 5..9,
   forces contextValue to 0, the global bucket the +0x800 getter reads). contextId 1
   throughout.
5. 0x3d CONTRACTS, 0x3e TRAINING and 0x40 FITNESS are READ by the panel but cannot
   be SET from here. No type string produces them.

THIS IS ALSO NOW THE HUB-TILE CANDIDATE. The investigation concluded the MY CLUB tile
does not read this store, but flagged that negative as BOUNDED: the interface comes
through a QueryInterface adapter, so the vtable is assembled at runtime and cannot be
read statically. Live evidence points the other way. The tile reads "0 TOTAL PLAYERS"
and the panel reads "Players 0" -- same quantity, both zero, both while we answer {}.
And FUT_CLUB_PAGE ruled out the alternative: 114 items served to /club, tile still 0,
so it is not a count of the list. Strong inference, not proof; this flag is the test.

The test is unusually clean: the club holds 114 items and all of them are players, so
every other row is an honest zero. If it works, exactly two numbers move (Players and
Rare Players, 0 -> 114) and nothing else changes.

Gold/silver/bronze thresholds are FIFA's rating convention (75+/65-74/under), not
something read out of the binary, and the code says so.

Default OFF. 392 + 61 checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 14:12:48 -07:00
parent 285d4f6cb7
commit b434a3efdc
+100 -3
View File
@@ -939,13 +939,110 @@ ROUTES = [
# staff,year} on the MY CLUB screen. They are suffix endpoints the request table
# never lists -- the same trap as /squad/list. They were being swallowed by the
# generic /club route, which answers with the FULL 28-item club list where the
# client asked for STATS: wrong shape, and re-sent on every poll. No schema is
# documented for them, so serve the proven-safe {} and let a capture refine it.
(re.compile(G + r"/club/stats"), lambda m, h: (200, {})),
# client asked for STATS: wrong shape, and re-sent on every poll.
(re.compile(G + r"/club/stats"), lambda m, h: club_stats_route(h)),
(re.compile(G + r"/club"), lambda m, h: club_route(h)),
]
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
# GET ut/%s/club/stats/<mode> -> FutStickerBookStats2ServerResponse, deser
# 0x180130150 (7,870 chars, read end to end). Wire schema, fully verified:
#
# {"stat":[{"contextId":int, "contextValue":int, "type":str, "typeValue":int}]}
# contextId(0xb6) INT 0x1801c79d0
# contextValue(0xb7) INT 0x1801c79d0
# type(0x354) STRING 0x1801c7aa0 -> copied into a 0x30-byte buffer
# typeValue(0x355) INT 0x1801c79d0
# Unknown keys route to FUN_180135ff0 at BOTH nesting levels, so extras are inert.
#
# FIVE THINGS THAT DECIDE WHETHER THIS WORKS, all learned the hard way:
#
# 1. EVERY RESPONSE WIPES THE WHOLE MAP FIRST. Nothing accumulates. So a good body on
# one mode followed by a thin body on another ERASES the first, and the ordering of
# the client's requests would decide what survives. The fix is to serve the SAME
# COMPLETE SET for every Stats2 mode: then whichever request lands last leaves the
# map correct and ordering stops mattering. (One investigator reported this factory
# does NOT wipe; a reviewer re-read it and refuted that. The wipe is real.)
# 2. /club/stats/staff IS A DIFFERENT CLASS. It is FutStaffBonus, shape
# {"bonus":[{"type":str,"value":int}]}, NOT Stats2. Sending a {"stat":[...]} body
# there is harmless but does nothing. Its type strings are not decoded, so it keeps
# {} -- which is safe, because that parser's top-level loop exits immediately on
# END_OBJECT. It also means staff does NOT wipe the Stats2 map.
# 3. ELEMENT-LOCAL VARIABLES ARE NOT RESET BETWEEN ELEMENTS. The clears happen once
# before the array loop, not inside it, so omitting a key in element N silently
# inherits element N-1's value. ALWAYS EMIT ALL FOUR KEYS IN EVERY ELEMENT.
# 4. The storage key is contextValue ALONE. contextId is only a guard: contextId == 1
# or 5 <= contextId <= 9 forces contextValue to 0, which is the global bucket the
# +0x800 getter reads. We use contextId 1 throughout to land everything there.
# 5. Three ids the panel READS can never be SET from here: 0x3d CONTRACTS,
# 0x3e TRAINING, 0x40 FITNESS. No type string produces them.
#
# type string -> internal id -> the on-screen row it moves (FUN_18012fd40 -> the club
# stats provider FUN_180043b90):
# players 1 PLAYERS rarePlayers 5 staff 0xa STAFF_EMPLOYED
# stadia 0x14 STADIA_OWNED balls 0x1e BALLS_EARNED kits 0x28 KITS_AVAILABLE
# badges 0x2d BADGES trophies 0x32 TROPHIES_WON
#
# WHY THIS IS NOW ALSO THE HUB-TILE CANDIDATE. The investigation concluded the MY CLUB
# tile does not read this store, but flagged that negative as BOUNDED: the interface
# comes through a QueryInterface adapter, so the vtable is assembled at runtime and
# cannot be read statically. Live evidence on 2026-08-04 points the other way. The hub
# tile reads "0 TOTAL PLAYERS" and the CLUB STATS panel reads "Players 0", the same
# quantity, both zero, while we answer {}. And FUT_CLUB_PAGE ruled out the alternative:
# we served 114 items to /club and the tile still said 0, so it is not a count of the
# list. Strong inference, not proof. This flag is the test.
#
# The test is unusually clean because the club holds 114 items and ALL of them are
# players: every other row is an honest zero. So if this works, exactly two numbers
# move (Players and Rare Players, 0 -> 114) and nothing else changes.
CLUBSTATS = os.environ.get("FUT_CLUBSTATS") == "1"
def _club_stat_set():
"""The complete global stat set, computed from what the club actually holds."""
items = STORE.items()
players = [i for i in items if i.get("itemType") == "player"]
rare = [i for i in players if i.get("rareflag")]
# Gold/silver/bronze is FIFA's rating convention (75+/65-74/below), NOT something
# read out of the binary. Marked as a convention because it is one; if a tile ever
# disagrees, this is the line to doubt.
gold = [i for i in players if (i.get("rating") or 0) >= 75]
silver = [i for i in players if 65 <= (i.get("rating") or 0) < 75]
bronze = [i for i in players if 0 < (i.get("rating") or 0) < 65]
counts = [
("players", len(players)),
("playersGold", len(gold)),
("playersSilver", len(silver)),
("playersBronze", len(bronze)),
("rarePlayers", len(rare)),
# Honest zeros: this club holds no non-player items of any kind.
("staff", 0),
("stadia", 0),
("balls", 0),
("kits", 0),
("badges", 0),
("trophies", 0),
]
# All four keys in every element -- see note 3 above.
return [{"contextId": 1, "contextValue": 0, "type": t, "typeValue": int(v)}
for t, v in counts]
def club_stats_route(h):
mode = h.path.split("/club/stats/", 1)[-1].split("?")[0] if "/club/stats/" in h.path else ""
if not CLUBSTATS:
return 200, {}
if mode.startswith("staff"):
# FutStaffBonus, a different class with a different shape. {} is safe and
# deliberately does not disturb the Stats2 map.
return 200, {}
stats = _club_stat_set()
log(" CLUBSTATS: %s -> %d stat rows (players=%d)"
% (mode or "(none)", len(stats), stats[0]["typeValue"]))
return 200, {"stat": stats}
# FUT_CLUB_PAGE -- an EXPERIMENT, not a fix, aimed at the MY CLUB hub counter.
#
# The counter's renderer is NOT in cardsdll.dll. There is no two-number formatter of