# The pack opening system, end to end Written 2026-08-05, after six parallel reversing passes over the store, the pack reveal and the card-disposition path, plus two adversarial verification rounds that overturned five of the original claims. FIFA 17 was running throughout as pid 4048 and was read strictly read-only. Nothing proposed here has been in front of the game yet. Slide used for every live read, re-derived and re-proven in this document's own final check: `live = static - 0x180000000 + 0x6ffffc140000`. Controls were the FNV hasher prologue at `0x180180d00` and the 64-byte `RS4:FutSquadSaveServerResponse` literal at `0x18022c618`, both taken from the on-disk PE rather than asserted. Both matched. These bases die with the process; the static addresses do not. --- ## 1. What we now know that we did not know this morning **There is no pack-inventory endpoint in FIFA 17, and there never was.** The client cannot list, claim or open an already-owned pack over HTTP, because no route exists for it and no client action composes one. Three independent proofs: - The UTAS API template array in `.rdata` at `0x18021df80..0x18021e280` holds 48 `(path, token)` pairs. The only pack-relevant entries are `ut/%s/store`, `ut/v2/%s/store`, `ut/%s/purchased` and `ut/%s/item`. A regex for `ut/` over all 3,179,952 bytes of the PE returns 59 literals and adds nothing. - The client action table at `0x1802caa20` (125 populated rows of 0x30 bytes, `{name, urlIndex, TAG, ...}`) is the complete set of requests the client can originate. Its pack rows are `PurchasePack` (urlIndex 0x1a), `PurchasedItems` (0x1a), `PurchaseItems` (0x1b), `StorePackTypes` (0x1b) and `StorePackQuantities` (0x1b). No claim, no list, no open. - The pending-pack surface that *does* exist is entirely inside responses we already serve. `userInfo.unopenedPacks` is a counter pair that lights a game-hub tile, and "My Packs" is a filter over the ordinary store catalogue: entries whose `displayGroup.value` string equals `mypacks` (`FUN_1800150d0`, literal at `0x1801ec008`). Neither issues a request. That is a negative result and it is the most valuable thing in this document, because it deletes work rather than creating it. "Serve the pack inventory" has been an implicit item behind the unclaimed-pack tile and the My Packs screen for two rounds. It is not an endpoint we are missing. It is two fields on responses we already build. The whole pack system reduces to four routes, all of which are live today. Four other findings change what we should do next, in descending order: **`packOpeningAnimationEnabled` is already ON, and this contaminates yesterday's settings-gate plan.** One reversing pass traced the flag from settings switch arm `0x20e` through `FUN_18011dc50` to `FutDataManagerImpl+0x1fd45`, concluded the byte defaults to zero, and recommended shipping a `/settings` row to turn it on. I read the byte out of pid 4048 myself, at a moment when `/proc/3740/environ` carries no `FUT_*` variables and `/tmp/utas.log` records `GET /ut/game/fifa17/settings` at 14:39:21 answered with `{"configs": []}`: ``` slot +0x2b0 -> 0x18011c500 disp 0x1fd3a (friendlySeasonsEnabled) value = 1 slot +0x2c8 -> 0x18011c4b0 disp 0x1fd3d (enableDraftMode) value = 1 slot +0x2e0 -> 0x18011c590 disp 0x1fd45 (packOpeningAnimationEnabled) value = 1 ``` A whole-`.text` disassembly scan shows `FUN_18011dc50` is the only writer of any byte in `0x1fd28..0x1fd50`, so the applier ran and the settings struct it was handed simply defaults those fields to 1. Two consequences. The proposed `packOpeningAnimationEnabled` row is a no-op, so if the reveal animation is not playing the cause is one of the other four terms of the gate. And `docs/plan-2026-08-05-settings-gate.md` section 1 asserts that `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have never been set to true by anything, on any run". Measured, both are 1. That premise is false for this struct, and the Seasons diagnosis built on it needs redoing before that launch is spent. **`duplicateItemIdList` is an array of objects, and both places the docs describe it are wrong in a freeze-risky direction.** Element parser `0x180138e10` reads `itemId` (0x16d), `duplicateItemId` (0xeb), `itemLoans` (0x16f) and `duplicateItemLoans` (0xed) into 0x20-byte records. `ENDPOINT_MAP.md:1095` and `:218` both call it an int list. The same binary provides the control that this is not a misread: `dreamSquads` (0xe9) in `FutMoveCard` genuinely is a bare int array, parsed by a `while (tok != 0xd)` loop calling the int getter directly, with no inner object loop. We currently serve `[]`, which is safe, so this is a documentation bug rather than a live bug. It becomes a live bug the moment somebody implements duplicates from the map as written. **`FutDiscardCardServerResponse` is `{"items":[{"id":N}], "totalCredits":N}`, and quick sell currently pays nothing.** `items` (0x171) is an array of OBJECTS; there is no top-level `id` key. `ENDPOINT_MAP.md:968-971` documents `{"items":[123456789], "totalCredits":15000, "id":123456789}`, which is the wrong shape twice over. Separately, the client sends a bulk discard as `{"itemId":[id, id, ...]}` (atom 0x16d) and a single discard with the id in the URL as `/%llu`, and `quick_sell_route()` in `utas_server.py` parses neither: it looks for `itemData` and `itemIds`. The route regex `/ut/delete/game/[^/]+/item` is unanchored, so a URL-suffix discard still *matches* the route; the id is simply never extracted. `ids` comes out empty, `STORE.quick_sell([])` sells nothing, and the response is a bare `{}` with no `items` array, so the client's own local removal (model slot +0xa30, driven by the ids it parses back) never fires either. **Two readings collide here and I am not going to smooth it over.** The static reading above says a quick sell today is a complete no-op. The docstring on `quick_sell_route()` records a *live observation* from 2026-08-04 saying the opposite: "nothing was credited, so a quick sell destroyed the cards for 0 coins." Both cannot be true of the code as it now stands. I would bet on the static reading, for two reasons: the code path is short enough to read end to end and it cannot remove a card it has no id for, and the docstring is describing the state that *prompted* the handler to be written, when the path was unmapped and answered with a generic body. But "the cards visibly disappeared from the reveal screen" is also exactly what a client-side removal looks like before a reload puts them back, and nobody has checked the save file after a quick sell. Section 7 step 4 settles it for the cost of one menu action, and until it does, treat "quick sell destroys nothing" as the more likely of two live possibilities rather than as established. **The FIFA 17 retail pack catalogue is recoverable from the running client and nowhere else.** 41 complete `` records from `data/store/storecfg.xml` are sitting in the heap at `0x3dabe000..0x3dac7000`, each with a numeric server id, a display name, an Origin offer id and an entitlement id of the form `FIFA17FUTPACKnnn`. The text is not in CardsDLL, not in the packed exe, not in `stp-origin_emu.dll`, and not in any of 256 files totalling 3.24 GiB scanned under the game install. It will be unrecoverable when this process exits. The full table is in section 5. --- ## 2. The pack opening system, end to end Everything below is graded CONFIRMED (read from the binary or observed on the wire), INFERRED (a chain of reasoning with no single decisive observation), or UNKNOWN. Addresses are static CardsDLL VAs at base `0x180000000`. ### 2.1 Catalogue **CONFIRMED.** `GET ut/%s/store/purchasegroup/all?ppInfo=true`, built by `FUN_180123430` from three literal appends. Response class `FutStoreGetPackTypesServerResponse`, root deser `0x1801234e0`, which reads `purchase` (0x260, array) and `timestamp` (0x31b, int). Each element goes to the pack deser `0x18013af30`, which fills a 0x158-byte record. **Only the first 100 elements survive**: at `0x18013bb02` a `CMP EDX,0x64` after dividing the vector span by 0x158 jumps past both push_back paths straight to the record destructor at `0x18013bb49`. Elements 101 and up are fully parsed and thrown away, silently. The dispatch is a pre-table ladder plus an MSVC jump table at `0x18013b792` (`LEA EAX,[R15-0x20f]` / `CMP EAX,0x89`, byte index table at `0x18013bcb4`, seven targets at `0x18013bc98`). Decoding that table settles the "these atoms are skipped" question independently of any decompiler. Field map, all offsets relative to the 0x158-byte record: | key | atom | getter | offset | note | |---|---|---|---|---| | `assetId` | 0x23 | INT | +0x74 | the real pack identity | | `id` | 0x15c | INT via `0x1800d7b10` | +0x70 | **u16 store**, values above 65535 truncate | | `description` | 0xd1 | STR | | tile caption | | `sortPriority` | 0x2cb | INT | +0x7c | | | `firstPartyStoreId` | 0x127 | **STR then `atoi()`** | +0x78 | int here is the documented freeze | | `displayGroup` | 0xd9 | **flat OBJECT** | | `value` (0x377, STR) to +0x00, `priority` (0x250, INT) to +0x34 | | `currencies` | 0xc5 | ARRAY | +0x40..+0x48 | 0x30-byte records via `0x180138bd0` | | `extPrice` | 0x119 | OBJECT | | see below | | `packContentInfo` | 0x20c | flat OBJECT | +0x144..+0x154 | exactly five children | | `state` | 0x2eb | STR vs `"active"` | +0xb0 | ctor default 0, i.e. not active | | `start` | 0x2e3 | INT, non-negative clamp | +0xb4 | **top level, not in packContentInfo** | | `end` | 0x102 | INT | +0xb8 | | | `quantity` | 0x26b | INT | +0xbc | a sent 0 is rewritten to -1 | | `purchaseLimit` | 0x265 | INT | +0xc0 | | | `purchaseCount` | 0x261 | INT | +0xc4 | | | `saleType` | 0x298 | STR to enum | +0xc8 | `NONE` 0, `QUANTITY` 1, `TIME` 2, `TIME_QUANTITY` 5, anything else 0 | | `dealType` | 0xcc | STR, lowercased | +0xcf/+0xd0 | compared against `promo` and a 4-char literal | | `useDefaultImage` | 0x36a | BOOL, **stored inverted** | +0xcc | | | `unopened` | 0x35d | BOOL, raw | +0xcd | **top level, not in packContentInfo** | | `isPremium` | 0x176 | BOOL | +0xce | | | `packType` | 0x20f | STR | +0x108 | ctor default `"INVALID"` | | `visible` | 0x37d | **no getter at all** | +0x138 | one instruction, `MOV byte [RBP+0x88],1`; presence sets it, the value is never read | | `points` | 0x240 | INT | +0x140 | | `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. Any other key inside it reaches the safe SKIP at `0x180135ff0`. Our `_pack_body()` sends `"unopened": false` inside it, which is therefore inert. `extPrice` is documented wrong. `finalPrice` (`0x180139070`) and `originalPrice` (`0x18013aae0`) are 152 instructions each and contain exactly one atom comparison apiece, `CMP r,0x11a`. Neither reads `amount` (0x1b) or `currency` (0xc4). The one key each reads is `externalPriceId` (0x11a, INT). `finalPrice` also writes its value straight into the pack's `firstPartyStoreId` slot at +0x78, so `extPrice.finalPrice` and the top-level `firstPartyStoreId` are the same slot reached two ways. Currency elements (`0x180138bd0`, stride 0x30): `name` 0x1d0 STR, `funds` 0x134 INT, `finalFunds` 0x124 INT, both ints through the non-negative clamp `0x1800d7b30` (a negative price silently becomes 0). The tile adapter takes the **coin** price from `finalFunds` and ignores `funds`; it takes the mtx price from `funds` and then overwrites it with the platform-store price anyway. **Consumer, and the verdict on packContentInfo: DECORATIVE. CONFIRMED.** Exactly one function in CardsDLL reads those five slots, `FUN_18002c3c0`, a record-to-tile adapter with a single code caller, `FUN_1800150d0`. It is a straight MOV-to-MOV copy into view model +0xc0..+0xd0 with no arithmetic and no comparison. An operand-text census over 13,308 functions and 469,335 instructions finds exactly three functions carrying all five offsets: that adapter, the vector copy `0x180133210` and the copy-assign `0x1801340e0`. Nothing anywhere compares the declared quantities against a delivered item list. `open_pack()` does not have to honour the declared distribution. It should eventually, because the numbers are rendered on the tile, but it is cosmetic and can be parked. ### 2.2 Purchase This is the part where the static reports and the wire disagreed, and the wire wins. **CONFIRMED, observed live this session.** At 14:39:28 on every boot the client sends: ``` PUT /ut/v2/game/fifa17/store/transaction/0 body: {"state":"TRANSACTIONCANCEL"} ``` That is unmistakably the `PurchaseItems` request serializer `FUN_180126440`: it always emits `state` (0x2eb) from the 9-entry table at `0x1802d02c0`, and the URL builder `FUN_180126720` appends `/transaction` and then `/` as `%lld` whenever the state enum exceeds 1 (`TRANSACTIONCANCEL` is 7, the transaction id is 0). One reversing pass concluded that this first-party checkout path "an offline emulator with no platform store will never see". It fires on every boot. The `state == "TRANSACTIONCANCEL"` branch in `store_buy()` is live code, not dead code, and it is the reason no phantom pack opens at startup. **Two distinct classes, chosen client-side. CONFIRMED.** `CreatePack` (ctor `0x1801623d0`, call id 0x4b, vtable `0x180228270`) and `PurchaseItems` (ctor `0x1801263a0`, call id 0x4c, vtable `0x1802202f8`) are separate ServerCall classes with separate response factories and deserializers. The two vtables differ at slots 6, 9, 12 and 16. Nothing in any server response selects between them; there is no fork for us to drive. The `CreatePack` body is produced by `FUN_180162530` and has exactly four keys, all derived from one mode integer at `this+0x20`: ``` packId (0x20b) = int useCredits (0x369) = (mode == 0) usePreOrder (0x36b) = (mode == 4) currency (0xc4) = "MTX" if mode==1, "POINTS" if mode==2, else "COINS"; omitted entirely when mode==4 ``` **Where the coin buy goes: INFERRED, high.** No static pass could find the URL builder for `CreatePack`. Two independent sources agree that it is `POST ut/%s/purchased`: the action table row `PurchasePack` carries urlIndex 0x1a, which resolves to `ut/%s/purchased`, and the live ground truth recorded in `utas_server.py:2557` is that FIFA sends `{"packId":1,"useCredits":1,"usePreOrder":0, "currency":"COINS"}` and then immediately polls `GET /purchased`. So the coin buy is `POST /purchased` with a `CreatePack` body, and `store/transaction` is the first-party path. **Consequence, and this one matters: the `createPackResponse` envelope has never actually been parsed by the client. INFERRED, high.** `store_buy()` only builds it when the body carries an integer `packId`, and the only bodies the client sends to `store/transaction` are state bodies, so that branch never fires. Meanwhile `POST /purchased` answers with the *request* shape (`packId`, `firstPartyStoreId`, `groupName`, `productId`, `purchasePackType`), and `FutCreatePackServerResponse`'s deser `0x180162880` recognises only four atoms (`duplicateItemIdList` 0xec, `itemList` 0x16e, `numberItems` 0x1dd, `purchasedPackId` 0x264), so all of it is skipped. The reveal gets its cards from the follow-up `GET /purchased` instead. That works because **both responses write the same container**: the shared item manager reached as `FUN_18011a830()` then vtable slot +0x160. `FutCreatePack` fills it and `FutGetPurchasedItems` fills it, and we happen to be using the second. `ENDPOINT_MAP.md` calls the `createPackResponse` body "already handled, VERIFIED byte-exact". It is verified against `store_buy()`, not against the client. Nothing has ever read it. > **RESOLVED, 2026-08-05 evening, by `tools/ghidra_queries/q_envelope_{1,2,3}.py`. > Reading A is correct. The envelope is structurally required. Its name is never > checked. Reading B rested on a factual error, corrected below.** > > The token enum, decoded from the class table at `DAT_18023dd40` and the switch in > the classifier `FUN_1801c67a0` (container state 2 = object, 3 = array, which is what > the push/pop arms key off): > > | token | meaning | set by | > |---|---|---| > | 9 | START_OBJECT | case 0x64, pushes state 2 | > | 10 | END_OBJECT | case 0x65, pops state 2 | > | 11 | FIELD_NAME | confirmed independently by `FUN_18013bd40`, which tests `+0xd0 == 0xb` then atom-hashes `+0xf8` | > | 12 | START_ARRAY | case 0x66, pushes state 3 | > | 13 | END_ARRAY | case 0x67, pops state 3 | > | 1 | error | the `caseD_78` sink | > > So the three unconditional tokens are **`{`, the first FIELD_NAME, and the token that > opens that field's value**. For `{"createPackResponse":{...}}` that is 9, 11, 9, and > the loop then reads the inner object's keys and exits on 10. The wrapper key's name is > consumed as token 2 and never hashed, which is why the ladder has no arm for atom > `0xbe` and does not need one. Coverage for that absence: the ladder has exactly four > arms (`0xec`, `0x16e`, `0x1dd`, `0x264`) and `0xbe` does not occur anywhere in the > full 4702-char decompile, printed in full. > > **The error in reading B.** It claimed the `/purchased` root "spends the same three > tokens". `FUN_180124ee0` spends **two**, then hands off to `FUN_18013bd40`, which > spends the third. The count is the same in total but it is split across two functions, > and the sub-parser is the one that loops on FIELD_NAME. So `/purchased` never was a > counterexample. It is `{"itemData":[...]}`, whose three tokens are 9, 11, 12, and the > array case is handled by the sub-parser rather than by the key ladder. > > **The general rule, and the thing to be careful about.** A census over all 86 > begin-object callers puts 67 at exactly three tokens before the first key read. For > those roots the first key/value pair of the body is consumed **blind**: the key is not > hashed and not dispatched. That is harmless when the first value is an object, because > the loop then walks that object. It silently discards data when the first value is a > scalar. Any flat multi-key body served to a three-token root therefore loses its first > key. `GET /hub` returns `{"clubPlayers":205,"auctionCount":0}` and is the obvious thing > to check against this, but I could not close it here: there is no > `RS4:FutGetHubServerResponse` literal in the image, and `clubPlayers` and > `auctionCount` appear only as atom-table entries with no code xref, so `/hub` is not > parsed by a generated root at all and the rule above may simply not apply to it. > That is the next thing to look at, and it is UNKNOWN, not "fine". **The envelope rule itself, as it stood before the resolution above: UNKNOWN, and two readings were live.** `FUN_180162880` calls the next-token primitive three times unconditionally at `0x180162942`, `0x18016294b` and `0x180162954`, tests only the third against token 10, and has no arm for `createPackResponse` (atom 0xbe) anywhere in its ladder. So it descends exactly one wrapper level without ever checking the wrapper key's name: the envelope is structurally required and its *name* is irrelevant. The competing reading is the verifier's, that the token count decides nothing, because `FutCreateUser` (`0x18014cc60`), `FutDiscardCard`, `FutMoveCard` and the `/purchased` root (`0x180124ee0` plus `0x18013bd40`) all spend the same three tokens, and we serve `POST /user` and `GET /purchased` unwrapped and both work. I would bet on the first reading for `CreatePack` specifically, because the three calls there are unconditional and only the third is tested, which is a descend and not a probe. But the two readings cannot both be right for `/purchased`, and I am not going to pretend the contradiction is settled. Section 4 gives the one decompile that settles it. Incidental but useful: `200` with `{}` is genuinely safe on the buy, not accidentally safe. For a body of `{}` the three unconditional tokens see `{`, `}`, end-of-input, the third returns 10 and the function jumps straight to its exit with the constructor's zeroed vectors intact. **HTTP status. CONFIRMED.** `FUN_1801844c0` is a single status-to-error table reached through ServerCall vtable slot 12. Only 200 maps to success. 204 maps to 99. **Any other 2xx, including 201 and 202, falls through to 999.** 461 maps to 3, which is what our insufficient-coins refusal already uses; most 4xx and 5xx map to 998. `PurchaseItems` alone overrides slot 12 with `FUN_1801267b0`, which turns HTTP 409 plus a body containing the exact substring `User already has a transaction` into code 0x70, a pending code the transaction state machine re-polls rather than failing. Audit result: `utas_server.py` returns only 200, 404 and 461 anywhere, so this is already clean. **FIFA Points: out of scope, CONFIRMED.** The tile price formatter `FUN_18002cc90` opens with `if (*(int *)(param_1 + 0x6c) == -1) return;` and then queries the platform store catalogue for the real-money price. `tile+0x6c` is the pack's `firstPartyStoreId`, whose constructor default is -1. Offline there is no platform catalogue, so the points path is dead by construction and needs nothing from us. ### 2.3 Reveal **CONFIRMED.** `FUN_1800aa440` is the whole reveal brain, registered as the handler for UI message 9 on the pack-open sublevel (`FUN_18001eb90` loads `gmLoadFUTPackOpenSublevel`); message `0x2742` is the teardown that broadcasts `gmUnloadFUTPackOpenAnimation`. It walks the shared item container (model vtable +0x160), keeps only items whose `cardtype` is 1, and picks a headline. **Neither branch issues any HTTP request**: the whole 17,996-character decompile contains no URL builder and no request enqueue. The five-way AND that chooses flow transition 0x33 (play the reveal) over 0x34 (skip it): 1. a best item was found, 2. `(item->resourceId & 0xffffff) != 0`, 3. `item->cardtype == 1`, 4. the data provider has an `ASSET_ID`, 5. `FutDataManagerImpl+0x1fd45` (`packOpeningAnimationEnabled`) is non-zero. Term 5 is measured at 1 right now. If the animation is not playing, look at terms 2 to 4. Term 2 is satisfied by what `open_pack()` already sends, since our `resourceId` is `(version << 24) | asset`. Selection, all client-side arithmetic over fields we already control: - **Ranking key is `discardValue` (item+0x38, atom 0xd7), not rating.** When it is zero or absent the client computes its own quick-sell price into item+0x3c from the local `fcc_discardcoins` table keyed on `cardtype`, `level` and `rare`, scaled by rating and divided by 100 with round-half-up. Sending a non-zero `discardValue` therefore overrides EA's own economy table and directly dictates which card gets the walkout. `level` (item+0x54) has no JSON arm at all and is always 0. - **Presentation tier** is `FUN_1800a9fe0(rareflag, rating)` returning 1, 2 or 3 from a hardcoded table: rareflag 5 or 6 always 3; rareflag 0xb, 0xc and 0x15 have their own thresholds; everything else is rating > 89 for 3, and rating > 83 or exactly 74 or exactly 64 for 2. The 74 and 64 cases are the tops of the silver and bronze bands. - **A special headline predicate outranks discardValue.** `FUN_1800aa330` returns true when `loans < 1` and (`rating > 87` or the playerid appears in the local `fcc_GrandStandPlayers` table). The first predicate-true item takes the headline regardless of value, and only another predicate-true item with a strictly greater value can displace it. - **Wire order is a tiebreak only, for the summary strip.** `FUN_1800a96a0` is a stable descending sort on `discardValue`; the best-item scan uses strict greater-than so the earliest item wins ties. The underlying vector keeps wire order, so `random.shuffle()` in `open_pack()` is a real ordering decision for any screen that renders the vector directly, and a tiebreak for the strip. `NUM_ITEMS_IN_PACK` and its four siblings are pushed to the data provider by `FUN_180015d80` from the pack **definition** at +0xc0..+0xd0, not from the opened item list. Whether those definition fields come off the wire is **UNKNOWN**: the read offsets are confirmed but the sole caller `FUN_1800147f0` was not traced, and nine unrelated functions write the same displacement group. The community term "walkout" does not exist in this client. The single `Walkout` string in 3.5 GiB of process memory sits between `Lineup` and `Handshake` in a match-presentation cutscene enum. ### 2.4 Disposition **CONFIRMED.** `duplicateItemIdList` is not inert. At the end of the createPack parse, `FUN_180162880` runs a post-pass that walks the reveal collection and, for every card whose id matches an entry's `itemId`, writes that entry's `duplicateItemId` into the card object at +0x10. The reveal controller `FUN_18009bc40` reads exactly that field: non-zero means duplicate, and the screen fires the UI event `GotoNewItems` and issues **no request**. Zero means the normal path, which builds a one-element `{id, pile=7, swap=0, tradeId=0}` record and submits it through model vtable slot +0xc0. The swap flow behind the duplicate branch is `FutMoveCard` with a per-item `swap` key (atom 0x2fe) carrying the second id. **Quick Sell is called Discard.** There is no `quickSell` byte sequence anywhere in CardsDLL, in any casing. Two shapes: - single: `ut/delete/%s/item` plus `/%llu` appended by `FUN_180127570`, no body; - bulk: `ut/delete/%s/item` with `{"itemId":[id, id, ...]}` (atom 0x16d) built by `FUN_180126f40`, one request for the whole batch, clamped client-side at 64. Response `FutDiscardCardServerResponse` (deser `0x180127300`, also reachable as slot +0x08 of the response vtable `0x180220488`): `items` (0x171) is an array of objects each carrying `id` (0x15c), and `totalCredits` (0x326) is an int. Each parsed id is also passed to model slot +0xa30, which removes the item from the client's local model. There is no top-level `id`. Nothing in CardsDLL reads `totalCredits` back out of +0x28: the response vtable's apply slot `0x180122420` is literally `return;` and the only two xrefs to the vtable are its own constructors. So the coin figure is server-authored and parsed, but the balance almost certainly moves via a `GET ut/%s/user` refresh, which we already serve correctly. **Send to Transfer List is the same route as Send to Club.** `PUT ut/%s/item`, `FutMoveCard`, with `pile` set to `trade` instead of `club`. The pile vocabulary is exactly three strings, decoded by `FUN_180142650`: `club` 7, `purchased` 6, `trade` 5, anything else 0. `FutMoveCard` (`0x180128600`, 6193 characters, whole function read) parses only `itemData` at the top level. Element keys are `id`, `pile`, `success`, `reason` and `dreamSquads`. **It does not parse `chemistry` (0x81), and neither does `FutMoveCardByRes` (`0x180128e30`).** The `"chemistry": true` in ENDPOINT_MAP rows 10 and 11 is a no-op on both. The string `Destination Full` under `reason` is special-cased to error code 0xf. The three `ByRes` actions (`ApplyCardByRes`, `DiscardCardByRes`, `MoveCardByRes`) all carry urlIndex 0x0e, which resolves to `ut/%s/item/resource`, not to `ut/%s/item`. ### 2.5 Inventory counters **CONFIRMED.** `userInfo.unopenedPacks` (0x35e) is an object whose only recognised children are `preOrderPacks` (0x24b) and `recoveredPacks` (0x27b). `count` (0xbc) is not read there, though the same class does read it under a different parent (atom 0x59). The two are summed and passed to model vtable slot +0x4e0 (`0x18011e120`), which stores the total at `model+0x20950` and broadcasts message `0x273d`, registered as the string `RELOAD_CENTRAL_PANEL`. That rebuilds the FUT game hub central panel, where tile type 0x1c renders `CentralUnclaimedPack` with destination `GOTO_STORE_MYPACK`. Measured live in pid 4048: `model+0x20950 = 0`, consistent with never having served the field. `FutUserCreditsServerResponse` (`0x180122c50`) also carries `unopenedPacks` with the same two children nested inside it, and `FutSBCSubmitChallengeServerResponse` (`0x180161b00`) carries `preOrderPacks` and `recoveredPacks` at the top level. So an SBC that awards a pack reports it by bumping counters, not by returning a pack object. Atom 0x20d `packList` is **not** a wire key: the only parser that dispatches on it reads the local file `packs/dreamsquad/dreamsquadpacklist.json`. Graded MEDIUM, because the uniqueness half was not independently reproduced (see section 4). --- ## 3. Server-authoritative versus client-side Strict reading: a thing is server-authoritative only if the response we send determines it. "The client parses it" is not enough; "the client renders it" is not enough either if the client would compute the same thing from something else. | Mechanism | Owner | Emulator has to reimplement? | |---|---|---| | Pack catalogue: which packs exist, ids, captions, sort order | SERVER | Yes, done | | Coin price of a pack (`currencies[].finalFunds`) | SERVER | Yes, done | | Coin balance and the debit | SERVER | Yes, done | | Which cards a pack contains (`itemList`) | SERVER | Yes, done | | Number of cards revealed | SERVER (the list length; `numberItems` is written and never read) | No extra work: send the list | | Duplicate detection (`duplicateItemIdList`) | SERVER | Yes, not built. Requires the createPack envelope path | | Quick-sell payout on the wire (`totalCredits`) | SERVER | Yes, not built. Currently pays nothing | | Which items a discard removes | SERVER | Yes, partly: our accounting works, our request parsing does not | | Move verdicts (`itemData[].success`, `reason`) | SERVER | Yes, done for `club`; `trade` and `purchased` untested | | Pile vocabulary (`club`/`purchased`/`trade`) | SERVER (we choose the string, client maps it) | Already correct | | `unopenedPacks` counters and the unclaimed-pack tile | SERVER | Yes, one field. Zero today | | "My Packs" grouping (`displayGroup.value == "mypacks"`) | SERVER | Yes, one string. Not sent today | | Pack availability: `state`, `saleType`, `quantity`, `purchaseLimit`, `purchaseCount`, `start`, `end` | SERVER (all parsed and copied to the tile) | Optional. The predicate that greys a tile is in the packed exe and unread | | HTTP status to FUT error code | SERVER | Yes, done: only ever answer 200 | | Transaction state machine (`state`, `transactionId`, 409 pending) | SERVER, on the first-party path only | Only the cancel, which we already answer with 200 `{}` | | `packContentInfo` tier quantities | SERVER, but purely decorative | Cosmetic only. Nothing compares them to delivery | | Reveal animation on/off (`packOpeningAnimationEnabled`) | SERVER in principle, via `/settings` | No. The byte is already 1 | | Which card gets the walkout | CLIENT, from `discardValue` we send | No logic. Only field values | | Presentation tier / card colours (`FUN_1800a9fe0`, `FUN_1800aa060`) | CLIENT, from `rareflag` and `rating` | No | | Grandstand headline override | CLIENT, from `loans`, `rating`, local table | No | | Summary strip ordering | CLIENT, stable sort on `discardValue` | No | | Quick-sell price shown on a card when `discardValue` is 0 | CLIENT, from `fcc_discardcoins` | No. Omitting the field is the EA-authentic behaviour | | `cardtype` from `cardsubtypeid` (`FUN_1800d8330`) | CLIENT | No | | Store tile rendering, grouping, backgrounds | CLIENT | No | | Game-hub tile types and captions (`FUN_1800b2680`) | CLIENT | No | | FIFA Points pricing | CLIENT plus platform store | No. Out of scope offline | | 64-item clamp on bulk operations | CLIENT | No, but accept a list of up to 64 | | 100-pack cap on the catalogue | CLIENT | No, but never send a 101st pack | | DIME entitlement mapping (`FIFA17FUTPACKnnn`) | CLIENT plus Origin | No. UTAS never serves it | The row that decides how much work exists: almost everything expensive-looking about pack opening (the walkout, the tiering, the colours, the ordering, the animation) is client-side arithmetic over fields we already send. The genuine outstanding server-side work is three items: duplicates, quick-sell credit, and the `unopenedPacks` counter. --- ## 4. What is still unknown, and the cheapest way to settle it ### Needs more decompiling (cheap, no launch, no risk) 1. ~~**The envelope rule.**~~ **DONE 2026-08-05 evening.** See the resolution box in section 2. Token enum decoded, reading A confirmed, `/purchased` shown not to be a counterexample. `POST /user` and `GET /purchased` are already correctly wrapped and need no change. The follow-up it opened, which is now the live question: **does the three-token blind-first-pair rule apply to `GET /hub`, which we answer with a flat two-key body?** There is no `FutGetHubServerResponse` class and no code xref to either atom, so `/hub` may not go through a generated root at all. UNKNOWN, and worth one query. 2. **`NUM_*_IN_PACK` authority.** Decompile `FUN_1800147f0`, the sole caller of `FUN_180015d80`, and see whether its `param_4` is the JSON-filled pack record. One function. Until then those five numbers are UNKNOWN, not SERVER. 3. **Whether anything reads `FutCreatePackServerResponse+0x28` (`numberItems`).** The original evidence was a misread of an adjacent vtable: `0x180228260` is a two-slot response vtable, `{0x1801624e0 dtor, 0x180162880 deser}`, and `0x180228270` is the separate ServerCall vtable. The conclusion survives trivially (a two-slot vtable has no accessor) but the non-virtual path is unproven and the offset is disp8-encodable, so no byte scan bounds it. 4. **The three unread consumers of the `mypacks` literal**, `FUN_180014df0`, `FUN_180014580` and `FUN_1800147f0`. Only `FUN_1800150d0` was read. 5. **Atom 0x20d uniqueness.** The dream-squad identification is solid; the "only parser" half was not independently reproduced, because a raw immediate scan cannot see running-sum ladder dispatch and its own controls failed. 6. **`class_deser()` is currently broken** in the rebuilt Ghidra projects: it returns `[]` for all three documented control classes, because it searches for `"\0"` while the only literal is `"RS4:ServerResponse"`. Fix it to take the full name or delete it. Until then, any absence claim resting on an empty result from it is worthless. ### Needs a live memory probe (cheap, read-only, no launch) 7. **Nothing about pack records can be checked live until the store screen is opened.** Two independent signatures found zero resident pack records this session: a scan of 3.2 GiB of writable memory for the constructor's own `CREATEPACK` literal, and a scan for the `FUT Vector` / `FUT String` pointer triple. So every runtime offset in section 2.1 is static-only. Re-running the probe with the store open would confirm the whole tile field map in one pass. 8. **`data/store/dimecfg.xml`.** The `file://` path string is live at `0x79049a0` but the parsed content was not located. It is the sibling of `storecfg.xml` and probably carries the group/category structure that `displayGroup` maps onto, which is exactly the field that froze the store once. Worth a targeted hunt before this process exits. 9. **The `FIFA17.exe` side of the reveal gate.** CardsDLL exposes `packOpeningAnimationEnabled` as a vtable slot; whether the packed exe also reads it is unknown, and would need a sweep of the decrypted arena at `0x1450f3000..0x14b1a3000`. Low value now that the byte is known to be 1. ### Needs a live in-game test the user has to drive (expensive, keep short) Ordered by value. Only the first three are worth a launch. 10. **What request the reveal's Quick Sell actually sends.** Single or bulk, URL suffix or body, and what the body looks like. This costs one menu action, needs **no server change at all**, and it is the difference between a quick sell that pays and one that silently pays zero. 11. **Whether `finalFunds` or `funds` is the tile price.** Two ints we already send, given different values. Settles a documented ambiguity and doubles as the positive control for everything else in the run. Zero freeze risk. 12. **Whether the reveal animation plays at all.** The gate byte is 1, so this observation now tests terms 2 to 4 of the AND rather than the flag. 13. Lower value, do not spend a launch on these alone: whether a populated `displayGroup` is safe; whether `unopenedPacks` alone lights the unclaimed-pack tile or whether the store must also return a `mypacks` group; whether `purchaseLimit` plus `purchaseCount` is what greys a tile out. ### Coverage, stated honestly - The live string sweep covered roughly 98% of the anonymous and W+X scope. Between the two passes, 64 MiB and 88 MiB respectively came back short on `pread` and were never retried or identified. The 549 MiB of readable file-backed non-W+X mappings were deliberately not swept. Any absence claim over live memory carries that hole, including "walkout does not exist". - The provenance of the `storecfg.xml` content is unresolved. It is not in CardsDLL, not in the packed exe, not in `stp-origin_emu.dll`, not in the repo, and not in 256 files totalling 3.24 GiB under the install. But `Data/` is 30 GiB of compressed archives that a raw grep cannot see into, and the file-scan skipped everything over 512 MiB. It is client-owned commerce data, not something UTAS sends; which channel delivered it is unknown. - The terminal consumer of every store-tile field lives in Denuvo-packed `FIFA17.exe`. We can prove data reaches the tile; we cannot read the predicate that turns it into a greyed-out tile. Every "how to make a pack sold out" statement in this document is a ranked candidate, not a recipe. - The absence proof for "nothing compares packContentInfo against itemList" has one hole that no byte scan or operand census can close: a pre-biased pointer, for example `LEA rax,[rcx+0x140]` then `MOV edx,[rax+4]`. Closing it needs symbolic data-flow, which nobody attempted. - One reversing pass reported the retail catalogue's heap-pool table with `FIFA17FUTPACK1100 -> Origin.OFR.50.0001484`. The XML records disagree and give that offer id to 1101. My own re-parse of the artifact initially reproduced the same class of skew, because the dumped XML contains `<>` markers where pages were not resident and a naive regex spans them. The table in section 5 is the gap-aware parse and is the one to trust; the eleven earliest SKUs, which survive only in the pool and not as XML records, have not been paired to that standard. --- ## 5. Proposed changes to ENDPOINT_MAP.md Do not apply these blind. They are correct about what the parser reads, which this project has learned twice is not the same statement as "safe to send". Everything below is a documentation change only; the server changes it implies are in section 6. One formatting note. The removal lines in these diffs are quoted from `ENDPOINT_MAP.md` character for character, and the new subsection headers follow its existing `### N. Name - confidence: X` convention. That file uses em dashes, so the quoted and paste-ready blocks below contain them. They are reproduced deliberately, so the diffs apply cleanly and the pasted sections match their neighbours. The prose in this document does not use them. ### 5.1 Line 1042, `displayGroup` is an object, not an array ```diff - | `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) | + | `displayGroup` | 0xd9 | **flat OBJECT** | `value`(0x377,STR)→rec+0x00, `priority`(0x250,INT)→rec+0x34. NOT recursive, NOT an array. Case 0xd9 loops on `tok != 10` (object end); the neighbouring `currencies` case loops on `tok != 0xd` (array end), which is the in-function control. An array here is the 2026-08-04 store freeze. | ``` ### 5.2 Line 1046, `extPrice` reads neither `amount` nor `currency` ```diff - | `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) | + | `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`). Each inner object reads **exactly one key, `externalPriceId`(0x11a,INT)**; `amount`(0x1b) and `currency`(0xc4) appear nowhere in either function (152 instructions each, one atom compare apiece at `0x180139245` / `0x18013acb4`). `finalPrice` also writes pack+0x78, i.e. **it is the same slot as top-level `firstPartyStoreId`** (freeze-risk) | ``` ### 5.3 Line 1047, `start` and `unopened` are not children of `packContentInfo` ```diff - | `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) | + | `packContentInfo` | 0x20c | **flat OBJECT, exactly five children** | `itemQuantity`(0x170)→+0x144, `goldQuantity`(0x149)→+0x148, `silverQuantity`(0x2c6)→+0x14c, `bronzeQuantity`(0x63)→+0x150, `rareQuantity`(0x273)→+0x154. Anything else inside reaches SKIP `0x180135ff0`. **`start`(0x2e3) and `unopened`(0x35d) are TOP-LEVEL siblings, not children** (freeze-risk) | + | `start` | 0x2e3 | INT | rec+0xb4, non-negative clamp `0x1800d7b30` | + | `end` | 0x102 | INT | rec+0xb8 | + | `unopened` | 0x35d | BOOL | rec+0xcd, stored raw (contrast `useDefaultImage` 0x36a → rec+0xcc, stored **inverted**) | + | `state` | 0x2eb | STR | compared against `"active"` → rec+0xb0. Constructor default is 0, i.e. **not active** | + | `quantity` | 0x26b | INT | rec+0xbc. A sent **0 is rewritten to -1** | + | `purchaseLimit` | 0x265 | INT | rec+0xc0 | + | `purchaseCount` | 0x261 | INT | rec+0xc4 | + | `saleType` | 0x298 | STR→enum | `NONE`0, `QUANTITY`1, `TIME`2, `TIME_QUANTITY`5, **anything else 0**. `"promo"` is not in the enum | + | `packType` | 0x20f | STR | rec+0x108, ctor default `"INVALID"` | + | `isPremium` | 0x176 | BOOL | rec+0xce | + | `id` | 0x15c | INT | rec+0x70 as a **u16**; values above 65535 truncate | + | `points` | 0x240 | INT | rec+0x140 | + | `visible` | 0x37d | **no getter** | one instruction, `MOV byte,1`. Presence sets it; the value is never read, so `false` reads as `true` | + | `firstPartyStoreId` | 0x127 | **STR then `atoi()`** | rec+0x78. Sending a JSON int here is a documented freeze | ``` ### 5.4 Line 1051, delete the "none of these atoms exist" paragraph ```diff - - `store_catalog()` currently emits `id, packType, quantity, purchaseLimit, purchaseCount, isPremium, saleType` — **none of these atoms exist in the pack deser** (`id`=0x15c, `quantity`=0x26b, `saleType`=0x298, `packType`=0x20f, `isPremium`=0x176 are all routed to SKIP `0x180135ff0`). They are harmless no-ops but do nothing. + - **CORRECTION 2026-08-05.** Every one of `id`, `quantity`, `saleType`, `packType`, `isPremium`, `purchaseLimit` and `purchaseCount` has a real dispatch arm and a real store. Proven three ways: the decompile, the raw sub-ladder at `0x18013b62e`, and a decode of the MSVC jump table at `0x18013b792` (byte index table `0x18013bcb4`, seven targets `0x18013bc98`), which shows six atoms getting real arms and the other 132 indices falling to the shared SKIP. They are not no-ops. Being parsed is still not the same as being safe to change. + - The catalogue is **capped at 100 packs**. `CMP EDX,0x64` at `0x18013bb02` jumps past both push_back paths to the record destructor. Elements 101 and up are parsed and silently discarded. + - **`packContentInfo` is DECORATIVE.** Exactly one function reads the five quantity slots (`FUN_18002c3c0`, single code caller `FUN_1800150d0`) and it is a MOV-to-MOV copy into the tile view model with no arithmetic and no comparison. An operand-text census over 13,308 functions finds no code anywhere that compares the declared counts against the delivered `itemList`. `open_pack()` does not have to honour the declared distribution; the numbers are rendered, so agreement is cosmetic. + - **The coin price the tile shows is `finalFunds`, not `funds`** (`FUN_18002c3c0`: `tile+0xa0 = currencyRecord+0x24`). `funds` feeds the mtx slot and is then overwritten by the platform-store lookup. Both ints pass the non-negative clamp `0x1800d7b30`, so a negative price silently becomes 0. UNTESTED live; see the plan's §7 control. ``` ### 5.5 Lines 920 to 923, the item-lifecycle rows ```diff -| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH | +| 8 | FutDiscardCard | `0x180127300` | POST-tunnelled DELETE `ut/delete/%s/item` (single: id in URL as `/%llu`; bulk: body `{"itemId":[…]}`, atom 0x16d) | `items`(0x171) → **array of OBJECTS**, each `{id:(0x15c,int)}` [FREEZE-RISK]; `totalCredits`(0x326) → int. **No top-level `id`.** Each parsed id is also passed to model slot +0xa30 (local removal) | GAP | HIGH | -| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH | +| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/%s/item/resource` (urlIndex 0x0e) | `totalCredits`(0x326) → int, and nothing else | GAP | HIGH | -| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH | +| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK], elements `id`(0x15c,int64) · `pile`(0x226,STR: club/purchased/trade only) · `success`(0x2fa,bool) · `reason`(0x279,STR; `"Destination Full"` → code 0xf) · `dreamSquads`(0xe9, genuine **array of bare ints**). **`chemistry`(0x81) is NOT parsed** (whole 6193-char function read) | ack live-proven | HIGH | -| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item` (move by res) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool (+ 2 str/1 int minor) | GAP | HIGH | +| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item/resource` (urlIndex 0x0e) | atoms are exactly `id`, `itemData`, `pile`, `reason`, `success`. **`chemistry`(0x81) is NOT parsed here either** (3040-char function read in full) | GAP | HIGH | ``` ### 5.6 Lines 968 to 975, the minimal known-good bodies ```diff // 8 DiscardCard - DELETE ut/delete/game/fifa17/item -{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 } +{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 } // 9 DiscardCardByRes - DELETE ut/delete/game/fifa17/item { "totalCredits": 15000 } // 10 MoveCard / 11 MoveCardByRes - PUT ut/game/fifa17/item -{ "itemData": [ /* moved item */ ], "chemistry": true } +{ "itemData": [ { "id": 123456789, "pile": "club", "success": true } ] } ``` ### 5.7 Line 1095 and line 218, `duplicateItemIdList` ```diff - | `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk | + | `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | `itemId`(0x16d,int64) · `duplicateItemId`(0xeb,int64) · `itemLoans`(0x16f,int32) · `duplicateItemLoans`(0xed,int32). NOT a list of ints (freeze-risk). Post-pass in `0x180162880` writes `duplicateItemId` into the matched card at +0x10; the reveal controller `0x18009bc40` reads it as "is duplicate" and diverts to `GotoNewItems` **without issuing any request** | ``` At line 218 change `duplicateItemIdList` → **array** to `duplicateItemIdList` → **array of objects**, and note the contrast with `dreamSquads` (0xe9), which really is an array of bare ints. ### 5.8 New subsection, ready to paste after Store §5 ```markdown ### 5a. Where the coin buy actually goes - confidence: HIGH (request), MEDIUM-HIGH (response binding) Two distinct ServerCall classes post into the store area and they are chosen client-side, before any request is sent. Nothing in a server response selects between them. | Call | id | Request builder | URL | Body | |---|---|---|---|---| | CreatePack (`PurchasePack`) | 0x4b | `0x180162530` | `ut/%s/purchased`, urlIndex 0x1a | `packId`(0x20b) · `useCredits`(0x369) · `usePreOrder`(0x36b) · `currency`(0xc4: COINS/MTX/POINTS, omitted when usePreOrder) | | PurchaseItems | 0x4c | `0x180126440` | `ut/v2/%s/store` + `/transaction` + `/` when state > 1 | always `state`(0x2eb) from the 9-entry table at `0x1802d02c0`, plus per-state extras | `ut/v2/%s/store/transaction/0` with `{"state":"TRANSACTIONCANCEL"}` is **observed on every boot** (2026-08-05 14:39:28). The PurchaseItems state machine is not first-party-only from our point of view; the cancel reaches us and must be answered `200 {}`. State table (`0x1802d02c0`, read from the image and live, nine entries, no tenth): `NOTRANSACTION` -1, `TRANSACTIONCREATED` 1, `PURCHASESTARTED` 2, `PURCHASECOMPLETE` 3, `PURCHASECONSUMABLECOUNT` 4, `PURCHASECONSUME` 5, `TRANSACTIONCOMPLETE` 6, `TRANSACTIONCANCEL` 7, `NONE` -1. The out-of-band refusal is the literal `PURCHASEERROR`, which sets error 998 at response+0x1c before the enum map runs, and it exists only on PurchaseItems. `FutCreatePackServerResponse` has no `state` and no `reason`. **HTTP status is a single table, `FUN_1801844c0`, reached through ServerCall vtable slot 12.** Only 200 is success. 204 → 99. **Any other 2xx, 201 and 202 included, falls through to 999.** 461 → 3. Most other 4xx/5xx → 998. PurchaseItems alone overrides slot 12 (`0x1801267b0`) so that 409 plus the body substring `User already has a transaction` becomes 0x70, a pending code the client re-polls. **`FutPurchaseItemsServerResponse` field corrections** (deser `0x1801269f0`): `transactionId`(0x33a) is an 8-byte store at +0x28; `firstPartyStoreId`(0x127) is read with the **INT** getter here (contrast the pack element, which uses STR + `atoi`); `packId`(0x20b) is stored as a **u16** at +0x98; and +0xa0/+0xa4/+0xa8 are not credit fields, they are `useCount`(0x368), `useTime`(0x375) and `useAuth`(0x367). **`useAuth` is read with the INT getter `0x1801c79d0` and then coerced by `TEST/SETNZ`, not with the BOOL getter.** If we ever serve it, it must be a JSON number, never `true`/`false`. ``` ### 5.9 New subsection, the recovered retail catalogue ```markdown ### 5b. The real FIFA 17 pack SKUs - confidence: HIGH, source is perishable Recovered 2026-08-05 from the live client heap of pid 4048 (parsed copy of `data/store/storecfg.xml`, buffer `0x3dabe000..0x3dac7000`). Not present in CardsDLL, in the packed exe, or in any readable file under the install. This is the id space the FIFA 17 store was built around. Our catalogue uses assetId 1, 5 and 6, which are not members of it; see the plan's risk register for why that is a lead and not an instruction. | serverId | name | Origin offer | entitlement | uniqueId | |---|---|---|---|---| | 504 | SILVER PLAYERS PACK | Origin.OFR.50.0001350 | FIFA17FUTPACK504 | 0f17f013 | | 506 | SILVER PLAYERS PREMIUM | Origin.OFR.50.0001351 | FIFA17FUTPACK506 | 0f17f03b | | 600-608 | FIFA Points 100 / 250 / 500 / 750 / 1050 / 1600 / 2200 / 4600 / 12000 | none | FP0600-FP0608 | 0f17f000-008 | | 809 | Draft Entry | Origin.OFR.50.0001335 | FIFA17FUTPACK809 | 0f17f809 | | 900 | Gold 13 PACK | Origin.OFR.50.0001359 | FIFA17FUTPACK900 | 0f17f042 | | 901 | Premium Gold 13 Pack | Origin.OFR.50.0001362 | FIFA17FUTPACK901 | 0f17f043 | | 902 | Jumbo Gold 26 Pack | Origin.OFR.50.0001360 | FIFA17FUTPACK902 | 0f17f044 | | 903 | Jumbo Premium Gold 26 Pack | Origin.OFR.50.0001361 | FIFA17FUTPACK903 | 0f17f045 | | 904 | VALUE PACK 5 | Origin.OFR.50.0001363 | FIFA17FUTPACK904 | 0f17f046 | | 1101 | LIGUE 1 PREMIUM GOLD PACK | Origin.OFR.50.0001484 | FIFA17FUTPACK1101 | 0f17f04E | | 1102 | SERIE A PREMIUM GOLD PACK | Origin.OFR.50.0001485 | FIFA17FUTPACK1102 | 0f17f04F | | 1103 | LIGA BBVA PREMIUM GOLD PACK | Origin.OFR.50.0001486 | FIFA17FUTPACK1103 | 0f17f050 | | 1104 | BUNDESLIGA PREMIUM GOLD PACK | Origin.OFR.50.0001487 | FIFA17FUTPACK1104 | 0f17f051 | | 1110 | BPL PREMIUM PLAYERS PACK | Origin.OFR.50.0001488 | FIFA17FUTPACK1110 | 0f17f052 | | 1111 | LIGUE 1 PREMIUM PLAYERS PACK | Origin.OFR.50.0001489 | FIFA17FUTPACK1111 | 0f17f053 | | 1112 | SERIE A PREMIUM PLAYERS PACK | Origin.OFR.50.0001490 | FIFA17FUTPACK1112 | 0f17f054 | | 1113 | LIGA BBVA PREMIUM PLAYERS PACK | Origin.OFR.50.0001491 | FIFA17FUTPACK1113 | 0f17f055 | | 1114 | BUNDESLIGA PREMIUM PLAYERS PACK | (record truncated by a page gap) | | 0f17f056 | | 1120 | BPL PRIME PLAYERS PACK | Origin.OFR.50.0001493 | FIFA17FUTPACK1120 | 0f17f057 | | 1121 | LIGUE 1 PRIME PLAYERS PACK | Origin.OFR.50.0001494 | FIFA17FUTPACK1121 | 0f17f058 | | 1122 | SERIE A PRIME PLAYERS PACK | Origin.OFR.50.0001495 | FIFA17FUTPACK1122 | 0f17f059 | | 1123 | LIGA BBVA PRIME PLAYERS PACK | Origin.OFR.50.0001496 | FIFA17FUTPACK1123 | 0f17f05A | | 1124 | BUNDESLIGA PRIME PLAYERS PACK | Origin.OFR.50.0001497 | FIFA17FUTPACK1124 | 0f17f05B | | 1200 | BPL SQUAD PACK | Origin.OFR.50.0001498 | FIFA17FUTPACK1200 | 0f17f05C | | 1201 | LIGUE 1 SQUAD PACK | Origin.OFR.50.0001499 | FIFA17FUTPACK1201 | 0f17f05D | | 1202 | SERIE A SQUAD PACK | Origin.OFR.50.0001500 | FIFA17FUTPACK1202 | 0f17f05E | | 1203 | LIGA BBVA SQUAD PACK | Origin.OFR.50.0001501 | FIFA17FUTPACK1203 | 0f17f05F | | 1204 | BUNDESLIGA SQUAD PACK | Origin.OFR.50.0001502 | FIFA17FUTPACK1204 | 0f17f060 | | 1210 | BPL TRIAL PACK | Origin.OFR.50.0001503 | FIFA17FUTPACK1210 | 0f17f061 | | 1211 | LIGUE 1 TRIAL PACK | Origin.OFR.50.0001504 | FIFA17FUTPACK1211 | 0f17f062 | | 1212 | SERIE A TRIAL PACK | Origin.OFR.50.0001505 | FIFA17FUTPACK1212 | 0f17f063 | | 1213 | LIGA BBVA TRIAL PACK | Origin.OFR.50.0001507 | FIFA17FUTPACK1213 | 0f17f064 | | 1214 | BUNDESLIGA TRIAL PACK | Origin.OFR.50.0001506 | FIFA17FUTPACK1214 | 0f17f065 | A second heap pool adds eleven earlier SKUs whose XML records did not survive: 102, 105, 200, 202, 203, 205, 300, 302, 304, 306, 400, plus names including PREMIUM GOLD JUMBO, PREMIUM SILVER JUMBO, PREMIUM SILVER PACK, JUMBO SILVER PACK, PREMIUM BRONZE JUMBO and JUMBO BRONZE PACK. Their SKU-to-offer pairing is **not** established to the same standard and one published pairing for 1100/1101 was off by one, so treat those eleven as names without ids. `CARDS_CB_ERR_PACK_NOT_IN_DIME` is **not** evidence of a client-side DIME lookup: it is `case 0x22` of `FUN_1800d7b70`, a pure error-code-to-display-name table of 57 `CARDS_*` strings. It is a code the server can return. ``` ### 5.10 New subsection, the inventory counters ```markdown ### 5c. unopenedPacks and the unclaimed-pack tile - confidence: HIGH `userInfo.unopenedPacks` (0x35e) is an OBJECT whose only recognised children are `preOrderPacks`(0x24b, INT) and `recoveredPacks`(0x27b, INT). `count`(0xbc) is **not** read there. The two are summed and stored at `model+0x20950` through model vtable slot +0x4e0 (`0x18011e120`), which then broadcasts message `0x273d`, registered as `RELOAD_CENTRAL_PANEL`. That rebuild is what can produce game-hub tile type 0x1c, `CentralUnclaimedPack`, caption `FUT_GH_UNCLAIMED_PACK_0`, destination `GOTO_STORE_MYPACK`. Nothing in the chain issues a request, and no request could exist: the 48-entry template table has no pack-inventory route. The **My Packs** screen is a filter over the ordinary store catalogue. `FUN_1800150d0` walks the same 0x158-byte pack array the store deserializer fills and selects entries whose `displayGroup.value` equals the literal `"mypacks"` (`0x1801ec008`), ordering them by `displayGroup.priority`. The same two counters also appear in `FutUserCreditsServerResponse` (`0x180122c50`, nested inside `unopenedPacks`) and in `FutSBCSubmitChallengeServerResponse` (`0x180161b00`, at the top level). An SBC that awards a pack reports it by bumping counters, not by returning a pack object. **Minimal known-good** (inside `userInfo`): `"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 1}` UNTESTED. Live value of `model+0x20950` measured at 0 on 2026-08-05. ``` --- ## 6. Risk register House rule applies to all of it: default off behind an env flag until it has been in front of the game once. The two worst regressions this project has had, the frozen store and packs failing to open, both came from technically correct field corrections shipped without a live test. | Change | Freeze risk | Type fidelity requirement | Default | |---|---|---|---| | **(A)** `quick_sell_route()` parses `{"itemId":[…]}` and the trailing `/%llu` in the URL | None on the wire (request parsing only) | n/a | **off**. It is not risk-free: today the handler matches nothing, so a quick sell destroys no cards and writes no save. With (A) it will delete cards and credit coins. A wrong id parse destroys real cards | | **(B)** discard response becomes `{"items":[{"id":N}],"totalCredits":C}` | Low. `items` must be an array of OBJECTS; the currently documented array of bare ints would feed the int getter an object token and spin `0x1801c7f1a` | `items` array of objects, `id` and `totalCredits` plain ints | off | | **(C)** move the `createPackResponse` envelope onto the `POST /purchased` response | Medium, and the danger is not a freeze. Both `FutCreatePack` and `FutGetPurchasedItems` write the **same** container (model vtable +0x160). Serving both could double-populate the reveal | `itemList` array, `numberItems` int, `purchasedPackId` int, `duplicateItemIdList` array of objects | off. Do not ship in the same round as anything else | | **(D)** serve `duplicateItemIdList` with real entries | High if (C) is not in place, because the field only reaches the client through the createPack path. Array of OBJECTS or nothing | `itemId`/`duplicateItemId` int64, `itemLoans`/`duplicateItemLoans` int32 | off, and blocked on (C) | | **(E)** serve `userInfo.unopenedPacks` with a non-zero pair | Low. Two ints inside an object; unknown keys inside it SKIP safely | object with two int children. `count` is not read there | off | | **(F)** send `displayGroup` as `{"value":"mypacks"}` on some packs | Medium and **unquantified**. This is the first field we would send that selects a render path rather than a value, and the tile builder is in the packed exe. `FUT_STORE_DISPLAYGROUP` already exists for exactly this and is documented as untested | flat object; `value` STR, `priority` INT. An array here is the 2026-08-04 freeze | off, existing flag | | **(G)** move `unopened` from inside `packContentInfo` to the top level | Low. It becomes a real BOOL at rec+0xcd, copied to tile+0x69. What the tile does with it is unread | JSON `true`/`false`; it is a genuine BOOL getter | off. Low value: today it is inert, tomorrow it does something unknown | | **(H)** accept `pile` values `trade` and `purchased` in `move_items()` | Low. Same route, same verdict body that is already live-proven for `club` | `pile` must be one of exactly three strings; anything else maps to enum 0 client-side | on is defensible, since the handler already stamps whatever pile it is given | | **(I)** set a pack's `finalFunds` different from `funds` | **None.** Both are plain ints we already send | ints, and negatives silently clamp to 0 | this is the §7 control, ship it for the test only | | **(J)** change `assetId` to a real DIME serverId | **Do not.** Packs open today with 1/5/6, so nothing on the coin path validates against DIME, and the one string that suggested otherwise turned out to be an error-code table entry | n/a | never, until somebody decompiles the DIME consumable lookup and shows the coin path reaches it | | **(K)** send `extPrice.finalPrice.externalPriceId` | **Do not.** It writes the same slot as `firstPartyStoreId`, and a non-negative value there sends the tile into a platform-store lookup that cannot succeed offline | int, but the correct action is to leave the current inert `{"amount":…,"currency":"mtx"}` alone or delete it | never for now | Things that are inert and should be left alone rather than "fixed": `saleType: "promo"` (not in the client enum, lands on NONE), `limitType` (not an atom at all), `visible` (the value is never read), `useDefaultImage` (stored inverted, so `true` sets false). Churning accurate-but-inert fields costs a live test and buys nothing. --- ## 7. The one-launch live test script Budget: **one server restart, one FIFA launch.** Steps 1 and 2 are the control and they go first. Steps 3 and 4 need no server change at all and are the highest value per unit of risk in the whole plan, because they read the client's own requests off the wire. ### Pre-flight, terminal, no launch ```bash cd fifa17-recon/tools grep -nE '\breturn 2(01|02|04|06)\b' utas_server.py # expect: no matches python3 -c "import fut_store as s; print([p['price'] for p in s.PACK_CATALOG])" env | grep FUT_PACK_AUTOCLUB # expect: nothing ``` Only 200, 404 and 461 may ever be returned. Anything else in the 2xx range maps to FUT error 999. Audited: the file returns only 200 (59 sites), 404 (once) and 461 (three times), so this check is already clean and is here as a guard against regression. **`FUT_PACK_AUTOCLUB` must be off, and the file lies about its own default.** Line 728 reads `os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"`, so the code default is off, but the comment at line 864 asserts "`FUT_PACK_AUTOCLUB=1` (the default)". The comment is stale and the code is right. This matters more than a stale comment normally would, because autoclub deposits a pack's cards into the club before the reveal screen ever asks, and the file's own history at lines 725 to 727 records the consequence: "the first live attempt at the correct response shape produced no PUT /item at all because autoclub had already emptied the pile." If it is on, steps 3 through 6 below produce no requests and the entire run reads as a false negative. Check the environment, not the comment. Optionally re-read the gate byte on the client that is running right now, read-only, no restart needed: ```bash python3 /tmp/.../packres/synth_check.py # expect: FNV control MATCH, +0x1fd45 value = 1 ``` ### The change to ship **One variable.** Gold Pack only: set its price to `4321` and serve `"currencies":[{"name":"coins","funds":5000,"finalFunds":4321}]`. Nothing else changes. Two ints we already send, given different values, and the server debits the same number the tile should show, so the state stays internally consistent. ### In the GUI, in this order 1. **Store. THE CONTROL.** Open it and read the Gold Pack price. 2. **Buy the Gold Pack.** Note the coin balance before and after. 3. **The reveal.** Record three things: whether an animation plays at all, how many cards appear, and whether one card is singled out as the headline. 4. **Quick Sell exactly one card** from the reveal, then leave the rest. 5. **Send to Transfer List** exactly one card, if the option is offered. 6. **Send to Club** the remainder, which is the live-proven path and doubles as the regression check. 7. Regression sweep: MY CLUB still 205 players, 189 gold, 8 silver, 8 bronze; managers still 34 staff cards with no DB Error; store still lists three packs. Then, and this is the part that carries the round: ```bash grep -nE 'delete/game/[^/]+/item|store/transaction|POST /ut/game/[^/]+/purchased' -A6 /tmp/utas.log ``` ### Reading the result | Step 1 price | Meaning | |---|---| | **4321** | The store body reached the tile **and** `finalFunds` is the coin price. Everything below is readable. Land the ENDPOINT_MAP correction at §5.4 as CONFIRMED-LIVE | | **5000** | The body reached the tile but `funds` wins. Also readable, and it inverts the §5.4 correction. Record it and fix the map the other way | | **unchanged from today, or blank** | The store body did not reach the tile. **Stop reading here.** Nothing in steps 2 to 6 is interpretable, and the failure is in delivery, not in any field | | **store fails to open** | Two ints broke it, which would be new information of a very unwelcome kind. Revert the price, restart, and treat the currency record as far more fragile than believed | | Observation | Meaning | |---|---| | Step 3, animation plays | The five-way AND is fully satisfied today. Delete `packOpeningAnimationEnabled` from the backlog and from yesterday's settings plan | | Step 3, no animation, byte is 1 | The blocker is term 2, 3 or 4: `resourceId & 0xffffff`, `cardtype == 1`, or `ASSET_ID` missing from the data provider. That is a card-field problem, not a settings problem, and it is cheap to bisect | | Step 3, one card visibly singled out | Confirms the headline selector runs on the client-computed quick-sell value, since we send no `discardValue`. No server work | | Step 4, log shows `ut/delete/.../item/` with no body | Single discard is URL-form. Ship (A) parsing the URL suffix | | Step 4, log shows a body `{"itemId":[…]}` | Bulk form even for one card. Ship (A) parsing `itemId` | | Step 4, log shows `itemData` or `itemIds` | The current handler was right all along and the static reading is wrong. Do not ship (A) | | Step 4, no request at all | Quick Sell is client-only until a batch is confirmed elsewhere. Re-run with "Quick Sell All" | | Step 4, coins increase | Impossible today, since the handler matches nothing. If it happens, our understanding of the route is wrong | | Step 5, `PUT /item` with `"pile":"trade"` | Confirms transfer-list send is the same route. Ship (H) | | Step 5, some other path | New route, capture it and stop guessing | | Step 6 fails while it worked yesterday | Regression from the price change. Revert first, analyse second | The control is doing real work here. Without step 1 a silent step 4 is ambiguous between "Quick Sell sends nothing" and "our store response never arrived, so the buy never happened the way we think". With it, a silent step 4 is a fact. ### What would make this whole plan wrong The reveal path could be fed by something we have not found. The argument that `GET /purchased` is what populates the reveal container rests on the fact that `FutGetPurchasedItems` writes model vtable slot +0x160 and that `POST /purchased` currently answers with a body the createPack deserializer skips entirely. If the cards actually arrive some third way, then section 2.2's account of the buy is wrong and (C) is not just risky, it is pointless. `finalFunds` versus `funds` could be moot if the tile price comes from somewhere else entirely, for instance the platform catalogue path when `firstPartyStoreId` is not -1. We do not send `firstPartyStoreId`, so that should not apply, but the formatter's early-return is the only thing standing between us and that branch. --- ## Next **DONE. The envelope rule is settled, and it found the bug it was looking for, though not the one that was predicted.** Full account in the resolution box in section 2. Queries: `tools/ghidra_queries/q_envelope_{1,2,3}.py`. The prediction in the earlier draft of this section was that `starterPack`, `squad` and `userData` were being swallowed on `POST /user`. **They are not.** The opposite is true, and it is narrower and more interesting. `FutCreateUser` (`0x18014cc60`) is a three-token root with ladder arms for exactly the five keys we send: `login` 0x1a5, `userData` 0x36d, `squad` 0x2cd, `starterPack` 0x2e5, `bonusPacks` 0x5d. We serve them flat, in that order. The three tokens consume `{`, the FIELD_NAME `login`, and the scalar `true`. The loop then dispatches `userData`, `squad`, `starterPack` and `bonusPacks` normally at the outer level, which is exactly where the ladder wants them. So four of the five keys are read, and **`login` is the single key that is silently discarded**. Its name is eaten as the anonymous envelope and its value as the third token. It has an arm, so the client does want it. It has never once been delivered. Two consequences, and the second is the one that matters more: 1. `login: true` has never reached the client. Whether that is harmful is UNKNOWN, since FUT plainly works without it. 2. **The key order of that dict is load-bearing and nothing in the code says so.** Any reordering that puts `userData` first would swallow `userData` instead, which would be a spectacular and very hard to diagnose failure. This is a landmine sitting in `utas_server.py` right now. The likely correct shape is `{"": {login, userData, squad, starterPack, bonusPacks}}`, wrapping all five one level down, since that is what the identical three-token preamble means for `FutCreatePack`. That is a hypothesis with a mechanism, not a proven fix, and it touches the login path, so it goes behind a flag defaulting to off per the house rule. If it is right, `login` starts being read and nothing else changes. If it is wrong, login desyncs and the game cannot enter FUT, so it is not a change to make casually or to bundle with anything else. **The actual next thing to read is `GET /hub`.** We answer it with a flat two-key body, `{"clubPlayers":205,"auctionCount":0}`, and under the confirmed rule a three-token root would silently eat `clubPlayers`. I could not settle it here: there is no `RS4:FutGetHubServerResponse` literal in the image and both atoms appear only as atom-table entries with no code xref, so `/hub` may not be parsed by a generated root at all. One query, no launch, no risk, and it is the same class of silent-discard bug that this pass just found in the auth body.