Files
OpenFUT/fifa17-recon/docs/plan-2026-08-04-blockers.md
T
funman300 285d4f6cb7 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
2026-08-04 14:04:00 -07:00

33 KiB

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:

"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:

[{"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:

{"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:

{"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.