# 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//item/`, 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":[, ...]}` 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. **APPLIED (2026-08-21), behind a default-off flag.** The table and the formula above are now in Rust as `openfut-adapter-fifa17::fut::discard`: `cardtype_for_subtype` is the decode, `discard_level` the 3/2/1 ladder, `table_price` the 141-row lookup (`0` for an absent key) and `discard_value` the `round_half_up(rating * price / 100)` formula. `DISCARD_COINS` is generated from `fifa17-recon/data/tables/fcc_discardcoins.json` and a test re-reads that file and asserts they still agree row for row, so the two cannot drift. The four worked examples above (`8 * rating`, `4 * rating`, the 50-rated bronze at 15, and an absent key paying 0) are tests. Wire and wallet are now ONE method. `ItemIdentityResolver::discard_value` both stamps the card's `discardValue` and prices the sale, because a non-zero `discardValue` suppresses the client's local computation — so whatever is sent is what the player is promised. The host's separate `quick_sell_value` ladder is deleted (it was a second copy that could drift), and a test with a resolver double returning an impossible price proves the credit follows the wire. `OPENFUT_FIFA17_DISCARD_TABLE=1` turns the table on; the default keeps the old placeholder ladder because switching revalues an existing club by **10.5x** (measured over the real 1991-item club: 1,820,400 -> 19,128,955 coins if wholly liquidated). Players drive it (an r93 special goes 1500 -> 74,400); consumables move the OTHER way (2,400 -> 437, i.e. the ladder was overpaying 5.5x). STAFF: CLOSED, and the `value`-is-the-rating question is now SETTLED against the running client rather than inferred. A staff wire record carries no `rating`, no `rareflag` and no `discardValue`, so the displayed price had to be read back out of memory. `tools/coach_probe.py` grades the four resident staff records HIT, which requires record `+0xb4` == the table's `value` and `+0x58` == its `rare`; `tools/discard_probe.py` (new) then reads the two discard slots directly — `+0x38` is what we sent, `+0x3c` is what the client computed: ``` resource sub ct rat lvl rar sent+38 calc+3c predicted 1000509 4 2 88 3 1 0 282 282 AGREES (manager) 9000081 6 10 66 2 0 0 36 36 AGREES (gk coach) 3000083 8 4 66 2 0 0 36 36 AGREES (fitness) ``` 4 of 4 agree, 0 disagree, and 36 on the `value`-66 GK coach was the stated falsifier. `openfut-import-fifa17::Entities::enrich_staff` now carries `value` -> rating and `rare` -> rareflag for the five families, so the catalog holds what the client re-rates to; verified on staging, a GK coach quick-sells for 36 rather than the 150 floor. The catalog diff is exactly the two coach entries. The same probe shows what production is doing to PLAYERS today: all 23 resident player records carry `sent+38 = 1500`, which suppresses the local computation, so the client displays 1500 for every one of them — against its own table's 688..752 for a gold rare, 11,102..11,468 for the 21/23/24 specials, 22,080..23,280 for rareflag 11, and 72,800 / 74,400 for the two rareflag 5/6 legends. A 50x underpay at the top and a 2x overpay at the bottom. STILL OPEN, and NOT a discard problem: the manager `fifa17_1000509` is owned in Core but has no catalog entry and no card definition (it reaches the client through the opaque squad extension), so pricing declines for it and falls back to the ladder — 150 against the client's 282. That is definition coverage. ### 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": [, ...]} # 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/` (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":[, ...]}` (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.