Files
OpenFUT/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.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

33 KiB
Raw Blame History

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_18007dab0FUN_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 guardcrash 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].

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:

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 == 0FUN_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 <abs64> 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 0x6ffffc224882RE 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.