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