docs(fifa17): record empty My Packs client contract
Full investigation record for bug 6c: baseline + Experiments A/B/C', Candidate F (contradicted), the explicit active-placeholder selection test, the minidump-confirmed CardsDLL crash, and the P2 decision. Marks ROOT CAUSE ESTABLISHED and documents the known UX limitations and the client-side follow-up. Files: docs/evidence/STORE_TILE_6C.md, docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md, the four genuine /store/purchasegroup captures (baseline, mypacks70, empty_no_sentinel, active_placeholder), and docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md (client-side design/research).
This commit is contained in:
@@ -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.
|
||||||
@@ -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 `"<VERB> <path>"` at `:3732`, and the
|
||||||
|
response line `" -> <code> <body[:200]>"` 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/<sku>/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": [<pack bodies>], "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":<price>,"finalFunds":<price>}]`,
|
||||||
|
`extPrice:{finalPrice/originalPrice:{amount:<price/100>,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.
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user