diff --git a/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md b/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md new file mode 100644 index 0000000..b4211bb --- /dev/null +++ b/docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md @@ -0,0 +1,413 @@ +# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup) + +> **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround +> IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks` +> category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the +> resolved group is non-null (crash if absent). Backend decision: **P2** — emit an +> **active** non-openable synthetic `mypacks` placeholder (id 65534) when +> `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests +> `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX +> limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven +> and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`. +> This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation, +> and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core). + +Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE +quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is +changed by this document; it is analysis only. + +Binaries (hashes verified 2026-08-13 on `.105`): +`CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c` +(load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`). +`FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed). + +## 1. Question + +How must the server represent an account that owns **zero unopened packs** in +`GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither +(a) crashes nor (b) shows "The pack you've selected is currently not available", +**without granting the user a real/openable pack** and without breaking the normal +bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks` +sentinel) only downgrades a crash to a dialog; it is not the correct contract. + +## 2. Established experimental behavior (OBSERVED) + +Profile-state ladder, all with normal packs 1/5/6/7 unchanged: + +| profile `unopenedPackIds` | `mypacks` group in response | client outcome | +|---|---|---| +| `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub | +| `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible | +| `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** | + +Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline), +`…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B). +Details in `STORE_TILE_6C.md` §14-§15. + +## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled) + +All addresses RE-space (CardsDLL base `0x180000000`): +- **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158` + wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the + literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`. + (store-subsystem §"wire record", :996.) +- **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each + pack, finds-or-creates a `0x108` display group by **exact strcmp of + `displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group + (`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` = + priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile + models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.) +- **`FUN_1800147f0`** — group **resolver/renderer**, called as + `FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`. + `screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value + → `FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL + on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard** + ("checked in raw disassembly … a real absence"). **"The only legal category values + are 0 and the ordinals 1..N."** (:163-169, :1051-1054.) +- **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase + literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives + each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no + matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`. + (:202-206, :1058-1062.) + +## 4. Store parser code path (OBSERVED, decompiled) + +``` +HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body) + → per-element deser FUN_18013af30 → 0x158 wire records + → FUN_1800150d0 → 0x108 display groups (by displayGroup.value), + tiles (0x1a8, FUN_18002c3c0) into group+0x40 + → render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …) + screen+0x290 == 0 → FUN_180014610 list this group's tiles + screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss + → [RAX+0x40] dereference (NO NULL GUARD) ← crash site +``` + +## 5. My Packs group construction (OBSERVED) + +A `mypacks` group exists **iff at least one `purchase[]` entry carries +`displayGroup.value == "mypacks"`** (the group is derived from packs; there is no +independent group object on the wire). Its ordinal is its 1-based creation position +in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live +in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group" +via `purchase[]` — removing the pack removes the group entirely. + +## 6. Default / selected group logic (partly UNKNOWN) + +- `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`; + legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is + not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420` + NULL → crash. (OBSERVED via §8 crash + RE.) +- **Whether the store defaults to / auto-resolves the `mypacks` category on open, and + why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen + controller and default-tab selection are Scaleform/packed-FIFA17.exe + ("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread", + store-subsystem :695). Experiment B proves that in this configuration the client + DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is + reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN. + +## 7. Pack availability predicate (UNKNOWN) + +The predicate that turns the baseline inactive sentinel into "pack not available" +(while active pack 70 passes) is **in the packed FIFA17.exe and unread** +(`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/ +purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys +a tile is in the packed exe and unread"). Candidate deciding fields, from the +baseline↔A diff (INFERRED, unproven): **`state` (`inactive`→`active`)** and/or +**`unopened` (`false`→`true`)**. The exact field is **UNKNOWN**. + +## 8. Experiment B crash analysis (OBSERVED) + +Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches +the `00:21:07` store request; SHA-256 `fbddda18…`), parsed: +- Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA + `0x0000000000000048`**. +- Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space + **`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`). +- Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by + `FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]` + and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read + of address `0x48` → access violation. This is exactly the "no null guard" branch + the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same + member region — the group struct's `+0x40` vector accessed via a `+0x48` field). +- Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105` + (tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled + RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code + is statically readable if a fresh project is ever needed. + +## 9. Empty My Packs client contract (the answer, as far as evidence allows) + +- **The client has NO guard for a missing selected group.** If the store resolves the + `mypacks` category and no `mypacks` group exists, it null-derefs and crashes. + (OBSERVED.) +- **A `mypacks` group can only exist if a `purchase[]` entry carries + `displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an + "empty group." +- **If the `mypacks` group's selected pack is not a valid/active pack, the client + shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.) +- Therefore, under the *current* client behavior, a zero-unopened-packs account is + only cleanly handled when the `mypacks` group contains a **valid active pack** + (Exp A). Whether an active-but-non-openable placeholder would also satisfy the + availability predicate is **UNKNOWN** (depends on §7). +- **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is + NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most + likely one of: (i) the retail store does not auto-select My Packs when the unopened + count is 0 (a client/Scaleform decision, possibly gated by a count the server sets), + or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state + via a field we have not identified. Neither is established. + +## 10. Candidate server representations + +| # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? | +|---|---|---|---|---|---| +| A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject | +| B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism | +| C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject | +| C′ | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) | +| D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known | +| E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known | +| F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first | + +## 11. Recommended next controlled experiment + +**Experiment C′ (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the +synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the +`mypacks` group's single tile is **active** — but leave its id `65534` **absent from +`PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it +(no pack can be opened → **no free pack, no economy change**). Observe whether the +store then opens without the "pack not available" dialog (would identify `state` as +the availability field and give an economy-safe fix), or still shows the dialog +(implicating another field / packed predicate). +- Requires a temporary code change to `store_catalog` (sentinel construction) → + restart. Same experiment discipline as Experiment B (patch container copy, capture, + revert, restart). Economy-safe because the placeholder remains non-openable. +- If C′ still fails, escalate to read-only RE of the availability predicate / the + count-gated default-tab hypothesis (candidate F) before any further change. + +## 12. Open questions + +1. Exact field that flips the sentinel from "pack not available" to acceptable + (`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C′ targets this.) +2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a + server-settable count that would stop it? UNKNOWN — Scaleform/packed. +3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record. +4. Is an active-but-non-openable placeholder (C′) accepted by the availability + predicate? HYPOTHESIS — untested. + +--- + +## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented) + +A correct permanent fix MUST satisfy ALL of: +- Zero unopened packs must **NOT** grant the user a free pack. +- No synthetic **openable** reward may be created (any placeholder must be rejected by + `store_buy`/`open_pack`/`consume_unopened_pack`). +- Client must **not crash** (a resolvable `mypacks` group must exist, OR the client + must be kept from resolving `mypacks` when empty). +- Client must **not** show "The pack you've selected is currently not available". +- Normal Bronze/Gold/Special store categories must still work unchanged. +- When a genuine unopened pack exists, My Packs must continue to work (Exp A). +- Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift). + +Nothing implemented. The evidence favours investigating an **economy-safe active +placeholder (C′)** and/or the **count-gated My-Packs default (F)**; it explicitly does +NOT support "grant pack 70 whenever My Packs is empty". + +--- + +## UPDATE after Experiment C′ (2026-08-13) — active non-openable placeholder tested + +Executed C′: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`; +65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture: +`store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C′-vs-baseline JSON +diff = only `65534.state`). + +**Resolves §7 (availability predicate), partially:** `state` **DOES participate** +(OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's +existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is +updated: **`state` (inactive vs active) is (at least) a deciding field** for the +dialog. `unopened` was NOT varied and remains untested. The full predicate may still +involve other fields, but `state` alone flips dialog→no-dialog. + +**Updated candidate table verdict:** +- **C′ (active placeholder, non-openable): SUPPORTED with UX caveats — best option so + far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic + transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op). + Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile** + ("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe: + opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation + gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are + not reachable until "My Packs" is opened first (not present with a genuine owned + pack, Exp A). +- A (real active pack 70): works cleanly but grants a real openable pack → economy + risk; rejected as the permanent fix. +- Candidate **F (count-gated My-Packs default)** gains weight: C′'s visible-empty-tile + and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter + My Packs when it should not with zero packs. If a server-settable count (e.g. + `userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs + default/tile, that could remove both the crash risk and the empty-tile artifact + without any placeholder. UNTESTED. + +**Permanent fix: still NOT established.** Even though C′ is the first +crash-free/dialog-free representation, the empty-tile UX + navigation gate + the +untested "explicit placeholder selection" behavior bar adoption. Required next steps +(design/authorize separately): (a) controlled test of explicitly focusing/opening the +active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake +tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current +evidence. + +--- + +# Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13) + +Question: can the server make FIFA decide **not** to resolve/default into My Packs +when the account owns zero unopened packs (avoiding any placeholder)? + +## 1. Server-sent unopened-pack signals (inventory, OBSERVED code) +| field / endpoint | source | value source | when sent | client consumer | conf | +|---|---|---|---|---|---| +| `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) | +| `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) | +| `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED | +| profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED | +`pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an +eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture +(8h logs); they carry a store-open `result`, not a My-Packs count. + +## 2. Pre-store request sequence (OBSERVED, captures) +Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo → +PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits → +GET /store/purchasegroup/all`. The only pack-count-bearing responses **before** +`purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`. + +## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures) +The single profile change `[] → [70]` altered exactly one pre-store wire signal: +- `/user/credits`: **`[]` → no `unopenedPacks` member** (C′ capture, all 3 fetches: + `{"credits":…,"currencies":[…]}`); **`[70]` → `"unopenedPacks":{"preOrderPacks":0, + "recoveredPacks":1}`** (A capture line 25). OBSERVED. +- `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with + `_UI=roster`). INFERRED from code; A-capture credits corroborates. +- **No other pre-store field changed.** + +Candidate signal: +``` +Candidate: unopenedPacks.recoveredPacks (count) +Endpoint: /user/credits and userMassInfo(userInfo) +Baseline([]) value: ABSENT (i.e. zero) +Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1} +Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds)) +Client-visible before purchasegroup?: YES +Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9 +``` +**Key point: this count is already CORRECT** — it reports zero (absent) when the +account is empty. OpenFUT is **not** misreporting a nonzero pack count. + +## 4. Navigation / client call path (OBSERVED, decompiled RE) +`FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists +group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss → +`[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the +screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies +the Flash movie message field `CATEGORY_ID` verbatim** into it +(store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the +**Scaleform movie**, not by any server response field. + +## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE) +`GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile** +(tile type 0x1c); "Nothing in the chain issues a request, and no request could +exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the +unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control +the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs** +/ the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path +(§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command, +**not** a server-state-gated store-category selector. + +## 6. Candidate count/flag fields — verdict per field +- `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge / + CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate + for the store My-Packs entry. +- No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie + `CATEGORY_ID`). + +## 7. EA-capture evidence +No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account +exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty +My Packs remain **UNKNOWN**. + +## 8. Where My Packs selection occurs (OBSERVED) +**Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides +which category to resolve and writes it to `screen+0x290` (§4). The server's role is +limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating +for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect +server count (the count is already correct). + +## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue) +My Packs selection is **not** controlled by server-sent unopened-pack state: +- OBSERVED: the server count is correctly zero/absent when empty, yet the store still + resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop + it. +- OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's + `CATEGORY_ID`, with no server-field input; default is 0. +Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open +is in packed Scaleform and is not statically readable — but no server lever into it +has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA +skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The +"clean count-gated fix" is therefore **not achievable server-side**. + +## 10. Proposed next experiment +Because F is contradicted, a count experiment is NOT recommended (the count is already +correct and does not gate the store). No single-variable server signal will make FIFA +enter Browse Packs instead of My Packs. The realistic next step is the previously +deferred **explicit active-placeholder selection test**: with the C′ active +non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the +operator explicitly focus/open the empty My-Packs tile and observe (server-safe per +§16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That +characterizes the best available server-side option (C′) before any adoption. +- Would it change profile state? No (`unopenedPackIds=[]`). +- Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C′), + reverted after. +- Code change? Yes (same one-line C′ patch). Restart? Yes. (Not authorized here.) +If explicit selection proves unsafe/ugly, the remaining options are all **client-side +/ out-of-scope** (the decision is in the Scaleform movie), or accepting C′ with its +documented UX artifacts. + +**Candidate F does NOT provide the hoped-for clean fix. The active non-openable +placeholder (C′) remains the best server-side representation; its empty-tile and +Browse-Packs navigation artifacts are movie-driven and not server-fixable.** + +--- + +# Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13) + +With the C′ active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/ +∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full +record in `STORE_TILE_6C.md` §17. +- **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer + available"** → back to My Packs → Hub; **no crash**, navigation stays usable. +- **No server request** on selection (no `/store/transaction`, no `/purchased/items`, + no 65534 reference); the verdict is client-side. (OBSERVED) +- **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/ + `last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not + persisted. (OBSERVED) + +**Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe + +navigable, but with user-visible defects (empty fake tile; "no longer available" on +explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current +inactive-sentinel baseline (which errors on store OPEN and bounces to Hub). + +**Backend-side question is now fully answered.** The complete zero-unopened-packs +ladder: +``` +no mypacks group -> CardsDLL null-deref CRASH (unsafe) +inactive placeholder -> "pack not available" on store open -> Hub (baseline) +active placeholder -> store loads; empty tile; "no longer available" only on + explicit click; recoverable; economy-safe (best backend option) +real active owned pack -> fully correct UI (but grants a real openable pack — economy risk) +``` + +**Permanent-fix recommendation: P2.** The active non-openable placeholder is the best +*safe* backend-only option, but a fully *clean* zero-pack experience is **not** +achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is +Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional +backend compatibility mode (safe, strictly better than baseline) AND pursue a +client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the +fully clean result. **Not implemented.** Do NOT grant a real pack. diff --git a/docs/evidence/STORE_TILE_6C.md b/docs/evidence/STORE_TILE_6C.md new file mode 100644 index 0000000..ad9d541 --- /dev/null +++ b/docs/evidence/STORE_TILE_6C.md @@ -0,0 +1,924 @@ +# Store Tile Investigation (bug 6c) + +Status: **ROOT CAUSE ESTABLISHED — P2 compatibility workaround IMPLEMENTED** +(2026-08-13). Full investigation complete (§1-§17); backend fix landed in +`fifa17-recon/tools/utas_server.py` `store_catalog` with regression tests in +`fifa17-recon/tools/test_empty_mypacks.py`. The store-tiles flags are unchanged +(`FUT_STORE_DISPLAYGROUP=ON`, `FUT_STORE_GROUPID=OFF`) and the profile is unchanged. + +## RESOLUTION (2026-08-13) + +**ROOT CAUSE (bug 6c):** FIFA 17's Store/Scaleform path RESOLVES the `mypacks` +category even when the account owns zero unopened packs (the category is chosen +client-side from the movie's `CATEGORY_ID` → `screen+0x290`; no server field gates +it — Candidate F CONTRADICTED). CardsDLL `FUN_1800147f0` then dereferences the +resolved group with NO null guard, so an absent `mypacks` group crashes the client +(`CardsDLL_Win64_retail.dll+0x14882`, `[NULL+0x48]` — minidump-confirmed, §8). + +**BACKEND RESULT / DECISION — P2 (compatibility workaround):** emit a synthetic, +**active**, non-openable `mypacks` placeholder (id 65534, absent from `PACK_CATALOG`) +only when `unopenedPackIds == []`. This is crash-safe AND economy-safe (explicit +selection is rejected client-side with "This pack is no longer available", sends no +backend request, and mutates nothing — §17). It is a FIFA-17 client-compatibility +shim, **NOT** an EA-authentic empty-My-Packs representation, and is confined to the +FIFA-17 adapter/backend layer (NOT OpenFUT Core). + +**KNOWN UX LIMITATIONS (unfixable server-side):** a fake empty "0 items" tile; an +explicit-selection dialog "This pack is no longer available"; and a Browse-Packs → +My-Packs navigation quirk. These are Scaleform/movie-driven. + +**CLEAN CLIENT FIX:** still unresolved; belongs to client-side work — +`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`. + +Evidence labels: **OBSERVED** (running code / `docker inspect` / live log / minidump / +table), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. + +### Hypotheses + +- **H1 (original — subtype/definition):** *Store tiles render "unknown" because the + server emits definitions whose type/subtype the client cannot map (prime suspects + 5004xxx misc {231,232,233,236}, 8010xxx league logos, FCC↔wire subtype gaps).* + **Verdict: CONTRADICTED by static store-handler evidence.** The `/store/purchasegroup` + handler emits PACK definitions only — no `cardsubtypeid`/`carddbid`/`cardassetid` + anywhere in the response (§2). Those subtype families belong to the separate + **club-item / consumable / equippable** paths (`fut_clubitems.py`, `fut_consumables`, + `/club?type=`), which are OUT OF SCOPE for this store-tile task. History preserved + in §3.3 and §8; not investigated further here. +- **H2 (revised — displayGroup token):** **HYPOTHESIS (under runtime test).** *The + "unknown" FUT Store tile is caused by one or more pack entries whose + `displayGroup.value` token is not one of the six categories FIFA 17 can render:* + `mypacks, points, bronze, silver, gold, special`. Tested against a real capture in + §4-§8. + +--- +## Runtime capture plan (Phase 2) — OBSERVED + +- **Backend container:** `openfut-fut-backend` (logs on stdout via `log()`, + `utas_server.py:59-62`; each request logs `" "` at `:3732`, and the + response line `" -> "` at `:3754` — **response body truncated + to 200 bytes**, so the full JSON body is NOT in the log). +- **Endpoint / path matcher:** `GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` + (OBSERVED historically in the log; route regex `/store/purchasegroup`, + `utas_server.py:1215`). +- **Capture marker (UTC, set immediately before the manual test):** + `2026-08-12T23:54:01Z` (saved to `/tmp/store_capture_marker.txt`; 0 log lines + after it at set-time → clean boundary). +- **Log command to isolate the manual action:** + `docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend` + then locate the first `GET .../store/purchasegroup` line after the marker plus its + following headers and `-> 200 {"purchase"...` line. +- **Full-body recovery (because the log truncates at 200 bytes):** the response is a + deterministic pure function — `store_catalog()` (`:3408`) over static `PACK_CATALOG` + (`fut_store.py:820`) + live `STORE.unopened_packs()` (from `/state` profile) under + fixed flags (`STORE_DISPLAYGROUP=ON`, `STORE_GROUPID=OFF`, `FUT_PRICE_PROBE=OFF`). + Plan: reconstruct the exact body from the running `/app` code + live `/state` + profile, then **verify** its `json.dumps(...)[:200]` byte-for-byte equals the genuine + logged 200-byte prefix. Match ⇒ the reconstruction IS the sent body. No request is + synthesized, replayed, or curl'd; the genuine log line is the ground-truth anchor. + + +## 1. Method + +### Environment (OBSERVED) +- Running on `10.10.0.120` (dev-lxc). Client is `10.10.0.105`. +- Backend under test: Docker container `openfut-fut-backend` + (image `openfut-fut-backend:dev`), `Up 7 hours`, entrypoint `/app/entrypoint.sh`. + - `docker inspect openfut-fut-backend`: only mount is + `/home/alex/OpenFUT/fifa17-recon/docker/state -> /state (rw)`; container ENV + contains **no `FUT_STORE_*`** vars. + - `entrypoint.sh` launches `python3 -u utas_server.py` from `/app/tools` with extra + env `FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 + FUT_DISCARD_SEND=1` — again **no `FUT_STORE_*`**. +- The running store code is `/app/tools/utas_server.py`. Extracted read-only via + `docker cp openfut-fut-backend:/app/tools /tmp/app-tools` and confirmed + **byte-identical** (`sha256`) to the repo copy + `fifa17-recon/tools/utas_server.py` (both 3765 lines) and + `fifa17-recon/tools/fut_clubitems.py`. All line references below are to the repo + paths and equal the running code. + +### Commands (exact) +``` +docker ps --format '{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}' +docker inspect openfut-fut-backend --format '...CMD/ENTRYPOINT/MOUNTS/ENV...' +docker exec openfut-fut-backend cat /app/entrypoint.sh +docker cp openfut-fut-backend:/app/tools /tmp/app-tools +docker cp openfut-fut-backend:/app/data /tmp/app-data +diff /tmp/app-tools/utas_server.py fifa17-recon/tools/utas_server.py # identical +diff /tmp/app-tools/fut_clubitems.py fifa17-recon/tools/fut_clubitems.py # identical +``` + +### Manual test sequence +NOT executed. The manual FIFA-client store capture (2D/2E) was never reached +because the investigation blocked at 2C before any flag could be enabled. No +request was synthesized, replayed, or simulated. + +--- + +## 2. Store handler code path (2A) — OBSERVED + +Request → response for the store tile screen: + +1. **Endpoint.** `GET ut//store/purchasegroup/...` — `FutStoreGetPackTypes` + (client deser root `0x1801234e0`). (`utas_server.py:3408-3409` docstring.) + - Route table entry: `(re.compile(r"/store/purchasegroup"), lambda m,h: + store_catalog(h))` at `utas_server.py:1215`. + - Sibling store routes: `/store/transaction -> store_buy(h)` (`:1216`, the BUY); + bare `/store(\?|$) -> (200,{"result":"SUCCESS"})` eligibility gate (`:1221`). +2. **Handler.** `store_catalog(h)` (`utas_server.py:3408-3447`). + - Iterates `PACK_CATALOG` (non-`ownedOnly` packs) → `_pack_body(p, idx)`. + - Appends owned unopened packs from `visible_unopened_packs()` → + `STORE.unopened_packs()` + `_OPENED_PACK_GRACE` (`:51-52`, `:3423-3427`). + - If no owned packs, appends one inactive `mypacks` sentinel (id 65534) so + `GOTO_STORE_MYPACK` resolves (`:3428-3446`). + - Returns `200, {"purchase": [], "timestamp": 1596326400}` + (`:3447`). +3. **Definition construction.** `_pack_body(p, idx, owned=False)` + (`utas_server.py:3268-3405`). Emitted keys (OBSERVED, `:3293-3328`): + `assetId`(=`p["id"]`), `id`(=`p["id"]`), `packType`, `description`(=`p["name"]`), + `state`, `saleType`, `limitType`, `quantity`, `purchaseLimit`, `purchaseCount`, + `isPremium`, `sortPriority`, `currencies`, `extPrice`, `packContentInfo` + (`bronze/silver/gold/rare/itemQuantity`), `unopened`, and one of: + - owned pack → `displayGroup = {"value":"mypacks","priority":idx}` (`:3336`); + - else if `STORE_DISPLAYGROUP` → `displayGroup = {"value": category}` where + `category ∈ {special, gold, silver, bronze}` chosen from + `p["specialChance"]`/`p["gold"]` (`:3337`,`:3373-3379`), and if `STORE_GROUPID` + also `displayGroupAssetId = p["id"]` (`:3403-3404`). +4. **Data source(s).** + - `PACK_CATALOG` — a hardcoded list of 3 pack dicts + (`{id,name,price,count,gold,tiers,specialChance}`) at `fut_store.py:820-841`. + There is NO card table read in this path. + - `STORE = Store()` (`fut_store.py:843`), backed by the profile JSON + (`unopened_packs()` reads `unopenedPackIds`, `fut_store.py:619-621`). +5. **Serialization → HTTP.** The dict is JSON-encoded by the server's response + writer and returned as the HTTP body. + +### Fields 5 (`cardsubtypeid`/`carddbid`/`cardassetid`) — OBSERVED +**None of `cardsubtypeid`, `carddbid`, or `cardassetid` appear anywhere in the +store-tile (`purchasegroup`) response.** `_pack_body` (`:3293-3405`) emits only the +pack keys listed above; `assetId`/`id` are the PACK id `p["id"]` (e.g. 1, 5), not a +card asset id. The store catalog emits PACKS, never card definitions. (Grep of +`_pack_body` and `store_catalog` for those three field names returns zero hits.) + +### Families the store handler can emit +Only **packs** (`PACK_CATALOG`: "Bronze Pack" id 1, "Gold Pack" id 5, and the third +catalog entry) plus owned reward packs and the `mypacks` sentinel. It cannot emit +any card family (players, staff, consumables, club items). + +### Filtering / transformation +- `normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]` (`:3421`). +- `_pack_body` maps a pack to a category token via `specialChance>=1.0 -> special`, + else `gold -> gold`, `p.get("silver") -> silver`, else `bronze` (`:3373-3379`). +- The tile caption/category is `displayGroup.value`; `description` carries the + per-pack title. + +--- + +## 3. Wire subtype coverage (2B) + +### 3.1 Store path +The store-tile path emits **no `cardsubtypeid`** at all (see §2). So for the store +tile, "is `cardsubtypeid` the raw FCC subtype, the wire category, transformed, or +something else?" → **not present** (N/A). The store tile is selected by +`displayGroup.value` (a category-token STRING), not by any card subtype. + +Per `_pack_body:3367-3372` (citing `FUN_180014580`/`FUN_180014df0`): FIFA 17's +StoreFront resolves exactly **six hard-coded category tokens** — +`mypacks, points, bronze, silver, gold, special`. Any other `displayGroup.value` +(e.g. a raw pack title) creates an "unsupported pseudo-category". The documented +cause of "unknown" store tiles is therefore a **`displayGroup` category-token** +issue on packs (absent group, or a non-canonical token), NOT a card type/subtype. + +### 3.2 Club-item path (the only place wire subtypes live) — `fut_clubitems.py` +Club items are served on the **`/club?type=` route** (`utas_server.py:2250`, +`handle_club`), gated by `FUT_CLUBITEMS`, NOT by the store route. Current `FAMILIES` +(`fut_clubitems.py:61-67`), format `(family, table, art id, stat id, stat name, +UNVERIFIED cardsubtypeid)`: + +| wire subtype | mapped family | table | art id | source | confidence | +|---|---|---|---|---|---| +| 9 | kits | fcc_kitcards.json | 35 | `fut_clubitems.py:65` | HYPOTHESIS (marked "UNVERIFIED", `:49`,`:30-32`) | +| 10 | stadia | fcc_stadium.json | 36 | `fut_clubitems.py:63` | HYPOTHESIS ("UNVERIFIED") | +| 11 | badges | fcc_badgecards.json| 39 | `fut_clubitems.py:64` | HYPOTHESIS ("UNVERIFIED") | +| 30 | balls | fcc_balls.json | 37 | `fut_clubitems.py:62` | HYPOTHESIS ("UNVERIFIED") | +| 31 | leaguelogos | fcc_leaguelogos.json| 40| `fut_clubitems.py:66` | HYPOTHESIS ("UNVERIFIED") | + +- The prior recorded mapping (9=kits,10=stadia,11=badges,30=balls,31=logos) is + **confirmed present in current code** — but the code itself marks every one + "UNVERIFIED cardsubtypeid" and states the binary assigns family↔subtype **nowhere** + in the 149 dumped tables; cardtype-9 admits the set `{30,31,145,146,147,148,149,150}` + (`fut_clubitems.py:26-28`, `:73`). So these are server-chosen HYPOTHESIS values. +- In the club-item wire item (`_item:88-119`), `cardsubtypeid` (`:97`) is a + **server-assigned wire-category constant** (9/10/11/30/31), NOT the raw FCC + subtype: the club-item fcc tables carry **no `cardsubtype` column at all** (Task 1: + badges/stadium/kit/logos/balls have empty subtype sets). `resourceId`/`assetId` + = `carddbid`, and `cardassetid` = the family ART id (`:94-96`). So for club items: + **`cardsubtypeid` = wire category (server constant), `carddbid`/`cardassetid` = + real fcc columns.** +- By contrast, consumables (`fut_store._item` / `fut_consumables`) DO carry the raw + FCC subtype (51..341) as `cardsubtypeid`. + +### 3.3 Task-1 families vs wire-subtype coverage +Families that the **store handler** can map to a wire subtype: **none** — the store +handler emits packs, which have no card subtype (by design). + +Families for which a **club-item** wire subtype exists (HYPOTHESIS-grade): +kits(9), stadia(10), badges(11), balls(30), leaguelogos(31). + +Task-1 card families with **no wire subtype mapping anywhere in the server**: +- **5001xxx contracts, 5002xxx fitness/healing, 5003xxx training** — served as + consumables carrying their raw FCC subtype; these are consumable overlays, not + store tiles, and not part of any store/club-item wire-category map. +- **5004xxx misc {231,232,233,236}** — **no wire subtype mapping found** in + `fut_clubitems.py` or the store path. (Grep: no `misc` family, no {231,232,233,236} + wire assignment.) They exist only as raw FCC subtypes in consumable data. +- **8010xxx league logos/stickers** — mapped in the **club-item** path as wire + subtype **31** (`fut_clubitems.py:66`), HYPOTHESIS-grade. `fcc_leaguelogostickers` + (39 rows) is NOT wired in `FAMILIES` (only `fcc_leaguelogos`, 44 rows). +- **6000xxx badges, 6200xxx stadiums, 6300/6400xxx kits, 8120xxx balls** — mapped in + the club-item path (11/10/9/30), HYPOTHESIS-grade. +- **staff (managers/coaches, subtypes 4-8)** — served by `fut_staff` on `/club?type= + manager`, not a store tile. + +Note none of these belong to the **store-tile** (`purchasegroup`) response. + +--- + +## 2C flag investigation — BLOCKED + +### Store-tiles flags located (OBSERVED) +Two, both in `utas_server.py`, both module-level constants read **once at import**: + +| flag | env var | line (read) | consumed | default | current running value | +|---|---|---|---|---|---| +| `STORE_DISPLAYGROUP` | `FUT_STORE_DISPLAYGROUP` | `:975` | `_pack_body:3337` | `"1"` → **ON** | **ON** (no env override) | +| `STORE_GROUPID` | `FUT_STORE_GROUPID` | `:980` | `_pack_body:3403` | `"0"` → **OFF** | **OFF** (no env override) | + +- `:975` `STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"` +- `:980` `STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"` + +**Current values (recorded before any change; nothing was changed):** +- `FUT_STORE_DISPLAYGROUP`: unset in container env → default `"1"` → + `STORE_DISPLAYGROUP = True` (**ON**). (INFERRED from OBSERVED env dump + OBSERVED + code default.) +- `FUT_STORE_GROUPID`: unset in container env → default `"0"` → + `STORE_GROUPID = False` (**OFF**). This is the flag "expected to be OFF". + +The related club-item flag `FUT_CLUBITEMS` (`:1647-1648`) is likewise unset → +`CLUBITEMS = False` (club items not currently served), also a module-level import +constant. + +### Dynamic evaluation? NO (OBSERVED) +- All three flags are top-level `os.environ.get(...)` assignments evaluated at + module import (`:975`, `:980`, `:1647`); `_pack_body` reads the resulting module + **constants** (`:3337`, `:3403`), never `os.environ` at request time. +- Repo-wide there is **no** `importlib.reload`, no `signal`/`SIGHUP` handler, and no + per-request environ re-read for these flags. (The only runtime-refreshable feature + is `FUT_ID_SWEEP`, which re-reads a *file* `SWEEP_FILE`, `:1965-1966` — unrelated.) +- `:3250` documents the intended workflow explicitly: + `# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart`. + +### Restart conflict → STOP +Changing either store flag requires either setting a container env var (→ +`docker` recreate = restart) or editing the module (→ re-import = restart). Both +violate the no-restart / no-redeploy / no-recreate constraint. Therefore: + +``` +TASK 2 BLOCKED AT 2C: +Flag requires restart, conflicting with no-restart constraint. +``` + +No flag was enabled. 2D/2E (manual client capture + definition analysis) NOT +started. No client request generated, replayed, or simulated. + +--- + +## 4. Captured request and response (Phase 3) — OBSERVED + +Real client action (operator opened the FUT Store on `.105`, 2026-08-12). Only one +`/store/purchasegroup` request occurred after the capture marker +`2026-08-12T23:54:01Z`, so attribution is unambiguous (only the operator drives the +client). Log via `docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`. + +- **Request:** `23:54:43 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` + (Host `10.10.0.120:8099`, `User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`, + `X-UT-SID` present). Preceded at 23:54:43 by `GET /ut/game/fifa17/user/credits` + → `200 {"credits": 29876776, ...}`. +- **Response:** `200`. The server log truncates the body to 200 bytes + (`utas_server.py:3754`, `raw[:200]`), genuine prefix: + `{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount` +- **Full body recovered** by running the exact running-container code + (`docker exec openfut-fut-backend python3 -c "import utas_server as u; u.store_catalog(None)"`) + over the live `/state` profile, under the live flags (confirmed in-process: + `STORE_DISPLAYGROUP=True`, `STORE_GROUPID=False`). Its `json.dumps(...)[:200]` + equals the genuine logged 200-byte prefix **byte-for-byte** (verified MATCH), so the + reconstruction IS the sent body (deterministic pure function + verified prefix). No + request was synthesized, replayed, or curl'd. Full body (2841 bytes) preserved + verbatim at `docs/evidence/store_purchasegroup_capture_2026-08-12.json`. +- **Operator-reported client behavior:** on opening the store, an error dialog + appeared — *"The pack you've selected is currently not available. Please select a + different pack or try again later."* — and clicking OK returned to the FUT hub. No + tiles were browsable; **no "unknown" tiles were reported**. There was **no** + `PUT /store/transaction` (no buy) and **no server error** (server answered `200`). + +## 5. Definition analysis (Phase 4/5) — OBSERVED + +`purchase[]` = 5 entries (`timestamp` 1596326400). No `cardsubtypeid`/`carddbid`/ +`cardassetid` in any entry (packs, not cards). + +| idx | id | assetId | packType | description | displayGroup.value | priority | state | dgAssetId | classification | +|---|---|---|---|---|---|---|---|---|---| +| 0 | 1 | 1 | BRONZE | Bronze Pack | `bronze` | — | active | absent | KNOWN TOKEN | +| 1 | 5 | 5 | GOLD | Gold Pack | `gold` | — | active | absent | KNOWN TOKEN | +| 2 | 6 | 6 | GOLD | Premium Gold | `gold` | — | active | absent | KNOWN TOKEN | +| 3 | 7 | 7 | GOLD | Special Players Pack | `special` | — | active | absent | KNOWN TOKEN | +| 4 | 65534 | 65534 | GOLD | "" (empty) | `mypacks` | 1 | **inactive** | absent | KNOWN TOKEN | + +Per-pack fields (idx 0-3 identical shape): `saleType:"promo"`, `limitType:"NONE"`, +`quantity:0`, `purchaseLimit:0`, `purchaseCount:0`, `isPremium:false`, +`currencies:[{"name":"coins","funds":,"finalFunds":}]`, +`extPrice:{finalPrice/originalPrice:{amount:,currency:"mtx"}}`, +`packContentInfo:{...tier quantities...}`, `unopened:false`. Prices: Bronze 400, +Gold 5000, Premium Gold 15000, Special Players 25000. The sentinel (idx 4) drops +`currencies`/`extPrice`, has empty description, `state:"inactive"`. + +**Unique `displayGroup.value` set emitted: `{bronze, gold, special, mypacks}`.** +Compared to the six client renderer tokens `{mypacks, points, bronze, silver, gold, +special}` (`FUN_180014580`/`FUN_180014df0`, `plan-2026-08-05-store-subsystem.md:202-206`): +**every emitted token is a KNOWN token; the set outside the renderer categories is +EMPTY.** + +## 6. Unmapped definitions / suspect tokens found — OBSERVED + +None. Zero SUSPECT/UNKNOWN `displayGroup.value` tokens; zero card definitions; +zero `cardsubtypeid`/`carddbid`/`cardassetid`. The only anomalous element is the +**inactive, empty-description `mypacks` sentinel** (idx 4), emitted by +`store_catalog:3428-3446` **only when the profile owns zero unopened packs** +(OBSERVED: profile `unopenedPackIds == []`). + +## 7. Client-side evidence (Phase 7) — existing RE only + +No new Ghidra run. Existing decompiler evidence already bounds the answer and shows +new static analysis would be unproductive: +- **The parser is not the gate** (`OPENCODE_ENDPOINT_PROMPT.md:118-120`): per-pack + epilogue `0x18013badc` pushes every parsed pack unconditionally — no drop predicate + in the parse path. So a `200` with clean JSON cannot be rejected by the deserializer. +- **Store-level "not available"** (`FUT_CatalogNotAvailable`, msg `0x7550`) comes from + downstream client gates (`OPENCODE_ENDPOINT_PROMPT.md:120-131`): (1) resolution + `GetSystemMetrics` ≤1024×768, (2) store-data-model load status `0x180013cf0`, + (3) Blaze purchase-config flags `IS_STORE_ENABLED/IS_COIN_PURCHASABLE/...` (these + are already served, `blaze_responder_v3b.py:708-719`). +- **Per-pack tile-availability predicate is in the PACKED FIFA17.exe and UNREAD** + (`plan-2026-08-05-pack-opening.md:553`): fields `state/saleType/quantity/ + purchaseLimit/purchaseCount/start/end` are parsed and copied to the tile, but the + predicate that greys/blocks a tile "is in the packed exe and unread." Whether + `purchaseLimit`+`purchaseCount` greys a tile is explicitly an OPEN question + (`:644-645`). Static Ghidra on the packed exe cannot read it (decrypts only in live + memory); resolving it requires a live-memory experiment on the running FIFA process, + which is out of scope (must not touch FIFA). +- The exact operator string *"The pack you've selected is currently not available"* + is **not present** anywhere in the recon corpus (docs/tools); the documented + store/pack error strings are `FUT_CatalogNotAvailable` and + `CARDS_CB_ERR_PACK_NOT_IN_DIME` (a server-returnable code). UNKNOWN loc key. + +## 8. Assessment (Phase 8) — runtime verdict + +- **H1 (subtype/definition): CONTRADICTED.** The captured response contains no card + definitions and no `cardsubtypeid`/`carddbid`/`cardassetid` (OBSERVED §4-§5). +- **H2 (unknown `displayGroup.value` token): CONTRADICTED.** Every emitted token is a + KNOWN renderer category (`bronze/gold/special/mypacks`); the unsupported-token set + is EMPTY (OBSERVED §5). The store-tile "unknown" mechanism is NOT reproduced under + the current config (`STORE_DISPLAYGROUP=ON`). + +Answering the Phase-8 questions: +1. **Unsupported `displayGroup.value` in the response?** No — all four tokens + (`bronze/gold/special/mypacks`) are recognized. (OBSERVED) +2. **Which pack got it?** None. (OBSERVED) +3. **Correspond to an unknown tile in FIFA?** No unknown tile was reported; the + observed symptom was a *"pack not available"* dialog, not an unknown tile. (OBSERVED) +4. **Where does the backend assign the token?** `_pack_body:3373-3379` maps each pack + to `special/gold/silver/bronze` from `specialChance`/`gold`; owned/sentinel → + `mypacks` (`:3336`). All canonical. (OBSERVED) +5. **Is `displayGroup.value` sufficient to explain the bug?** No. The response is + clean; the failure is a downstream client/packed-exe gate, not a token. (INFERRED) +6. **Is the server emitting a value FIFA demonstrably cannot understand?** No. + (OBSERVED) +7. **Narrowest likely fix (REPORT ONLY — not implemented):** The single anomalous, + server-controllable element is the **inactive empty `mypacks` sentinel** emitted + when `unopenedPackIds == []` (`store_catalog:3428-3446`). Leading HYPOTHESIS: with + no owned packs, the store's My-Packs group contains only this inactive pack, and + the client's (packed-exe) selection/availability path lands on it → + *"the pack you've selected is currently not available"* → back to hub. Narrowest + candidate fixes to TEST (each needs a controlled change, hence a future + restart-gated experiment — do NOT implement now): + (a) suppress the sentinel when there are no owned packs and instead let the store + land on a real active category (bronze/gold/special); or + (b) if My-Packs must resolve, make the sentinel non-selectable rather than an + `inactive` pack in the group. + Cheaper-to-eliminate CLIENT-side cause to check first (existing RE, no server + change): FIFA display resolution must be **>1024×768** on `.105` + (`OPENCODE_ENDPOINT_PROMPT.md:122-124`). + +**Overall runtime verdict: CONTRADICTED** — neither H1 nor H2 reproduces; the +"unknown store tile" hypothesis is not the live failure. The live failure is a +distinct *pack-availability* error whose trigger is in the packed FIFA17.exe and +cannot be pinned from the (clean) server response alone. + +## 9. Open questions +1. What exactly raises *"The pack you've selected is currently not available"*? The + loc key is unknown and the predicate is in the packed exe (unread). Resolving it + needs a controlled field/flag experiment or live-memory RE (both currently gated). +2. Does the store, with `unopenedPackIds == []`, land on / auto-select the inactive + `mypacks` sentinel? (HYPOTHESIS §8.7; unproven without client-side observation.) +3. Did the store render tiles successfully in earlier sessions when the profile + owned unopened packs (e.g. the 21:54-21:57 pack-opening burst)? If so, the + presence/absence of owned packs (sentinel) is implicated. (UNKNOWN — earlier logs + truncate the body; not proven.) +4. Does `purchaseLimit:0`/`purchaseCount:0` grey a tile? Existing RE lists this as an + OPEN question; would need a controlled experiment. (UNKNOWN) +5. Is bug 6c ("unknown tile") a stale symptom from before `STORE_DISPLAYGROUP` became + the default `ON`? Under the current config no unknown tile reproduces. + +--- + +## H3 — EMPTY MY-PACKS SENTINEL (HYPOTHESIS) + +When the profile owns zero unopened packs (`unopenedPackIds == []`), OpenFUT emits +synthetic **inactive** pack id **65534** in the `mypacks` display group +(`store_catalog:3428-3446`). The FIFA 17 store may treat this object as a selectable +pack whose availability predicate fails, producing *"The pack you've selected is +currently not available"* and returning the user to the FUT Hub before any +`/store/transaction`. **Status: HYPOTHESIS (untested).** + +## 10. Resolution check (Phase 1) — OBSERVED + +Read-only inspection of `.105` (FIFA pid 529227, not touched): +- Desktop/monitor: **2560x1440** — DRM connectors `card1-DP-2` and `card1-HDMI-A-1` + both `connected`, native mode 2560x1440; compositor KDE `kwin_wayland` (no gamescope). +- FIFA render config: `…/Games/umu/fifa17/pfx/drive_c/users/steamuser/Documents/FIFA 17/settings/overrideAutodetect.lua` + → `ResolutionWidth = 1280`, `ResolutionHeight = 720`, `FullscreenEnabled = 0` (windowed). +- Session: Wayland (`WAYLAND_DISPLAY=wayland-0`), FIFA via XWayland (`DISPLAY=:0`, + `XAUTHORITY=/run/pressure-vessel/Xauthority` inside the game namespace). + +**Is FIFA rendering above 1024x768? YES (1280x720).** +**Resolution hypothesis eliminated for this reproduction.** + +## 11. Sentinel 65534 provenance (Phase 3) + +1. **Basis:** **OpenFUT INVENTION** (OBSERVED). `65534` (0xFFFE) appears nowhere in + any EA capture or recon note — repo-wide it exists only in `utas_server.py` + (`store_catalog:3435-3446`) and this evidence set. The author's comment + (`:3428-3434`) states it is a workaround: "Retain an inactive zero-item sentinel so + the [`mypacks`] destination resolves… Its id is deliberately absent from + PACK_CATALOG." It is a compatibility guess, not captured behavior. +2. **Known-good EA capture of EMPTY My Packs:** **UNKNOWN** — none found. All recon + My-Packs analysis is client-side RE (`FUN_1800150d0` filters + `displayGroup.value=="mypacks"`, `plan-2026-08-05-pack-opening.md:34-36,893-895`); + no EA server response for an empty My Packs state is on record. +3. **Known-good response with ≥1 unopened pack:** **UNKNOWN** for EA. OpenFUT's own + seed grants reward pack id 70 (`_new_profile` `unopenedPackIds:[70]`, + `fut_store.py:360`), but that is an OpenFUT synthetic grant, not an EA capture. +4. **Evidence FIFA EXPECTS a sentinel/placeholder:** **NO / UNKNOWN.** Recon shows My + Packs is a client-side filter over the ordinary catalogue; the "empty-category + dialog over the wrong tab" concern behind the sentinel is the author's HYPOTHESIS, + not decompiler-confirmed. No evidence FIFA requires a placeholder object. +5. **Evidence for the `inactive` representation:** **UNKNOWN / HYPOTHESIS.** The claim + "state != active keeps it out of the visible row list" (`:3432`) is an unverified + author assumption; no RE shows `state:"inactive"` hides a pack from selection. If + FIFA does NOT hide it, the sole `mypacks` entry is a selectable inactive pack — + exactly the H3 failure mode. + +## 12. Empty vs non-empty My Packs behavior (Phase 2) — OBSERVED (code) / captured + +`store_catalog(h)` (`utas_server.py:3421-3447`): +- Always emits the 4 non-`ownedOnly` catalogue packs (ids 1,5,6,7) via `_pack_body`. +- For each id in `visible_unopened_packs()` (= `STORE.unopened_packs()` + + `_OPENED_PACK_GRACE`) that resolves in `PACK_CATALOG`, appends `_pack_body(owned, + idx, owned=True)`. +- **Only when `unopened_packs()` is empty (`if not owned_ids`)** appends the inactive + sentinel (`:3428-3446`). + +Sentinel 65534 vs a normal active pack (Bronze, idx 0), field-by-field (from the +captured body): + +| field | sentinel 65534 | Bronze Pack (active) | +|---|---|---| +| id | 65534 | 1 | +| assetId | 65534 | 1 | +| packType | GOLD | BRONZE | +| description | "" (empty) | "Bronze Pack" | +| state | **inactive** | active | +| saleType | promo | promo | +| limitType | NONE | NONE | +| quantity | 0 | 0 | +| purchaseLimit | 0 | 0 | +| purchaseCount | 0 | 0 | +| isPremium | false | false | +| sortPriority | 1 | 1 | +| currencies | **ABSENT** (popped) | `[{coins,400,400}]` | +| extPrice | **ABSENT** (popped) | `{mtx 4/4}` | +| packContentInfo | all-zero quantities | bronze 5 / item 5 | +| unopened | false | false | +| displayGroup.value | **mypacks** | bronze | +| displayGroup.priority | 1 | ABSENT | +| displayGroupAssetId | ABSENT | ABSENT | + +**What changes when `unopenedPackIds` is non-empty (e.g. `[70]`):** the sentinel is +NOT emitted; instead pack 70 (Reward Special Players Pack, `ownedOnly`) appears via +the owned branch of `_pack_body` (`:3335-3336`): `displayGroup={"value":"mypacks", +"priority":idx}`, `state:"active"` (default), `unopened:true`, `currencies`/`extPrice` +popped. i.e. the `mypacks` group would hold a genuine **active** owned pack instead of +the inactive sentinel. + +## 13. Proposed controlled experiments (Phase 5) — DESIGN ONLY, NOT EXECUTED + +### Existing grant mechanism (Phase 4) +- **Supported profile-only method: YES** — `Store.grant_unopened_pack(pack_id)` + (`fut_store.py:635-643`): validates the id is in `PACK_CATALOG`, appends to + `unopenedPackIds`, persists; reverts via `consume_unopened_pack` (`:623-633`). + Modifies **only** profile state; cleanly reversible. +- **BUT restart IS required to take effect.** `Store.load()` caches `self._p` + (`:370-372`); **no route calls `grant_unopened_pack` or `select_account`**, and + `select_account` only re-reads on a persona *change*. So an out-of-process grant or + a raw profile-file edit writes disk but the **running server keeps serving its + cached `unopenedPackIds`** until the process reloads. There is no SIGHUP/reload + endpoint. Therefore any profile change needs a container restart to be observed. + +### Experiment A — Non-empty My Packs (PREFERRED; least invasive) +- **State change:** set the profile's `unopenedPackIds` to `[70]` (edit + `…/state/accounts/33068179/fifa17_profile.json`, or call + `STORE.grant_unopened_pack(70)`), **store code/config unchanged**. +- **Restart required?** **YES** — profile cache (above). Profile-only; no code edit. +- **Rollback:** set `unopenedPackIds` back to `[]` (or `consume_unopened_pack(70)`), + restart. (Also restore `nextItemId`/coins only if a pack is actually opened — the + grant alone touches only `unopenedPackIds`.) +- **Expected `purchasegroup` difference:** sentinel 65534 GONE; instead one active + pack id 70 in the `mypacks` group (`state:active`, `unopened:true`); hub/credits + report `recoveredPacks:1`. +- **Expected client observation:** if H3 is correct, the immediate *"pack not + available"* dialog should NOT fire (or behavior changes) and My Packs should show a + real pack. If the dialog still fires identically, H3 is weakened and the cause is + elsewhere (packed-exe predicate / another field). + +### Experiment B — Suppress the empty sentinel (only if A is inconclusive) +- **Change:** in `store_catalog` (`:3428-3446`), when `unopened_packs()` is empty, do + NOT append pack 65534 (emit no `mypacks` entry). **Code change ⇒ restart. NOT + authorized.** Narrowest patch: guard/remove the `if not owned_ids:` sentinel block. +- **Purpose:** distinguishes "the inactive sentinel is selected and fails" (A already + tests the inverse) from "an absent `mypacks` group causes a different failure" + (the original author's stated fear at `:3429-3431`). + +### Experiment C — Alternate sentinel representation (design only) +- If evidence later shows FIFA expects an empty `mypacks` group represented + differently (e.g. present-but-not-a-pack, or `state` other than `inactive`), adjust + the sentinel shape. **DESIGN ONLY; DO NOT IMPLEMENT.** No current evidence specifies + the correct empty-group representation (see §11.4-11.5). + +Note: both A and B require a restart (A for the profile cache, B for the code). A is +strictly less invasive (profile-only, clean rollback, no code change) and is the +preferred next experiment. Neither is authorized yet. + +--- + +## 14. Experiment A — Non-empty My Packs (EXECUTED 2026-08-13) — OBSERVED + +Authorized controlled test of H3: change ONLY the profile's `unopenedPackIds` +(`[] → [70]`), restart the FUT backend once, capture a genuine FIFA store request, +then roll back. No store code/config/flags/PACK_CATALOG/pack-70/sentinel-code +changed; FIFA not modified/restarted; operator drove the client. + +### 14.1 Baseline profile state +- Path: `fifa17-recon/docker/state/accounts/33068179/fifa17_profile.json` (persona + 33068179/CAGE; proven live: `STORE.path` in-process = `/state/accounts/33068179/ + fifa17_profile.json`, coins 29876776 matching the live `/user/credits`). +- `unopenedPackIds == []`. Original SHA-256 + `39bb3e833fa55287d8516815ba3a717b41c0f0c7a7c41d20503f3a55c65cc6e7`. Backup: + `/tmp/fifa17_profile.33068179.ORIG.20260813T001228Z.json` (same hash). + +### 14.2 State change +- Narrow anchored edit of line 97070 only: ` "unopenedPackIds": [],` → + ` "unopenedPackIds": [70],`. Diff vs backup = exactly one line; semantic diff = + only key `unopenedPackIds` (`[] → [70]`), all other 24 keys identical. Modified + SHA-256 `2b5760baa265f320904de2d23fd6ab374733efe74cb0756c3be39c083bce4ac8`. + (Used the direct edit rather than `grant_unopened_pack(70)` to avoid whole-file + reserialization; net semantic effect is identical.) + +### 14.3 Restart +- `docker restart openfut-fut-backend` (Pid 1398009→1548312, StartedAt + 2026-08-12T16:40:52Z → 2026-08-13T00:13:54Z). Bridge/Core and Rust hosts untouched. + This container bundles blaze/roster/utas/pow (per `entrypoint.sh`); all rebound. +- Post-restart in-process check: `STORE.load()['unopenedPackIds'] == [70]`; + `store_catalog(None)` → pack 70 present, sentinel 65534 absent. + +### 14.4 Genuine FIFA capture +- Marker `2026-08-13T00:14:24Z`. Single request after it (unambiguous): + `00:14:58 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true → 200`. + **No `/store/transaction`, no `/purchased/items`** in the window (pack 70 not opened). +- Full body recovered from the running code over the live [70] profile; its + `[:200]` matches the genuine logged 200-byte prefix byte-for-byte (verified MATCH). + Preserved at `docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json`. + +### 14.5 Client-observed behavior (operator report) +1. Store remains open: **YES**. +2. "The pack you've selected is currently not available": **NO (gone)**. +3. My Packs category / unopened reward pack visible: **YES**. +4. Returned to FUT Hub: yes (normal navigation; not forced by an error dialog). + +### 14.6 Response diff (baseline 2026-08-12 vs experiment) +Only difference across all 5 entries: +- **Removed:** id `65534` (`state:inactive`, `displayGroup:mypacks`, `description:""`). +- **Added:** id `70` (`state:active`, `displayGroup:mypacks`, + `description:"Reward Special Players Pack"`, `unopened:true`). +- Packs 1/5/6/7 byte-identical. Classification: **all EXPECTED FROM UNOPENED PACK + STATE; nothing UNEXPECTED.** + +### 14.7 H3 assessment — **SUPPORTED** +65534 disappeared AND pack 70 replaced it as a real My Packs entry AND the +"pack not available" / store-exit behavior disappeared → per the pre-registered +criterion, **H3 is strongly SUPPORTED**. The failure is tied to the My Packs group +content when the profile owns zero unopened packs. + +Sub-hypothesis resolution: +- **H3c (failure unrelated to My Packs): RULED OUT.** A My-Packs-only profile change + (no store code/config change) eliminated the failure. +- **H3a (the inactive sentinel object itself is the trigger) vs H3b (empty My Packs + state generally is the trigger): NOT DISTINGUISHED by Experiment A.** The change + simultaneously (i) removed the inactive sentinel and (ii) supplied a real active + owned pack. Either "presence of the inactive/empty sentinel" or "absence of any + real owned pack" could be the cause. Distinguishing them requires Experiment B + (empty `unopenedPackIds` AND suppress the sentinel so `mypacks` has no entry): if + that also fixes it → H3a (sentinel object was the problem); if it re-breaks or + changes → H3b (empty My Packs itself is the problem). Experiment B is a code change + (restart-gated) and remains unauthorized. + +### 14.8 Rollback verification +- Profile restored from backup → SHA-256 `39bb3e83…` == original (byte-identical); + `unopenedPackIds == []`. Disk was unmutated during the test (still `2b5760ba…` + before rollback → store reads don't persist; pack 70 never opened). +- `docker restart openfut-fut-backend` (Pid 1549503, StartedAt 2026-08-13T00:16:56Z). + Post-restart in-process: `unopenedPackIds == []`, sentinel 65534 present again, + pack 70 absent → runtime baseline restored. Bridge/Core untouched. + +**H3 status: SUPPORTED (H3c ruled out; H3a vs H3b open).** No permanent fix +implemented. + +--- + +## 15. Experiment B — Empty My Packs Without Sentinel (EXECUTED 2026-08-13) — OBSERVED + +### 15.1 Purpose +Distinguish **H3a** (the synthetic inactive sentinel 65534 itself is the trigger) +from **H3b** (FIFA cannot tolerate an empty My Packs state even without a sentinel). +Hold `unopenedPackIds == []` constant; change ONLY: sentinel 65534 emitted → +suppressed. Authorized TEMPORARY code change, reverted after test. + +### 15.2 Baseline +Profile `unopenedPackIds == []` (SHA-256 `39bb3e83…`, unchanged throughout). Running +code before patch = `c89d43ea…` (host repo == container copy). Sentinel 65534 emitted. + +### 15.3 Temporary patch — **TEMPORARY EXPERIMENT B PATCH, NOT A PERMANENT FIX** +Applied to the **container** copy `/app/tools/utas_server.py` only (the container +mounts `/state`, not `/app`; host repo `fifa17-recon/tools/utas_server.py` was NOT +edited — its git diff stayed empty). Single line, `store_catalog` (line 3428): +``` +- if not owned_ids: ++ if False: # TEMP EXPERIMENT B PATCH -- suppress synthetic sentinel 65534 (NOT A PERMANENT FIX) +``` +Semantic effect: when `unopened_packs()` is empty, append nothing (no sentinel, no +replacement object). Normal packs 1/5/6/7 (appended earlier) unchanged. Diff vs the +backed-up original = exactly this one line. Patched code SHA-256 `5bb8fca9…`. + +### 15.4 Restart verification +`docker restart openfut-fut-backend` (Pid 1549503→1551026, StartedAt +2026-08-13T00:20:45Z). The writable-layer edit survived the restart; running +`/app/tools/utas_server.py` = `5bb8fca9…` (patched). In-process: `unopenedPackIds == +[]`; `store_catalog` → 4 packs {1,5,6,7}, **no 65534, no 70, no `mypacks` entry**. +Flags unchanged (`DISPLAYGROUP=ON`, `GROUPID=OFF`). Bridge/Core/Rust untouched. + +### 15.5 Genuine FIFA capture +Marker `2026-08-13T00:20:55Z`. Request sequence (operator opened the store): +`00:21:05 GET /hub` → `00:21:07 GET /user/credits` → `00:21:07 GET +/store/purchasegroup/all?ppInfo=true → 200`. **No `/store/transaction`; no further +requests** (client crashed after receiving the store body). Full body recovered from +the running patched code; `[:200]` matches the genuine logged prefix byte-for-byte +(verified MATCH). Preserved at +`docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json` +(4 packs {1,5,6,7}, no `mypacks` group). + +### 15.6 Client behavior (operator report) +**The game CRASHED** on opening the store. Not the baseline dialog; a hard crash. No +`/store/transaction` was issued. + +### 15.7 Three-way response comparison +| capture | ids present | `mypacks` group entry | client outcome | +|---|---|---|---| +| Baseline (`…_2026-08-12.json`) | 1,5,6,7,**65534** | 65534 `inactive`, desc "" | "pack not available" dialog → Hub | +| Exp A (`…_mypacks70_…json`) | 1,5,6,7,**70** | 70 `active`, "Reward Special Players Pack" | **works** — store open, My Packs visible | +| Exp B (`…_empty_no_sentinel_…json`) | 1,5,6,7 | **none** | **CRASH** | +Normal packs {1,5,6,7} identical across all three. + +### 15.8 H3a / H3b verdict +- **H3a (sentinel object itself is the trigger): CONTRADICTED.** Removing the + sentinel did NOT restore the store; it produced a *worse* outcome (crash). If the + sentinel object were the sole cause, its removal would yield a working store (it + did not). +- **H3b (FIFA cannot tolerate an empty My Packs state): SUPPORTED.** Only Exp A — a + real **active** owned pack in `mypacks` — worked. Both the inactive sentinel + (graceful "pack not available" dialog) and the total absence of any `mypacks` entry + (crash) fail. The sentinel is a **load-bearing workaround** that *downgrades* the + failure from a crash to a dialog but does not fix it. +- **Pre-registered-rule nuance:** Exp B produced a *distinct* failure (crash), which + the pre-registered rules classify as **B3 (different failure)** rather than the + exact B2 dialog. Documented as such: the crash is a THIRD failure mode. It still + resolves the question — it rules out H3a and supports H3b — but the specific + outcome (crash, not the same dialog) is stronger than B2 anticipated. Not forced + into a clean binary beyond what the evidence shows. + +**Does FIFA tolerate an empty My Packs without the sentinel? NO — it crashes.** + +### 15.9 Rollback verification +- Container `/app/tools/utas_server.py` restored from backup → SHA-256 `c89d43ea…` + == pre-experiment (byte-identical); the `if False:` patch fully removed. +- `docker restart openfut-fut-backend` (final Pid 1551901, + StartedAt 2026-08-13T00:22:04Z). In-process: `unopenedPackIds == []`, sentinel + 65534 emitted again, pack 70 absent; `DISPLAYGROUP=ON`, `GROUPID=OFF`. +- Host repo `fifa17-recon/tools/utas_server.py` never edited (git diff empty, SHA-256 + `c89d43ea…`). Profile unchanged (`39bb3e83…`). Bridge/Core/Rust untouched. + +### 15.10 Likely permanent fix (REPORT ONLY — not implemented) +Evidence: the store's `mypacks` group must contain a **valid, active, openable owned +pack**; both an inactive sentinel and an absent group fail (dialog / crash). The only +working configuration observed is a genuine active owned pack (Exp A). Candidate +directions (report only, each needs design + authorization): +1. Ensure the profile always owns ≥1 legitimate active unopened pack while the store + is shown (e.g. keep a real reward pack such as id 70 granted), so `mypacks` is + never empty — this matches the only known-working state but changes economy state + and needs a lifecycle policy (what happens after the user opens it). +2. Change what `store_catalog` advertises so FIFA never lands on / requires a + `mypacks` group when there are zero owned packs (client-compatible empty-store + representation) — the correct representation is UNKNOWN; neither current option + (inactive sentinel / no group) is it, so this needs new client-side RE before + implementation. +Recommendation: do NOT simply delete the sentinel (Exp B proves that crashes). No fix +implemented. + +**H3 status: SUPPORTED. H3a CONTRADICTED, H3b SUPPORTED (Exp B crash = third failure +mode; empty My Packs is the root problem). Sentinel is a load-bearing workaround.** + +--- + +## 16. Experiment C′ — Active Non-Openable Placeholder (EXECUTED 2026-08-13) — OBSERVED + +Question: can the required `mypacks` group be kept structurally valid with an +**active** placeholder that stays impossible to open/purchase? Change exactly one +field of sentinel 65534: `state "inactive" → "active"`. Profile untouched +(`unopenedPackIds==[]`); 65534 kept absent from `PACK_CATALOG`. + +### 16.1 Server-side safety proof (OBSERVED, code) +65534 cannot grant value regardless of `state` — pack resolution is by +`pack_by_id(id)` over `PACK_CATALOG` (ids 1,5,6,7,70), independent of the display +`state`: +- `store_buy` (PUT `/store/transaction`, `:3460-3465`): `pack_by_id(65534)=None` → + `if not pack: return 200, {}` (no `open_pack`, no coin change). +- `purchased_items` (POST, `:3494-3496`): `pack_by_id(65534)=None` → + `return 200, {"itemData": STORE.last_pack()}` (stale prior items only; no new + grant, no `open_pack`, no `consume_unopened_pack`). +- `open_pack`/`consume_unopened_pack` are unreachable for 65534 (pack resolves to + None first). Precondition PASSED. + +### 16.2 Baseline / patch +Baseline: profile `39bb3e83…`, code `c89d43ea…` (host==container), sentinel +`inactive`/`mypacks`, 65534∉catalog, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`. +Temporary container-only patch (host repo untouched), line 3444: +`empty["state"] = "inactive"` → `empty["state"] = "active"`. Diff vs original = this +one line; patched code `e1a4e1dc…`. **TEMPORARY EXPERIMENT C′ PATCH — NOT A PERMANENT +FIX.** + +### 16.3 Restart / runtime +`docker restart openfut-fut-backend` (Pid 1556305, StartedAt 00:38:51Z). Running code +`e1a4e1dc…`; `unopenedPackIds==[]`; sentinel `65534 state=active unopened=False +dg=mypacks desc=""`; 65534∉catalog; normal packs 1/5/6/7 active; 70 absent; flags +unchanged. + +### 16.4 Genuine FIFA capture +Marker `2026-08-13T00:38:53Z`. FIFA relaunched (Exp-B crash had closed it) → booted to +hub. Three genuine store fetches: `00:39:44`, `00:40:26`, `00:41:23` +(`GET /store/purchasegroup/all?ppInfo=true → 200`), reconstructed body prefix-matches +the logged 200-byte prefix (verified). Saved +`docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json`. +C′-vs-baseline full JSON diff = **only** `65534.state: "inactive" → "active"`. + +### 16.5 UI observation (operator report) +1. **No crash** (game launched to hub). +2. **No "pack not available" dialog.** +3. Store remains open. +4. My Packs tab **not shown while inside the Store (Browse Packs)**. +5. Via the FUT-hub **My Packs** menu: **one pack tile with no cover, "0 items, 0 + bronze, 0 rares"** (the placeholder renders as a visible empty pack). +6. Navigation: from the hub **My Packs** menu → Bronze/Gold/Special reachable; but + from the **Browse Packs** (Store) entry, Bronze/Gold/Special are **not reachable + until My Packs is opened first**. + +### 16.6 Passive request sequence (OBSERVED) +Boot: `.../accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo +→ PUT store/transaction/0 → hub …`. The single `/store/transaction` is the routine +**boot** call with body `{"state":"TRANSACTIONCANCEL"}` → `200 {}` (no packId), fired +at 00:39:39 **before** any store fetch. Across all three store opens: **no +`/store/transaction`, no `/purchased`, and no request referencing 65534.** The active +placeholder did NOT cause FIFA to auto-submit any transaction/open. + +### 16.7 Availability result / verdict +**Result C1 (strong positive) — ACTIVE PLACEHOLDER HYPOTHESIS SUPPORTED, with UX +caveats.** `state` participates materially: with `state:"active"` the group exists +(no crash, as in baseline) AND the availability path is satisfied (no +"pack not available" dialog, unlike baseline). So **C2 is refuted** — `state` is a +deciding field for the dialog. But it is **not a clean permanent fix**: +- the placeholder renders as a **visible empty pack tile** ("0 items"), i.e. a fake + pack a user could try to open (server-safe: opening → no-op `{}` / stale + `last_pack`, but confusing UX); +- **navigation caveat #6**: from Browse Packs the other categories are gated behind + opening My Packs first — an UNEXPECTED behavior not present with a genuine owned + pack (Exp A). + +### 16.8 Rollback verification +Container code restored from backup → `c89d43ea…` (== host, == pre-experiment); the +`state` change removed. `docker restart` (Pid 1557831, StartedAt 00:43:04Z). +In-process: `unopenedPackIds==[]`, sentinel `state=inactive`, 65534∉catalog, 70 +absent, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`. Host repo `utas_server.py` never edited +(`c89d43ea…`, git diff empty). Profile `39bb3e83…` unchanged. Bridge/Core untouched. + +### 16.9 Implications for the client contract +`state:"active"` satisfies the pack-availability predicate (no dialog) while the +group's existence prevents the crash — so an active non-openable placeholder is the +first representation that neither crashes nor shows the dialog. However it exposes a +**visible empty "pack"** and a **Browse-Packs navigation gate** (#5/#6), so it is NOT +adopted. **Permanent fix NOT established.** Open follow-ups: (a) what happens if the +user explicitly selects/opens the active placeholder (a later controlled test — +server-safe per §16.1 but UX-unknown); (b) whether a count-gated My-Packs default or a +representation that avoids rendering a fake tile can remove the empty-tile/navigation +artifacts. Do NOT adopt `state:"active"` as the fix on this evidence alone. + +--- + +## 17. Explicit Active-Placeholder Selection Test (EXECUTED 2026-08-13) — OBSERVED + +**Purpose:** with the C′ active placeholder in place, characterize what happens when +the user *explicitly opens* the empty 65534 My-Packs tile (the last open backend-side +question). + +**Server safety proof (re-confirmed, code `c89d43ea`):** `pack_by_id(65534)=None`; +65534∉PACK_CATALOG∉unopenedPackIds. `store_buy`→`200 {}`; `purchased_items`→ +`200 {"itemData": last_pack}` (stale). No inventory/coin/profile mutation possible. + +**Temporary C′ state:** container-only one-line patch `65534.state "inactive"→"active"` +(patched `e1a4e1dc…`), restart (Pid 1560774). `unopenedPackIds=[]`, 65534 +active/mypacks/∉catalog, normal packs unchanged, flags ON/OFF, host code + profile +unchanged. Marker `2026-08-13T00:53:09Z`. + +**Manual selection behavior (operator report):** opened FUT → My Packs → the empty +placeholder tile visible → selected/opened it ONCE: +1. Dialog: **YES**. 2. Exact text: **"This pack is no longer available"**. +3. Stays in My Packs: yes. 4. After closing the dialog → returns to My Packs. +5. Then navigates back to the FUT Hub successfully. 6. **No crash.** 7. No spinner. +8. **Navigation remains fully usable afterward.** + +**Genuine request sequence (OBSERVED, marker `00:53:09Z`):** boot (`…/auth → +userMassInfo → PUT store/transaction/0 {"state":"TRANSACTIONCANCEL"}→200 {} → hub → +credits → purchasegroup`) then navigation (`hub→credits→purchasegroup` ×2 for the +store/My-Packs views). **The explicit tile selection generated NO server request** — +no `/store/transaction`, no `/purchased/items`, and NO reference to 65534 anywhere. +The "no longer available" verdict is rendered **client-side**. + +**Result class: S1 — pure client-side rejection.** + +**Post-test profile/economy integrity (OBSERVED):** coins 29876776, nextItemId +100004837, items 1995, purchased 0, `unopenedPackIds` `[]`, `last_pack` empty — ALL +unchanged vs pre-test; 65534 not persisted in items or unopenedPackIds; profile +SHA-256 `39bb3e83…` byte-identical. **Zero mutation.** + +**UX assessment:** crash-safe ✓, economy-safe ✓ (no request even sent), navigation +recoverable ✓. Blemishes: a **visible empty "0 items" tile**, a **"This pack is no +longer available" dialog on explicit click**, and (from §16.5) the **Browse-Packs +navigation gate** (must open My Packs first). Notably this is a *strict improvement* +over the inactive-sentinel baseline, which throws "pack not available" immediately on +STORE OPEN and bounces to the Hub; the active placeholder only errors if the user +deliberately clicks the empty tile, and recovers cleanly. + +**Active-placeholder verdict: MARGINALLY ACCEPTABLE.** Safe (crash + economy) and +usable, but visibly imperfect (fake tile + click-dialog + browse nav gate). Not +UNACCEPTABLE (no crash/economy risk, recoverable); not fully ACCEPTABLE (user-visible +defects). + +**Permanent-fix decision: P2.** The active sentinel technically works and is safe, but +its UX is poor and — per Candidate F (CONTRADICTED) — **no backend-only *clean* +solution exists** (the store's My-Packs resolution is Scaleform/movie-driven, not +server-gated). Recommendation: keep the active placeholder as an optional/temporary +backend compatibility mode (strictly better than the current inactive-sentinel +baseline) and pursue a **client-side** fix for a fully clean zero-pack experience +(hiding the fake tile / suppressing the forced My-Packs resolution). NOT implemented. + +**Rollback verification:** container code restored to `c89d43ea…` (== host, == +pre-experiment), sentinel back to `inactive`; `docker restart` (Pid 1562665, StartedAt +00:59:04Z); `unopenedPackIds=[]`, 65534 inactive/∉catalog, 70 absent, flags ON/OFF; +host `utas_server.py` never edited; profile `39bb3e83…` unchanged. Bridge/Core +untouched. + diff --git a/docs/evidence/store_purchasegroup_capture_2026-08-12.json b/docs/evidence/store_purchasegroup_capture_2026-08-12.json new file mode 100644 index 0000000..729f500 --- /dev/null +++ b/docs/evidence/store_purchasegroup_capture_2026-08-12.json @@ -0,0 +1,199 @@ +{ + "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" + } + }, + { + "assetId": 65534, + "id": 65534, + "packType": "GOLD", + "description": "", + "state": "inactive", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, + "packContentInfo": { + "bronzeQuantity": 0, + "silverQuantity": 0, + "goldQuantity": 0, + "rareQuantity": 0, + "itemQuantity": 0 + }, + "unopened": false, + "displayGroup": { + "value": "mypacks", + "priority": 1 + } + } + ], + "timestamp": 1596326400 +} \ No newline at end of file diff --git a/docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json b/docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json new file mode 100644 index 0000000..14b9502 --- /dev/null +++ b/docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json @@ -0,0 +1,199 @@ +{ + "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" + } + }, + { + "assetId": 65534, + "id": 65534, + "packType": "GOLD", + "description": "", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, + "packContentInfo": { + "bronzeQuantity": 0, + "silverQuantity": 0, + "goldQuantity": 0, + "rareQuantity": 0, + "itemQuantity": 0 + }, + "unopened": false, + "displayGroup": { + "value": "mypacks", + "priority": 1 + } + } + ], + "timestamp": 1596326400 +} \ No newline at end of file diff --git a/docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json b/docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json new file mode 100644 index 0000000..1a91377 --- /dev/null +++ b/docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json @@ -0,0 +1,173 @@ +{ + "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 +} \ No newline at end of file diff --git a/docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json b/docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json new file mode 100644 index 0000000..fc7870d --- /dev/null +++ b/docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json @@ -0,0 +1,199 @@ +{ + "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" + } + }, + { + "assetId": 70, + "id": 70, + "packType": "GOLD", + "description": "Reward Special Players Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, + "packContentInfo": { + "bronzeQuantity": 0, + "silverQuantity": 0, + "goldQuantity": 11, + "rareQuantity": 11, + "itemQuantity": 11 + }, + "unopened": true, + "displayGroup": { + "value": "mypacks", + "priority": 1 + } + } + ], + "timestamp": 1596326400 +} \ No newline at end of file diff --git a/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md b/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md new file mode 100644 index 0000000..b8578eb --- /dev/null +++ b/docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md @@ -0,0 +1,115 @@ +# 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_18007dab0` → `FUN_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 guard** → + **crash 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]`. + +## 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_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.