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.
33 KiB
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 frompurchase[]; amypacksgroup exists iff some pack hasdisplayGroup.value=="mypacks".group+0x104=(value=="mypacks"), tiles ingroup+0x40, ordinal ingroup+0x00(1-based creation order).FUN_18007dab0→FUN_1800147f0(model, screen+0x290, …)— renders/resolves a category.screen+0x290==0lists group tiles (FUN_180014610); otherwiseFUN_180014420exact-matches the ordinal and returns NULL on a miss, after whichFUN_1800147f0dereferences[RAX+0x48]with no null guard → crash at0x180014882(ACCESS_VIOLATIONread of0x48, minidump-confirmed).screen+0x290is written in exactly two CardsDLL sites: ctorFUN_18007d1a0writes0;FUN_18007e7f0case0x7551copies the Flash movie message fieldCATEGORY_IDverbatim into it. So the category is chosen by the Scaleform movie.FUN_18007e5e0binds the six store tabs (FUN_180014580:mypacks, points, bronze, silver, gold, special) toPANEL_ID= matching group ordinal, or hides the panel.- Unopened-pack count signals (server, already correct at 0 when empty):
userInfo.unopenedPacks.recoveredPacksand/user/credits .unopenedPacks. The hubCentralUnclaimedPacktile (destinationGOTO_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 theCentralUnclaimedPacktile). - 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_IDis 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
mypacksis never resolved when absent. - Evidence:
screen+0x290 ← CATEGORY_ID(FUN_18007e7f0case0x7551); 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_180014420returns 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
mypacksgroup the resulting empty view is unverified (could be a blank/empty-category state). - Compatibility: binary patch (client-side).
- Fixes UX or just crash? Crash only.
- Evidence: minidump faulting instruction
CardsDLL+0x14882,[NULL+0x48].
4. Recommended long-term outcome
Rank 1 (Scaleform default-category gating) is the clean fix: with count 0 the store
opens on Browse Packs, no mypacks resolution, no fake tile — and the backend
sentinel 65534 can be dropped for patched clients (the server would simply omit the
mypacks group when empty, which is safe once the client no longer resolves it).
Rank 3 (null guard) is a cheap universal crash-safety net that could ship alongside.
Until a client-side fix exists, the backend P2 sentinel remains the required
compatibility behavior for unpatched retail clients.
5. Open questions / next research (no execution here)
- Locate the Store movie's default/initial
CATEGORY_IDselection 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
mypacksgroup 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.dllSHA-2564706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c, size 3179952, PETimeDateStamp1497050156 (2017-06-09T23:15:56Z),SizeOfImage0x31d000, image base0x180000000(RE-space). Unpacked → statically analysable.FIFA17.exeSHA-25629c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899, size 224639408, Denuvo-packed → the Scaleform/StoreFront ActionScript that decides to emitCATEGORY_IDis 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.pyrun under~/.venv(PYTHONPATH=/opt/ghidra/Ghidra/Features/PyGhidra/pypkg/src:/usr/lib/python3.14/site-packages;jpype1reinstalled 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;+0x290is the requested category.- Tab→id map
FUN_180014580(store, n)(RVA0x14580):0=mypacks, 1=points, 2=bronze, 3=silver, 4=gold, 5=special. Each returns the group's 1-based ordinal (via caption compareFUN_180014380) or-1if that group is absent. So category ids are DYNAMIC ordinals, not fixed constants. The tab publisherFUN_18007df60pushesMYPACK_/BRONZE_/…_CATEGORY_IDto the movie from these lookups; the movie echoes one back asCATEGORY_ID. - The points tab is the only one special-cased (commerce/region gate). My Packs
is NOT special-cased — it falls into the
elseand is resolved byFUN_1800147f0.
Resolver FUN_1800147f0 (RVA 0x147f0) — the crash (ESTABLISHED, instruction
level):
0x14856: 85 ff TEST EDI,EDI ; EDI = category ordinal (param_2)
0x14858: 75 0f JNZ 0x14869 ; ==0 → list-all (Browse), else resolve
0x1485a: … CALL 0x14610 ; FUN_180014610 list ALL group tiles
0x14867: eb 29 JMP 0x14892
0x14869: 8b d7 MOV EDX,EDI
0x1486b: e8 … CALL 0x14420 ; FUN_180014420(store, ordinal) → RAX (group|NULL)
0x14870: 48 8d 50 40 LEA RDX,[RAX + 0x40] ; RDX = group+0x40 (=0x40 when RAX=NULL)
0x14878: 48 3b c2 CMP RAX,RDX
0x1487b: 74 15 JZ 0x14892
0x14882: 4c 8b 42 08 MOV R8,[RDX + 0x8] ; <-- FAULT: read [0x40+0x8]=0x48 when NULL
0x14886: 48 8b 12 MOV RDX,[RDX] ; [0x40]
FUN_180014420 (RVA 0x14420) exact-matches group+0x00 (ordinal), stride 0x108,
returns NULL on a miss, with no guard in the caller → faulting read of VA 0x48
at 0x180014882. This is byte-for-byte the Experiment-B minidump
(0xC0000005 READ 0x48 at CardsDLL+0x14882).
param_2 == 0→FUN_180014610lists all group tiles = the safe "Browse Packs" view.param_2 == existing ordinal→ resolves.param_2 == a non-existent ordinal(e.g.-1, whichMYPACK_CATEGORY_IDbecomes 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()thenFUN_180009c80(&p, seed)→p(release withp->vtbl[0x08](p)). This exact accessor already runs insideFUN_180014420andFUN_1800147f0, so any store-category hook can reach it. - Read count:
p->vtbl[0x4d8](p)→ int. Write:p->vtbl[0x4e0](p, n). Confirmed inFUN_180019780, which reads slot0x4d8, adds the number of set booleans in a pack response, and writes slot0x4e0(it also fetchesFutGetPurchasedItems). - 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 != 0is the predicate. (The hubCentralUnclaimedPacktile is gated by this same count viamodel+0x20950, written byFUN_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.dllfrom the app dir at process start →DllMain(DLL_PROCESS_ATTACH)→install_hooks(). - Existing hooks (
lib.rs):getaddrinfo(IAT viaiat::resolve),connect(inline detour),WSAConnect,WSAIoctl/ConnectEx, origin_spy registry/mutex, crypt32CertVerifyCertificateChainPolicy, 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-byteFF 25 00000000 <abs64>JMP;restore_originalrestores saved bytes (unhook → call real → rehook, avoiding trampoline relocation). - Config:
openfut.cfgbeside the DLL (host/ports today; astore_mypacks_fixflag 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 frominstall_hooks, reusing thessl_patchsignature-scan and theconnect_hookinline-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_idwith count>0 is left untouched → normal My Packs). Affects other categories? No (cat!=mypacks_idpath 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
mypacksis 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):
- Module gate: only proceed if
GetModuleHandleA("CardsDLL_Win64_retail.dll")resolves (FIFA23 has no such module → auto-skip). - Build gate (both, belt-and-braces):
- Exact hash/PE gate: on-disk SHA-256 ==
4706a881…, or PESizeOfImage==0x31d000&&TimeDateStamp==1497050156(cheap in-memory check). - Signature scan + validation: locate
FUN_18007dab0by a unique prologue/byte window rather than trusting the RVA, and assert the known bytes at the branch (85 ff 75 0fregion) and at the resolverCALL 0x14420site match before installing. Recommend both: hash to reject the wrong game fast, signature to confirm the exact patch site.
- Exact hash/PE gate: on-disk SHA-256 ==
- 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-hookwith strategy-Astore_hook, gated behindopenfut.cfgstore_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):
- Deploy patched
version.dll; confirmopenfut_hook.logshows the store hook installed + build gate PASSED. - 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
mypacksgroup. - Operator opens Store. Predicted (patched + zero packs + no sentinel): Store
opens, defaults to Browse Packs, no
mypacksresolve, no crash, no dialog, no fake tile. - Set
unopenedPackIds=[70]; reopen. Predicted: My Packs works normally (hook body skipped because count>0). - Revert backend to the active sentinel.
- Deploy patched
- 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 originalversion.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
mypacksgroup 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 throughFUN_1800147f0; the unopened-count singleton and itsvtbl[0x4d8]/[0x4e0]accessors, reachable from store code; theopenfut-hook/version.dllvehicle 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==0list-all in this empty configuration (to be observed in the experiment); whether any non-store path also drivesscreen+0x290to a My-Packs ordinal (none found;FUN_18007e7f0case0x7551and 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 0fat0x180014856(FIFA pid 547843, autopatch pid 547621; logENFORCED guarded store patch @ … (JNZ->JG), orig75 0fmatched). - BACKEND: sentinel 65534 suppressed by a one-line
if not owned_ids:→if False:in the container copy only (committed sourcef42279funtouched; backup/tmp/utas_server.EXP_ORIG.py). GenuineGET /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, sha39bb3e83…— unchanged throughout.
Result — F3 (CRASH)
Minidump CrashDump_2026.08.12_20.44.40.302.dmp (preserved /tmp/expF_crash.dmp, sha 4dcb0cb7…):
0xC0000005 READ of VA 0x48 at ExceptionAddress 0x6ffffc224882 → RE 0x180014882 —
the identical resolver crash instruction as Experiment B (FUN_1800147f0,
MOV R8,[RDX+0x8] with the group ptr NULL).
Mechanism (decisive): 0x14882 lives in the resolve branch, which the guard's JG
reaches only when category > 0. Since the guard was verified in place, the client
presented a positive My-Packs ordinal that no longer resolves (no mypacks group) →
FUN_180014420 returned NULL → crash. The guard's design assumption — absent mypacks ⇒
category -1 — did NOT hold on this path.
Confound (uncontrolled variable)
FIFA was not relaunched after the backend flipped to no-sentinel; the client carried
stale store/tab state from the sentinel-present safe stage, where the mypacks group
existed at a positive ordinal N (MYPACK_CATEGORY_ID = N). Reopening the Store reused
that stale positive ordinal rather than the -1 a fresh launch publishes
(FUN_18007df60 → FUN_180014580(store,0) = -1 when absent). So the intended clean A/B (client
only ever sees the no-sentinel response) was not achieved — the category that reached the
resolver was a stale >0, exactly the case the negative-only guard does not divert.
Conclusion / strategy status
- The guard as-written does NOT handle a positive, now-invalid My-Packs ordinal — proven
by this crash. Diverting only
category < 0is 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 the0x14870/0x14882site), not merely whencategory < 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 0fmatched →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.