Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.
THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:
value = round_half_up(rating * price / 100)
level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3,
derived from rating, NOT a wire field)
cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
across every subtype 0..599 with zero disagreements
A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.
This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.
Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.
ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.
THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.
The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.
extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.
A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.
Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.
Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.
Live: 439 contract checks pass, market suite passes, both flags off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
71 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.
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)
- Which atom writes wire
+0x70. It feedsSERVER_IDand therefore the purchasepackId. DecodeFUN_18013af30's jump table at0x18013b001and find the arm that stores to frame slotrecord-0xb0+0x70. High value for the price. FUN_18013fe00's jump table. Nobody has enumerated it, which is why theloans(0x19b) toitem+0x90attribution is only corroborated and why the atom that writesitem+0x58(rare flag) is unidentified. Same byte/dword table shape as theuserInfoone at0x18013f2c8/0x18013f29c.- Is
FUN_180016ef0reached on the store path? Its only caller isFUN_180017420. This decides whether the introsort landmine in 2.3 is live. - The remaining
vt+0x4d8/vt+0x4e0callers.FUN_1800706e0doesset(get() + delta)and nobody knows where the delta comes from, nor which response class feedsFUN_180019780's five booleans. Do this before anyone ever serves a non-zero unopened count. - The bulk-discard action row. Which action index owns
FUN_180126f40, and the URL and method. The pointer block at0x1801f3110mixes it with the single-id URL builder and the three action factories at0x180123cc0/cd0/ce0are not disassembled as functions in the current project. - Re-run dimension 4's
item+0x10write 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)
- Which field the UI renders for quick sell,
item+0x38oritem+0x3c. The cheap version needs a throwaway server on a spare port serving one item withdiscardValue999. Until this is settled, do not start emittingdiscardValue. - What
commerce_mgr->vt[0x140]()returns. It gates thepointscurrency arm. Its code is in Denuvo-decrypted rwx pages ofFIFA17.exe, so it needs a live read at the right moment, not a decompile. - 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.
- The silver band. No resident item is rated 65..74, though the profile
contains eight. Load one and confirm
item+0x54reads 2 and the value matches the table.
Needs a launch the user must drive (the scarce resource, ranked)
- 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. FUT_STORE_DISPLAYGROUP=0plus buy two different packs. Answers the never-asked question of whether anypackIdbut 6 has ever gone out.- Canonical lowercase category tokens (
bronze,gold,special). - Drop
extPriceand confirm theor %1sline 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.