docs(fifa17): record empty My Packs client contract
Full investigation record for bug 6c: baseline + Experiments A/B/C', Candidate F (contradicted), the explicit active-placeholder selection test, the minidump-confirmed CardsDLL crash, and the P2 decision. Marks ROOT CAUSE ESTABLISHED and documents the known UX limitations and the client-side follow-up. Files: docs/evidence/STORE_TILE_6C.md, docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md, the four genuine /store/purchasegroup captures (baseline, mypacks70, empty_no_sentinel, active_placeholder), and docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md (client-side design/research).
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup)
|
||||
|
||||
> **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround
|
||||
> IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks`
|
||||
> category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the
|
||||
> resolved group is non-null (crash if absent). Backend decision: **P2** — emit an
|
||||
> **active** non-openable synthetic `mypacks` placeholder (id 65534) when
|
||||
> `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests
|
||||
> `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX
|
||||
> limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven
|
||||
> and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
||||
> This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation,
|
||||
> and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core).
|
||||
|
||||
Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE
|
||||
quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is
|
||||
changed by this document; it is analysis only.
|
||||
|
||||
Binaries (hashes verified 2026-08-13 on `.105`):
|
||||
`CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
||||
(load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`).
|
||||
`FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed).
|
||||
|
||||
## 1. Question
|
||||
|
||||
How must the server represent an account that owns **zero unopened packs** in
|
||||
`GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither
|
||||
(a) crashes nor (b) shows "The pack you've selected is currently not available",
|
||||
**without granting the user a real/openable pack** and without breaking the normal
|
||||
bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks`
|
||||
sentinel) only downgrades a crash to a dialog; it is not the correct contract.
|
||||
|
||||
## 2. Established experimental behavior (OBSERVED)
|
||||
|
||||
Profile-state ladder, all with normal packs 1/5/6/7 unchanged:
|
||||
|
||||
| profile `unopenedPackIds` | `mypacks` group in response | client outcome |
|
||||
|---|---|---|
|
||||
| `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub |
|
||||
| `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible |
|
||||
| `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** |
|
||||
|
||||
Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline),
|
||||
`…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B).
|
||||
Details in `STORE_TILE_6C.md` §14-§15.
|
||||
|
||||
## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled)
|
||||
|
||||
All addresses RE-space (CardsDLL base `0x180000000`):
|
||||
- **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158`
|
||||
wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the
|
||||
literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`.
|
||||
(store-subsystem §"wire record", :996.)
|
||||
- **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each
|
||||
pack, finds-or-creates a `0x108` display group by **exact strcmp of
|
||||
`displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group
|
||||
(`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` =
|
||||
priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile
|
||||
models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.)
|
||||
- **`FUN_1800147f0`** — group **resolver/renderer**, called as
|
||||
`FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`.
|
||||
`screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value
|
||||
→ `FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL
|
||||
on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard**
|
||||
("checked in raw disassembly … a real absence"). **"The only legal category values
|
||||
are 0 and the ordinals 1..N."** (:163-169, :1051-1054.)
|
||||
- **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase
|
||||
literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives
|
||||
each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no
|
||||
matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`.
|
||||
(:202-206, :1058-1062.)
|
||||
|
||||
## 4. Store parser code path (OBSERVED, decompiled)
|
||||
|
||||
```
|
||||
HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body)
|
||||
→ per-element deser FUN_18013af30 → 0x158 wire records
|
||||
→ FUN_1800150d0 → 0x108 display groups (by displayGroup.value),
|
||||
tiles (0x1a8, FUN_18002c3c0) into group+0x40
|
||||
→ render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)
|
||||
screen+0x290 == 0 → FUN_180014610 list this group's tiles
|
||||
screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss
|
||||
→ [RAX+0x40] dereference (NO NULL GUARD) ← crash site
|
||||
```
|
||||
|
||||
## 5. My Packs group construction (OBSERVED)
|
||||
|
||||
A `mypacks` group exists **iff at least one `purchase[]` entry carries
|
||||
`displayGroup.value == "mypacks"`** (the group is derived from packs; there is no
|
||||
independent group object on the wire). Its ordinal is its 1-based creation position
|
||||
in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live
|
||||
in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group"
|
||||
via `purchase[]` — removing the pack removes the group entirely.
|
||||
|
||||
## 6. Default / selected group logic (partly UNKNOWN)
|
||||
|
||||
- `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`;
|
||||
legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is
|
||||
not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420`
|
||||
NULL → crash. (OBSERVED via §8 crash + RE.)
|
||||
- **Whether the store defaults to / auto-resolves the `mypacks` category on open, and
|
||||
why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen
|
||||
controller and default-tab selection are Scaleform/packed-FIFA17.exe
|
||||
("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread",
|
||||
store-subsystem :695). Experiment B proves that in this configuration the client
|
||||
DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is
|
||||
reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN.
|
||||
|
||||
## 7. Pack availability predicate (UNKNOWN)
|
||||
|
||||
The predicate that turns the baseline inactive sentinel into "pack not available"
|
||||
(while active pack 70 passes) is **in the packed FIFA17.exe and unread**
|
||||
(`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/
|
||||
purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys
|
||||
a tile is in the packed exe and unread"). Candidate deciding fields, from the
|
||||
baseline↔A diff (INFERRED, unproven): **`state` (`inactive`→`active`)** and/or
|
||||
**`unopened` (`false`→`true`)**. The exact field is **UNKNOWN**.
|
||||
|
||||
## 8. Experiment B crash analysis (OBSERVED)
|
||||
|
||||
Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches
|
||||
the `00:21:07` store request; SHA-256 `fbddda18…`), parsed:
|
||||
- Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA
|
||||
`0x0000000000000048`**.
|
||||
- Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space
|
||||
**`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`).
|
||||
- Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by
|
||||
`FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]`
|
||||
and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read
|
||||
of address `0x48` → access violation. This is exactly the "no null guard" branch
|
||||
the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same
|
||||
member region — the group struct's `+0x40` vector accessed via a `+0x48` field).
|
||||
- Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105`
|
||||
(tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled
|
||||
RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code
|
||||
is statically readable if a fresh project is ever needed.
|
||||
|
||||
## 9. Empty My Packs client contract (the answer, as far as evidence allows)
|
||||
|
||||
- **The client has NO guard for a missing selected group.** If the store resolves the
|
||||
`mypacks` category and no `mypacks` group exists, it null-derefs and crashes.
|
||||
(OBSERVED.)
|
||||
- **A `mypacks` group can only exist if a `purchase[]` entry carries
|
||||
`displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an
|
||||
"empty group."
|
||||
- **If the `mypacks` group's selected pack is not a valid/active pack, the client
|
||||
shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.)
|
||||
- Therefore, under the *current* client behavior, a zero-unopened-packs account is
|
||||
only cleanly handled when the `mypacks` group contains a **valid active pack**
|
||||
(Exp A). Whether an active-but-non-openable placeholder would also satisfy the
|
||||
availability predicate is **UNKNOWN** (depends on §7).
|
||||
- **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is
|
||||
NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most
|
||||
likely one of: (i) the retail store does not auto-select My Packs when the unopened
|
||||
count is 0 (a client/Scaleform decision, possibly gated by a count the server sets),
|
||||
or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state
|
||||
via a field we have not identified. Neither is established.
|
||||
|
||||
## 10. Candidate server representations
|
||||
|
||||
| # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? |
|
||||
|---|---|---|---|---|---|
|
||||
| A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject |
|
||||
| B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism |
|
||||
| C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject |
|
||||
| C′ | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) |
|
||||
| D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known |
|
||||
| E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known |
|
||||
| F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first |
|
||||
|
||||
## 11. Recommended next controlled experiment
|
||||
|
||||
**Experiment C′ (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the
|
||||
synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the
|
||||
`mypacks` group's single tile is **active** — but leave its id `65534` **absent from
|
||||
`PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it
|
||||
(no pack can be opened → **no free pack, no economy change**). Observe whether the
|
||||
store then opens without the "pack not available" dialog (would identify `state` as
|
||||
the availability field and give an economy-safe fix), or still shows the dialog
|
||||
(implicating another field / packed predicate).
|
||||
- Requires a temporary code change to `store_catalog` (sentinel construction) →
|
||||
restart. Same experiment discipline as Experiment B (patch container copy, capture,
|
||||
revert, restart). Economy-safe because the placeholder remains non-openable.
|
||||
- If C′ still fails, escalate to read-only RE of the availability predicate / the
|
||||
count-gated default-tab hypothesis (candidate F) before any further change.
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
1. Exact field that flips the sentinel from "pack not available" to acceptable
|
||||
(`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C′ targets this.)
|
||||
2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a
|
||||
server-settable count that would stop it? UNKNOWN — Scaleform/packed.
|
||||
3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record.
|
||||
4. Is an active-but-non-openable placeholder (C′) accepted by the availability
|
||||
predicate? HYPOTHESIS — untested.
|
||||
|
||||
---
|
||||
|
||||
## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented)
|
||||
|
||||
A correct permanent fix MUST satisfy ALL of:
|
||||
- Zero unopened packs must **NOT** grant the user a free pack.
|
||||
- No synthetic **openable** reward may be created (any placeholder must be rejected by
|
||||
`store_buy`/`open_pack`/`consume_unopened_pack`).
|
||||
- Client must **not crash** (a resolvable `mypacks` group must exist, OR the client
|
||||
must be kept from resolving `mypacks` when empty).
|
||||
- Client must **not** show "The pack you've selected is currently not available".
|
||||
- Normal Bronze/Gold/Special store categories must still work unchanged.
|
||||
- When a genuine unopened pack exists, My Packs must continue to work (Exp A).
|
||||
- Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift).
|
||||
|
||||
Nothing implemented. The evidence favours investigating an **economy-safe active
|
||||
placeholder (C′)** and/or the **count-gated My-Packs default (F)**; it explicitly does
|
||||
NOT support "grant pack 70 whenever My Packs is empty".
|
||||
|
||||
---
|
||||
|
||||
## UPDATE after Experiment C′ (2026-08-13) — active non-openable placeholder tested
|
||||
|
||||
Executed C′: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`;
|
||||
65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture:
|
||||
`store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C′-vs-baseline JSON
|
||||
diff = only `65534.state`).
|
||||
|
||||
**Resolves §7 (availability predicate), partially:** `state` **DOES participate**
|
||||
(OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's
|
||||
existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is
|
||||
updated: **`state` (inactive vs active) is (at least) a deciding field** for the
|
||||
dialog. `unopened` was NOT varied and remains untested. The full predicate may still
|
||||
involve other fields, but `state` alone flips dialog→no-dialog.
|
||||
|
||||
**Updated candidate table verdict:**
|
||||
- **C′ (active placeholder, non-openable): SUPPORTED with UX caveats — best option so
|
||||
far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic
|
||||
transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op).
|
||||
Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile**
|
||||
("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe:
|
||||
opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation
|
||||
gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are
|
||||
not reachable until "My Packs" is opened first (not present with a genuine owned
|
||||
pack, Exp A).
|
||||
- A (real active pack 70): works cleanly but grants a real openable pack → economy
|
||||
risk; rejected as the permanent fix.
|
||||
- Candidate **F (count-gated My-Packs default)** gains weight: C′'s visible-empty-tile
|
||||
and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter
|
||||
My Packs when it should not with zero packs. If a server-settable count (e.g.
|
||||
`userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs
|
||||
default/tile, that could remove both the crash risk and the empty-tile artifact
|
||||
without any placeholder. UNTESTED.
|
||||
|
||||
**Permanent fix: still NOT established.** Even though C′ is the first
|
||||
crash-free/dialog-free representation, the empty-tile UX + navigation gate + the
|
||||
untested "explicit placeholder selection" behavior bar adoption. Required next steps
|
||||
(design/authorize separately): (a) controlled test of explicitly focusing/opening the
|
||||
active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake
|
||||
tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current
|
||||
evidence.
|
||||
|
||||
---
|
||||
|
||||
# Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13)
|
||||
|
||||
Question: can the server make FIFA decide **not** to resolve/default into My Packs
|
||||
when the account owns zero unopened packs (avoiding any placeholder)?
|
||||
|
||||
## 1. Server-sent unopened-pack signals (inventory, OBSERVED code)
|
||||
| field / endpoint | source | value source | when sent | client consumer | conf |
|
||||
|---|---|---|---|---|---|
|
||||
| `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) |
|
||||
| `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) |
|
||||
| `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED |
|
||||
| profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED |
|
||||
`pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an
|
||||
eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture
|
||||
(8h logs); they carry a store-open `result`, not a My-Packs count.
|
||||
|
||||
## 2. Pre-store request sequence (OBSERVED, captures)
|
||||
Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo →
|
||||
PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits →
|
||||
GET /store/purchasegroup/all`. The only pack-count-bearing responses **before**
|
||||
`purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`.
|
||||
|
||||
## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures)
|
||||
The single profile change `[] → [70]` altered exactly one pre-store wire signal:
|
||||
- `/user/credits`: **`[]` → no `unopenedPacks` member** (C′ capture, all 3 fetches:
|
||||
`{"credits":…,"currencies":[…]}`); **`[70]` → `"unopenedPacks":{"preOrderPacks":0,
|
||||
"recoveredPacks":1}`** (A capture line 25). OBSERVED.
|
||||
- `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with
|
||||
`_UI=roster`). INFERRED from code; A-capture credits corroborates.
|
||||
- **No other pre-store field changed.**
|
||||
|
||||
Candidate signal:
|
||||
```
|
||||
Candidate: unopenedPacks.recoveredPacks (count)
|
||||
Endpoint: /user/credits and userMassInfo(userInfo)
|
||||
Baseline([]) value: ABSENT (i.e. zero)
|
||||
Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1}
|
||||
Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds))
|
||||
Client-visible before purchasegroup?: YES
|
||||
Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9
|
||||
```
|
||||
**Key point: this count is already CORRECT** — it reports zero (absent) when the
|
||||
account is empty. OpenFUT is **not** misreporting a nonzero pack count.
|
||||
|
||||
## 4. Navigation / client call path (OBSERVED, decompiled RE)
|
||||
`FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists
|
||||
group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss →
|
||||
`[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the
|
||||
screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies
|
||||
the Flash movie message field `CATEGORY_ID` verbatim** into it
|
||||
(store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the
|
||||
**Scaleform movie**, not by any server response field.
|
||||
|
||||
## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE)
|
||||
`GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile**
|
||||
(tile type 0x1c); "Nothing in the chain issues a request, and no request could
|
||||
exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the
|
||||
unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control
|
||||
the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs**
|
||||
/ the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path
|
||||
(§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command,
|
||||
**not** a server-state-gated store-category selector.
|
||||
|
||||
## 6. Candidate count/flag fields — verdict per field
|
||||
- `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge /
|
||||
CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate
|
||||
for the store My-Packs entry.
|
||||
- No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie
|
||||
`CATEGORY_ID`).
|
||||
|
||||
## 7. EA-capture evidence
|
||||
No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account
|
||||
exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty
|
||||
My Packs remain **UNKNOWN**.
|
||||
|
||||
## 8. Where My Packs selection occurs (OBSERVED)
|
||||
**Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides
|
||||
which category to resolve and writes it to `screen+0x290` (§4). The server's role is
|
||||
limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating
|
||||
for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect
|
||||
server count (the count is already correct).
|
||||
|
||||
## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue)
|
||||
My Packs selection is **not** controlled by server-sent unopened-pack state:
|
||||
- OBSERVED: the server count is correctly zero/absent when empty, yet the store still
|
||||
resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop
|
||||
it.
|
||||
- OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's
|
||||
`CATEGORY_ID`, with no server-field input; default is 0.
|
||||
Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open
|
||||
is in packed Scaleform and is not statically readable — but no server lever into it
|
||||
has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA
|
||||
skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The
|
||||
"clean count-gated fix" is therefore **not achievable server-side**.
|
||||
|
||||
## 10. Proposed next experiment
|
||||
Because F is contradicted, a count experiment is NOT recommended (the count is already
|
||||
correct and does not gate the store). No single-variable server signal will make FIFA
|
||||
enter Browse Packs instead of My Packs. The realistic next step is the previously
|
||||
deferred **explicit active-placeholder selection test**: with the C′ active
|
||||
non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the
|
||||
operator explicitly focus/open the empty My-Packs tile and observe (server-safe per
|
||||
§16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That
|
||||
characterizes the best available server-side option (C′) before any adoption.
|
||||
- Would it change profile state? No (`unopenedPackIds=[]`).
|
||||
- Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C′),
|
||||
reverted after.
|
||||
- Code change? Yes (same one-line C′ patch). Restart? Yes. (Not authorized here.)
|
||||
If explicit selection proves unsafe/ugly, the remaining options are all **client-side
|
||||
/ out-of-scope** (the decision is in the Scaleform movie), or accepting C′ with its
|
||||
documented UX artifacts.
|
||||
|
||||
**Candidate F does NOT provide the hoped-for clean fix. The active non-openable
|
||||
placeholder (C′) remains the best server-side representation; its empty-tile and
|
||||
Browse-Packs navigation artifacts are movie-driven and not server-fixable.**
|
||||
|
||||
---
|
||||
|
||||
# Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13)
|
||||
|
||||
With the C′ active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/
|
||||
∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full
|
||||
record in `STORE_TILE_6C.md` §17.
|
||||
- **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer
|
||||
available"** → back to My Packs → Hub; **no crash**, navigation stays usable.
|
||||
- **No server request** on selection (no `/store/transaction`, no `/purchased/items`,
|
||||
no 65534 reference); the verdict is client-side. (OBSERVED)
|
||||
- **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/
|
||||
`last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not
|
||||
persisted. (OBSERVED)
|
||||
|
||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe +
|
||||
navigable, but with user-visible defects (empty fake tile; "no longer available" on
|
||||
explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current
|
||||
inactive-sentinel baseline (which errors on store OPEN and bounces to Hub).
|
||||
|
||||
**Backend-side question is now fully answered.** The complete zero-unopened-packs
|
||||
ladder:
|
||||
```
|
||||
no mypacks group -> CardsDLL null-deref CRASH (unsafe)
|
||||
inactive placeholder -> "pack not available" on store open -> Hub (baseline)
|
||||
active placeholder -> store loads; empty tile; "no longer available" only on
|
||||
explicit click; recoverable; economy-safe (best backend option)
|
||||
real active owned pack -> fully correct UI (but grants a real openable pack — economy risk)
|
||||
```
|
||||
|
||||
**Permanent-fix recommendation: P2.** The active non-openable placeholder is the best
|
||||
*safe* backend-only option, but a fully *clean* zero-pack experience is **not**
|
||||
achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is
|
||||
Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional
|
||||
backend compatibility mode (safe, strictly better than baseline) AND pursue a
|
||||
client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the
|
||||
fully clean result. **Not implemented.** Do NOT grant a real pack.
|
||||
Reference in New Issue
Block a user