e8ee6c34e7
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).
925 lines
54 KiB
Markdown
925 lines
54 KiB
Markdown
# 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.
|
||
|