Files
OpenFUT/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md
T
funman300 755f237f17 fifa17: carry the staff rating the client re-rates to, verified live
Staff quick-sell could not be priced correctly: for cardtypes 2/3/4/5/10 the
client overwrites the rating and rare flag we send with values from its own card
database, and a staff wire record carries no rating, no rareflag and no
discardValue at all. The server had no way to know the displayed price from what
it sent, so pricing declined for staff and fell back to the placeholder ladder.

The missing input was read straight out of the running client (pid 6580), no UI
interaction required:

  * tools/coach_probe.py grades all four resident staff records HIT, which by
    construction requires record +0xb4 == the table's `value` and +0x58 == its
    `rare`. That settles `value`-is-the-rating, which was previously an inference
    and was deliberately not shipped on that basis.
  * tools/discard_probe.py (new) reads both discard slots -- +0x38, the value we
    sent, and +0x3c, the value the client computed for itself:

      1000509  sub 4  ct 2  rat 88  rare 1   sent 0   calc 282   predicted 282
      9000081  sub 6  ct 10 rat 66  rare 0   sent 0   calc  36   predicted  36
      3000083  sub 8  ct 4  rat 66  rare 0   sent 0   calc  36   predicted  36

    4 of 4 agree, 0 disagree. 36 on the value-66 GK coach was the exact falsifier
    written for this last commit.

Entities::enrich_staff now fills rating from `value` and rareflag from `rare` for
the five staff families, and the catalog emits the real rareflag instead of a
hardcoded 0 (it is not cosmetic -- it selects the discard price column, which is
why the rare-1 manager prices at 282 and a rare-0 coach at 36). Players and
consumables are untouched; their wire values are authoritative.

Verified on staging: a GK coach quick-sells for 36, not the 150 floor. The
catalog diff is exactly the two coach entries gaining rating 66; 1710 entries in
and out, nothing else changed.

The same probe shows what production does to PLAYERS today: every resident player
carries sent+38 = 1500, which suppresses the client's own computation, against a
real 688..752 for a gold rare and 72,800 / 74,400 for the two legends.

Still open, and not a discard problem: manager fifa17_1000509 is owned in Core
but has no catalog entry or definition (it reaches the client through the opaque
squad extension), so it declines to the ladder. That is definition coverage.

Importer 41 tests, fmt and clippy clean.
2026-08-21 22:51:16 +00:00

74 KiB

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 imm32s past the end of the function: those three 0x108s are the field addresses param_1 + 0x108 and param_2 + 0x108 of a FUT String member inside the 0x158 record, and two of the cited addresses are inside the next function, the record constructor at 0x1801342d0. Two verifiers decompiled FUN_1801340e0 in full and it is a memberwise copy with no striding of any kind, last member at +0x154. The task premise that was declared "contradicted by live memory" was in fact exactly right: displayGroupAssetId really is at wire record +0x30, displayGroup.priority at +0x34, displayGroupUseDefaultImage at +0x38. Restore those.

Three smaller things worth carrying forward:

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

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

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


2. The grouping bug

2.1 Current best explanation

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

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

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

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

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

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

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

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

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

2.2 Should we sit at FUT_STORE_DISPLAYGROUP=0?

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

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

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

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

2.3 Ranked experiments

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

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

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

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

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

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

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

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


3. The store and pack wire model as now understood

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

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

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

3.2 The 0x158 wire pack record, deser FUN_18013af30

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

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

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

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

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

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

3.3 Currencies, FUN_180138bd0, 0x30 stride

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

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

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

3.4 extPrice, and why it should go

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

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

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

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

3.5 Purchase

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

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

3.6 Quick sell, and the discard economy

This is the most complete part of the run.

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

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

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

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.

# 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():

        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.

# 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

    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

# 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:

        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.

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

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

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

P5. Reshape the CreateUser body

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

# 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"
    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:

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

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

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

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

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

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

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

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

Section on discard, replace the FutDiscardCardServerResponse body.

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

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

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

FutCreateUserServerResponse.

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

userInfo.unopenedPacks.

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

8. Coverage, honestly

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

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

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

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

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

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

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


Next action

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