fc29c2eb9b
Land the client-side empty-My-Packs resolver evidence and the session-stability invariant established by the F3/R1 experiments. - PART III (F3, CONFOUNDED CRASH): a mid-process sentinel -> no-sentinel flip left a stale POSITIVE My-Packs ordinal that still took the resolve branch and crashed at 0x180014882. Preserved verbatim (not a guard failure). - PART IV (R1, SUCCESS): backend set no-sentinel first, then a FRESH FIFA process; genuine purchasegroup response ids [1,5,6,7] is byte-identical to the F3 capture, so client process lifetime is the only changed variable. Store opens on Browse Packs, no crash, no dialog. Guard PROVEN on the tested build. - New INVARIANT: empty-My-Packs capability MUST be session-stable -- the server must not switch a running client between sentinel-present and sentinel-absent for the My Packs group within one FIFA process, because the client caches the group ordinal and a stale positive ordinal still crashes the resolver. - Both no-sentinel captures kept: client_guard (F3) and freshretest (R1). Backend P2 active-sentinel (65534) remains production default; no capability handshake is implemented yet.
444 lines
28 KiB
Markdown
444 lines
28 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded)
|
||
|
||
A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ`→`JG`, routing
|
||
category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition
|
||
(sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group).
|
||
The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because
|
||
it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the
|
||
`-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it
|
||
reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a
|
||
positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a
|
||
clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was
|
||
restored immediately (mandatory rollback). Full record + candidate stronger guard:
|
||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III.
|
||
|
||
## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
||
|
||
Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a
|
||
**fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and
|
||
opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/
|
||
mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client
|
||
process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs
|
||
navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh
|
||
client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all
|
||
with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves
|
||
Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately
|
||
(`f416e71e…`, `state=active`) and remains production default. Full record:
|
||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV.
|