diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py index 7e89cb1..dff91cc 100644 --- a/fifa17-recon/tools/test_fut_contract.py +++ b/fifa17-recon/tools/test_fut_contract.py @@ -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: diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index bc69b31..e7d7a2d 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -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/ -> FutStickerBookStats2ServerResponse, deser # 0x180130150 (7,870 chars, read end to end). Wire schema, fully verified: