fifa17-recon: THE MY CLUB COUNTER -- it was clubPlayers in GET /hub all along

The tile's big number is `clubPlayers` (atom 0x90) in the body of GET ut/%s/hub, a
route we have answered with {} for the life of the project.

The chain, re-derived independently by two agents (one via Ghidra, one via raw PE plus
capstone with no decompiler) and checked by two reviewers:

  clubPlayers(0x90) --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 becomes 0)
    -> R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is literally
       `lea rax,[rcx+0x1fd70]; ret`)
    -> read by FUN_1800b0250, published as TEXT0 of TILE_ID 0x210
    -> captions FUT_GH_TOTAL_PLAYERS_0/_1 at 0x18020a0f8 / 0x18020a110
  auctionCount(0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile

FUN_180139610 (the hub body parser, 14855 chars, censused in full: 18 atoms, none
missed) is the ONLY writer of +0x3c anywhere in the image, one write, guarded by
`if (iVar6 != 0x90)`. This is not a candidate, it is the field.

I SPENT A DAY ON THE WRONG SURFACE AND WROTE THE WRONG CONCLUSION. REBUILD_RESEARCH
S19 declared the counter "not server-fixable" with a mechanism that was internally
correct and completely beside the point: the tile never read the club-stat store.
Two things reinforced the error and both are now fixed in the docs:

  * ENDPOINT_MAP said this response "uses C++ reflection / vtable dispatch, NOT an
    inline atom ladder -- no static field ladder to read" and marked it a GAP. False.
    There is an inline ladder, one indirection away.
  * The eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
    publishes six keys, and I treated the six-versus-eight mismatch as a puzzle rather
    than as evidence. It is a DIFFERENT provider, FUN_180094ce0, using a different
    string family (FUT_MYCLUB_*), reading neither the mode tag nor any type id we were
    sending. Two providers; we were reading the wrong one.

That is the third negative claim of this shape to fail today, after "this deserializer
has no skip handler" and "the factory does not wipe the stat map".

auctionCount is included as a FREE CONTROL: different field, different tile, so if MY
CLUB moves and TRANSFERS does not, delivery is fine and something is specific to +0x3c.

Default ON. Freeze risk is low by construction rather than by belief: a flat object of
two integers, both read with the INT getter, so there is no array, no nested object and
no type-desync surface. FUT_HUBDATA=0 restores {}.

Contract guard added, and verified to bite rather than merely pass:
  default        439 checks, 0 failed
  FUT_HUBDATA=0  435 checks, 3 FAILED  (clubPlayers missing / not a number)
A regression here would otherwise be silent: still 200, still valid JSON, tile quietly
back to 0.

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 15:29:59 -07:00
parent ffb0e6033c
commit 4c5cc3ab4b
2 changed files with 83 additions and 2 deletions
+30 -1
View File
@@ -481,13 +481,42 @@ def test_move_verdict_shape():
repr(r.get("success")))
def test_hub_counters():
"""GET /hub must carry the tile counters as INTEGERS.
clubPlayers (atom 0x90) is the MY CLUB tile's big number: the hub body parser
FUN_180139610 is the ONLY writer of the field it lands in (R+0x3c, read by
FUN_1800b0250 as TEXT0 of TILE_ID 0x210). auctionCount (0x33) feeds the TRANSFERS
tile the same way via R+0x38.
This route answered {} for the life of the project, which is why the tile read 0,
and a day was spent looking at /club/stats instead. A regression to {} would be
silent: still 200, still valid JSON, tile quietly back to zero.
Both are read with the INT getter 0x1801c79d0, so a string here would desync.
"""
d = _get(G + "/hub")
check("hub body is an object", is_obj(d), repr(d))
if not is_obj(d):
return
check("hub.clubPlayers present (the MY CLUB tile counter)", "clubPlayers" in d, repr(sorted(d)))
check("hub.clubPlayers is a number, not a string", is_num(d.get("clubPlayers")),
repr(d.get("clubPlayers")))
check("hub.auctionCount is a number", is_num(d.get("auctionCount")),
repr(d.get("auctionCount")))
# the clamp FUN_1800d7b30 turns <=0 into 0, so a negative would silently read as 0
if is_num(d.get("clubPlayers")):
check("hub.clubPlayers is not negative (clamped to 0 by the client)",
d["clubPlayers"] >= 0, repr(d["clubPlayers"]))
def main():
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
test_auction_record_shape, test_squad_boot, test_squad_list_shape,
test_squad_list_endpoint, test_massinfo_shape, test_club_items,
test_identity_consistency, test_club_user_shape, test_club_info_shape,
test_accountinfo_shape, test_club_rename_roundtrip,
test_move_verdict_shape]
test_move_verdict_shape, test_hub_counters]
try:
_get(G + "/user/credits")
except Exception as e:
+53 -1
View File
@@ -889,7 +889,7 @@ ROUTES = [
# /ut/delete/ path prefix, same as trade/watchList/squad).
(re.compile(r"/ut/delete/game/[^/]+/match"), lambda m, h: match_route(h)),
(re.compile(G + r"/match"), lambda m, h: match_route(h)),
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
(re.compile(G + r"/hub"), lambda m, h: (200, hub_data())),
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
# ---- game modes (FUT_MODES=1; default keeps the proven {} everywhere) ----
@@ -946,6 +946,58 @@ ROUTES = [
]
# ---- GET ut/%s/hub : THE MY CLUB TILE COUNTER --------------------------------
# FutGetHubDataServerResponse. Body parser FUN_180139610 (14,855 chars, censused in
# full: 18 atom comparisons, none missed). Root container is a FLAT OBJECT, verified
# from the prologue by tokenizer-call calibration against the live-proven massinfo
# parser rather than assumed.
#
# THE CHAIN, end to end, re-derived independently by two agents (one via Ghidra, one
# via raw PE plus capstone with no decompiler) and checked by two reviewers:
#
# clubPlayers (atom 0x90) --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 -> 0)
# -> stored at R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is
# literally `lea rax,[rcx+0x1fd70]; ret`)
# -> read by FUN_1800b0250 and published as TEXT0 of TILE_ID 0x210
# -> captions FUT_GH_TOTAL_PLAYERS_0 / _1 at 0x18020a0f8 / 0x18020a110
# auctionCount (atom 0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile
#
# `FUN_180139610` is the ONLY writer of +0x3c anywhere in the image (one write in
# 14,855 chars, guarded by `if (iVar6 != 0x90)`). So this is not a candidate, it is the
# field.
#
# WHY IT TOOK SO LONG, recorded because the error is instructive. The hunt went to
# /club/stats and stayed there for a day, and REBUILD_RESEARCH S19 concluded the
# counter was "not server-fixable" with a mechanism that was internally correct and
# entirely beside the point: the tile never read the club-stat store. Two things
# reinforced the wrong path. ENDPOINT_MAP said this response "uses C++ reflection /
# vtable dispatch, NOT an inline atom ladder, no static field ladder to read" and
# marked it a GAP, which is wrong: there IS an inline ladder, one indirection away.
# And the eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
# publishes six keys; it is actually FUN_180094ce0, a different provider using a
# different string family (FUT_MYCLUB_*), which reads neither the mode tag nor any
# type id we were sending. Two providers, and we were reading the wrong one.
#
# auctionCount is deliberately included as a FREE CONTROL: it lands in a different
# field (+0x38) and a different tile, so if the MY CLUB tile moves and TRANSFERS does
# not, the delivery is fine and something is specific to +0x3c.
#
# DEFAULT ON. Freeze risk is genuinely low rather than merely believed low: the body
# is a flat object of two integers, both read with the INT getter, so there is no
# array, no nested object, and no type-desync surface. FUT_HUBDATA=0 restores {}.
HUBDATA = os.environ.get("FUT_HUBDATA", "1") == "1"
def hub_data():
"""GET ut/%s/hub -- the FUT hub tile counters."""
if not HUBDATA:
return {}
players = len([i for i in STORE.items() if i.get("itemType") == "player"])
auctions = len(STORE.listings())
log(" HUB: clubPlayers=%d auctionCount=%d" % (players, auctions))
return {"clubPlayers": players, "auctionCount": auctions}
# ---- 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: