# FIFA 17 — Clean Empty-My-Packs client fix (DESIGN / RESEARCH ONLY) Status: **design only — no client binary/movie changes made.** This is the client-side follow-up to bug 6c. The backend already ships a compatibility workaround (P2, active non-openable sentinel 65534; see `docs/evidence/STORE_TILE_6C.md` §17 and `FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`). This plan describes what a *client-side* fix would need to change so the backend shim can eventually become unnecessary for patched clients. Do NOT patch the executable, DLLs, or Scaleform movies in this task. ## 1. Established client-side evidence Binary: `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c` (dump load base `0x00006FFFFC120000`; RE-space base `0x180000000`). `FIFA17.exe` (`29c31cef…`) is Denuvo-packed (decrypts only in live memory). Store category pipeline (all decompiled; see `docs/plan-2026-08-05-store-subsystem.md`): - `FUN_1800150d0` — builds display groups from `purchase[]`; a `mypacks` group exists iff some pack has `displayGroup.value=="mypacks"`. `group+0x104=(value=="mypacks")`, tiles in `group+0x40`, ordinal in `group+0x00` (1-based creation order). - `FUN_18007dab0` → `FUN_1800147f0(model, screen+0x290, …)` — renders/resolves a category. `screen+0x290==0` lists group tiles (`FUN_180014610`); otherwise `FUN_180014420` exact-matches the ordinal and **returns NULL on a miss**, after which `FUN_1800147f0` dereferences `[RAX+0x48]` with **no null guard** → **crash at `0x180014882`** (`ACCESS_VIOLATION` read of `0x48`, minidump-confirmed). - `screen+0x290` is written in exactly two CardsDLL sites: ctor `FUN_18007d1a0` writes `0`; **`FUN_18007e7f0` case `0x7551` copies the Flash movie message field `CATEGORY_ID` verbatim** into it. So the category is chosen by the Scaleform movie. - `FUN_18007e5e0` binds the six store tabs (`FUN_180014580`: `mypacks, points, bronze, silver, gold, special`) to `PANEL_ID` = matching group ordinal, or hides the panel. - Unopened-pack count signals (server, already correct at 0 when empty): `userInfo.unopenedPacks.recoveredPacks` and `/user/credits .unopenedPacks`. The hub `CentralUnclaimedPack` tile (destination `GOTO_STORE_MYPACK`) is gated by this count in the hub model (`model+0x20950`). **Candidate F (a server count gating the STORE's My-Packs resolution) was CONTRADICTED**: the count is correct at 0 yet the store still resolves My Packs, because the decision is movie-side. ## 2. Desired clean client behavior ``` unopened-pack count == 0: Store defaults to Browse Packs (e.g. a real category such as bronze/gold) My Packs is NOT selected/resolved no synthetic placeholder tile is required from the server unopened-pack count > 0: existing My Packs behavior unchanged ``` ## 3. Candidate insertion points (ranked) Ranking favors fixing the UX (not merely preventing the crash) and the smallest, lowest-risk change that achieves it. ### Rank 1 (preferred, best UX) — Scaleform / category-selection layer Prevent the movie from emitting `CATEGORY_ID == mypacks` (and from defaulting the store into My Packs) when the unopened-pack count is 0; default to Browse Packs instead. - **Where:** the FUT Store Scaleform movie / ActionScript (`StoreFront`, `CATEGORY_ID`/`ACTION_GET_PACKLIST`, `GOTO_STORE_MYPACK`), which the packed exe hosts and which reads the hub model (it already knows the count for the `CentralUnclaimedPack` tile). - **Behavior changed:** the store's initial/selected category when empty. - **Scope:** movie asset edit (client-side), no native-code patch. - **Risk:** medium — Scaleform RE/editing is fiddly; must find where the default `CATEGORY_ID` is chosen and gate it on the count without breaking the count>0 path. - **Compatibility:** per-client asset change; does not touch protocol or other clients. - **Fixes UX or just crash?** **UX** — no fake tile, correct default; the crash also disappears because `mypacks` is never resolved when absent. - **Evidence:** `screen+0x290 ← CATEGORY_ID` (`FUN_18007e7f0` case `0x7551`); count already available client-side (hub model / `unopenedPacks`). ### Rank 2 — Native Store resolver fallback (CardsDLL) Make `FUN_1800147f0`/`FUN_180014420` fall back to a safe category (e.g. list-tiles `N==0`, or the first existing group) when the requested ordinal misses, instead of dereferencing NULL. - **Behavior changed:** category-miss handling for ALL categories, not just mypacks. - **Scope:** small, localized CardsDLL binary patch near `0x180014420`/`0x180014882`. - **Risk:** medium — alters native store behavior globally; could mask other legitimate misses; the movie may still believe it is in My Packs (empty/odd view). - **Compatibility:** binary patch to the shipped DLL (client-side). - **Fixes UX or just crash?** Crash + partial UX (no crash, but the empty-My-Packs view may still be awkward). - **Evidence:** the no-guard deref at `0x180014882`; `FUN_180014420` returns NULL on miss. ### Rank 3 (cheapest, crash-only) — CardsDLL null guard Insert a null check before the `[RAX+0x48]` dereference in `FUN_1800147f0` (a single `TEST/JZ` around the deref) so a NULL group is skipped/returned safely. - **Behavior changed:** only the crash path. - **Scope:** minimal (a few bytes) binary patch at `~0x180014882`. - **Risk:** low — smallest change; but purely crash-prevention. With no `mypacks` group the resulting empty view is unverified (could be a blank/empty-category state). - **Compatibility:** binary patch (client-side). - **Fixes UX or just crash?** Crash only. - **Evidence:** minidump faulting instruction `CardsDLL+0x14882`, `[NULL+0x48]`. ## 4. Recommended long-term outcome Rank 1 (Scaleform default-category gating) is the clean fix: with count 0 the store opens on Browse Packs, no `mypacks` resolution, no fake tile — and the **backend sentinel 65534 can be dropped for patched clients** (the server would simply omit the `mypacks` group when empty, which is safe once the client no longer resolves it). Rank 3 (null guard) is a cheap universal crash-safety net that could ship alongside. Until a client-side fix exists, the backend P2 sentinel remains the required compatibility behavior for unpatched retail clients. ## 5. Open questions / next research (no execution here) - Locate the Store movie's default/initial `CATEGORY_ID` selection and confirm it can read the unopened count (Rank 1 feasibility). - Confirm, via a guarded-resolver experiment, what the empty-My-Packs view degrades to if the `mypacks` group is simply absent + a null guard is present (Rank 2/3). - Determine whether the Browse-Packs→My-Packs navigation gate (observed with the active sentinel) also resolves under Rank 1. --- # PART II — Native client-fix design (RE-backed, 2026-08-13) Investigation-and-design phase (no client binary/movie changed, no backend changed, no new live Store experiment). CardsDLL was re-analysed in Ghidra on `.105`; the in-repo decompiled addresses were reconfirmed against a freshly-built project. Every claim below is labelled **ESTABLISHED** (read from this build's binary / crash dump), **PROPOSED** (design, not yet implemented), or **UNKNOWN**. ## 6. Binary + environment verification (ESTABLISHED) Hashes re-verified on `.105` (`/mnt/games/FIFA 17/`) — identical to the recorded RE: - `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`, size 3179952, PE `TimeDateStamp` 1497050156 (2017-06-09T23:15:56Z), `SizeOfImage` `0x31d000`, image base `0x180000000` (RE-space). **Unpacked → statically analysable.** - `FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899`, size 224639408, **Denuvo-packed** → the Scaleform/StoreFront ActionScript that *decides* to emit `CATEGORY_ID` is NOT statically readable. This is why a pure-Scaleform edit (old "Rank 1") is not the practical vehicle; the fix is taken at the readable native boundary in CardsDLL instead. - Ghidra project rebuilt at `.105:/tmp/ghidra_fut/cardsdll` (headless import+analysis succeeded). Tooling: `fifa17-recon/tools/ghidra_env.py` run under `~/.venv` (`PYTHONPATH=/opt/ghidra/Ghidra/Features/PyGhidra/pypkg/src:/usr/lib/python3.14/site-packages`; `jpype1` reinstalled offline from pip cache). RVAs below = static VA − `0x180000000`. ## 7. Category-selection path (ESTABLISHED — decompiled this build) Store message dispatch `FUN_18007d880` (RVA `0x7d880`) routes Flash message ids: `0x753f → FUN_18007dab0` (render), `0x278a → FUN_18007df60` (publish category ids), and the input handler `FUN_18007e7f0` (RVA `0x7e7f0`) case **`0x7551`** copies the movie field `CATEGORY_ID` verbatim into `screen+0x290` (the only non-ctor writer; ctor `FUN_18007d1a0` writes 0). **Store render `FUN_18007dab0` (RVA `0x7dab0`), decompiled verbatim, is the decision point:** ```c iVar1 = *(int *)(param_1 + 0x290); // requested CATEGORY_ID (screen+0x290) iVar6 = FUN_180014580(store, 1); // the *points* category id (see tab map) if (iVar1 == iVar6) { // requested category is POINTS (real-money) if (region_check() == 0) { post "REGION_MISMATCH"; return; } if (FUN_180014de0(store) != 0) return; // points group present → handled FUN_180014b60(store, dp); // else points render } else { FUN_1800147f0(store, iVar1, dp, 0, 0); // EVERY other category, incl. My Packs } ``` - `param_1` (RCX) = the store-screen object; `+0x290` is the requested category. - **Tab→id map `FUN_180014580(store, n)` (RVA `0x14580`): `0=mypacks, 1=points, 2=bronze, 3=silver, 4=gold, 5=special`.** Each returns the group's **1-based ordinal** (via caption compare `FUN_180014380`) or **`-1`** if that group is absent. So category ids are DYNAMIC ordinals, not fixed constants. The tab publisher `FUN_18007df60` pushes `MYPACK_/BRONZE_/…_CATEGORY_ID` to the movie from these lookups; the movie echoes one back as `CATEGORY_ID`. - The **points** tab is the only one special-cased (commerce/region gate). **My Packs is NOT special-cased — it falls into the `else` and is resolved by `FUN_1800147f0`.** **Resolver `FUN_1800147f0` (RVA `0x147f0`) — the crash (ESTABLISHED, instruction level):** ``` 0x14856: 85 ff TEST EDI,EDI ; EDI = category ordinal (param_2) 0x14858: 75 0f JNZ 0x14869 ; ==0 → list-all (Browse), else resolve 0x1485a: … CALL 0x14610 ; FUN_180014610 list ALL group tiles 0x14867: eb 29 JMP 0x14892 0x14869: 8b d7 MOV EDX,EDI 0x1486b: e8 … CALL 0x14420 ; FUN_180014420(store, ordinal) → RAX (group|NULL) 0x14870: 48 8d 50 40 LEA RDX,[RAX + 0x40] ; RDX = group+0x40 (=0x40 when RAX=NULL) 0x14878: 48 3b c2 CMP RAX,RDX 0x1487b: 74 15 JZ 0x14892 0x14882: 4c 8b 42 08 MOV R8,[RDX + 0x8] ; <-- FAULT: read [0x40+0x8]=0x48 when NULL 0x14886: 48 8b 12 MOV RDX,[RDX] ; [0x40] ``` `FUN_180014420` (RVA `0x14420`) exact-matches `group+0x00` (ordinal), stride `0x108`, **returns NULL on a miss, with no guard in the caller** → faulting read of VA `0x48` at `0x180014882`. This is byte-for-byte the Experiment-B minidump (`0xC0000005` READ `0x48` at `CardsDLL+0x14882`). - `param_2 == 0` → `FUN_180014610` lists **all** group tiles = the safe "Browse Packs" view. `param_2 == existing ordinal` → resolves. `param_2 == a non-existent ordinal` (e.g. `-1`, which `MYPACK_CATEGORY_ID` becomes when the group is absent) → NULL → crash. **Why it crashes with zero packs (ESTABLISHED):** with `unopenedPackIds==[]` and no sentinel, no `mypacks` group exists, so `FUN_180014580(store,0) = -1`, `MYPACK_CATEGORY_ID = -1`, the movie still selects My Packs and echoes `CATEGORY_ID = -1`, and `FUN_1800147f0(store, -1, …)` → `FUN_180014420(-1)=NULL` → crash. The active sentinel (65534) works only because it makes a real `mypacks` ordinal exist to resolve. ## 8. Zero-pack state client-side (ESTABLISHED) The client already holds the correct unopened-pack count in a **data-manager singleton** (the same one the store resolver uses): - Obtain: `seed = FUN_1800d7170()` then `FUN_180009c80(&p, seed)` → `p` (release with `p->vtbl[0x08](p)`). This exact accessor already runs inside `FUN_180014420` and `FUN_1800147f0`, so any store-category hook can reach it. - **Read count: `p->vtbl[0x4d8](p)` → int. Write: `p->vtbl[0x4e0](p, n)`.** Confirmed in `FUN_180019780`, which reads slot `0x4d8`, adds the number of set booleans in a pack response, and writes slot `0x4e0` (it also fetches `FutGetPurchasedItems`). - Representation: plain `int`; **0 = no unopened packs**, `>0` = count. Lifetime: the singleton persists for the session; updated on pack acquire/open. - No dedicated "hasUnopenedPacks" boolean helper was found; `count != 0` is the predicate. (The hub `CentralUnclaimedPack` tile is gated by this same count via `model+0x20950`, written by `FUN_18010cdc0`/`FUN_18011e120` — the hub mirror, not the store gate.) ## 9. Implementation vehicle (ESTABLISHED — reuse, do not build a new loader) OpenFUT **already ships a client hook framework**: `openfut-launcher/openfut-hook` (`crate-type=["cdylib"]`) builds **`version.dll`**, a proxy DLL placed in the game dir (`/mnt/games/FIFA 17/version.dll`, present & active; log `~/.wine/drive_c/openfut_hook.log`). - Load path: Wine/Windows loads `version.dll` from the app dir at process start → `DllMain(DLL_PROCESS_ATTACH)` → `install_hooks()`. - Existing hooks (`lib.rs`): `getaddrinfo` (IAT via `iat::resolve`), `connect` (inline detour), `WSAConnect`, `WSAIoctl`/ConnectEx, origin_spy registry/mutex, crypt32 `CertVerifyCertificateChainPolicy`, **and in-memory byte-patching of the loaded (packed) main exe + EAWebKit** (`ssl_patch`: `GetModuleHandleA` → scan for a unique prologue → `VirtualProtect`+`copy_nonoverlapping`). - Inline-hook primitive (`connect_hook`): `write_hook(target, dest)` lays a 14-byte `FF 25 00000000 ` JMP; `restore_original` restores saved bytes (unhook → call real → rehook, avoiding trampoline relocation). - Config: `openfut.cfg` beside the DLL (`host`/ports today; a `store_mypacks_fix` flag would be added there). - **Suitability for the Store fix: direct.** The DLL is in-process with full access to the loaded `CardsDLL_Win64_retail.dll`; the store fix is a NEW module (`store_hook.rs`) installed from `install_hooks`, reusing the `ssl_patch` signature-scan and the `connect_hook` inline-detour patterns. No new loader, no ASI, no separate injector. ## 10. Three strategies re-evaluated against the RE (Task 4) ### A. Category-selection redirect — **PREFERRED** (best UX, native, targeted) Hook `FUN_18007dab0` (RVA `0x7dab0`) at entry; before the original runs, redirect a zero-pack My-Packs request to Browse Packs: ``` cat = *(int*)(store + 0x290) mypacks_id = FUN_180014580(store, 0) // -1 when the group is absent if (cat == mypacks_id) { // movie asked for My Packs (incl. cat==-1==id) if (unopened_count() == 0) // singleton vtbl[0x4d8] *(int*)(store + 0x290) = 0; // 0 = FUN_180014610 list-all = Browse Packs } // then call the original FUN_18007dab0(store) ``` - Uses the real count? **Yes** (singleton `vtbl[0x4d8]`). Removes the fake 65534 tile? **Yes** (server can omit the group). Removes the click-dialog? **Yes** (no placeholder to click). Removes the Browse→My-Packs nav gate? **Yes** (store lands on Browse, not an empty My-Packs). Preserves count>0? **Yes** (`cat==mypacks_id` with count>0 is left untouched → normal My Packs). Affects other categories? **No** (`cat!=mypacks_id` path is unmodified; points/bronze/… unchanged). - Prevents the crash as a side effect (My Packs is never resolved when its group is absent). This is the old "Rank 1" INTENT, implemented at the readable native boundary instead of in packed Scaleform. ### B. Resolver fallback — acceptable safety net, less targeted In `FUN_1800147f0` (or right after the `CALL 0x14420` at RVA `0x1486b`): if the resolved group is NULL, fall back to list-all (`param_2=0`) instead of dereferencing. - Prevents crash? **Yes.** Fixes default nav / removes fake tile? **Partially** — the movie still believes it is in My Packs, so the view may be an empty/odd My-Packs rather than a clean Browse. Leaves other lookups unchanged? **It changes miss-handling for ALL categories** — a generic NULL fallback that could mask a genuine missing-category protocol bug. Higher risk than A for that reason; keep as a belt-and-braces guard, not the primary UX fix. The resolver does NOT know *why* `mypacks` is missing, which is exactly the concern the task flags. ### C. Null-guard only — weakest (crash-only) Insert `TEST RAX,RAX; JZ 0x14892` immediately after `CALL 0x14420` (RVA `0x1486b`), before `LEA RDX,[RAX+0x40]`. Needs a trampoline (no inline slack). - Converts the crash into whatever an empty tile-vector renders (unverified; likely a blank/empty category). Does **not** remove the fake tile or fix the default category; the sentinel would still be needed for acceptable UX. Verified as expected-weakest. ## 11. Concrete hook target for strategy A (Task 6, PROPOSED) ``` module: CardsDLL_Win64_retail.dll (GetModuleHandleA) function: FUN_18007dab0 (store render / message 0x753f) RVA: 0x7dab0 (static VA 0x18007dab0) calling conv: Microsoft x64 fastcall; single arg store-screen ptr in RCX screen offset: store+0x290 = requested CATEGORY_ID (int) helpers to call: FUN_180014580 (RVA 0x14580) tab→ordinal, arg0=RCX store, arg1=EDX index(0=mypacks) count singleton: FUN_1800d7170 (0xd7370-seed) + FUN_180009c80 (0x9c80), read vtbl[0x4d8] redirect target: set store+0x290 = 0 (FUN_180014610 list-all → Browse Packs) original behavior: zero packs → resolves absent mypacks ordinal → FUN_180014420 NULL → crash at 0x14882 desired behavior: zero packs + mypacks requested → store+0x290 forced to 0 → Browse Packs; no crash/dialog/tile ``` Hook mechanics (reuse `connect_hook`): lay a 14-byte `FF 25` JMP at `base+0x7dab0` to a Rust `hooked_store_render(store)`; inside: apply the redirect, unhook, call real `FUN_18007dab0(store)`, rehook, return its value. Intercepting only the entry means the minimum interception is the 14 JMP bytes; the first instructions of `FUN_18007dab0` (`MOV RAX,RSP; MOV [RAX+8],RCX; PUSH …`) are a standard prologue safe to save/restore. Alt insertion point (earlier): `FUN_18007e7f0` case `0x7551`, where `CATEGORY_ID` is written to `screen+0x290` — redirect there instead of at render. Entry-hook of `FUN_18007dab0` is preferred (single, well-typed arg; runs once per store render). Thread/context: the store screen runs on the client's UI/update thread; the hook reads one int and (rarely) writes one int on the same object the callee immediately reads — no new synchronization needed. Called for categories other than My Packs? The FUNCTION is, but the redirect body only fires when `cat==mypacks_id`, so other tabs are untouched. ## 12. Version / build safety (Task 7, PROPOSED) FIFA17-specific compat code MUST validate the client before hooking, and MUST no-op on any other build (the same `version.dll` is also used for FIFA23): 1. **Module gate:** only proceed if `GetModuleHandleA("CardsDLL_Win64_retail.dll")` resolves (FIFA23 has no such module → auto-skip). 2. **Build gate (both, belt-and-braces):** - Exact hash/PE gate: on-disk SHA-256 == `4706a881…`, or PE `SizeOfImage==0x31d000` && `TimeDateStamp==1497050156` (cheap in-memory check). - Signature scan + validation: locate `FUN_18007dab0` by a unique prologue/byte window rather than trusting the RVA, and assert the known bytes at the branch (`85 ff 75 0f` region) and at the resolver `CALL 0x14420` site match before installing. Recommend **both**: hash to reject the wrong game fast, signature to confirm the exact patch site. 3. **Failure behavior:** any check fails (unknown/updated build) → **do NOT patch**, log, and leave the **backend P2 active-sentinel (65534) as the fallback**. Never patch or crash an unrecognised build. ## 13. First controlled client experiment (Task 8, PROPOSED — not executed here) Goal: prove a patched client sends zero-pack Store entry to Browse Packs with **no** active placeholder. - Build `openfut-hook` with strategy-A `store_hook`, gated behind `openfut.cfg` `store_mypacks_fix=1` (opt-in; default off preserves today's behavior). - Test profile: `unopenedPackIds == []`. - Sequence (each variable changed alone; operator drives FIFA; read-only capture): 1. Deploy patched `version.dll`; confirm `openfut_hook.log` shows the store hook installed + build gate PASSED. 2. **Backend test mode (LATER, separately authorized — NOT in this task):** switch the backend to *empty-no-sentinel* (the Exp-B config that crashed the UNPATCHED client) so the patched client must handle a genuinely-absent `mypacks` group. 3. Operator opens Store. **Predicted (patched + zero packs + no sentinel):** Store opens, defaults to Browse Packs, no `mypacks` resolve, **no crash, no dialog, no fake tile**. 4. Set `unopenedPackIds=[70]`; reopen. **Predicted:** My Packs works normally (hook body skipped because count>0). 5. Revert backend to the active sentinel. - **Backend change eventually required for this experiment: YES** — a controlled empty-no-sentinel test mode to force the absent group. It is NOT performed in this phase and MUST be separately authorized (same experiment discipline: patch the container copy, capture, revert, restart; never synthesize a client request). - **Client rollback:** flip `store_mypacks_fix=0` (hook not installed) or restore the original `version.dll`; the game reverts to depending on the backend sentinel. No FIFA binaries/movies/config are modified on disk — the hook is in-memory only, so rollback is a file/flag swap. ## 14. Interaction with the backend 65534 fallback (ESTABLISHED + PROPOSED) - **Keep the backend sentinel deployed** until strategy A is implemented AND verified. It remains the required behavior for unpatched retail clients and for any client whose build gate fails. - Once strategy A is verified, the server MAY, **for patched clients only**, omit the `mypacks` group when empty (the safe representation the client will then handle) — but only behind explicit detection/opt-in; do NOT drop the sentinel globally, since unpatched clients still crash without it. ## 15. ESTABLISHED / PROPOSED / UNKNOWN summary - **ESTABLISHED:** binary hashes/build; the full native category path and addresses (`FUN_18007d880/18007dab0/18007e7f0/1800147f0/180014420/180014580/180014610`); the instruction-level crash (`0x14882`, `[NULL+0x48]`); tab→ordinal map; that My Packs is not special-cased and funnels through `FUN_1800147f0`; the unopened-count singleton and its `vtbl[0x4d8]/[0x4e0]` accessors, reachable from store code; the `openfut-hook`/`version.dll` vehicle and its hook/patch primitives. - **PROPOSED (not implemented):** the strategy-A entry hook and its redirect logic; the build-guard scheme; the opt-in config flag; the first experiment and its backend test-mode requirement; the per-patched-client server relaxation. - **UNKNOWN:** exactly why the packed Scaleform movie selects My Packs on store open (Denuvo-packed, unread) — not needed for strategy A, which intercepts the native result; the precise rendered appearance of `category==0` list-all in this empty configuration (to be observed in the experiment); whether any non-store path also drives `screen+0x290` to a My-Packs ordinal (none found; `FUN_18007e7f0` case `0x7551` and the ctor are the only writers). --- # PART III — Final no-sentinel resolver experiment (2026-08-13) — RESULT F3 (CRASH), CONFOUNDED Vehicle change: the resolver guard was implemented as an **`autopatch.py` memory patch** (the live FIFA-17 client-patch mechanism), NOT the `version.dll` proxy — Proton loads its builtin `version.dll`, so the earlier `store_hook`/`version.dll` prototype was inert and has been rolled back. Guard: at CardsDLL `0x180014858`, `JNZ 0x14869` (`75 0f`) → `JG 0x14869` (`7f 0f`), orig-verified; routes category `< 0` (and `== 0`) to the safe list-all/Browse path (`FUN_180014610`), category `> 0` to the existing resolver. `TEST EDI,EDI` at `0x180014856` is the flag source (OF cleared ⇒ `JG` = signed `> 0`). ## Setup (verified) - CLIENT: guard active/enforced — live bytes `85 ff 7f 0f` at `0x180014856` (FIFA pid 547843, autopatch pid 547621; log `ENFORCED guarded store patch @ … (JNZ->JG)`, orig `75 0f` matched). - BACKEND: sentinel 65534 suppressed by a one-line `if not owned_ids:` → `if False:` in the container copy only (committed source `f42279f` untouched; backup `/tmp/utas_server.EXP_ORIG.py`). Genuine `GET /store/purchasegroup` (02:44:39Z) → ids `[1,5,6,7]`, **no 65534, no mypacks group**, normal packs unchanged (evidence: `docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json`). - PROFILE: `unopenedPackIds=[]`, coins 29,876,776, sha `39bb3e83…` — unchanged throughout. ## Result — F3 (CRASH) Minidump `CrashDump_2026.08.12_20.44.40.302.dmp` (preserved `/tmp/expF_crash.dmp`, sha `4dcb0cb7…`): `0xC0000005` READ of VA `0x48` at `ExceptionAddress 0x6ffffc224882` → **RE `0x180014882`** — the **identical** resolver crash instruction as Experiment B (`FUN_1800147f0`, `MOV R8,[RDX+0x8]` with the group ptr NULL). **Mechanism (decisive):** `0x14882` lives in the *resolve* branch, which the guard's `JG` reaches **only when category `> 0`**. Since the guard was verified in place, the client presented a **positive** My-Packs ordinal that no longer resolves (no mypacks group) → `FUN_180014420` returned NULL → crash. The guard's design assumption — *absent mypacks ⇒ category `-1`* — did NOT hold on this path. ## Confound (uncontrolled variable) FIFA was **not relaunched** after the backend flipped to no-sentinel; the client carried **stale store/tab state** from the sentinel-present safe stage, where the mypacks group existed at a *positive* ordinal `N` (`MYPACK_CATEGORY_ID = N`). Reopening the Store reused that stale positive ordinal rather than the `-1` a **fresh** launch publishes (`FUN_18007df60 → FUN_180014580(store,0) = -1` when absent). So the intended clean A/B (client only ever sees the no-sentinel response) was not achieved — the category that reached the resolver was a stale `>0`, exactly the case the negative-only guard does not divert. ## Conclusion / strategy status - **The guard as-written does NOT handle a positive, now-invalid My-Packs ordinal** — proven by this crash. Diverting only `category < 0` is insufficient when the client presents a stale/positive ordinal for an absent group. - **Not falsified for the fresh-client case.** Whether a fresh no-sentinel launch presents `-1` (guard diverts → Browse, no crash) or still a positive ordinal is **UNKNOWN** and needs a **clean re-test**: launch FIFA fresh with the backend already in no-sentinel mode so the client never sees a mypacks group. That is the proper equivalent of Experiment B. - **Candidate stronger guard** (design only, not implemented): divert to list-all when the resolved group is NULL for *any* category (guard `FUN_180014420`'s NULL return at the `0x14870`/`0x14882` site), not merely when `category < 0`. This covers the positive-invalid ordinal too, at the cost of being a generic miss-fallback (the higher-risk Rank-2 behavior). Do NOT implement without authorization and a clean re-test first. **Strategy A / resolver guard status: NOT PROVEN.** Crash-guard installs and is build-validated and dormant-safe with the sentinel present, but the first no-sentinel test CRASHED at the resolver via a positive stale ordinal (confounded by no relaunch). Backend P2 active-sentinel was restored immediately (mandatory rollback; source `f416e71e…`, sentinel `state=active`), and remains the production safety net. Guard left in `autopatch.py` (dormant) pending the clean re-test decision; `autopatch.py.pre-storeguard.bak` available to remove it. --- # PART IV — Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS) Corrects PART III's confound. This time the mandatory ordering was enforced: the backend entered no-sentinel mode **while FIFA was closed**, then FIFA launched **fresh** (new pid, new autopatch) so the process never saw a sentinel-present Store response. ## Setup (verified, clean A/B) - BACKEND set no-sentinel at 02:55:09Z with FIFA down; genuine `GET /store/purchasegroup` (02:58:45Z) served to the fresh client = ids `[1,5,6,7]`, **no 65534, no mypacks group**, packs 1/5/6/7 present. This body is **byte-identical** to the PART III (F3) no-sentinel capture — the ONLY changed variable vs F3 is the client process lifetime. Evidence: `docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json`. - CLIENT: NEW FIFA pid 553220, NEW autopatch pid 552999; guard ENFORCED (orig `75 0f` matched → `85 ff 7f 0f` = `TEST EDI,EDI; JG`). Process never saw a sentinel response (0 purchasegroup responses containing 65534 after the no-sentinel restart). - PROFILE unchanged throughout (`39bb3e83…`, `[]`, coins 29,876,776). ## Result — R1 (operator-observed) - **No crash** (FIFA 553220 alive after the test; no new minidump), **no dialog**, **Store stays open**, opens on **Browse Packs**, Bronze/Gold/Special packs visible and navigable. - Cosmetic-only imperfections (pre-existing, NOT caused by the guard): the six-tab bar is unbound (no tabs), packs render without cover art, and tiles show "0 items". These match the known store tab-bind / list-all rendering quirks (`plan-2026-08-05-store-subsystem.md` §2.1) and are independent of the resolver guard. ## Causal conclusion (decisive A/B) ``` server response (no sentinel, no mypacks group) == byte-identical across F3 and R1 client original JNZ + this response -> CRASH 0x180014882 (Experiment B) client JG (stale positive ordinal) -> CRASH 0x180014882 (PART III F3, contaminated) client JG (FRESH, category = -1) -> NO CRASH, Browse Packs (PART IV R1) ✅ ``` A **fresh** client publishes `MYPACK_CATEGORY_ID = FUN_180014580(store,0) = -1` for the absent group; the movie echoes `-1`; `TEST EDI,EDI; JG` does **not** take the resolve branch, so the client runs the list-all/Browse path (`FUN_180014610`) — no `FUN_180014420(NULL)` deref, no crash. **PART III's F3 is confirmed as stale-positive-ordinal contamination** (FIFA not relaunched across the sentinel→no-sentinel flip), not a guard failure. ## Strategy status **Strategy A / resolver guard: PROVEN ON THE TESTED FIFA 17 BUILD** (CardsDLL `4706a881…`) for the clean process-lifetime case — it safely routes the absent My-Packs category to Browse Packs with no crash and no dialog, needing **no** backend sentinel. Scope caveats: (1) tested build only; (2) the negative-only guard does NOT cover a stale/positive invalid ordinal (PART III) — only arises if the client's Store state predates a sentinel→ no-sentinel change within one process, which does not happen on a normal launch; a NULL-return guard at `FUN_180014420` would additionally cover that, deferred/not implemented; (3) UX still has the pre-existing no-tabs/no-art/"0 items" cosmetics. Backend P2 active-sentinel was restored immediately after capture (mandatory rollback; source `f416e71e…`, sentinel `state=active`) and **remains production default**. The clean UX is only safe to serve when the server knows the client is patched — see PART II §12 rollout options (recommend B: suppress the sentinel only when client patch-capability is known; keep the sentinel universal by default). Guard retained in `autopatch.py` (dormant with the sentinel). ## INVARIANT — empty-My-Packs capability MUST be session-stable F3 vs R1 establish a hard operational invariant for any deployment (sentinel or client guard): **the server MUST NOT switch a running FIFA client between sentinel-present and sentinel-absent for the My Packs group within a single FIFA process lifetime.** Rationale: the client resolves and caches the My-Packs group **ordinal** (positive when a group — real or sentinel — is present; `-1` when absent) from the `purchasegroup` response seen at Store-subsystem init. The resolver guard only reclassifies the ordinal *sign* (`≤0` → Browse). If a client that already cached a **positive** ordinal later receives a no-sentinel topology, the stale positive ordinal still takes the resolve branch and `FUN_180014420` returns NULL → crash at `0x180014882` (exactly F3). A **fresh** process that only ever sees the no-sentinel topology caches `-1` and is routed to Browse safely (R1). Practical rules: - Choose the My-Packs representation (sentinel-present vs sentinel-absent) **before** a client starts its session, and hold it for that session. - The future patch-capability handshake (PART II §12) MUST therefore be decided at login/session start, not toggled mid-session. - A NULL-return guard at `FUN_180014420` (deferred) is the only thing that would make a mid-session flip crash-safe; until then, session stability is mandatory.