Files
OpenFUT/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md
T
funman300 fc29c2eb9b docs(fifa17): record no-sentinel client resolver proof
Land the client-side empty-My-Packs resolver evidence and the session-stability
invariant established by the F3/R1 experiments.

- PART III (F3, CONFOUNDED CRASH): a mid-process sentinel -> no-sentinel flip
  left a stale POSITIVE My-Packs ordinal that still took the resolve branch and
  crashed at 0x180014882. Preserved verbatim (not a guard failure).
- PART IV (R1, SUCCESS): backend set no-sentinel first, then a FRESH FIFA
  process; genuine purchasegroup response ids [1,5,6,7] is byte-identical to the
  F3 capture, so client process lifetime is the only changed variable. Store
  opens on Browse Packs, no crash, no dialog. Guard PROVEN on the tested build.
- New INVARIANT: empty-My-Packs capability MUST be session-stable -- the server
  must not switch a running client between sentinel-present and sentinel-absent
  for the My Packs group within one FIFA process, because the client caches the
  group ordinal and a stale positive ordinal still crashes the resolver.
- Both no-sentinel captures kept: client_guard (F3) and freshretest (R1).

Backend P2 active-sentinel (65534) remains production default; no capability
handshake is implemented yet.
2026-08-13 03:13:40 +00:00

28 KiB
Raw Blame History

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 (inactiveactive) and/or unopened (falsetrue). 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

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 JNZJG, 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.