fifa17-recon: the blockers plan from the multi-agent pass

Five parallel Ghidra investigations, one adversarial reviewer each, one synthesis.
Kept in the repo because the reviewer corrections are load-bearing: they refuted claims
in four of the five reports, two of which would have shipped wrong behaviour (a
speculative /season body justified by our own curl traffic in the log, and a store
field block that was a freeze rather than a regression).

Carries the next live session (one launch, three flags, four menu actions, one
read-only memory probe), the implementation queue, what is genuinely blocked and why,
and a what-could-make-this-plan-wrong section that names the store change as the
concrete regression risk.

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:04:00 -07:00
parent 1e9cfb6da9
commit 285d4f6cb7
@@ -0,0 +1,226 @@
> Produced 2026-08-04 by a multi-agent pass: five parallel Ghidra investigations, one
> adversarial reviewer per investigation, then this synthesis. The reviewers refuted
> real claims in four of the five reports, including two that would have shipped wrong
> behaviour, so read the corrections in section 1 as part of the findings and not as
> commentary on them.
>
> Status of section 3 as of this commit: item 0 is DONE (both dead store flags deleted,
> the season root corrected, ENDPOINT_MAP's two hang recipes removed), items 1 and 3
> are IMPLEMENTED behind `FUT_STORE_DISPLAYGROUP` and `FUT_CLUB_PAGE`, both default
> off, and items 2 and 4 were already shipped. Items 5 and 6 are not built.
# OpenFUT / FIFA 17 FUT: next execution plan
Date: 2026-08-04. Written against `fifa17-recon/tools/utas_server.py` at the current working tree.
---
## 1. What the investigations actually established
### Store tiles render "unknown" (slug: store)
**Verified in binary, and independently reproduced by the reviewer.** `displayGroup` (atom `0xd9`) is parsed inline as a flat JSON **object** by the pack element parser `FUN_18013af30` (19279 chars, read in full by both parties). Inside it, exactly two members are read: `priority` (`0x250`, INT getter `0x1801c79d0`) and `value` (`0x377`, STRING getter `0x1801c7aa0`). The literal `"unknown"` occurs exactly once in cardsdll.dll, at `0x180223108`, written by `FUN_180133f60` into record offset `+0x00`, which is precisely the slot `displayGroup.value` overwrites. The caller chain is a single thread: `FUN_1801234e0` (store root) to `FUN_18013af30` to `FUN_1801342d0` to `FUN_180133f60`. The old freeze is fully traced: an **array**-shaped `displayGroup` desyncs and runs the outer loop past EOF into `FUN_1801c7f10`, whose entry is `0x1801c7f10` and whose known spin PC `0x1801c7f1a` is inside it.
**Reviewer corrections.** The investigator's list of top-level parsed atoms was incomplete: 27, not 14. The eight it missed are `0x8 actionType`, `0x5c bonus`, `0x102 end`, `0x127 firstPartyStoreId`, `0x240 points`, `0x2e3 start`, `0x36a useDefaultImage`, `0x37d visible`. That omission is not academic: it is how the reviewer found a **live landmine in code the investigator reviewed and left alone**. The `FUT_STORE_FIELDS` block (currently default off, `utas_server.py:674`) sends `actionType` and `firstPartyStoreId` as JSON **integers**, and both atoms are read with the STRING getter `0x1801c7aa0`. That is the exact type-desync class that produces the `0x1801c7f1a` spin. Also: `0x37d visible` does not read its value at all (`local_130 = 1;` unconditional), and `0x36a useDefaultImage` stores inverted, same as `0xdb`. The claim that the tile caption renders from `value` is a **strong inference**, not verified: the renderer is in Denuvo-packed FIFA17.exe. The claim that atom ids were checked "against the binary, not the TSV" is a distinction without a difference; `docs/fut_atoms.tsv` is a dump of the same table at `0x1802d2760` and a fresh diff showed zero differences.
**Net:** the proposed one-key change is the best-supported item in this whole batch. Reviewer verdict: serve as written, strip nothing.
### Draft hangs the client (slug: draft)
**Verified in binary by both parties, independently.** `FutGetDraftCurrentState` deserializer is `0x180147070` (8720 chars, read end to end twice). Its root container is a **JSON array of objects**: init, two discarded `FUN_1801c7f10` calls, then `if (t3 != 0xd)` around a `do { ... } while (t != 0xd)` element loop. Top-level atom set is exactly seven: `0x2cd squad`, `0x108 entranceCriteria`, `0x13b gamesWonCurrentMatch`, `0x293 roundsInfo`, `0x2d5 squadState`, `0x2ee stateParam1`, `0x2ef stateParam2`. The skip handler `FUN_180135ff0` is present at both defaults, so unknown keys are inert. The hang mechanism was re-traced by the reviewer with the same end state.
**This is where the project's "negative claims deserve more scepticism" lesson repeats.** The investigator's census claimed only 3 array-root readers in the DLL. The reviewer found at least 4: it missed `0x1801510c0` = `FutGetDraftAward`, whose `ENDPOINT_MAP.md` entry currently documents an **object-root** minimal body. That documented body is a hang recipe. `0x180148ec0` is a hybrid that accepts either root and fits neither bucket. The reviewer's own regex census was wrong in the opposite direction. **Do not cite either census as complete;** roughly 23 of 86 top-level readers remain unclassified.
**Other reviewer corrections.** The base URL template is at `0x18021e7f8`, not `0x18021e7f4` (that address is the string `"TFA"`). The claim that the binary proves the full path is overreach: `FUN_180146ac0` appends the suffix to a caller-supplied buffer and has no resolvable callers. The path `ut/game/fifa17/squad/mode/draft/state` is established by the **live capture** in `docs/REBUILD_RESEARCH.md` S15, not statically. `stateParam2` may be a JSON number or string (the string getter stringifies token types 2/3/4); sending a string is still correct but not mandatory. `roundsInfo` element offsets for `penaltyScore` and `score` are swapped in both the report and `ENDPOINT_MAP.md`. The non-terminating loop is the inner `while (t != 10)`, not the outer do-while.
**Operationally important and understated:** `FUT_DRAFT_STATE` is already implemented (route `utas_server.py:869`, handler at 1078) and **defaults to on**. The untested body ships by default today. That is defensible (the status quo provably hangs) but must be stated, not implied.
### Seasons refuses with zero network requests (slug: seasons)
**Partly wrong.** Verified and reproduced: the RPC descriptor table at `0x1802caa28` (stride 0x30) with `i=69 SEASONLIST -> ut/%s/season`; `LoadOfflineSeasons` = `FUN_180057560` and `LoadSeasons` = `FUN_1800576b0` issue **no** network call and only read a u16 count; `LoadCurrentOfflineSeason` = `FUN_180057230` has no precondition; `FutDataManagerImpl` vtable `0x18021c2a0` slots `+0x588 -> this+0x7138`, `+0x898 -> this+0x5c68`, `+0x850` enqueue; seasons is not feature-flagged in the hub tile builder `FUN_1800b2680`; there is no `IS_SEASON(S)_ENABLED` and no entitlement/SKU gate. All confirmed, no truncation anywhere.
**Refuted, and this is the topic's headline failure.** The proposed body root shape is wrong. `FutSeasonListServerResponse` resolves to deserializer `0x1801683f0`, which runs an **object** key loop and matches exactly one key, atom `0x2ad` = `seasons`; only inside it does an array open, per element parsed by `FUN_180167740`. The investigator read the **element** parser and served its key set at the document root. A bare `[]` populates nothing. `utas_server.py`'s own comment at line 1010 ("ARRAY root") is wrong in the same way.
**Refuted by the project's own log.** The reviewer attributed all 21584 lines of `/tmp/utas_server.log` by User-Agent. The game is `ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`: 486 real client requests across roughly 30 boots, and **zero** to any `/season` path. The `/season` hits in the log are `curl/8.21.0` and `Python-urllib/3.14`, our own harness. The investigator's proposed discriminating test ("grep for /season, if present ship the change") would have returned a false positive off our own curl. Run as intended, with a UA filter, it lands on its other branch: do not ship a speculative body.
**Also refuted:** the headline mechanism that the count at `model+0x96` "is only ever filled by SEASONLIST". Neither `0x1801683f0` nor `0x180167740` writes `+0x96` at all. Nothing resolved this session identifies its writer. Element stride is `0x318`, not `0x1f8` (`0x1f8` is the offset of the compared short **inside** an element). The short the online path matches is written from `divisionId` as `(0xb - divisionId)`, not from `id`. Three of the six keys in the proposed body (`eligibilityKey`, `eligibilitySlot`, `eligibilityValue`) are inner members of `elgReq` and are completely inert at top level.
**One thing the investigator missed that helps us:** on no-match, `FUN_180057330` fires a script event with the literal `"NOSEASONS"` (`0x1801f92c0`, single xref from `0x1800574be`). That is a named failure signal we can look for.
### /match request and response shape (slug: match)
**Sound, verified on both sides.** The match family is descriptor rows 49 to 54, all using URL index 16 = `ut/%s/match`, with suffixes appended by a params-object virtual at slot `+0x08`: `/ready`, `/end`, `/reset`, `/keepalive`, and nothing for create and playgame. **There is no `/match/{id}` URL anywhere.** The result is carried by `endReason` (atom `0x104`), a string enum of nine values from `FUN_1801365a0`. Score lives in `myMatchStats.goals` vs `opponentMatchStats.goals`, both written by `FUN_180121480` with the same 15 fields; both objects are **omitted** when `endReason` is DNF or QUIT (guard `if (1 < *(int *)(param_1 + 0xa8) - 4U)`). The DestroyMatch response deserializer is `FUN_180121b60`, established by the reviewer through the factory/vtable route because `class_deser` returned `[]` for this class. Top-level `coins` is **not** parsed; `coins` (atom `0x95`) is read only inside `gameModeAward` (`0x136`). Reset's deserializer is `0x18016fe90`, not the documented `0x18016fd10`.
**Reviewer corrections.** `qualifiedChampionEventId` (`0x269`) is **not** a plain int: its branch compares the stored value against `*(int *)(mgr + 0x328)` and sets `*(byte *)(mgr + 0x32c) = 1` on a match. A habitual `0` pokes champion-event state for no reward. Strip it. The `bidTokens` "freeze trap" is real as an unhandled match but the desync consequence is **inferred and wrong for scalars**: `FUN_180135ff0` is a no-op on scalar tokens and getters do not advance the stream. Correct rule: never emit `bidTokens` inside `gameModeAward` **as an object or array**; a scalar is inert. Omit it either way. `startDateTime` in the CreateMatch response takes the INT getter, not the number getter. The claim "the reward path can never fire" is overreach given the verb is unknown rather than known-wrong. Cited line numbers matched neither HEAD nor the working tree.
**Unexamined and cheap:** before building the URL, `FUN_18016c510` fills a separate 0x200-byte buffer via the RPC object's own vtable slot `+0x38` and passes it as the first argument to the WebService send. Its content is unidentified. The verb question may not be entirely outside cardsdll.dll after all.
### MY CLUB counter reads 0 (slug: clubcount)
**This topic produced a lot of verified schema and no usable finding on its stated question.**
Verified and reproduced exactly, including a 475402-instruction census: the club-stat store is reached through slots `+0x7f0` / `+0x7f8` / `+0x800` on the object from `FUN_18011a830`, and the only callers of `+0x7f8` / `+0x800` are five CLUB STATS panel providers (`FUN_180043b90`, `FUN_180094ce0`, `FUN_180095360`, `FUN_180096670`, `FUN_180097c70`). The seven hub tile publishers dispatched from `FUN_1800ae8a0` appear in none of them. `FutStickerBookStats2` schema is `{"stat":[{contextId, contextValue, type, typeValue}]}` with the first three as JSON ints and `type` as a JSON string into a 0x30 buffer; the store is keyed by `contextValue` **alone**, and `contextId == 1` or `5 <= contextId <= 9` forces `contextValue` to 0. Element-local parse variables are not reset between elements, so all four keys must appear in every element. The 40-entry type-string to internal-id map is complete and matches the atom table with zero mismatches. `/club/stats/staff` is a genuinely different class, `FutStaffBonus` (deser `0x18012b730`, shape `{"bonus":[{"type","value"}]}`).
**Refuted, and this one is dangerous.** Finding 7 said the deserializer does not wipe the map, therefore bodies are sticky and `{}` is inert. The deserializer indeed does not wipe. **The factory does, on every response, before the parse:** `FUN_18012f6d0` calls `FUN_180116240` (recursive nested-map erase) and then resets head/root/size. So the original belief the investigator set out to refute was correct, and it refuted a claim it never tested by searching only the deserializer. **Operational consequence: our current `{}` on any `/club/stats/<mode>` destroys every number previously loaded, and two Stats2 responses in a row do not accumulate.**
**Also refuted or corrected.** The headline "not reachable from /club/stats" is bounded, not exhaustive: the census cannot exclude the service class reading its own member store through `this`, including the `+0x1f8` slot the investigator itself flags as feeding the `FUT_GH_TOTAL_PLAYERS` tile. `FutGetClubInfo` delegates every array element to `FUN_18012c990`, which the investigator never opened; the conclusion (no count atom) survives, but a club-size number would have lived exactly there. The store is a **single-context cache**: `FUN_18012fbe0` writes the request context to `store+0x78/+0x7c/+0x80`, `FUN_18012fa90` serves from cache and skips the HTTP request, and `FUN_180043b90` switches the entire panel on `*(store+0x78)` with **case 1 (club mode) the only branch that publishes the global block**. For the nation and league panels, `PLAYERS_EMPLOYED` is the sum of ids 2+3+4, not id 1. The four `players*` rows with `contextId 1` in the proposed body land in bucket 0 where no reader looks.
**Upgraded to verified:** `FutStickerBookSearch` (deser slot resolved as `FUN_18012eff0`) carries no count or total atom; atoms `0xbc count` and `0x325 total` exist in the table and are not parsed.
**Bottom line on the stated question:** the MY CLUB counter's renderer and binding are not in cardsdll.dll. No `%d/%d`, `%i/%i`, `%d of %d` or `/%i` formatter exists anywhere in it. It lives in FIFA17.exe (Denuvo-packed) or in the Scaleform UI assets. This topic did not produce a fix, and the club-stats body it proposed is not the cure it was framed as.
---
## 2. The next live session
**One launch. Three flags, one passive probe, four menu actions, in this order.**
Flags:
| Flag | Value | State today |
|---|---|---|
| `FUT_STORE_DISPLAYGROUP` | `1` | new, default `0` |
| `FUT_DRAFT_STATE` | `1` | already default `1` |
| `FUT_CLUB_PAGE` | `1` | new, default `0` (see queue item 3) |
| `FUT_STORE_FIELDS` | `0` | must stay off, see section 5 |
| `FUT_STORE_GROUPS` | `0` | must stay off |
| `FUT_MODES` | `0` | must stay off |
| `FUT_CLUBSTATS` | not built yet | see queue item 5 |
Menu actions, in order, riskiest last:
1. **Store.** Open the pack store. Photograph the tiles. Read the caption on each of the three packs. Then **buy one pack and open it.** This is the regression guard: the buy path is live-proven today and `FUT_STORE_GROUPS` / `FUT_STORE_FIELDS` have each already broken it once.
2. **Club.** Enter MY CLUB. Record the counter's exact rendered text and whether it tracks the enlarged first `/club` page. Record the CLUB STATS panel numbers as a baseline (they should still be zero; we are not changing `/club/stats` this launch).
3. **Memory probe, at the hub, no relaunch.** Using the `/proc/PID/mem` technique already working in `tools/watch_online_mode.py`, read: (a) the season count u16 at `FutDataManagerImpl + 0x7138 + 0x96`; (b) the object at `DAT_1802e6398` and its vtable, then dump slots `+0x1f8`, `+0x7f0`, `+0x7f8`, `+0x800`. This is read-only, costs no flag, and is the single measurement that unblocks both Seasons and the MY CLUB counter.
4. **Online Draft, then Single Player Draft.** Last, because this is the only step with a history of hanging the client. If it hangs, everything above is already recorded.
**Why these share one launch.** The three flags touch three disjoint request paths that are individually attributable in the access log: `/store/purchasegroup`, `/club?...`, `/squad/mode/draft/state`. They light three disjoint screens. A failure in any one is attributable from the log line plus the screen it appeared on, so this is not a two-variables-one-observation situation. The memory probe changes nothing served and cannot confound anything.
**What genuinely cannot share this launch:** the `/club/stats` correctness work and the `FUT_CLUB_PAGE` counter experiment both target the same on-screen number. If both ran and the counter changed, we could not say which caused it. `FUT_CLUB_PAGE` gets this launch because it is the cheaper of the two (no new decompilation needed, see queue item 5) and because a null result from it is what licenses the more expensive club-stats work. Seasons gets no launch at all this round: across 486 real client requests the game has never asked for `/season`, so no body we serve is observable.
**What to record.** The full `utas_server.log` for the session, filtered to User-Agent `ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)` before any conclusion is drawn from it. Screenshots of the store tiles, the MY CLUB screen, and each draft screen. The probe output. Whether `"NOSEASONS"` behaviour appears anywhere. Whether the client ever emits `/season` or `/club/stats/club`.
---
## 3. The implementation queue
### 0. Zero-launch fixes (no flag, do before the session)
These cost nothing, ship no new shape, and remove three landmines.
- **Fix or delete the `FUT_STORE_FIELDS` block** (`utas_server.py:674`). It sends `"actionType": 0` and `"firstPartyStoreId": 0` as integers; both atoms take the STRING getter `0x1801c7aa0`. That is the freeze class this entire exercise exists to avoid. Deleting is preferable to fixing, since neither field has an established purpose. Its `useDefaultImage: True` is a silent no-op (atom `0x36a` stores inverted).
- **Delete the `FUT_STORE_GROUPS` block** (`utas_server.py:671`). It is proven wrong: an array where an object is parsed. It is the original freeze.
- **Fix `season_list()` and its docstring** (`utas_server.py:1008`). Root is an object with one key `seasons` (atom `0x2ad`), not an array; the deserializer is `0x1801683f0`, not `0x180167740` (that is the element parser). Keep it behind `FUT_MODES`, still off.
- **Fix `docs/ENDPOINT_MAP.md`**, four entries: the draft/state path is missing the `squad/mode` segment (line 487) and its "MINIMAL known-good" body is object-root with the non-existent spelling `DRAFTSQUAD_ON` and a nested `squad` object, i.e. a hang recipe; `GetDraftAward` (`0x1801510c0`) is array-root and its documented object body would hang identically; `roundsInfo` element offsets `penaltyScore` and `score` are swapped (`penaltyScore` is `+0x18`, `score` is `+0x10`); `FutSeasonList`'s root shape. Also delete the "Stats2 is sticky" claim wherever it was recorded.
Freeze risk: none, these all remove or correct unserved code. Proof it worked: the store still opens and packs still open at step 1 of the live session.
### 1. `FUT_STORE_DISPLAYGROUP` (default `0`)
Add exactly one key per pack entry, changing nothing else in the live-proven buy-path body:
```json
"displayGroup": {"value": "Bronze Pack"}
```
and correspondingly `{"value": "Gold Pack"}` and `{"value": "Premium Gold"}`. Distinct values so grouping stays 1:1 and the current one-tile-per-pack layout is preserved. Omit `priority` (`0x250`), `displayGroupAssetId` (`0xda`) and `displayGroupUseDefaultImage` (`0xdb`): each would be a second variable, and `0xdb` is stored inverted so the old `true` was always a no-op.
**Freeze risk: low at the parser, unknown at the UI.** Parser-side it is provably balanced: the store root `FUN_1801234e0` consumes each element's START_OBJECT, so case `0xd9` dispatches with the value token current, and `{"value": "..."}` consumes exactly FIELD_NAME, string scalar, END_OBJECT. `{}` is balanced too. What the token-balance proof does **not** cover is that going from no-displayGroup to a-displayGroup may switch FIFA17.exe from an ungrouped-tile render path to a group-tile render path, and that code is packed.
**Proof it worked:** tiles read "Bronze Pack" / "Gold Pack" / "Premium Gold" instead of "unknown", and a pack still buys and opens. Blank tiles mean `value` is a localisation key, not a literal (retry next launch with `"GOLD"` / `"BRONZE"` / `"PREMIUM"`). Still "unknown" means the key never landed, so check the request actually carried it. Tiles collapsing to one means the UI groups by `value`, which distinct values should already prevent.
### 2. `FUT_DRAFT_STATE` (already shipped, default `1`)
Serving today on `GET /ut/game/fifa17/squad/mode/draft/state`, route at `utas_server.py:869`, above the generic `/squad` entry:
```json
[{"squadState":"INVALID","stateParam1":"INVALID","stateParam2":"0","gamesWonCurrentMatch":0,"roundsInfo":[]}]
```
Both reviewers traced this token by token to a clean exit (16 reads, tokenizer left at END_DOCUMENT). `squad` (`0x2cd`) and `entranceCriteria` (`0x108`) are correctly omitted and omission is provably inert.
**Freeze risk: low structurally, but this is untested live and ships on by default.** The two semantic guesses are `squadState` and `stateParam1`: `"INVALID"` writes enum 1, and omitting the key is **not** equivalent (it leaves whatever the constructor set). Neither is known-better.
**Proof it worked:** the Draft screen renders instead of hanging, and the log shows the request answered with a 200 rather than being the last line before silence. If it hangs, set `FUT_DRAFT_STATE=0` and consider the bare `[]` floor, which is also verified safe.
### 3. `FUT_CLUB_PAGE` (new, default `0`)
Not a shape change. Raise the number of items returned on the first `GET /ut/game/fifa17/club?year=2017&type=player&count=11&level=any` page well above 11, with no new keys.
**Freeze risk: low.** No JSON member changes type or nesting; only the length of an already-served array changes.
**Proof it worked:** the MY CLUB counter tracks the new item count. That would establish the counter is a Flash-side count over the club list, which the binary evidence permits (nothing in cardsdll composes a two-number caption) and would make the whole `/club/stats` line of attack irrelevant to this symptom. If the counter does not move, the hypothesis is dead and queue item 5 becomes the next candidate.
### 4. `FUT_MATCH_END` (already shipped, default `1`)
The match tail is already implemented in the working tree and already carries both reviewer corrections: the reward moved into `gameModeAward.coins`, `qualifiedChampionEventId` is omitted with its side effect documented, `bidTokens` is never emitted, and the destroy gate is path-based (`/match/end`) rather than requiring a verb the binary cannot tell us. Body served today:
```json
{"allCoins":15400,"matchCoins":400,"seasonCoins":0,"tournamentCoins":0,"boostConis":0,"participationAward":0,"teamOfTournamentWinner":false,"gameModeAward":{"coins":400}}
```
Do **not** add `boostCountLeft` (atom `0x61`, `+0x4c`) yet. It is type-safe but its value would be a habitual 0, which is what the field already holds, so it buys no observation and adds a variable.
**Freeze risk: low.** Every scalar type-checks against a getter resolved in its own atom branch. The omitted members (`matchCoinMultipliers`, `matchCoinPartials`, `userData`, `seasonEndResult`) are all skip-safe.
**Proof it worked:** cannot be obtained yet. `/match` is unreachable until Seasons or Draft opens a route into a match. This item is correct-on-first-attempt work, not testable work, and it should not consume a launch.
### 5. `FUT_CLUBSTATS` — not yet. Here is the missing piece.
Two gaps block a body, and both are cheap static work, not live work.
- **The per-mode reader branches are not enumerated.** `FUN_180043b90` switches the whole panel on `*(store + 0x78)`, the mode of the last loaded response. Only case 1 (club) and case 5 (per nation/league/team) were read. The live capture shows the client requests `/club/stats/staff`, `/club/stats/year` and `/club/stats/consumables` at hub entry and **never** `/club/stats/club`. So we do not know which `type` strings the year and consumables branches actually read. Serving a club-summary payload on those modes cannot light the summary page. **Next step: decompile `FUN_180043b90`'s cases for modes 2 and 6 in full.**
- **The `FutStaffBonus` type strings are not decoded.** The shape is known (`{"bonus":[{"type":"<string>","value":<int>}]}`) but the string-to-effect map is not, so we have no defensible values. `{}` remains safe for that endpoint (the parser's top-level loop exits immediately on END_OBJECT).
**And a rule that must be encoded before any body ships:** every `FutStickerBookStats2` response **wipes the entire club-stat map first** (factory `FUN_18012f6d0` to `FUN_180116240` plus a head/root/size reset). Nothing accumulates. Whatever we serve for a mode must be complete and self-contained, and serving a good body for one mode followed by a thin one for another erases the first. When bodies do exist: emit all four keys in every element (locals are not reset between elements); `contextId`/`contextValue`/`typeValue` as JSON integers and `type` as a JSON string; and do not use `contextId 1` for the per-context ids 2, 3, 4, 5 (bronze/silver/gold/rare), because `contextId 1` forces `contextValue` to 0 and those ids are only ever read through the per-context getter `+0x7f8`.
**Freeze risk when it does ship: low.** The only type-desync surfaces are four scalars, each with its getter inside its own atom branch, and unknown keys route to `FUN_180135ff0` at both nesting levels.
**Proof it will have worked:** the CLUB STATS panel shows non-zero numbers. Note explicitly: this is a **correctness fix for the CLUB STATS panel**, not a fix for the MY CLUB hub counter, and it should never again be framed as the latter.
### 6. `FUT_SEASONS` — not yet, and possibly never at this layer.
If a body is eventually served, the correct minimal shape is:
```json
{"seasons":[{"type":"OFFLINE","id":1,"divisionId":10}]}
```
Object root, single key `seasons` (atom `0x2ad`). `type` must precede `divisionId` because the `divisionId` branch reads the already-parsed type field at `elem+0x1b4`. Keep `prizeSet` (`0xf7`... correction: `prizeSet` is `0x253`), `elgReq` (`0xf7`) and `matches` (`0x1b8`) omitted: all three are `while (tok != 0xd)` array loops and a scalar there is the `0x1801c7f1a` spin. Note the semantic hazard: omitting `untilEndSeconds` makes the season's end timestamp equal to now.
**But do not ship it.** Across 486 real client requests the game has never asked for `/season`. Serving any body changes nothing observable. The missing piece is upstream, see section 4.
---
## 4. What is blocked and honestly why
- **The MY CLUB hub counter's renderer.** Not in cardsdll.dll: no two-number formatter of any spelling exists there, no tab-strip layout, no display label. It is in Denuvo-packed FIFA17.exe or in the Scaleform UI assets. It cannot be determined statically from the DLL. The one non-live route worth trying is extracting the `.gfx`/`.swf` panel that consumes `numTabs` / `numCards` / `TILE_ID` and decompiling it. TODO/CONFIRM whether those assets are extractable from the FIFA 17 data at all; loc data lives in 30 GB compressed Win32 superbundles and `Data/loc` does not exist.
- **Why Seasons refuses.** The decision is made in the front-end script layer, which is data in the Frostbite bundles, not code in cardsdll. `FUN_180101680` / `FUN_1801016c0` (set `FUT::CompetitionManager` mode, singleton `DAT_1802e6328`) have zero callers inside the DLL; they are driven from outside. The cheapest remaining measurement is the live memory probe in section 2, plus watching for the `"NOSEASONS"` script event.
- **What writes the season count at `FutDataManagerImpl + 0x7138 + 0x96`.** Nothing found so far writes it: not `0x1801683f0`, not `0x180167740`. Until that writer is identified, the claim "an empty season list causes the refusal" has no mechanism behind it. TODO/CONFIRM.
- **The HTTP verb for each match RPC.** `"PUT"` and `"DELETE"` do not exist as strings anywhere in cardsdll.dll (0 hits each), and the descriptor rows carry no verb field. **Partially plannable, though:** `FUN_18016c510` fills a separate 0x200-byte buffer via the RPC object's own vtable slot `+0x38` and passes it as the first argument to the WebService send. That virtual is one unexamined hop and is worth reading before declaring the verb unknowable. Until then, accept any verb on all five match paths, which the current code does.
- **Store tile item counts.** `packContentInfo` is parsed and stored at `+0x144` through `+0x154`, and nothing in cardsdll reads those offsets back. The consumer is in the packed executable. Only resolvable live, and only after the tile-name test.
- **The FUT service vtable slots `+0x1f8`, `+0x7f0`, `+0x7f8`, `+0x800`.** The interface is obtained via a `QueryInterface(0xed84b12)` adapter, so the vtable is assembled at runtime and cannot be read statically. This is why the "no hub tile publisher reads the club-stat store" negative is bounded rather than exhaustive. The live probe in section 2 closes it in one shot.
- **The remaining unclassified root containers.** Roughly 23 of 86 top-level readers have not been classified as object-root or array-root, and both censuses run so far were wrong in opposite directions. No endpoint from `ENDPOINT_MAP.md` should be served until its root container is classified by reading the actual prologue, not by regex.
- **Whether `class_deser` is currently reliable.** It returned `[]` for `FutStoreGetPackTypes`, `FutGetUserMassInfoServerResponse`, `FutStickerBookSearchServerResponse`, `FutGetDraftAwardServerResponse` and `FutDestroyMatchServerResponse`, including on a known-good control. A zero result from that helper is presently indistinguishable from a broken query. Never make a class-to-deser claim without a passing control in the same batch.
---
## 5. What could make this plan wrong
**The store change could break the store, which currently works.** This is the concrete regression risk in the launch. `displayGroup` is the first field we have ever sent that plausibly selects a *render path* rather than a value: an ungrouped-tile layout versus a group-tile layout, chosen in code we cannot read. If tiles collapse into one group, or render blank because `value` is a loc key rather than a caption, the store goes from "ugly but functional" to "unusable", and the buy path we just validated goes with it. The parser proof is airtight and does not cover this at all. Mitigation: flag defaults to `0`, and the buy-and-open action in step 1 exists precisely to catch it.
**Enabling `FUT_STORE_FIELDS` in the same window would freeze the store outright.** Two of its fields are integers where the binary calls the STRING getter. This is the third time a store "correction" has been the thing that broke the store. It stays off, and queue item 0 deletes it.
**`FUT_DRAFT_STATE` is on by default and has never been tested live.** Two things could be wrong at once. First, `squadState: "INVALID"` may be the wrong state for the Draft entry screen; the enum mapping is verified but what the state machine does with each value is in the packed exe. Second, and worse: the identification of this endpoint as `FutGetDraftCurrentState` rests on a live path capture plus a class-name-to-deser resolution, **not on a measured RIP inside `0x180147070` during the observed hang**. If the class is wrong, the array root is wrong, and we have shipped by default the exact freeze we believe we fixed.
**`FUT_CLUB_PAGE` could break the club screen.** Returning far more items than the client expects on the first page is not a shape change but it is a volume change, and paging behaviour on the client side is unknown (`FutStickerBookSearch` parses no `count` or `total`, so we do not know how it decides a page is complete). If the club screen misbehaves, the counter reading is worthless and the club-stats hypothesis is untested rather than refuted.
**The `/club/stats` wipe rule cuts against us today, not just tomorrow.** We currently answer every `/club/stats/<mode>` with `{}`, and every such response destroys the whole map first. If we later serve a good body for one mode and leave `{}` on another, the ordering of the client's three hub-entry requests decides whether anything survives. A partial club-stats rollout can make the CLUB STATS panel *worse* than the all-zeros it shows now.
**We may be chasing a symptom with no server-side cause.** If the MY CLUB counter is composed entirely in Scaleform from data the wire never carries, then neither `FUT_CLUB_PAGE` nor `FUT_CLUBSTATS` can move it, and the correct outcome of this plan is to prove that and stop, rather than to keep generating candidate bodies.
**Negative claims in this batch are load-bearing and two of them have already failed.** "The factory does not wipe the map" was false and would have produced a body built on a wrong persistence model. "Only 3 array-root readers exist" was false and left a documented hang recipe in `ENDPOINT_MAP.md`. The still-standing negatives that could fail the same way: "no hub tile publisher reads the club-stat store" (bounded by the runtime-assembled vtable), "verb selection happens outside this DLL" (one unexamined virtual away from testable), and "the client never requests `/season`" (true of 486 requests across roughly 30 boots, which is strong but is not the same as true of all reachable states).
**Log attribution can fabricate a result.** Any conclusion drawn from `/tmp/utas_server.log` without filtering to User-Agent `ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)` is at serious risk of reading our own `curl` and `Python-urllib` traffic back to us. That exact mistake was already built into a proposed discriminating test and would have shipped a body on the strength of the tester's own request.
**`FUT_MATCH_END` widened the destroy gate to a path match.** If FIFA emits `/match/end` more than once per match, or emits it for an abandoned match, we now award coins on every one. The `endReason` values DNF, QUIT and NO_CONTEST are handled as no-award, which covers the obvious cases, but the repeat-delivery case is TODO/CONFIRM and will only surface once a match is actually reachable.