fifa17-recon: club stats -- key the buckets the way the READER looks them up

Second correction in an hour, and this one comes from reading the provider instead of
reasoning about it. The per-context getter is (+0x7f8)(store, contextValue, typeId),
and contextValue comes from THE UI ROW, not from the URL:

  case 3:  uVar7  = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")
           bronze = (+0x7f8)(store, uVar7, 2)
           silver = (+0x7f8)(store, uVar7, 3)
           gold   = (+0x7f8)(store, uVar7, 4)
           publish "PLAYERS_EMPLOYED", gold + silver + bronze
           rare/kits/badges = (+0x7f8)(store, uVar7, 5 / 0x28 / 0x2d)
  case 4:  keyed by "TEAM_ID"; reads 1 (players), 0x28 (kits), 0x2e (badgeDBid)

Three things my previous commit got wrong:

1. It keyed every row to the id in the URL. The reader iterates the SCREEN'S ROWS and
   looks up each row's own id, so one response must carry a bucket per row. Keying to
   the URL id fills exactly one bucket the screen never asks for, which is why the
   ENGLAND tab still showed zeros after the "fix".
2. PLAYERS_EMPLOYED is COMPUTED as gold + silver + bronze in the per-context cases and
   is never read from the store, so sending `players` (type id 1) does nothing there.
   The tier counts are mandatory.
3. The screens NEST: country/<id> lists the LEAGUES in that nation (case 3, LEAGUE_ID)
   and league/<id> lists the TEAMS (case 4, TEAM_ID). That matches the live navigation
   exactly: selecting ENGLAND produced Premier League / Championship / League One /
   League Two.

Because every response wipes the whole map, each response only needs its own screen's
buckets, which also avoids a real collision: the storage key is contextValue alone, so
nation 14 and league 14 would otherwise share a bucket.

Live output now:

  country/14 -> 41 rows, 5 league buckets
                league 13 gold=17 -> PLAYERS_EMPLOYED=17   (Premier League)
                league 19 gold=26, league 53 gold=55, ...
  league/13  -> 6 team buckets {5:20, 21:15, 22:11, 240:21, 241:30, 243:17}

Recorded as a method note: two rounds of reasoning about this endpoint produced two
wrong bodies, and reading twenty lines of the provider produced the right one. The
question "what does the reader look up" is answerable and was not asked.

392 + 61 checks green, zero tracebacks. Still behind FUT_CLUBSTATS, default off.

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:45:40 -07:00
parent 9ee21afb56
commit f65c197942
+58 -33
View File
@@ -1013,42 +1013,63 @@ def _counts_for(players):
]
def _club_stat_context(kind, ctx_id):
"""Per-context rows for the nation / league / team tabs.
def _club_stat_context(kind):
"""Per-context rows, keyed the way the READER actually looks them up.
LIVE 2026-08-04, and this is a correction to the first version of this route.
Selecting the ENGLAND tab on the MY CLUB screen issues exactly one request:
Read out of the club-stats provider FUN_180043b90, not guessed. The per-context
getter is `(+0x7f8)(store, contextValue, typeId)`, and the crucial detail is where
contextValue comes from: THE UI ROW, not the URL.
GET /ut/game/fifa17/club/stats/country/14 (14 = England)
case 3: uVar7 = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")
bronze = (+0x7f8)(store, uVar7, 2)
silver = (+0x7f8)(store, uVar7, 3)
gold = (+0x7f8)(store, uVar7, 4)
publish "PLAYERS_EMPLOYED", gold + silver + bronze
rare = (+0x7f8)(store, uVar7, 5)
kits = (+0x7f8)(store, uVar7, 0x28)
badges = (+0x7f8)(store, uVar7, 0x2d)
case 4: keyed by "TEAM_ID"; reads 1 (players), 0x28 (kits), 0x2e (badgeDBid)
and NO item-list request, so that tab is driven entirely by per-nation stats. The
tab showed nothing while the club holds 8 England players, because every row we
sent carried contextId 1.
THREE CONSEQUENCES, all of which my first attempt got wrong:
THE GUARD IS THE WHOLE POINT. In the deserializer, contextId == 1 or
5 <= contextId <= 9 FORCES contextValue to 0, which is the global bucket the
+0x800 getter reads. The per-nation view reads the +0x7f8 getter keyed by the
nation id instead. So a body with contextId 1 can only ever populate the global
bucket, no matter what contextValue says, and the per-context tabs stay empty.
1. One response must carry a bucket for EVERY ROW the screen will show, because
the reader iterates rows and looks up each row's own id. Keying everything to
the id in the URL, which is what I did first, fills exactly one bucket that the
screen never asks for.
2. `PLAYERS_EMPLOYED` is COMPUTED as gold + silver + bronze. It is never read from
the store in the per-context cases, so sending `players` (type id 1) does
nothing there. The tier counts are mandatory, not decoration.
3. The screens NEST: country/<id> lists the LEAGUES in that nation (case 3, keyed
by LEAGUE_ID) and league/<id> lists the TEAMS (case 4, keyed by TEAM_ID). That
matches the live navigation exactly: selecting ENGLAND produced a Premier
League / Championship / League One / League Two list.
contextId 3 is used here purely because it is OUTSIDE the guard and therefore
preserves contextValue. `TODO/CONFIRM` what contextId means semantically; nothing
read so far assigns it a meaning beyond that guard.
Because every response WIPES the whole map, each response only needs the buckets
for its own screen. That is also what keeps nation ids and league ids from
colliding: the storage key is contextValue alone, so nation 14 and league 14 would
otherwise share a bucket. One kind per response, no collision.
contextId 3 is used because it is OUTSIDE the guard (contextId == 1, or 5..9,
force contextValue to 0) and therefore preserves contextValue. `TODO/CONFIRM`
whether contextId carries further meaning; nothing read so far gives it one.
"""
items = STORE.items()
if kind == "country":
sel = [i for i in items if i.get("itemType") == "player" and i.get("nation") == ctx_id]
elif kind == "league":
sel = [i for i in items if i.get("itemType") == "player" and i.get("leagueId") == ctx_id]
else:
sel = [i for i in items if i.get("itemType") == "player" and i.get("teamid") == ctx_id]
rows = [{"contextId": 3, "contextValue": int(ctx_id), "type": t, "typeValue": int(v)}
for t, v in _counts_for(sel)]
# kits/badges are per-context too (case 3 reads KITS_AVAILABLE and BADGES_AVAILABLE
# through the same +0x7f8 getter). We own none, so these are honest zeros.
rows += [{"contextId": 3, "contextValue": int(ctx_id), "type": t, "typeValue": 0}
for t in ("kits", "badges")]
return rows, len(sel)
players = [i for i in STORE.items() if i.get("itemType") == "player"]
field = {"country": "leagueId", "league": "teamid"}.get(kind)
if not field:
return [], 0
ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
rows = []
for ctx in ctxs:
sel = [i for i in players if i.get(field) == ctx]
if field == "teamid":
counts = [("players", len(sel)), ("kits", 0), ("badgeDBid", 0)]
else:
counts = [c for c in _counts_for(sel) if c[0] != "players"]
counts += [("kits", 0), ("badges", 0)]
rows += [{"contextId": 3, "contextValue": int(ctx), "type": t, "typeValue": int(v)}
for t, v in counts]
return rows, len(ctxs)
def _club_stat_set():
@@ -1096,10 +1117,14 @@ def club_stats_route(h):
# wipes the whole map first, so anything left out of this body is erased.
parts = mode.split("/")
if len(parts) >= 2 and parts[1].isdigit():
ctx_rows, n = _club_stat_context(parts[0], int(parts[1]))
# The id in the URL says which screen we are on, NOT which bucket to fill.
# country/<n> renders a list of leagues keyed by LEAGUE_ID; league/<n> renders
# a list of teams keyed by TEAM_ID. So fill every bucket that screen can show.
ctx_rows, n = _club_stat_context(parts[0])
stats = stats + ctx_rows
log(" CLUBSTATS: %s -> %d rows (global players=%d, context %s=%d players=%d)"
% (mode, len(stats), stats[0]["typeValue"], parts[0], int(parts[1]), n))
log(" CLUBSTATS: %s -> %d rows (global players=%d, %d %s buckets)"
% (mode, len(stats), stats[0]["typeValue"], n,
{"country": "league", "league": "team"}.get(parts[0], parts[0])))
return 200, {"stat": stats}
log(" CLUBSTATS: %s -> %d stat rows (players=%d)"
% (mode or "(none)", len(stats), stats[0]["typeValue"]))