# The store subsystem: grouping, prices, quick sell

Written 2026-08-05, later the same evening as `plan-2026-08-05-pack-opening.md`.
Five parallel reversing passes over the store catalogue, the display-group
resolver, the FIFA Points price line, the duplicate list and the unopened-pack
counter, plus three adversarial verification rounds that refuted four claims and
corrected eleven more. FIFA 17 was running throughout as pid 134663, in the store
screen, and was read strictly read-only. No server was restarted and no server
code was changed.

Slide used for every live read in this document, re-derived from
`/proc/134663/maps` and the on-disk PE by three separate agents rather than
asserted: `live = static - 0x180000000 + 0x6ffffc140000`. Controls were the FNV
hasher prologue at `0x180180d00` (`.text`) and the single-occurrence literal
`RS4:FutSquadSaveServerResponse` at `0x18022c618` (`.rdata`, located by searching
the file rather than by trusting an address). One verifier added a third control:
the instruction bytes `41 81 fe 1a 01 00 00` at `0x180139245`, which also proves
the running code is unpatched. All three matched byte for byte, every time. The
live addresses die with the process; the static ones do not.

---

## 1. What we now know that we did not know this morning

**The store grouping bug is not in CardsDLL.** Every display group in live memory
owns exactly the right pack. The group captioned "Gold Pack", ordinal 2, contains
one pack record and that record is the 5000-coin, 7-item, `id` 5, `packType`
`GOLD` one. Bronze owns bronze, Premium owns premium. An exhaustive pointer sweep
over all 4186 MiB of readable memory finds exactly one referrer per node and no
second copy of any pack record anywhere in the address space, and a separate
verbatim-byte-window search (76 bytes spanning id, price and all five quantities,
matched at any alignment) also returns exactly one hit each. There is no stale
copy, no aliased group, no mis-parse. Nothing we send is being misread.

That is a negative result and it leads this document because it deletes an entire
class of work. Every fix we were about to spend a launch on was a change to what
the server sends, aimed at a parser that is already producing the correct answer.
The fault is downstream of the C++ model, in the Scaleform layer that turns a
click on a tile back into a category id, and that layer is in Denuvo-packed
`FIFA17.exe` and in `.apt` movie bytecode nobody has read.

**The fix that was ranked first is refuted, and it would have cost a launch.**
One pass recommended setting `displayGroupAssetId` equal to the group's ordinal so
that the tile's `ASSET_ID` and `CHILD_CATEGORY` Flash fields would carry the same
number and the drill-down would be right whichever one the movie echoes back. The
report's own live datum kills it. `displayGroupAssetId` is currently served as
1, 5, 6, and the store screen's selected-category field reads **3**. Three is not
in that set, so `ASSET_ID` is not what comes back. Worse, `FUN_180014610`, the
group-tile builder, writes the tile's own `CATEGORY_ID` field (`model+0x94`)
as literal **0** and puts the ordinal in `CHILD_CATEGORY` (`model+0x9c`), so a
group tile does not even carry a category id for the movie to echo. The
mechanism the recommendation rested on does not exist.

**The real FIFA 17 quick-sell value table is recovered, complete, and verified.**
The client does not need the server to tell it what a card is worth and it does
not invent a number: it runs a SQL query against a table in its own loaded game
database, `fcc_discardcoins`, 141 rows keyed `(cardtype, level, rare) -> price`,
and scales by rating. The full table, the formula, the `cardsubtypeid -> cardtype`
map and the rating-to-level rule are all in section 3.6 of this document, checked
against 22 live club-item structs with 22 of 22 exact. Our `quick_sell()` pays an
invented 600/300/150/50 tier that is wrong for every card: a 94-rated gold rare is
worth 752, not 600, and a 50-rated bronze common is worth 15, not 50. This is a
pure server-side arithmetic fix with zero wire risk, and it is the largest piece
of finished, shippable work this run produced.

**There is no correct `extPrice` JSON. The correct `extPrice` is no `extPrice`.**
Both inner deserializers read exactly one key, `externalPriceId` (0x11a, INT);
`amount` and `currency` are discarded by the value-SKIP. But before parsing
anything, both of them look up, and if missing **create**, an entry named `mtx`
in the pack's currency vector, and the tile adapter turns the real-money price
line on purely because that row exists. The number that should fill it is fetched
from the Origin/Dime commerce catalogue by `externalPriceId`, a catalogue that no
longer exists offline, so the formatter early-returns and every string stays at
its constructor default. That is where the `or %1s` on the tile comes from: a
Scaleform format string whose only possible argument is one of four
catalogue-sourced strings that were never written. Sending `extPrice` at all is
what switches the broken line on. Deleting the key strictly reduces executed code,
including an unguarded call into the packed exe's commerce vtable that currently
runs on every store load.

**The 0x108 versus 0x158 versus 0x1a8 confusion is resolved, and it matters
because three previous documents compute offsets from the wrong one.** There are
three structures, all real, none of them a contradiction:

| size | what it is | built by |
|---|---|---|
| `0x158` | the deserialized **wire pack record**, one per element of `purchase[]` | `FUN_18013af30`, copy-ctor `FUN_1801340e0`, grow `FUN_180132180` |
| `0x108` | the **display group** | `FUN_180012950`, pushed by `FUN_1800150d0` |
| `0x1a8` | the **store-tile view model**, used for both pack rows and group tiles | `FUN_18002c3c0` from the wire record, `FUN_180014610` for group tiles |

The ground-truth pass declared the 0x158 record non-existent because a
stride-0x158 memory search found nothing. It found nothing because the wire
records are transient: `FUN_1800150d0` converts them and the response object is
freed. Its secondary evidence, that `FUN_1801340e0` "carries 0x108 three times and
0x158 never", was an artefact of counting `imm32`s past the end of the function:
those three 0x108s are the field addresses `param_1 + 0x108` and `param_2 + 0x108`
of a `FUT String` member inside the 0x158 record, and two of the cited addresses
are inside the *next* function, the record constructor at `0x1801342d0`. Two
verifiers decompiled `FUN_1801340e0` in full and it is a memberwise copy with no
striding of any kind, last member at `+0x154`. **The task premise that was
declared "contradicted by live memory" was in fact exactly right**:
`displayGroupAssetId` really is at wire record `+0x30`, `displayGroup.priority` at
`+0x34`, `displayGroupUseDefaultImage` at `+0x38`. Restore those.

Three smaller things worth carrying forward:

**`CREATEPACK` in every parsed pack record is a constructor default, not a client
decision.** `FUN_1801342d0` initialises the record's string at `+0xd8` from the
literal at `0x180223110`, which is the only occurrence of `CREATEPACK` in the file
and has exactly one code reference, that one. It reaches the tile at `+0x08` and
is pushed to Flash as `POST_PURCHASE_ACTION`. It is not a member of the
`TRANSACTIONCANCEL` state vocabulary and it tells us nothing about the
CreatePack-versus-PurchaseItems fork. The same literal also exists twice in the
Scaleform string pool, so it is a UI constant as well.

**`unopenedPacks` is not a display counter, and `FUT_USERINFO=packs` should stay
off.** One pass reported the counter at `model+0x20950` as read by three consumers
with no HTTP path. A verifier found nine getter call sites in six functions and
six setter sites in six functions, three of which read-modify-write it. One of the
missed ones, `FUN_180019780`, reads the counter, increments it by the number of
set booleans in a response, writes it back, and then dispatches a request whose
vtable `0x1801ed690` has `FUN_180124ee0` at slot `+0x20`, the `FutGetPurchasedItems`
deserializer. So the counter is client-mutable and is wired to a fetch. At count 0
the field buys nothing; above 0 it enters a subsystem with client-side arithmetic
that nobody has audited. Separately the ladder is non-monotonic: `packs` does not
include `squadList`, so switching from the current `roster` default would remove
the MY SQUADS roster that is known working.

**`POST /ut/game/fifa17/user` is a latent hard freeze and has never been
exercised.** The body `user_post()` builds sends `login` as a bool, `bonusPacks`
as an array and `starterPack` as an object. The real shapes are the opposite in
every case: `login` is the `userInfo` record object, `bonusPacks` is a bool,
`starterPack` is an array of shared ITEM objects. A bool fed to the `login` arm
makes the `userInfo` deserializer swallow the rest of the body through its
container-aware SKIP, after which the tokenizer returns 8 or 1 at EOF, never 10,
and the CreateUser loop re-dispatches the stale atom forever inside
`FUN_1801c7f10`. The project's known freeze PC `0x1801c7f1a` is ten bytes into
that function. One verifier upgraded this from MEDIUM to HIGH by reading the EOF
path in raw assembly rather than from a decompile. It has never fired because the
client's own `ProtoHttp` user agent has never issued that POST: all 48 `GET /user`
hits in the log are the Python contract suite, and `POST /user` appears zero times
in any log.

---

## 2. The grouping bug

### 2.1 Current best explanation

The chain, all of it decompiled and most of it live-confirmed:

`FUN_1800150d0` walks the `purchase[]` array in **array order**. For each pack it
takes `displayGroup.value` (wire record `+0x00`) and calls `FUN_180014380`, a
plain string compare against `group+0x70` across the group vector at stride
0x108. On a miss it calls `FUN_180012950` to build a new group whose `+0x00` is a
**1-based ordinal equal to the number of groups so far plus one**, `+0x04` is
`displayGroupAssetId`, `+0x100` is `displayGroup.priority` and `+0x104` is
`value == "mypacks"`. Three copies of the caption go to `+0x70`, `+0xa0` and
`+0xd0`, and they are not redundant: `+0x70` is the lookup key, `+0xa0` becomes
the tile NAME and `+0xd0` becomes its DESCRIPTION and CONTENT. The pack is
converted by `FUN_18002c3c0` into a 0x1a8 tile model and pushed into `group+0x40`.

Rendering is `FUN_18007dab0` calling `FUN_1800147f0(model, screen+0x290,
dataProvider, 0, 0)`. `screen+0x290 == 0` means "list the group tiles"
(`FUN_180014610`); any other value goes to `FUN_180014420`, which exact-matches
`group+0x00` and returns NULL on a miss, after which `FUN_1800147f0` dereferences
`[RAX+0x40]` with no guard. The absence of that guard was checked in raw
disassembly against a same-form control (the compiler does emit `CMP`/`JZ` twenty
instructions later where the source has one), so it is a real absence. **The only
legal values of `screen+0x290` are 0 and the ordinals 1..N.**

`screen+0x290` is written in exactly two places in CardsDLL, and this was checked
by enumerating not just displacement-0x290 writes but qword writes at 0x288 and
0x28c that would cover the byte, plus XMM stores over the range: the screen
constructor `FUN_18007d1a0` writes 0, and `FUN_18007e7f0` case `0x7551` copies the
Flash message field `CATEGORY_ID` verbatim and posts `0x753f`. Thirteen other
functions write that displacement and none of them appears in the store screen's
vtable `0x1801ff690`. So the group ordinal makes a round trip through the movie
and comes back as `CATEGORY_ID`.

Live right now: the store screen object is at `0x41934180`, `+0x290` is 3 and
`+0x294` (`SERVER_ID`) is 6. Sampled three times over fifteen minutes, stable.
That state is fully self-consistent with **correct** behaviour: category 3 is the
Premium group and server id 6 is the Premium pack. It is not by itself evidence
of the bug. What it does establish is that the number the movie sends lives in
ordinal space, which eliminates `ASSET_ID` (1, 5, 6) as the carrier.

That leaves two candidates for what the movie echoes as `CATEGORY_ID`. The tile's
own `CHILD_CATEGORY` field, which `FUN_180014610` sets to the group ordinal, or
`GetActiveTabId()`, which `StoreFront::populateCategory` uses when it sends
`ACTION_GET_PACKLIST`. **I would bet on `CHILD_CATEGORY`**, because 3 is a valid
ordinal and an unbound tab id is -1, and because six-tab lookups are currently
all missing (below), so a tab-derived id would be wrong for Bronze too and Bronze
resolves correctly. If `CHILD_CATEGORY` is the carrier then the fault is not in
which number a tile holds but in which tile the movie thinks was clicked, and the
suspects are the highlight events `ACTION_STORE_ITEM_GROUP_HIGHLIGHTED` and
`StoreFront::cbfnOnRowUpdate`. That is a movie-side selection desync, and no
field we send can fix it directly.

There is a second, structural problem sitting underneath, and it is the one we
can actually act on. The FIFA 17 store UI is a fixed six-tab category bar bound by
hardcoded lowercase strings. `FUN_180014580` is a switch 0..5 selecting the
literals `mypacks`, `points`, `bronze`, `silver`, `gold`, `special`, each passed
to the same `FUN_180014380` caption compare; `FUN_180014df0` is the same table as
an existence test; `FUN_18007e5e0` gives each of the six UI panels `PANEL_ID` = the
matching group's ordinal or **hides the panel**; `FUN_18007df60` publishes
`MYPACK_/BRONZE_/SILVER_/GOLD_/SPECIAL_/POINTS_CATEGORY_ID` to the movie. Our
captions are "Bronze Pack", "Gold Pack" and "Premium Gold", so all six lookups
miss, all six ids are -1 and all six panels are hidden. The store is running with
no bound tabs at all. Whatever the movie does with `GetActiveTabId()` in that
state, it is doing it in a configuration EA never shipped.

One live datum is consistent with the movie being in a strange place: the store
screen is in a visible re-entry loop. Reconstructed telemetry shows the cycle
`StoreFront -> livemessaging -> gameHub -> FUT_STORE -> FUT_WAIT_FOR_STORE ->
StoreFront` twice, matching seven `RS4::StorePackTypes sz:1908` entries in the
client's own trace ring buffer for one session. Not diagnostic on its own, but
worth watching after any change.

And one datum that should worry us more than it has: **the client has sent
`packId` 6 on every purchase it has ever made.** Six `POST /purchased/items` in
the logs, six occurrences of `"packId":6`, zero of 1 or 5, corroborated by the
client's own telemetry (`"store_id":"6"`). So we have never observed a successful
purchase of anything other than the Premium pack, in any configuration, grouped
or ungrouped. The purchase id is statically resolved: `SERVER_ID` is tile `+0x70`,
which `FUN_18002c3c0` fills from wire `+0x70` truncated to 16 bits, i.e. the `id`
field, not `assetId` and not `displayGroupAssetId`. So the question "does anything
but pack 6 buy" is genuinely open and is not the same question as "does the Gold
tile open the Gold group".

### 2.2 Should we sit at `FUT_STORE_DISPLAYGROUP=0`?

**Yes. Sit at 0 between experiments and switch it on only for the launch being
tested.** But the argument has to be made from mechanism, not from history,
because the history is uncited.

The mechanism is sound. With no `displayGroup` key, wire `+0x00` keeps its
constructor default on every pack, `FUN_180014380` matches the first group every
time, and all three packs land in one group with ordinal 1. There is then exactly
one legal value of `screen+0x290` besides 0, the six-tab bar is irrelevant, and
there is no group-to-pack round trip through the movie left to get wrong. The
packs are listed as rows in that one group and each row carries its own
`SERVER_ID`, which is the value the buy uses. Fewer moving parts, and the ones
that remain are the ones we have direct evidence work.

The uncited part is the claim that this configuration had "3 of 3 packs buyable"
against the current "2 of 3". No log line, no capture and no memory read supports
it, and the purchase log actively fails to support it: every purchase ever
recorded is `packId` 6. Do not let a decision rest on the 3-of-3 number. Rest it
on the mechanism, and treat "can the ungrouped layout buy pack 1" as an open
question that experiment 1 below answers for free.

Ugly and working still beats pretty and unbuyable. But we should stop saying
"working" until a `packId` other than 6 appears in the log.

### 2.3 Ranked experiments

One launch each unless marked otherwise. Ranking is the deliverable.

**#0. Free, no launch, do it before anything else while pid 134663 is alive.**
Ask the user to click the Gold Pack tile, then read `0x41934180 + 0x290`
(read-only, this pid only; re-resolve the pid by `comm` and re-check the two
controls first, and if the client has been relaunched this experiment needs a
fresh object address, found by scanning writable memory for the slid ctor vtable
`0x1801ff690 -> 0x6ffffc33f690` with `+0x138` and `+0x2d0` matching). Cost: zero.
Mechanism: `screen+0x290` is the category the resolver will use, written straight
from the movie's `CATEGORY_ID`. Outcomes: **2** means the movie sent the right
ordinal, the drill-down is correct and the reported symptom is something else
entirely, probably the tile captions or the detail panel, and the whole ranking
below is aimed at the wrong thing. **3** means the Gold tile really does produce
the Premium ordinal, and since `ASSET_ID` is eliminated, the fault is in the
movie's tile-to-click association or in the unbound tab path. Falsifier for the
whole exercise: any value outside {0, 1, 2, 3} would mean something writes that
field from outside CardsDLL and the ordinal model is incomplete.

**#1. Sit at `FUT_STORE_DISPLAYGROUP=0` and buy two different packs.** Cost: the
launch you were going to do anyway to get back to a usable store. Mechanism: as
in 2.2, one group, one legal category, no round trip. What it tests that has never
been tested: whether a `packId` other than 6 ever reaches the wire. Watch
`/tmp/utas_server.log` for `POST /purchased/items`. Falsifier: if buying the
Bronze row still sends `"packId":6`, the bug is not grouping at all, it is row
selection, and every experiment below is moot. This is ranked above the candidate
fixes precisely because it can invalidate them.

**#2. Serve `displayGroup.value` as three distinct canonical lowercase tokens:
`bronze`, `gold`, `special`.** Mechanism: those are three of the six literals
`FUN_180014580` hardcodes. Binding them gives three of the six panels a real
`PANEL_ID` instead of -1 and makes `GOLD_CATEGORY_ID` and friends carry the
group's ordinal, which means the category id can come from CardsDLL instead of
surviving a round trip through an unbound tab bar. It keeps one pack per group, so
it does not touch the never-exercised multi-pack-per-group path. Cost: the Premium
pack appears under the Specials tab, and tile captions become the client's own
localised tab labels rather than our strings, so the "unknown" cosmetics problem
comes back in a different form. Send them in exact lowercase; whether the compare
thunk `FUN_1800081c0` is case sensitive was never established. Falsifier: if the
tab bar still does not appear, or a category comes back empty
(`FUT_ZERO_PACKS_AVAILABLE`, `StoreFront::_OnEmptyCategory`), the tab theory is
dead and we are back to the movie-side selection hypothesis.

**#3. Only if #2 binds the tabs but you want Premium under Gold: merge to
`bronze`, `gold`, `gold`.** Mechanism: identical, one fewer group. This is how the
real FUT 17 catalogue was shaped. Cost and risk: it is the first time any group
has ever held two packs. That activates `FUN_1800159b0`'s `BEST_COINS_PRICE` and
`BEST_POINTS_PRICE` loops and the `sortPriority` merge sort in `FUN_180010890`,
neither of which has ever run with more than one element, and the second of those
price loops decompiles oddly enough that one agent flagged it. Falsifier: the gold
tab shows one pack instead of two, or a price of 0.

**#4. Do not spend a launch on these.** `displayGroupAssetId = ordinal` is
refuted (section 1). `displayGroupUseDefaultImage` only chooses whether the tile's
`OVERRIDE_ASSET_ID` is the assetId or -1, and note it is **not** the field that
gates that: the adapter's gate is `useDefaultImage` (atom 0x36a) at wire `+0xcc`,
a different atom, while `displayGroupUseDefaultImage` (0xdb) at wire `+0x38` only
picks the group's `packs_backgrounds_%d.dds` texture. `state`, `saleType`,
`quantity`, prices and contents are already correct in the live model, measured
field by field.

**A note on `sortPriority` and `displayGroup.priority`, which one pass called
inert dead ends.** That was a false absence, caught by a verifier searching the
whole `.text` listing rather than reading eight functions. `FUN_180016ef0` walks
the group vector and calls `FUN_180011480` on the groups, whose comparator reads
`group+0x100`, which is `displayGroup.priority`, as a median-of-3 introsort key;
then for each group it calls into `FUN_180010890`, whose merge key is
`model+0x1a0`, which is `sortPriority`. Both have readers. Neither explains the
current symptom, because an introsort permuting the group vector permutes each
group's caption and ordinal together, so the pairing a tile presents cannot break
that way, and the live vector is in creation order anyway. But an introsort on
all-equal keys is not order preserving, and we send neither field, so this is a
landmine: a future response could reorder the tiles without changing any ordinal.
If you ever want deterministic tile order, send distinct `displayGroup.priority`
values, and expect the order to change.

**A second landmine, found unprompted.** A group is only rendered as a tile if it
contains at least one pack whose tile `+0x00` visible byte is non-zero
(`FUN_180014610` gated on `FUN_18002c8b0`, and the per-row loop applies the same
test at `0x1800148d9`). That byte comes from wire `+0x138`, written only by atom
`0x37d` `visible`, a key we do not send, and it is currently 1 on all three packs,
so nothing is hidden today. If a future response ever makes it false, a whole tile
vanishes and every later tile shifts up while its `CHILD_CATEGORY` does not.

---

## 3. The store and pack wire model as now understood

Confidence is marked per element. CONFIRMED means an address plus either a live
read or two independent decompiles that agree. INFERRED means the code says so and
nothing has contradicted it. UNKNOWN means exactly that.

### 3.1 `GET ut/%s/store/purchasegroup/...` root

CONFIRMED. Deser `0x1801234e0`. Two keys: `purchase` (0x260) is an ARRAY of pack
objects each dispatched to `FUN_18013af30`; `timestamp` (0x31b) is an INT to
`[rdi+0x5c]`. The body we serve is 1908 bytes and the client's own trace ring
buffer logs it as `RS4::StorePackTypes sz:1908`.

### 3.2 The 0x158 wire pack record, deser `FUN_18013af30`

Dispatch in this function is a **jump table for atoms below 0x119 plus running-sum
`sub`/`cmp` ladders above it**. An immediate grep for an atom value returns zero
hits even for atoms that provably have arms. Do not make absence claims about this
parser without enumerating the table at `0x18013b001`/`0x18013b7ba` and decoding
the ladders.

CONFIRMED offsets, from the frame arithmetic of the stack struct plus the field
types recovered from the copy constructor `FUN_1801340e0`:

| key | atom | type | wire offset | notes |
|---|---|---|---|---|
| `displayGroup` | 0xd9 | flat OBJECT | | `value` (0x377, STR) to `+0x00`, `priority` (0x250, INT) to `+0x34` |
| `displayGroupAssetId` | 0xda | INT | `+0x30` | premise restored, see section 1 |
| `displayGroupUseDefaultImage` | 0xdb | BOOL | `+0x38` | group texture only |
| `currencies` | 0xc5 | ARRAY | `+0x40..+0x48` | 0x30-byte records via `FUN_180138bd0` |
| `extPrice` | 0x119 | OBJECT | | see 3.4 |
| (id) | see below | INT | `+0x70` | truncated to u16 into tile `+0x70` = `SERVER_ID` = purchase `packId` |
| `assetId` | 0x23 | INT | `+0x74` | tile `+0xac` = `ASSET_ID` |
| `firstPartyStoreId` | 0x127 | STR then `atoi()` | `+0x78` | an int here is the documented freeze |
| `sortPriority` | 0x2cb | INT | `+0x7c` | tile `+0x1a0`, merge-sort key |
| `description` | 0xd1 | STR | `+0x80` | tile caption |
| `state` | 0x2eb | STR vs `"active"` | `+0xb0` | ctor default 0, not active |
| `start` | 0x2e3 | INT, clamped | `+0xb4` | **top level, not inside packContentInfo** |
| `end` | 0x102 | INT | `+0xb8` | |
| `quantity` | 0x26b | INT | `+0xbc` | a sent 0 becomes -1 |
| `purchaseLimit` | 0x265 | INT | `+0xc0` | |
| `purchaseCount` | 0x261 | INT | `+0xc4` | copied to tile `+0x78` |
| `limitType` | | STR to enum | `+0xc8` | tile `+0x90`, Flash `SALE_TYPE`; `NONE` 0, `QUANTITY` 1, `TIME` 2, `TIME_QUANTITY` 5 |
| `saleType` | 0x298 | STR | | `promo` lands at tile `+0xbb` `IS_PROMO` |
| `dealType` | 0xcc | STR, lowercased | `+0xcf/+0xd0` | |
| `useDefaultImage` | 0x36a | BOOL, stored inverted | `+0xcc` | gates tile `+0xb0` `OVERRIDE_ASSET_ID` |
| `unopened` | 0x35d | BOOL | `+0xcd` | **top level**; tile `+0x69`; live 0 on all three |
| `isPremium` | 0x176 | BOOL | `+0xce` | tile `+0xb8` |
| `actionType` | 0x08 | STR | `+0xd8` | ctor default `"CREATEPACK"`; tile `+0x08` `POST_PURCHASE_ACTION` |
| `packType` | 0x20f | STR | `+0x108` | ctor default `"INVALID"`; tile `+0x38`; **no Flash consumer** |
| `visible` | 0x37d | presence only | `+0x138` | value never read; gates whether the tile renders |
| `points` | 0x240 | INT | `+0x140` | |
| `packContentInfo` | 0x20c | flat OBJECT | `+0x144..+0x154` | exactly five children |

`packContentInfo` has exactly five children and nothing else: `itemQuantity`
0x170 to `+0x144`, `goldQuantity` 0x149 to `+0x148`, `silverQuantity` 0x2c6 to
`+0x14c`, `bronzeQuantity` 0x63 to `+0x150`, `rareQuantity` 0x273 to `+0x154`.
The inner loop's default reaches the safe SKIP and jumps back into the **inner**
loop; the `unopened`/`start` ladder's default jumps back into the **outer** loop.
Two different back edges, which is how the nesting was proven. Our `_pack_body()`
sends `"unopened": false` inside `packContentInfo`, which is therefore inert, and
`packContentInfo` does reach the tile: the live tiles read 5/7/11 items.

UNKNOWN: which atom writes wire `+0x70`. It is almost certainly `id` (0x15c) and
the verifier calls it that, but we serve `id` and `assetId` identically so no live
read can separate them, and the arm was not located in the jump table. This
matters more than it looks: wire `+0x70` is `SERVER_ID`, and `SERVER_ID` is the
`packId` the purchase sends. `ENDPOINT_MAP.md` currently claims `id` is skipped
entirely, which cannot be right if `+0x70` is fed from anywhere.

`packType` deserves a note because two agents disagreed. One argued it is the
"strongest candidate lookup key" for the mis-resolution, on the grounds that it is
the only string field where Gold and Premium collide while Bronze differs. It is
not a key for anything: it lands at tile `+0x38`, and a full dump of
`FUN_180015d80`'s Flash push list (32 fields) does not contain `+0x38`. The
{Bronze} versus {Gold, Premium} partition is a coincidence of two packs sharing a
tier. The six store tabs key on `displayGroup.value`, not on `packType`.

### 3.3 Currencies, `FUN_180138bd0`, 0x30 stride

CONFIRMED, and this is the canonical example of the sub-ladder dispatch form:
`sub edx,0x124` then `sub edx,0x10` then `cmp edx,0x9c`, so no single immediate
equals any atom. `name` 0x1d0 STR to `+0x00`, `finalFunds` 0x124 INT to `+0x24`,
`funds` 0x134 INT to `+0x20`, both through the non-negative clamp
`FUN_1800d7b30` so a negative price silently becomes 0.

The asymmetry here is real and was verified twice. The **store tile** takes its
coin price from `finalFunds` (`elem+0x24`, adapter at `0x18002c75b`), which
matches tonight's live probe where `funds=15000, finalFunds=4321` rendered 4,321.
The **HUD balance** parser `FUN_180122c50` takes `funds` (`elem+0x20`) for all
three recognised names, `coins`, `points` and `DRAFT_TOKEN`. Same array, two
fields, two consumers.

The only server-side lever for a FIFA Points number on a tile is a second
currency entry named lowercase `points`, which the adapter maps to tile `+0xa4`
and `+0xa8` behind flag `+0xb7`, gated by a call into the commerce manager whose
return value is unknowable statically. INFERRED, medium. Note that the `mtx` arm
and the `points` arm **both write tile `+0xa4`**, so with both rows present the
later vector element silently wins. Never test a `points` row with `extPrice`
still in place.

### 3.4 `extPrice`, and why it should go

CONFIRMED, by two agents with different toolchains, one of them printing both
functions end to end from `.pdata`-derived bounds. `FUN_180139070` (finalPrice,
0x227 bytes) and `FUN_18013aae0` (originalPrice, 0x220 bytes) each contain
exactly one atom comparison, `cmp r,0x11a`, for `externalPriceId`, an INT.
Neither reads `amount` (0x1b) or `currency` (0xc4); both go to the value-SKIP.
Neither function contains an indirect jump, a sub ladder or a switch, and the atom
register is seeded to `0x38c`, a value with no atom, so nothing can fall through.
`finalPrice` writes its value to both `[rdi+0x2c]` and the pack's
`firstPartyStoreId` slot at `+0x78`, so `extPrice.finalPrice` and top-level
`firstPartyStoreId` are the same slot reached two ways.

The side effect is the whole story. Before parsing, both scan the pack's currency
vector for an entry named `mtx` and create one if absent, memmoving 3 bytes from
the literal at `0x1801efea0` (`"mtx\0coins\0"`). The adapter then sets tile
`+0xb5 = 1` at `0x18002c729`, the only write to that byte outside the bulk
zeroing, purely because the row exists. `FUN_18002cc90` would fill the price text
from the commerce catalogue, but its first statement is `if (tile+0x6c == -1)
return`, and `+0x6c` is pre-seeded to -1 by the adapter at `0x18002c4a2` and
copied from wire `+0x78` at `0x18002c685`. We send no `externalPriceId`, so it
stays -1, the formatter returns, and all four catalogue strings keep their
constructor defaults. Live on all three tiles: `+0x6c` = -1, `+0xb5` = 1,
`+0xb6` = 1, `+0xb7` = 0, `+0xa4` = 0, `+0x168` = `"0"`, `+0xd8`/`+0x108`/`+0x138`
= `" "`.

`or %1s` occurs exactly once in the entire 4.09 GiB process, at `0xb8697c20`, and
it is a Scaleform string-pool record: the eight preceding bytes are
`01 00 06 00 07 00 00 00`, where 6 is the length and 7 the length plus one, the
same header shape as the neighbouring `highbid`. There is no `%1s` anywhere in the
3,179,952-byte PE. Its argument can only be one of the four strings the formatter
never wrote.

A third safe shape exists that nobody had noticed: `"extPrice": {}` opens the
nested loop at `0x18013b59d`, immediately reads END_OBJECT and exits, so it
creates no `mtx` row. Useful if schema fidelity is wanted for some reason, but
deletion is simpler and strictly better.

### 3.5 Purchase

CONFIRMED from the wire. `POST /ut/game/fifa17/purchased/items`, body
`{"packId":6,"useCredits":1,"usePreOrder":0,"currency":"COINS"}`, six times, always
6. Serializer `FUN_180162530`, four keys from one mode integer: `packId` (0x20b),
`useCredits` (0x369) = mode 0, `usePreOrder` (0x36b) = mode 4, `currency` (0xc4) =
`MTX`/`POINTS`/`COINS` and omitted entirely when mode is 4. The request-side
currency vocabulary is UPPERCASE; the response-side currency names are lowercase.
Do not mix them.

The separate first-party path `PUT /ut/v2/game/fifa17/store/transaction/0` with
`{"state":"TRANSACTIONCANCEL"}` fires on every boot and is live code.

### 3.6 Quick sell, and the discard economy

This is the most complete part of the run.

**The wire.** `DELETE /ut/game/<sku>/item/<id>`, no body, confirmed live tonight
and now served. The URL suffix builder emits `/%llu`. The response is
balance-bearing: answering `{}` put 1,133,686,384 coins on screen. The real
response class is `FutDiscardCardServerResponse` (`RS4:` literal `0x180220540`,
vtable `0x180220488`, deser `FUN_180127300` at slot `+0x08`), and its shape is
`{"items":[{"id":N}], "totalCredits":N}`. `items` (0x171) is an array of OBJECTS
with an inner `id` (0x15c); there is no top-level `id`. `totalCredits` (0x326) is
a plain `MOV` into a freshly allocated 0x38-byte object at `+0x28`; an exhaustive
whole-DLL scan in cmp, sub-ladder, dec-ladder and switch-range forms finds exactly
two functions carrying that atom, both discard responses, and neither accumulates.
Whether the wallet assigns or adds is still unproven because the consumer is a
delegate registered outside CardsDLL. Keep sending the absolute balance; the field
is named `totalCredits` and `GET /user/credits` resyncs within seconds anyway.

**Bulk quick sell exists and our parser expects a shape that does not.**
`FUN_180126f40`, printed in full at 0x21a bytes, emits `{"itemId":[<int64>, ...]}`
using atom 0x16d over a vector of 8-byte elements. `quick_sell_route()` looks for
`itemData` and `itemIds`, which appear nowhere in that serializer. The URL and
method for the bulk call were not established; the decisive test is a live capture
of "Quick Sell All".

**The value the client displays is computed locally.** `FUN_18013fe00`, the shared
item deserializer, stores our `discardValue` (atom 0xd7) at item `+0x38`. Then, at
`0x180141025`, `cmp dword [rbp+0x198],0` followed by `ja` skips the entire local
computation if that value is non-zero. When it is zero, the client runs

```sql
SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==?
```

(literals at `0x1802231e4`, `0x1802231f0`, `0x180223208`, `0x180207848`,
`0x18022315c`) and then, at `0x180141119..0x180141140`:

```
value = (rating * price) / 100, rounded half up
```

stored at item `+0x3c`. If the query returns no row the price register stays 0 and
the value is 0.

`cardtype` comes from `cardsubtypeid` through `FUN_1800d8330`, decoded from its
raw two-level jump table and checked against every subtype 0..599 with zero
disagreements:

```
0..3        -> 1   (players)
4           -> 2
5           -> 3
6           -> 10
7           -> 5
8           -> 4
9..11       -> 7
30,31,145..150,231,232,233,236 -> 9
51..136, 201..220, 250..273, 300..341 -> 6
anything else -> 0   (no table row, value 0)
```

`level` is derived purely from rating at the tail of `FUN_180141660`
(`0x180141e8a..0x180141ea3`): 3 if rating >= 75, 2 if 65..74, else 1. It is not a
wire field; the slot at item `+0x54` is never written through the deserializer's
frame, which was confirmed by an operand census showing `rbp+0x1b4` appearing
exactly once in all 1801 instructions of `FUN_18013fe00`, as a read.

The table itself, 141 rows, decoded from the client's loaded DB at `0x4278e1a8`
with bit layout `level[0:7] id[7:17] cardtype[17:24] price[24:44] rare[44:51]`.
That layout is not merely self-consistent: an exhaustive search over all field
offsets and widths produced exactly one split satisfying sequential ids 1..141,
cardtype in 1..10, price under 200000 and unique keys. Prices are given per level
1 / 2 / 3:

```
cardtype 1 (players)
  rare 0                                  30 /   150 /   400
  rare 1                                  75 /   350 /   800
  rare 7                                1500 /  5000 /  9000
  rare 2,3,10,13,17..31                 2000 /  7000 / 12200
  rare 4,8,9                            6000 / 10000 / 18000
  rare 11                              10000 / 15000 / 24000
  rare 5,6                             20000 / 40000 / 80000
  rare 12                             120000 /120000 /120000
cardtype 2  rare 0  20 /  70 / 110      rare 1  25 / 120 / 320
cardtype 3  rare 0  10 /  55 / 110      rare 1  50 / 100 / 300
cardtype 4  rare 0  10 /  55 / 110      rare 1  50 / 100 / 300
cardtype 5  rare 0  10 /  55 / 110      rare 1  50 / 100 / 300
cardtype 6  rare 0   5 /  20 /  40      rare 1  20 /  50 /  70
cardtype 7  rare 0   5 /  20 /  40      rare 1  20 /  50 /  70
cardtype 8  rare 0   5 /  20 /  40      rare 1  20 /  50 /  70
cardtype 9  rare 0   5 /  20 /  40      rare 1  20 /  50 /  70
cardtype 10 rare 0  10 /  55 / 110      rare 1  50 / 100 / 300
```

Rare values 14, 15 and 16 are absent for cardtype 1, and cardtypes 2..10 carry
only rare 0 and 1. A key we do not have pays 0, so use a `.get(key, 0)`, not a
subscript.

Verified against 22 live club items, 22 of 22 exact. A gold rare player is
`8 * rating`: 75 gives 600, 94 gives 752. A gold common is `4 * rating`. A
50-rated bronze common is 15. Our placeholder underpays a 94 gold rare by 152 and
overpays a bronze common by 35, and is blind to both `rareflag` and card type.

One scope correction worth having, because it inverts the practical advice one
pass gave. For **cardtypes 2, 3, 4, 5 and 10** the client overwrites the rating
and rare flag we send with values from its own card database before computing,
querying `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and
`gkcoachcards` by `carddbid`. For **cardtypes 6, 7, 8 and 9** the jump table at
rva `0x141eb4` goes straight to the default arm with no DB query and no overwrite.
Every consumable in our profile (`cardsubtypeid` 52, 54, 92, 98, 100) maps to
cardtype 6, live-confirmed on the two resident consumables, so for exactly the
items the warning was aimed at, the server's rating and rare flag are
authoritative.

### 3.7 `duplicateItemIdList`

CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
0x20-byte records: `itemId` (0x16d, int64) at `+0x00`, `itemLoans` (0x16f, int32
narrowed) at `+0x08`, `duplicateItemId` (0xeb, int64) at `+0x10`,
`duplicateItemLoans` (0xed, int32 narrowed) at `+0x18`. It is an array of objects,
not an int list, in both places the old docs describe it.

Four deserializers accept the key (`FUN_180162880` CreatePack, `FUN_18013bd40`
shared purchased/massinfo, `FUN_1801293d0` FutViewCards, `FUN_18013e7f0` shared
IS-list, which reaches the item through the auction record at `+0xb0`). All four
run the identical fixup: for each record, walk the item list parsed from the same
response and where `item+0x08` equals the record's `itemId`, set `item+0x10` to
`duplicateItemId`. Only record indices 0 and 2 are ever touched; both loans fields
are parsed and discarded. An entry naming an item not in the same response has no
effect at all.

The only visible consequence is a boolean. Six sites publish the Scaleform key
`HAS_DUPLICATE` as `item+0x10 != 0` (the literal at `0x1801f6510` has exactly one
occurrence in the file and, per a live scan, one in the whole process; the six
functions are `FUN_180043880`, `FUN_180084720`, `FUN_180094220`, `FUN_180094ae0`,
`FUN_180096490`, `FUN_180096670`). One C++ flow branches on it, `FUN_18009bc40`,
the completion of the sign-loan-player chain: not duplicate means the client
auto-issues `PUT ut/%s/item` with `{"itemData":[{"id":N,"pile":"club","swap":0,
"tradeId":0}]}`, duplicate means it suppresses that call and navigates to
`GotoNewItems`. So the flag gates an endpoint we already serve rather than
unlocking a new one, and no request anywhere serialises any of the four atoms: a
census of all 257 mentions of the atom-name helper `FUN_180180cd0` (216 calls plus
41 tail calls) resolves 168 distinct atoms and none of them is 0xeb, 0xec, 0xed or
0x16f, and each of the four wire-name literals has exactly one reference and it is
the atom-name table.

Net server work: optional. Continuing to send `[]` is coherent. Implementing it
needs no new endpoint.

### 3.8 `FutCreateUserServerResponse`, deser `FUN_18014cc60`

CONFIRMED, 3662 characters decompiled in full by two agents independently.
`login` (0x1a5) is the `userInfo` record object into `response+0x28`;
`userData` (0x36d) is an object with exactly four keys parsed by `FUN_180142470`
(`onlineELORating` 0x1f2 INT, `onlineRatedUser` 0x1f3 BOOL, `accountResetCount`
0x1f4 INT, `winForm` 0x383 INT **narrowed to a single byte**); `squad` (0x2cd) is
an object to `FUN_18013d1f0`; `starterPack` (0x2e5) is an ARRAY of shared ITEM
objects into a 0x18-stride vector at `response+0x158`; `bonusPacks` (0x5d) is a
BOOL byte at `response+0x150`. `ENDPOINT_MAP.md` has `login` and `userData`
swapped and types `bonusPacks` as an array.

The `userInfo` deserializer's top-level arm set, recovered by decoding its 225-entry
byte table at `0x18013f2c8` and 11-entry dword table at `0x18013f29c` plus its
ladders, is `{0x6, 0xb, 0x59, 0x8d, 0x8e, 0x8f, 0xc5, 0xdd, 0xde, 0xe6, 0x110,
0x11c, 0x121, 0x122, 0x1a6, 0x1be, 0x21b, 0x262, 0x27e, 0x2bb, 0x2d4, 0x330, 0x340,
0x35e, 0x387}`. `0xbc` and `0x363` are children of `bidTokens` (0x59), not
top-level.

`unopenedPacks` (0x35e) reads only `preOrderPacks` (0x24b) and `recoveredPacks`
(0x27b); `count` (0xbc) has no arm and is skipped. The sum goes through model
vtable slot `+0x4e0` (`FUN_18011e120`) to `model+0x20950` and broadcasts UI event
`0x273d`. The same pair also arrives on the credits response (`FUN_180122c50`,
`resp+0x34`/`+0x38`) with two consumers, `FUN_180017390` and `FUN_1800ad390`, and
the client requests `/user/credits` fourteen times a session against five
`userMassInfo` calls, so that is the more-travelled carrier.

The claimable-items container is `model+0x5a38`, reached through vtable slot
`+0x160` (`FUN_18011b780`). One pass said nothing fills it but `starterPack`. It
is filled by the CreatePack deserializer `FUN_180162880`, pushing 0x18-stride
items directly, and `FUN_18009bc40` searches that same container, so the two
dimensions contradicted each other and the CreatePack reading is the right one.
Live it is empty with capacity 0x180 already allocated, i.e. it has been populated
and drained this session.

---

## 4. Server-authoritative versus client-side

Strict test: server-authoritative only if the server's response actually
determines the value. This is the table that decides what work exists.

| thing | authority | why |
|---|---|---|
| Which packs exist, their captions, prices, quantities, limits, dates | SERVER | every field lands in the wire record and reaches the tile; live-verified field by field |
| The purchase `packId` | SERVER | it is wire `+0x70` echoed back as `SERVER_ID`; we choose the number |
| Which display groups exist and what is in each | SERVER | membership keys only on `displayGroup.value`; verified live |
| The group **ordinal** | CLIENT | 1-based creation order assigned by `FUN_180012950`; the server can only influence it through array order |
| The six store tabs and their labels | CLIENT | six hardcoded lowercase literals in `FUN_180014580`; the server can only match them |
| Tile background art | CLIENT | `packs_backgrounds_%d.dds` chosen by `displayGroupAssetId`; the file must exist |
| Which tile the movie thinks you clicked | CLIENT | Scaleform, unread |
| The FIFA Points price line | CLIENT | Origin/Dime catalogue by `externalPriceId`; the catalogue is gone, so this is unfixable server-side |
| `or %1s` | CLIENT | AS string-pool constant; suppressed only by not creating the `mtx` currency row |
| Quick sell **displayed** value | CLIENT when `discardValue` is 0 or absent | `fcc_discardcoins` in the client's own game DB, scaled by rating |
| Quick sell displayed value | SERVER when `discardValue` is non-zero | but sending it **suppresses** the client's own correct number, and which field the UI renders is unproven |
| Quick sell **paid** value | SERVER | it is our arithmetic; today it disagrees with the display |
| `level` (bronze/silver/gold tier for pricing) | CLIENT | derived from rating alone |
| `cardtype` | SERVER | derived from the `cardsubtypeid` we send |
| `rating` and `rareflag` for cardtypes 6..9 (consumables) | SERVER | no DB override on that arm |
| `rating` and `rareflag` for cardtypes 2,3,4,5,10 (staff) | CLIENT | overwritten from local card DB before pricing |
| `totalCredits` after a discard | SERVER | assigned into the response object; wallet consumer unproven |
| Which items a pack contains | SERVER | `GET /purchased` fills the item manager |
| Declared pack contents (`packContentInfo`) versus delivered items | SERVER, unchecked | nothing compares them; cosmetic |
| Duplicate flagging | SERVER | `item+0x10` is written only by the four response deserializers; the client never derives it |
| CreatePack versus PurchaseItems | CLIENT | two separate ServerCall classes chosen client-side; no response selects between them |
| Unopened pack count | SERVER for the initial value, CLIENT thereafter | five CardsDLL functions read-modify-write `model+0x20950` |
| `packOpeningAnimationEnabled` and friends | CLIENT | settings struct defaults are already 1 |

---

## 5. Remaining unknowns and the cheapest experiment for each

### Needs decompiling (free, no launch, no live process)

1. **Which atom writes wire `+0x70`.** It feeds `SERVER_ID` and therefore the
   purchase `packId`. Decode `FUN_18013af30`'s jump table at `0x18013b001` and find
   the arm that stores to frame slot `record-0xb0+0x70`. High value for the price.
2. **`FUN_18013fe00`'s jump table.** Nobody has enumerated it, which is why the
   `loans` (0x19b) to `item+0x90` attribution is only corroborated and why the atom
   that writes `item+0x58` (rare flag) is unidentified. Same byte/dword table shape
   as the `userInfo` one at `0x18013f2c8`/`0x18013f29c`.
3. **Is `FUN_180016ef0` reached on the store path?** Its only caller is
   `FUN_180017420`. This decides whether the introsort landmine in 2.3 is live.
4. **The remaining `vt+0x4d8`/`vt+0x4e0` callers.** `FUN_1800706e0` does
   `set(get() + delta)` and nobody knows where the delta comes from, nor which
   response class feeds `FUN_180019780`'s five booleans. Do this before anyone ever
   serves a non-zero unopened count.
5. **The bulk-discard action row.** Which action index owns `FUN_180126f40`, and
   the URL and method. The pointer block at `0x1801f3110` mixes it with the
   single-id URL builder and the three action factories at `0x180123cc0/cd0/ce0`
   are not disassembled as functions in the current project.
6. **Re-run dimension 4's `item+0x10` write census with a raw-instruction matcher.**
   The original was decompiler-based and the verifier declined to reproduce it, so
   it currently carries only one agent's confidence. Low urgency, listed for
   honesty.

### Needs a live probe (read-only, no launch, needs a running client)

1. **Which field the UI renders for quick sell, `item+0x38` or `item+0x3c`.** The
   cheap version needs a throwaway server on a spare port serving one item with
   `discardValue` 999. Until this is settled, do not start emitting `discardValue`.
2. **What `commerce_mgr->vt[0x140]()` returns.** It gates the `points` currency
   arm. Its code is in Denuvo-decrypted rwx pages of `FIFA17.exe`, so it needs a
   live read at the right moment, not a decompile.
3. **Does the movie hold pack captions at all?** "Gold Pack" has zero ActionScript
   string nodes in the whole process while "Bronze Pack" has three; the only
   rendered UTF-16 store caption anywhere is a doubled "Premium Gold". Re-run the
   census after scrolling the store to the Gold tile to find out whether that
   absence is a fact about the pool or just about what is on screen.
4. **The silver band.** No resident item is rated 65..74, though the profile
   contains eight. Load one and confirm `item+0x54` reads 2 and the value matches
   the table.

### Needs a launch the user must drive (the scarce resource, ranked)

1. **Click the Gold tile and read `screen+0x290`.** Strictly this needs no launch
   at all if the current session is still alive, which is why it is experiment #0
   in section 2.3 and the closing recommendation of this document.
2. **`FUT_STORE_DISPLAYGROUP=0` plus buy two different packs.** Answers the
   never-asked question of whether any `packId` but 6 has ever gone out.
3. **Canonical lowercase category tokens** (`bronze`, `gold`, `special`).
4. **Drop `extPrice`** and confirm the `or %1s` line disappears and, critically,
   that all tiles remain buyable. Can be combined with 2 or 3, at the cost of
   ambiguity if something breaks.

---

## 6. Proposed patches

Not applied. Every new flag defaults off. Each carries its freeze risk and its
type-fidelity argument, because the last two rounds were broken by
technically-correct fields.

### P1. Real quick-sell values in `fut_store.py`

Zero wire change. This is the one patch I would argue for defaulting **on** after
a single verification, but it ships off.

```python
# tools/fut_store.py, near the top

# FUT_DISCARD_TABLE: pay the real FIFA 17 discard value instead of an invented
# tier. The table is fcc_discardcoins, read out of the running client's own
# loaded game DB (141 rows, pid 134663, 2026-08-05) and verified against 22 live
# club items, 22/22 exact. See docs/plan-2026-08-05-store-subsystem.md 3.6.
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"

# (cardtype, level, rare) -> price. Compressed: prices are level 1/2/3.
_DP = {}
def _dp(ct, rares, p1, p2, p3):
    for r in rares:
        _DP[(ct, 1, r)] = p1; _DP[(ct, 2, r)] = p2; _DP[(ct, 3, r)] = p3
_dp(1, [0], 30, 150, 400)
_dp(1, [1], 75, 350, 800)
_dp(1, [7], 1500, 5000, 9000)
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
_dp(1, [4, 8, 9], 6000, 10000, 18000)
_dp(1, [11], 10000, 15000, 24000)
_dp(1, [5, 6], 20000, 40000, 80000)
_dp(1, [12], 120000, 120000, 120000)
_dp(2, [0], 20, 70, 110);   _dp(2, [1], 25, 120, 320)
for _ct in (3, 4, 5, 10):
    _dp(_ct, [0], 10, 55, 110); _dp(_ct, [1], 50, 100, 300)
for _ct in (6, 7, 8, 9):
    _dp(_ct, [0], 5, 20, 40);   _dp(_ct, [1], 20, 50, 70)

def _cardtype(sub):
    """FUN_1800d8330, exact. 0 means no table row, i.e. a value of 0."""
    if sub is None:                     return 0
    if 0 <= sub <= 3:                   return 1
    if sub == 4:                        return 2
    if sub == 5:                        return 3
    if sub == 6:                        return 10
    if sub == 7:                        return 5
    if sub == 8:                        return 4
    if 9 <= sub <= 11:                  return 7
    if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
                                        return 9
    if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
       or (300 <= sub <= 341):          return 6
    return 0

def discard_value(item):
    """round_half_up(rating * price / 100), price from fcc_discardcoins."""
    ct = _cardtype(item.get("cardsubtypeid"))
    r  = int(item.get("rating") or 0)
    lvl = 3 if r >= 75 else 2 if r >= 65 else 1
    price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
    n = r * price
    return n // 100 + (1 if n % 100 >= 50 else 0)
```

and inside `quick_sell()`:

```python
        def value(it):
            dv = it.get("discardValue") or 0
            if dv:
                return int(dv)
            if DISCARD_TABLE:
                return discard_value(it)
            r = it.get("rating") or 0
            return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
```

**Freeze risk: none.** Nothing on the wire changes; `discardValue` is still not
emitted. **Type fidelity: not applicable** for the same reason. The only failure
mode is arithmetic, and it is bounded: a missing key pays 0, which is exactly what
the client does. Verify with one gold rare 85, which must credit 680, not 600.

### P2. Stop sending `extPrice`

Invert the current unconditional emission into a flag that defaults to not
sending it.

```python
# tools/utas_server.py, with the other store flags near line 800

# FUT_STORE_EXTPRICE: send the extPrice object. DEFAULT OFF as of 2026-08-05.
# extPrice's two sub-parsers (0x180139070, 0x18013aae0) read exactly ONE key,
# externalPriceId (0x11a); amount and currency are SKIPped. Before parsing,
# both CREATE an "mtx" row in the pack's currency vector, and the tile adapter
# FUN_18002c3c0 sets tile+0xb5 = 1 purely because that row exists, which is what
# turns on the real-money price line. The number that should fill it comes from
# the Origin/Dime catalogue by externalPriceId, which does not exist offline, so
# FUN_18002cc90 early-returns on tile+0x6c == -1 and every string stays at its
# ctor default. That is the "or %1s" on the tile. There is no correct extPrice.
STORE_EXTPRICE = os.environ.get("FUT_STORE_EXTPRICE", "0") == "1"
```

and in `_pack_body()`, replace the unconditional `"extPrice": {...}` member with

```python
    if STORE_EXTPRICE:
        body["extPrice"] = {"finalPrice":    {"amount": mtx, "currency": "mtx"},
                            "originalPrice": {"amount": mtx, "currency": "mtx"}}
```

**Freeze risk: strictly negative.** Omitting the key means atom 0x119 never
matches at `0x18013afc9`, the nested loop at `0x18013b59d` never opens and neither
sub-parser runs. No reader-state change of any kind. It also removes an unguarded
`call FUN_1801a0040` followed by `mov rdx,[rax]` at `0x18013ba98`, with no null
test, that currently executes on every store load because an `mtx` row exists.
**Type fidelity:** the genuine hazard in this area is sending `finalPrice` or
`originalPrice` as a scalar, since `FUN_180139070` runs its own loop until
END_OBJECT unconditionally; omitting the key removes even that.

**The untested part, stated plainly:** the step "tile `+0xb5` drives the observed
`or %1s` line" is inference, not proof. And the displayGroup precedent is exactly
this shape: a plausible field change made two packs unbuyable. Check buyability
after this change, not just the price line.

### P3. Canonical lowercase category tokens

```python
# tools/utas_server.py

# FUT_STORE_CATTOKENS: send displayGroup.value as the client's own hardcoded
# lowercase category tokens instead of our pack names. FUN_180014580 switches
# 0..5 over exactly mypacks/points/bronze/silver/gold/special and looks each up
# by the same caption strcmp (FUN_180014380) that builds groups. Today all six
# miss, FUN_18007e5e0 hides all six panels and the store runs with NO bound tabs,
# which is the degenerate state the drill-down bug lives in. Tile captions become
# the client's localised tab labels. DEFAULT OFF.
STORE_CATTOKENS = os.environ.get("FUT_STORE_CATTOKENS", "0") == "1"
_CAT_TOKEN = {1: "bronze", 5: "gold", 6: "special"}   # keyed by pack id
```

in `_pack_body()`, inside the existing `if STORE_DISPLAYGROUP:` block:

```python
        if STORE_CATTOKENS:
            body["displayGroup"] = {"value": _CAT_TOKEN.get(p["id"], "special")}
        else:
            body["displayGroup"] = {"value": p["name"]}
```

**Freeze risk: none beyond what `displayGroup` already carries.** It is the same
key, the same flat object, the same single STR member into the same STR getter.
Only the string content changes. **Type fidelity: unchanged.** The risk is
behavioural, not structural: this is a field that selects a render path, and the
last time we changed one of those two packs became unbuyable. Send exact
lowercase; the case sensitivity of the compare thunk `FUN_1800081c0` was never
established.

### P4. Accept the real bulk-discard body

Additive. Do not remove the working single-item `DELETE` path.

```python
# in quick_sell_route(), before the existing itemData/itemIds handling

    # The client's bulk discard serializer FUN_180126f40 emits exactly
    #   {"itemId": [<int64>, ...]}
    # using atom 0x16d. It emits no itemData and no itemIds; those two names
    # appear nowhere in that function. Kept additive because the URL and method
    # for the bulk call are still unknown and the single-item DELETE works.
    if isinstance(body, dict) and isinstance(body.get("itemId"), list):
        ids = [int(x) for x in body["itemId"] if isinstance(x, (int, str))]
```

**Freeze risk: none.** Request parsing only; nothing changes on the response side.
**Type fidelity: not applicable.**

### P5. Reshape the CreateUser body

Behind a flag because the route has never been exercised and there is no captured
EA response to validate the reshape against.

```python
# FUT_CREATEUSER_FIX: POST /user currently sends login as a BOOL, bonusPacks as
# an ARRAY and starterPack as an OBJECT. FutCreateUserServerResponse's deser
# 0x18014cc60 wants the opposite in every case: login is the userInfo OBJECT,
# bonusPacks is a BOOL, starterPack is an ARRAY of shared ITEM objects, userData
# is a 4-key OBJECT. Each of the three mismatches is independently sufficient to
# desync the SAX reader into the freeze at 0x1801c7f1a. The client has never
# issued this POST (zero occurrences in any log; all GET /user hits are the
# Python suite), so this is latent, not live. DEFAULT OFF: shape derived from
# the deserializer alone, never validated against a real response.
CREATEUSER_FIX = os.environ.get("FUT_CREATEUSER_FIX", "0") == "1"
```

```python
    if CREATEUSER_FIX:
        body = {"login":       _user_info(),      # OBJECT, deser 0x18013ec10
                "squad":       _active_squad(),   # OBJECT, deser 0x18013d1f0
                "starterPack": [],                # ARRAY of ITEM objects
                "bonusPacks":  False,             # BOOL
                "userData":    {"onlineELORating": 0, "onlineRatedUser": False,
                                "accountResetCount": 0, "winForm": 0}}
```

**Freeze risk: the patch removes three freezes and adds none**, provided
`_user_info()` returns an object and `starterPack` stays an array. **Type
fidelity:** `login`, `squad` and `userData` reach object parsers; `starterPack`
reaches an array loop that calls the shared item element deser `0x18013fe00`, so
its elements follow the ordinary `itemData` element schema verbatim; `bonusPacks`
reaches the BOOL getter `0x1801c7620`. Note `winForm` is narrowed to a single byte
by `FUN_1800d7ab0`, so anything above 255 truncates silently.

---

## 7. Proposed `ENDPOINT_MAP.md` corrections

Paste-ready, in the existing entry format.

**Section 1, `FutStoreGetPackTypesServerResponse`, pack object field table.**
Replace the `displayGroup`, `extPrice` and `packContentInfo` rows and add the
missing offsets:

```
  | `displayGroup` | 0xd9 | **flat OBJECT** | NOT an array. Exactly two members: `value` (0x377, STR) → record `+0x00`, `priority` (0x250, INT) → record `+0x34`. The `+0x00` slot's ctor default is the literal `"unknown"`, which is why untouched tiles read "unknown". `value` is the ONLY key group membership is decided by (`FUN_180014380`, exact strcmp against `group+0x70`). |
  | `displayGroupAssetId` | 0xda | INT | record `+0x30` → `group+0x04` → tile `ASSET_ID` and the `packs_backgrounds_%d.dds` texture name. NOT a lookup key anywhere in CardsDLL. |
  | `displayGroupUseDefaultImage` | 0xdb | BOOL | record `+0x38`. Chooses the GROUP tile texture only. It is NOT the gate on `OVERRIDE_ASSET_ID`; that is `useDefaultImage` (0x36a) at record `+0xcc`, a different atom. |
  | `extPrice` | 0x119 | **OBJECT** | → `finalPrice` (0x125, `0x180139070`) + `originalPrice` (0x205, `0x18013aae0`). **Each reads exactly ONE inner key, `externalPriceId` (0x11a, INT). `amount` (0x1b) and `currency` (0xc4) do NOT exist in either parser and are SKIPped.** `finalPrice` also writes the pack's `firstPartyStoreId` slot at `+0x78`. SIDE EFFECT, and this is the reason not to send the key at all: both parsers CREATE an `"mtx"` entry in the pack's currency vector if one is absent, and the tile adapter sets tile `+0xb5 = 1` purely because that row exists, enabling a real-money price line the client can never fill offline. **Recommended: omit `extPrice` entirely.** |
  | `packContentInfo` | 0x20c | **flat OBJECT** | record `+0x144..+0x154`. EXACTLY five children: `itemQuantity` (0x170), `goldQuantity` (0x149), `silverQuantity` (0x2c6), `bronzeQuantity` (0x63), `rareQuantity` (0x273). **`unopened` (0x35d) and `start` (0x2e3) are TOP-LEVEL pack keys, NOT children of this object.** Sending them nested is inert, not harmful. |
  | `unopened` | 0x35d | BOOL | **top level.** Record `+0xcd` → tile `+0x69`. Consumer is in packed FIFA17.exe; meaning unestablished. |
  | `start` | 0x2e3 | INT | **top level.** Record `+0xb4`. |
  | `packType` | 0x20f | STR | Record `+0x108` → tile `+0x38`. Ctor default `"INVALID"`. Pushed to NO Flash field; it is not a tab key and not a lookup key. |
  | `actionType` | 0x08 | **STR, not INT** | Record `+0xd8` → tile `+0x08`, Flash `POST_PURCHASE_ACTION`. Ctor default is the literal `"CREATEPACK"` (`0x180223110`, written by `FUN_1801342d0`). |
  | `sortPriority` | 0x2cb | INT | Record `+0x7c` → tile `+0x1a0`. Pushed to no Flash field, but it IS the merge key of `FUN_180010890`, which sorts each group's pack list. Not inert. |
```

**Section 1, delete the "none of these atoms exist in the pack deser" bullet.**
It is wrong for at least `quantity` (0x26b → `+0xbc`), `saleType` (0x298),
`packType` (0x20f → `+0x108`) and `isPremium` (0x176 → `+0xce`), all of which have
arms. `id` (0x15c) is unresolved, but record `+0x70` exists, feeds tile `+0x70` as
`SERVER_ID` and is the number the purchase sends as `packId`, so something writes
it. Replace with:

```
  - **Dispatch in `0x18013af30` is a jump table (bound `cmp eax,0xfa`) for atoms below 0x119 plus running-sum `sub`/`cmp` ladders above it.** An immediate grep for an atom value returns ZERO hits even for atoms that provably have arms. No absence claim about this parser is valid without decoding the tables at `0x18013b001` / `0x18013b7ba` and the ladders.
  - **The purchase identity is record `+0x70`**, copied to tile `+0x70` truncated to u16, published to Flash as `SERVER_ID` and sent back as `packId`. It is NOT `assetId` (record `+0x74` → tile `+0xac` → `ASSET_ID`) and NOT `displayGroupAssetId`. Which atom writes `+0x70` is an open question; `id` (0x15c) is the obvious candidate and we currently serve `id` == `assetId`, which is why no live read can separate them.
```

**Section 1, corrected minimal known-good:**

```json
{"purchase":[{"id":1,"assetId":1,"description":"Bronze Pack","sortPriority":1,
  "packType":"BRONZE","state":"active",
  "displayGroup":{"value":"bronze"},
  "currencies":[{"name":"coins","funds":400,"finalFunds":400}],
  "packContentInfo":{"bronzeQuantity":5,"silverQuantity":0,"goldQuantity":0,
                     "rareQuantity":0,"itemQuantity":5}}],
 "timestamp":1596326400}
```

Note the deliberate absence of `extPrice`. `finalFunds` is the number the tile
renders; `funds` is not displayed on the tile but IS what the HUD balance reads
from the `/credits` currencies array. Same field name, two consumers, two
meanings.

**New subsection, display groups.** There is no entry for this today.

```
### 1a. Display groups (client-side, built from the store response)

Builder `FUN_1800150d0`. Walks `purchase[]` in ARRAY ORDER. For each pack it
finds-or-creates a group by exact strcmp of `displayGroup.value` against
`group+0x70` (`FUN_180014380`). A new group (`FUN_180012950`, 0x108 bytes) gets:
`+0x00` = a 1-BASED ORDINAL equal to (groups so far)+1, `+0x04` =
`displayGroupAssetId`, `+0x70`/`+0xa0`/`+0xd0` = three copies of the caption
(`+0x70` is the lookup key, `+0xa0` the tile NAME, `+0xd0` the DESCRIPTION and
CONTENT), `+0x100` = `displayGroup.priority`, `+0x104` = (value == "mypacks"),
`+0x40` = the group's vector of 0x1a8 tile models.

Resolution: `FUN_1800147f0(model, N, ...)` treats N == 0 as "list the group
tiles" and otherwise calls `FUN_180014420`, which EXACT-matches `group+0x00` and
returns NULL on a miss, after which the caller dereferences `[RAX+0x40]` with no
guard. **The only legal category values are 0 and the ordinals 1..N.** N arrives
from the Flash movie: `FUN_18007e7f0` case 0x7551 copies the message field
`CATEGORY_ID` verbatim into `screen+0x290`.

The store's tab bar is SIX hardcoded lowercase tokens, `mypacks`, `points`,
`bronze`, `silver`, `gold`, `special` (`FUN_180014580`, `FUN_180014df0`), each
looked up by the same caption strcmp. `FUN_18007e5e0` gives panel 0..5 a
`PANEL_ID` of the matching group's ordinal or HIDES the panel; `FUN_18007df60`
publishes `MYPACK_/BRONZE_/SILVER_/GOLD_/SPECIAL_/POINTS_CATEGORY_ID`. Serving
captions outside that vocabulary leaves all six at -1 and all six panels hidden.

A group renders only if it holds at least one pack whose tile `+0x00` visible byte
is non-zero (`FUN_18002c8b0`), which comes from `visible` (0x37d) at record
`+0x138`, presence-only.
```

**Section on discard, replace the `FutDiscardCardServerResponse` body.**

```
- **name VA** `0x180220540` · **vtable** `0x180220488` · **deserializer** `0x180127300` (slot +0x08)
- Wire: `DELETE ut/%s/item/<id>` (URL suffix builder emits `/%llu`), NO body. CONFIRMED live 2026-08-05.
- Response: `{"items":[{"id":123456789}], "totalCredits":15000}`
  - `items` (0x171) is an **ARRAY OF OBJECTS**; the inner key is `id` (0x15c), int64. There is NO top-level `id`. Each parsed id calls the item manager's `vt[0xa30]`, which removes the card from the club.
  - `totalCredits` (0x326) is an INT, plain assign into a freshly allocated 0x38-byte response object at `+0x28`. No accumulation anywhere in CardsDLL. Send the ABSOLUTE new balance.
- Bulk discard: the client's body serializer `FUN_180126f40` emits `{"itemId":[<int64>, ...]}` (atom 0x16d). It emits NO `itemData` and NO `itemIds`. URL and method not yet established.
- **The client computes the displayed value itself** from its own game DB table `fcc_discardcoins` whenever the server's `discardValue` (0xd7) is 0 or absent: `value = round_half_up(rating * price / 100)` with `price` keyed on `(cardtype, level, rare)`, `cardtype = FUN_1800d8330(cardsubtypeid)` and `level` = 3 if rating>=75, 2 if 65..74, else 1. Sending a non-zero `discardValue` SUPPRESSES that computation (guard `ja` at `0x180141025`). Table and formula in docs/plan-2026-08-05-store-subsystem.md 3.6.
```

**`duplicateItemIdList`, both places it appears (`:1095` and `:218`).**

```
  | `duplicateItemIdList` | 0xec | **ARRAY OF OBJECTS**, not an int list | element deser `0x180138e10`, 0x20-byte records: `itemId` (0x16d, int64), `itemLoans` (0x16f, int32), `duplicateItemId` (0xeb, int64), `duplicateItemLoans` (0xed, int32). Only `itemId` and `duplicateItemId` are ever read; both loans fields are parsed and discarded. Effect: for each record, where an item in the SAME response has `id == itemId`, set that item's `+0x10` to `duplicateItemId`. Entries naming an item not in the same body do nothing. Accepted by `0x180162880` (CreatePack), `0x18013bd40` (purchased/massinfo), `0x1801293d0` (ViewCards) and `0x18013e7f0` (IS-list, item reached via auction `+0xb0`). The only C++ consequence is `HAS_DUPLICATE` (a `!= 0` test) plus one branch in the sign-loan-player completion that SUPPRESSES an automatic `PUT ut/%s/item`. No request ever serialises any of these four atoms. Serving `[]` is coherent. |
```

**`FutCreateUserServerResponse`.**

```
  | `login` | 0x1a5 | **OBJECT** (the userInfo record, deser `0x18013ec10`) → `response+0x28` | was documented as a bool |
  | `userData` | 0x36d | **OBJECT**, deser `0x180142470`, exactly four keys: `onlineELORating` (0x1f2, INT), `onlineRatedUser` (0x1f3, BOOL), `accountResetCount` (0x1f4, INT), `winForm` (0x383, INT **narrowed to one byte**) | was swapped with `login` |
  | `squad` | 0x2cd | **OBJECT**, LoadActiveSquad `0x18013d1f0` | |
  | `starterPack` | 0x2e5 | **ARRAY of shared ITEM objects** (`0x18013fe00`) → 0x18-stride vector at `response+0x158` | was documented as an object |
  | `bonusPacks` | 0x5d | **BOOL** → `response+0x150` | was documented as an array |
```

**`userInfo.unopenedPacks`.**

```
  | `unopenedPacks` | 0x35e | OBJECT | exactly two children, `preOrderPacks` (0x24b) and `recoveredPacks` (0x27b), both INT. **`count` (0xbc) has no arm here and is SKIPped.** Their SUM goes to `model+0x20950` via model vtable slot `+0x4e0` and broadcasts UI event 0x273d. The SAME pair also arrives on `FutUpdateCreditsServerResponse` (`FUN_180122c50`, `resp+0x34`/`+0x38`), which the client requests ~14x per session against ~5 userMassInfo calls. The counter is CLIENT-MUTABLE: at least five CardsDLL functions read-modify-write it, and `FUN_180019780` increments it and then issues `GET ut/%s/purchased`. Do not serve a non-zero value until that subsystem is audited. |
```

---

## 8. Coverage, honestly

Two dimensions came back thick and hold up: the quick-sell economy and the price
model. Both were reproduced end to end by a verifier using a different toolchain
(`.pdata`-bounded objdump instead of Ghidra, brute-force table relocation instead
of the original pointer chain) and 20 of 23 claims survived with no refutations.
The discard table's bit layout was upgraded from self-consistent to provably
unique.

The grouping dimension came back confident and was the one that took damage. Its
top-ranked recommendation is refuted, its "sortPriority and displayGroup.priority
are inert" claim was a false absence found by searching the whole `.text` listing
rather than eight functions, and its ActionScript evidence is constant-pool string
order, which is suggestive and not proof. What survives is the mechanism, which is
solid and well controlled: the group builder, the ordinal, the exact-match
resolver, the missing null guard and the six-tab vocabulary were all read in raw
disassembly with same-form controls.

Dimension 6 (live state) contradicted dimension 1 twice and lost both times: it
claimed `packType` was the likely lookup key, and that the store tabs key on
`packType` tier. Both are wrong; the tabs key on `displayGroup.value` and
`packType` has no Flash consumer at all. Its other work, the process-wide sweeps
and the telemetry reconstruction, was not re-verified but is presence-only
evidence from direct reads, which is the lowest-risk category. Its author also
caught and logged one of its own filter bugs mid-run, which is worth more than the
findings it corrected.

The duplicates dimension is thorough and entirely static. It predicts a flow (loan
signing) that has never been exercised, and its one exhaustive-census claim was
explicitly not reproduced by the verifier, so treat that claim as carrying one
agent's confidence rather than two.

The unopened-packs dimension had the single largest error of the run, calling a
counter display-only when six functions write it and one of them fires an HTTP
request. That was caught only because a verifier grepped for vtable-slot call
sites rather than for the raw field displacement. The lesson generalises: a field
reached exclusively through a vtable slot is invisible to a displacement search.

**The unresolved contradiction I would flag hardest** is which Flash field becomes
`CATEGORY_ID`. `ASSET_ID` is eliminated by live memory. Between `CHILD_CATEGORY`
and `GetActiveTabId()`, nobody read the AS2 bytecode and nobody should pretend
otherwise. I would bet on `CHILD_CATEGORY`, because 3 is a valid ordinal and an
unbound tab id is -1, and because a tab-derived id would break Bronze too and
Bronze works. If that bet is right, no field we send fixes the bug directly and
the value of binding the tabs is that it removes a degenerate subsystem, not that
it addresses the carrier. I would not spend more than one launch on it before
someone unpacks the FUT SWF.

The whole ActionScript layer is unread. That is the thinnest and most consequential
coverage gap in this document, and every remaining store mystery lives there.

---

## Next action

Before anything else, and before the process dies: while pid 134663 is still in
the store, have the user click the **Gold Pack** tile, then read
`0x41934180 + 0x290` out of `/proc/134663/mem` read-only, having first
re-resolved the pid by `comm` and re-checked both slide controls. That single
number decides which half of this document matters. If it reads 2, the drill-down
is correct, the bug is not group resolution at all, and the entire ranked
experiment list in section 2.3 is aimed at the wrong layer. If it reads 3, the
movie is echoing the wrong ordinal and the tab-binding experiment becomes the
first launch worth spending. It costs nothing, it needs no restart, it touches no
server, and it is the only observation available right now that can invalidate a
whole branch of planned work.
