diff --git a/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md b/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md index b4211bb..857d7ac 100644 --- a/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md +++ b/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md @@ -411,3 +411,33 @@ Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional backend compatibility mode (safe, strictly better than baseline) AND pursue a client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the fully clean result. **Not implemented.** Do NOT grant a real pack. + +--- + +## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded) + +A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ`→`JG`, routing +category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition +(sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group). +The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because +it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the +`-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it +reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a +positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a +clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was +restored immediately (mandatory rollback). Full record + candidate stronger guard: +`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III. + +## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS) + +Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a +**fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and +opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/ +mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client +process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs +navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh +client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all +with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves +Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately +(`f416e71e…`, `state=active`) and remains production default. Full record: +`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV. diff --git a/docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json b/docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json new file mode 100644 index 0000000..bea529d --- /dev/null +++ b/docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json @@ -0,0 +1 @@ +{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400} diff --git a/docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json b/docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json new file mode 100644 index 0000000..bea529d --- /dev/null +++ b/docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json @@ -0,0 +1 @@ +{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400} diff --git a/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md b/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md index 5d44e80..03f3745 100644 --- a/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md +++ b/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md @@ -380,3 +380,144 @@ active placeholder. 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 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 < 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.