# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup) > **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround > IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks` > category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the > resolved group is non-null (crash if absent). Backend decision: **P2** — emit an > **active** non-openable synthetic `mypacks` placeholder (id 65534) when > `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests > `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX > limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven > and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`. > This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation, > and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core). Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is changed by this document; it is analysis only. Binaries (hashes verified 2026-08-13 on `.105`): `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c` (load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`). `FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed). ## 1. Question How must the server represent an account that owns **zero unopened packs** in `GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither (a) crashes nor (b) shows "The pack you've selected is currently not available", **without granting the user a real/openable pack** and without breaking the normal bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks` sentinel) only downgrades a crash to a dialog; it is not the correct contract. ## 2. Established experimental behavior (OBSERVED) Profile-state ladder, all with normal packs 1/5/6/7 unchanged: | profile `unopenedPackIds` | `mypacks` group in response | client outcome | |---|---|---| | `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub | | `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible | | `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** | Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline), `…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B). Details in `STORE_TILE_6C.md` §14-§15. ## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled) All addresses RE-space (CardsDLL base `0x180000000`): - **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158` wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`. (store-subsystem §"wire record", :996.) - **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each pack, finds-or-creates a `0x108` display group by **exact strcmp of `displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group (`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` = priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.) - **`FUN_1800147f0`** — group **resolver/renderer**, called as `FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`. `screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value → `FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard** ("checked in raw disassembly … a real absence"). **"The only legal category values are 0 and the ordinals 1..N."** (:163-169, :1051-1054.) - **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`. (:202-206, :1058-1062.) ## 4. Store parser code path (OBSERVED, decompiled) ``` HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body) → per-element deser FUN_18013af30 → 0x158 wire records → FUN_1800150d0 → 0x108 display groups (by displayGroup.value), tiles (0x1a8, FUN_18002c3c0) into group+0x40 → render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …) screen+0x290 == 0 → FUN_180014610 list this group's tiles screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss → [RAX+0x40] dereference (NO NULL GUARD) ← crash site ``` ## 5. My Packs group construction (OBSERVED) A `mypacks` group exists **iff at least one `purchase[]` entry carries `displayGroup.value == "mypacks"`** (the group is derived from packs; there is no independent group object on the wire). Its ordinal is its 1-based creation position in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group" via `purchase[]` — removing the pack removes the group entirely. ## 6. Default / selected group logic (partly UNKNOWN) - `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`; legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420` NULL → crash. (OBSERVED via §8 crash + RE.) - **Whether the store defaults to / auto-resolves the `mypacks` category on open, and why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen controller and default-tab selection are Scaleform/packed-FIFA17.exe ("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread", store-subsystem :695). Experiment B proves that in this configuration the client DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN. ## 7. Pack availability predicate (UNKNOWN) The predicate that turns the baseline inactive sentinel into "pack not available" (while active pack 70 passes) is **in the packed FIFA17.exe and unread** (`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/ purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys a tile is in the packed exe and unread"). Candidate deciding fields, from the baseline↔A diff (INFERRED, unproven): **`state` (`inactive`→`active`)** and/or **`unopened` (`false`→`true`)**. The exact field is **UNKNOWN**. ## 8. Experiment B crash analysis (OBSERVED) Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches the `00:21:07` store request; SHA-256 `fbddda18…`), parsed: - Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA `0x0000000000000048`**. - Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space **`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`). - Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by `FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]` and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read of address `0x48` → access violation. This is exactly the "no null guard" branch the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same member region — the group struct's `+0x40` vector accessed via a `+0x48` field). - Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105` (tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code is statically readable if a fresh project is ever needed. ## 9. Empty My Packs client contract (the answer, as far as evidence allows) - **The client has NO guard for a missing selected group.** If the store resolves the `mypacks` category and no `mypacks` group exists, it null-derefs and crashes. (OBSERVED.) - **A `mypacks` group can only exist if a `purchase[]` entry carries `displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an "empty group." - **If the `mypacks` group's selected pack is not a valid/active pack, the client shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.) - Therefore, under the *current* client behavior, a zero-unopened-packs account is only cleanly handled when the `mypacks` group contains a **valid active pack** (Exp A). Whether an active-but-non-openable placeholder would also satisfy the availability predicate is **UNKNOWN** (depends on §7). - **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most likely one of: (i) the retail store does not auto-select My Packs when the unopened count is 0 (a client/Scaleform decision, possibly gated by a count the server sets), or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state via a field we have not identified. Neither is established. ## 10. Candidate server representations | # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? | |---|---|---|---|---|---| | A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject | | B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism | | C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject | | C′ | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) | | D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known | | E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known | | F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first | ## 11. Recommended next controlled experiment **Experiment C′ (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the `mypacks` group's single tile is **active** — but leave its id `65534` **absent from `PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it (no pack can be opened → **no free pack, no economy change**). Observe whether the store then opens without the "pack not available" dialog (would identify `state` as the availability field and give an economy-safe fix), or still shows the dialog (implicating another field / packed predicate). - Requires a temporary code change to `store_catalog` (sentinel construction) → restart. Same experiment discipline as Experiment B (patch container copy, capture, revert, restart). Economy-safe because the placeholder remains non-openable. - If C′ still fails, escalate to read-only RE of the availability predicate / the count-gated default-tab hypothesis (candidate F) before any further change. ## 12. Open questions 1. Exact field that flips the sentinel from "pack not available" to acceptable (`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C′ targets this.) 2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a server-settable count that would stop it? UNKNOWN — Scaleform/packed. 3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record. 4. Is an active-but-non-openable placeholder (C′) accepted by the availability predicate? HYPOTHESIS — untested. --- ## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented) A correct permanent fix MUST satisfy ALL of: - Zero unopened packs must **NOT** grant the user a free pack. - No synthetic **openable** reward may be created (any placeholder must be rejected by `store_buy`/`open_pack`/`consume_unopened_pack`). - Client must **not crash** (a resolvable `mypacks` group must exist, OR the client must be kept from resolving `mypacks` when empty). - Client must **not** show "The pack you've selected is currently not available". - Normal Bronze/Gold/Special store categories must still work unchanged. - When a genuine unopened pack exists, My Packs must continue to work (Exp A). - Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift). Nothing implemented. The evidence favours investigating an **economy-safe active placeholder (C′)** and/or the **count-gated My-Packs default (F)**; it explicitly does NOT support "grant pack 70 whenever My Packs is empty". --- ## UPDATE after Experiment C′ (2026-08-13) — active non-openable placeholder tested Executed C′: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`; 65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture: `store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C′-vs-baseline JSON diff = only `65534.state`). **Resolves §7 (availability predicate), partially:** `state` **DOES participate** (OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is updated: **`state` (inactive vs active) is (at least) a deciding field** for the dialog. `unopened` was NOT varied and remains untested. The full predicate may still involve other fields, but `state` alone flips dialog→no-dialog. **Updated candidate table verdict:** - **C′ (active placeholder, non-openable): SUPPORTED with UX caveats — best option so far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op). Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile** ("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe: opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are not reachable until "My Packs" is opened first (not present with a genuine owned pack, Exp A). - A (real active pack 70): works cleanly but grants a real openable pack → economy risk; rejected as the permanent fix. - Candidate **F (count-gated My-Packs default)** gains weight: C′'s visible-empty-tile and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter My Packs when it should not with zero packs. If a server-settable count (e.g. `userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs default/tile, that could remove both the crash risk and the empty-tile artifact without any placeholder. UNTESTED. **Permanent fix: still NOT established.** Even though C′ is the first crash-free/dialog-free representation, the empty-tile UX + navigation gate + the untested "explicit placeholder selection" behavior bar adoption. Required next steps (design/authorize separately): (a) controlled test of explicitly focusing/opening the active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current evidence. --- # Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13) Question: can the server make FIFA decide **not** to resolve/default into My Packs when the account owns zero unopened packs (avoiding any placeholder)? ## 1. Server-sent unopened-pack signals (inventory, OBSERVED code) | field / endpoint | source | value source | when sent | client consumer | conf | |---|---|---|---|---|---| | `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) | | `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) | | `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED | | profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED | `pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture (8h logs); they carry a store-open `result`, not a My-Packs count. ## 2. Pre-store request sequence (OBSERVED, captures) Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo → PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits → GET /store/purchasegroup/all`. The only pack-count-bearing responses **before** `purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`. ## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures) The single profile change `[] → [70]` altered exactly one pre-store wire signal: - `/user/credits`: **`[]` → no `unopenedPacks` member** (C′ capture, all 3 fetches: `{"credits":…,"currencies":[…]}`); **`[70]` → `"unopenedPacks":{"preOrderPacks":0, "recoveredPacks":1}`** (A capture line 25). OBSERVED. - `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with `_UI=roster`). INFERRED from code; A-capture credits corroborates. - **No other pre-store field changed.** Candidate signal: ``` Candidate: unopenedPacks.recoveredPacks (count) Endpoint: /user/credits and userMassInfo(userInfo) Baseline([]) value: ABSENT (i.e. zero) Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1} Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds)) Client-visible before purchasegroup?: YES Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9 ``` **Key point: this count is already CORRECT** — it reports zero (absent) when the account is empty. OpenFUT is **not** misreporting a nonzero pack count. ## 4. Navigation / client call path (OBSERVED, decompiled RE) `FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss → `[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies the Flash movie message field `CATEGORY_ID` verbatim** into it (store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the **Scaleform movie**, not by any server response field. ## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE) `GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile** (tile type 0x1c); "Nothing in the chain issues a request, and no request could exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs** / the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path (§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command, **not** a server-state-gated store-category selector. ## 6. Candidate count/flag fields — verdict per field - `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge / CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate for the store My-Packs entry. - No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie `CATEGORY_ID`). ## 7. EA-capture evidence No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty My Packs remain **UNKNOWN**. ## 8. Where My Packs selection occurs (OBSERVED) **Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides which category to resolve and writes it to `screen+0x290` (§4). The server's role is limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect server count (the count is already correct). ## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue) My Packs selection is **not** controlled by server-sent unopened-pack state: - OBSERVED: the server count is correctly zero/absent when empty, yet the store still resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop it. - OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's `CATEGORY_ID`, with no server-field input; default is 0. Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open is in packed Scaleform and is not statically readable — but no server lever into it has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The "clean count-gated fix" is therefore **not achievable server-side**. ## 10. Proposed next experiment Because F is contradicted, a count experiment is NOT recommended (the count is already correct and does not gate the store). No single-variable server signal will make FIFA enter Browse Packs instead of My Packs. The realistic next step is the previously deferred **explicit active-placeholder selection test**: with the C′ active non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the operator explicitly focus/open the empty My-Packs tile and observe (server-safe per §16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That characterizes the best available server-side option (C′) before any adoption. - Would it change profile state? No (`unopenedPackIds=[]`). - Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C′), reverted after. - Code change? Yes (same one-line C′ patch). Restart? Yes. (Not authorized here.) If explicit selection proves unsafe/ugly, the remaining options are all **client-side / out-of-scope** (the decision is in the Scaleform movie), or accepting C′ with its documented UX artifacts. **Candidate F does NOT provide the hoped-for clean fix. The active non-openable placeholder (C′) remains the best server-side representation; its empty-tile and Browse-Packs navigation artifacts are movie-driven and not server-fixable.** --- # Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13) With the C′ active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/ ∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full record in `STORE_TILE_6C.md` §17. - **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer available"** → back to My Packs → Hub; **no crash**, navigation stays usable. - **No server request** on selection (no `/store/transaction`, no `/purchased/items`, no 65534 reference); the verdict is client-side. (OBSERVED) - **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/ `last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not persisted. (OBSERVED) **Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe + navigable, but with user-visible defects (empty fake tile; "no longer available" on explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current inactive-sentinel baseline (which errors on store OPEN and bounces to Hub). **Backend-side question is now fully answered.** The complete zero-unopened-packs ladder: ``` no mypacks group -> CardsDLL null-deref CRASH (unsafe) inactive placeholder -> "pack not available" on store open -> Hub (baseline) active placeholder -> store loads; empty tile; "no longer available" only on explicit click; recoverable; economy-safe (best backend option) real active owned pack -> fully correct UI (but grants a real openable pack — economy risk) ``` **Permanent-fix recommendation: P2.** The active non-openable placeholder is the best *safe* backend-only option, but a fully *clean* zero-pack experience is **not** achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional backend compatibility mode (safe, strictly better than baseline) AND pursue a client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the fully clean result. **Not implemented.** Do NOT grant a real pack. --- ## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded) A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ`→`JG`, routing category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition (sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group). The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the `-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was restored immediately (mandatory rollback). Full record + candidate stronger guard: `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III. ## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS) Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a **fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/ mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately (`f416e71e…`, `state=active`) and remains production default. Full record: `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV.