Files
OpenFUT/docs/evidence/STORE_TILE_6C.md
T
funman300 e8ee6c34e7 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).
2026-08-13 01:08:47 +00:00

925 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.