# 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.
