21a81ad63c
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>
1172 lines
71 KiB
Markdown
1172 lines
71 KiB
Markdown
# The store subsystem: grouping, prices, quick sell
|
|
|
|
Written 2026-08-05, later the same evening as `plan-2026-08-05-pack-opening.md`.
|
|
Five parallel reversing passes over the store catalogue, the display-group
|
|
resolver, the FIFA Points price line, the duplicate list and the unopened-pack
|
|
counter, plus three adversarial verification rounds that refuted four claims and
|
|
corrected eleven more. FIFA 17 was running throughout as pid 134663, in the store
|
|
screen, and was read strictly read-only. No server was restarted and no server
|
|
code was changed.
|
|
|
|
Slide used for every live read in this document, re-derived from
|
|
`/proc/134663/maps` and the on-disk PE by three separate agents rather than
|
|
asserted: `live = static - 0x180000000 + 0x6ffffc140000`. Controls were the FNV
|
|
hasher prologue at `0x180180d00` (`.text`) and the single-occurrence literal
|
|
`RS4:FutSquadSaveServerResponse` at `0x18022c618` (`.rdata`, located by searching
|
|
the file rather than by trusting an address). One verifier added a third control:
|
|
the instruction bytes `41 81 fe 1a 01 00 00` at `0x180139245`, which also proves
|
|
the running code is unpatched. All three matched byte for byte, every time. The
|
|
live addresses die with the process; the static ones do not.
|
|
|
|
---
|
|
|
|
## 1. What we now know that we did not know this morning
|
|
|
|
**The store grouping bug is not in CardsDLL.** Every display group in live memory
|
|
owns exactly the right pack. The group captioned "Gold Pack", ordinal 2, contains
|
|
one pack record and that record is the 5000-coin, 7-item, `id` 5, `packType`
|
|
`GOLD` one. Bronze owns bronze, Premium owns premium. An exhaustive pointer sweep
|
|
over all 4186 MiB of readable memory finds exactly one referrer per node and no
|
|
second copy of any pack record anywhere in the address space, and a separate
|
|
verbatim-byte-window search (76 bytes spanning id, price and all five quantities,
|
|
matched at any alignment) also returns exactly one hit each. There is no stale
|
|
copy, no aliased group, no mis-parse. Nothing we send is being misread.
|
|
|
|
That is a negative result and it leads this document because it deletes an entire
|
|
class of work. Every fix we were about to spend a launch on was a change to what
|
|
the server sends, aimed at a parser that is already producing the correct answer.
|
|
The fault is downstream of the C++ model, in the Scaleform layer that turns a
|
|
click on a tile back into a category id, and that layer is in Denuvo-packed
|
|
`FIFA17.exe` and in `.apt` movie bytecode nobody has read.
|
|
|
|
**The fix that was ranked first is refuted, and it would have cost a launch.**
|
|
One pass recommended setting `displayGroupAssetId` equal to the group's ordinal so
|
|
that the tile's `ASSET_ID` and `CHILD_CATEGORY` Flash fields would carry the same
|
|
number and the drill-down would be right whichever one the movie echoes back. The
|
|
report's own live datum kills it. `displayGroupAssetId` is currently served as
|
|
1, 5, 6, and the store screen's selected-category field reads **3**. Three is not
|
|
in that set, so `ASSET_ID` is not what comes back. Worse, `FUN_180014610`, the
|
|
group-tile builder, writes the tile's own `CATEGORY_ID` field (`model+0x94`)
|
|
as literal **0** and puts the ordinal in `CHILD_CATEGORY` (`model+0x9c`), so a
|
|
group tile does not even carry a category id for the movie to echo. The
|
|
mechanism the recommendation rested on does not exist.
|
|
|
|
**The real FIFA 17 quick-sell value table is recovered, complete, and verified.**
|
|
The client does not need the server to tell it what a card is worth and it does
|
|
not invent a number: it runs a SQL query against a table in its own loaded game
|
|
database, `fcc_discardcoins`, 141 rows keyed `(cardtype, level, rare) -> price`,
|
|
and scales by rating. The full table, the formula, the `cardsubtypeid -> cardtype`
|
|
map and the rating-to-level rule are all in section 3.6 of this document, checked
|
|
against 22 live club-item structs with 22 of 22 exact. Our `quick_sell()` pays an
|
|
invented 600/300/150/50 tier that is wrong for every card: a 94-rated gold rare is
|
|
worth 752, not 600, and a 50-rated bronze common is worth 15, not 50. This is a
|
|
pure server-side arithmetic fix with zero wire risk, and it is the largest piece
|
|
of finished, shippable work this run produced.
|
|
|
|
**There is no correct `extPrice` JSON. The correct `extPrice` is no `extPrice`.**
|
|
Both inner deserializers read exactly one key, `externalPriceId` (0x11a, INT);
|
|
`amount` and `currency` are discarded by the value-SKIP. But before parsing
|
|
anything, both of them look up, and if missing **create**, an entry named `mtx`
|
|
in the pack's currency vector, and the tile adapter turns the real-money price
|
|
line on purely because that row exists. The number that should fill it is fetched
|
|
from the Origin/Dime commerce catalogue by `externalPriceId`, a catalogue that no
|
|
longer exists offline, so the formatter early-returns and every string stays at
|
|
its constructor default. That is where the `or %1s` on the tile comes from: a
|
|
Scaleform format string whose only possible argument is one of four
|
|
catalogue-sourced strings that were never written. Sending `extPrice` at all is
|
|
what switches the broken line on. Deleting the key strictly reduces executed code,
|
|
including an unguarded call into the packed exe's commerce vtable that currently
|
|
runs on every store load.
|
|
|
|
**The 0x108 versus 0x158 versus 0x1a8 confusion is resolved, and it matters
|
|
because three previous documents compute offsets from the wrong one.** There are
|
|
three structures, all real, none of them a contradiction:
|
|
|
|
| size | what it is | built by |
|
|
|---|---|---|
|
|
| `0x158` | the deserialized **wire pack record**, one per element of `purchase[]` | `FUN_18013af30`, copy-ctor `FUN_1801340e0`, grow `FUN_180132180` |
|
|
| `0x108` | the **display group** | `FUN_180012950`, pushed by `FUN_1800150d0` |
|
|
| `0x1a8` | the **store-tile view model**, used for both pack rows and group tiles | `FUN_18002c3c0` from the wire record, `FUN_180014610` for group tiles |
|
|
|
|
The ground-truth pass declared the 0x158 record non-existent because a
|
|
stride-0x158 memory search found nothing. It found nothing because the wire
|
|
records are transient: `FUN_1800150d0` converts them and the response object is
|
|
freed. Its secondary evidence, that `FUN_1801340e0` "carries 0x108 three times and
|
|
0x158 never", was an artefact of counting `imm32`s past the end of the function:
|
|
those three 0x108s are the field addresses `param_1 + 0x108` and `param_2 + 0x108`
|
|
of a `FUT String` member inside the 0x158 record, and two of the cited addresses
|
|
are inside the *next* function, the record constructor at `0x1801342d0`. Two
|
|
verifiers decompiled `FUN_1801340e0` in full and it is a memberwise copy with no
|
|
striding of any kind, last member at `+0x154`. **The task premise that was
|
|
declared "contradicted by live memory" was in fact exactly right**:
|
|
`displayGroupAssetId` really is at wire record `+0x30`, `displayGroup.priority` at
|
|
`+0x34`, `displayGroupUseDefaultImage` at `+0x38`. Restore those.
|
|
|
|
Three smaller things worth carrying forward:
|
|
|
|
**`CREATEPACK` in every parsed pack record is a constructor default, not a client
|
|
decision.** `FUN_1801342d0` initialises the record's string at `+0xd8` from the
|
|
literal at `0x180223110`, which is the only occurrence of `CREATEPACK` in the file
|
|
and has exactly one code reference, that one. It reaches the tile at `+0x08` and
|
|
is pushed to Flash as `POST_PURCHASE_ACTION`. It is not a member of the
|
|
`TRANSACTIONCANCEL` state vocabulary and it tells us nothing about the
|
|
CreatePack-versus-PurchaseItems fork. The same literal also exists twice in the
|
|
Scaleform string pool, so it is a UI constant as well.
|
|
|
|
**`unopenedPacks` is not a display counter, and `FUT_USERINFO=packs` should stay
|
|
off.** One pass reported the counter at `model+0x20950` as read by three consumers
|
|
with no HTTP path. A verifier found nine getter call sites in six functions and
|
|
six setter sites in six functions, three of which read-modify-write it. One of the
|
|
missed ones, `FUN_180019780`, reads the counter, increments it by the number of
|
|
set booleans in a response, writes it back, and then dispatches a request whose
|
|
vtable `0x1801ed690` has `FUN_180124ee0` at slot `+0x20`, the `FutGetPurchasedItems`
|
|
deserializer. So the counter is client-mutable and is wired to a fetch. At count 0
|
|
the field buys nothing; above 0 it enters a subsystem with client-side arithmetic
|
|
that nobody has audited. Separately the ladder is non-monotonic: `packs` does not
|
|
include `squadList`, so switching from the current `roster` default would remove
|
|
the MY SQUADS roster that is known working.
|
|
|
|
**`POST /ut/game/fifa17/user` is a latent hard freeze and has never been
|
|
exercised.** The body `user_post()` builds sends `login` as a bool, `bonusPacks`
|
|
as an array and `starterPack` as an object. The real shapes are the opposite in
|
|
every case: `login` is the `userInfo` record object, `bonusPacks` is a bool,
|
|
`starterPack` is an array of shared ITEM objects. A bool fed to the `login` arm
|
|
makes the `userInfo` deserializer swallow the rest of the body through its
|
|
container-aware SKIP, after which the tokenizer returns 8 or 1 at EOF, never 10,
|
|
and the CreateUser loop re-dispatches the stale atom forever inside
|
|
`FUN_1801c7f10`. The project's known freeze PC `0x1801c7f1a` is ten bytes into
|
|
that function. One verifier upgraded this from MEDIUM to HIGH by reading the EOF
|
|
path in raw assembly rather than from a decompile. It has never fired because the
|
|
client's own `ProtoHttp` user agent has never issued that POST: all 48 `GET /user`
|
|
hits in the log are the Python contract suite, and `POST /user` appears zero times
|
|
in any log.
|
|
|
|
---
|
|
|
|
## 2. The grouping bug
|
|
|
|
### 2.1 Current best explanation
|
|
|
|
The chain, all of it decompiled and most of it live-confirmed:
|
|
|
|
`FUN_1800150d0` walks the `purchase[]` array in **array order**. For each pack it
|
|
takes `displayGroup.value` (wire record `+0x00`) and calls `FUN_180014380`, a
|
|
plain string compare against `group+0x70` across the group vector at stride
|
|
0x108. On a miss it calls `FUN_180012950` to build a new group whose `+0x00` is a
|
|
**1-based ordinal equal to the number of groups so far plus one**, `+0x04` is
|
|
`displayGroupAssetId`, `+0x100` is `displayGroup.priority` and `+0x104` is
|
|
`value == "mypacks"`. Three copies of the caption go to `+0x70`, `+0xa0` and
|
|
`+0xd0`, and they are not redundant: `+0x70` is the lookup key, `+0xa0` becomes
|
|
the tile NAME and `+0xd0` becomes its DESCRIPTION and CONTENT. The pack is
|
|
converted by `FUN_18002c3c0` into a 0x1a8 tile model and pushed into `group+0x40`.
|
|
|
|
Rendering is `FUN_18007dab0` calling `FUN_1800147f0(model, screen+0x290,
|
|
dataProvider, 0, 0)`. `screen+0x290 == 0` means "list the group tiles"
|
|
(`FUN_180014610`); any other value goes to `FUN_180014420`, which exact-matches
|
|
`group+0x00` and returns NULL on a miss, after which `FUN_1800147f0` dereferences
|
|
`[RAX+0x40]` with no guard. The absence of that guard was checked in raw
|
|
disassembly against a same-form control (the compiler does emit `CMP`/`JZ` twenty
|
|
instructions later where the source has one), so it is a real absence. **The only
|
|
legal values of `screen+0x290` are 0 and the ordinals 1..N.**
|
|
|
|
`screen+0x290` is written in exactly two places in CardsDLL, and this was checked
|
|
by enumerating not just displacement-0x290 writes but qword writes at 0x288 and
|
|
0x28c that would cover the byte, plus XMM stores over the range: the screen
|
|
constructor `FUN_18007d1a0` writes 0, and `FUN_18007e7f0` case `0x7551` copies the
|
|
Flash message field `CATEGORY_ID` verbatim and posts `0x753f`. Thirteen other
|
|
functions write that displacement and none of them appears in the store screen's
|
|
vtable `0x1801ff690`. So the group ordinal makes a round trip through the movie
|
|
and comes back as `CATEGORY_ID`.
|
|
|
|
Live right now: the store screen object is at `0x41934180`, `+0x290` is 3 and
|
|
`+0x294` (`SERVER_ID`) is 6. Sampled three times over fifteen minutes, stable.
|
|
That state is fully self-consistent with **correct** behaviour: category 3 is the
|
|
Premium group and server id 6 is the Premium pack. It is not by itself evidence
|
|
of the bug. What it does establish is that the number the movie sends lives in
|
|
ordinal space, which eliminates `ASSET_ID` (1, 5, 6) as the carrier.
|
|
|
|
That leaves two candidates for what the movie echoes as `CATEGORY_ID`. The tile's
|
|
own `CHILD_CATEGORY` field, which `FUN_180014610` sets to the group ordinal, or
|
|
`GetActiveTabId()`, which `StoreFront::populateCategory` uses when it sends
|
|
`ACTION_GET_PACKLIST`. **I would bet on `CHILD_CATEGORY`**, because 3 is a valid
|
|
ordinal and an unbound tab id is -1, and because six-tab lookups are currently
|
|
all missing (below), so a tab-derived id would be wrong for Bronze too and Bronze
|
|
resolves correctly. If `CHILD_CATEGORY` is the carrier then the fault is not in
|
|
which number a tile holds but in which tile the movie thinks was clicked, and the
|
|
suspects are the highlight events `ACTION_STORE_ITEM_GROUP_HIGHLIGHTED` and
|
|
`StoreFront::cbfnOnRowUpdate`. That is a movie-side selection desync, and no
|
|
field we send can fix it directly.
|
|
|
|
There is a second, structural problem sitting underneath, and it is the one we
|
|
can actually act on. The FIFA 17 store UI is a fixed six-tab category bar bound by
|
|
hardcoded lowercase strings. `FUN_180014580` is a switch 0..5 selecting the
|
|
literals `mypacks`, `points`, `bronze`, `silver`, `gold`, `special`, each passed
|
|
to the same `FUN_180014380` caption compare; `FUN_180014df0` is the same table as
|
|
an existence test; `FUN_18007e5e0` gives each of the six UI panels `PANEL_ID` = the
|
|
matching group's ordinal or **hides the panel**; `FUN_18007df60` publishes
|
|
`MYPACK_/BRONZE_/SILVER_/GOLD_/SPECIAL_/POINTS_CATEGORY_ID` to the movie. Our
|
|
captions are "Bronze Pack", "Gold Pack" and "Premium Gold", so all six lookups
|
|
miss, all six ids are -1 and all six panels are hidden. The store is running with
|
|
no bound tabs at all. Whatever the movie does with `GetActiveTabId()` in that
|
|
state, it is doing it in a configuration EA never shipped.
|
|
|
|
One live datum is consistent with the movie being in a strange place: the store
|
|
screen is in a visible re-entry loop. Reconstructed telemetry shows the cycle
|
|
`StoreFront -> livemessaging -> gameHub -> FUT_STORE -> FUT_WAIT_FOR_STORE ->
|
|
StoreFront` twice, matching seven `RS4::StorePackTypes sz:1908` entries in the
|
|
client's own trace ring buffer for one session. Not diagnostic on its own, but
|
|
worth watching after any change.
|
|
|
|
And one datum that should worry us more than it has: **the client has sent
|
|
`packId` 6 on every purchase it has ever made.** Six `POST /purchased/items` in
|
|
the logs, six occurrences of `"packId":6`, zero of 1 or 5, corroborated by the
|
|
client's own telemetry (`"store_id":"6"`). So we have never observed a successful
|
|
purchase of anything other than the Premium pack, in any configuration, grouped
|
|
or ungrouped. The purchase id is statically resolved: `SERVER_ID` is tile `+0x70`,
|
|
which `FUN_18002c3c0` fills from wire `+0x70` truncated to 16 bits, i.e. the `id`
|
|
field, not `assetId` and not `displayGroupAssetId`. So the question "does anything
|
|
but pack 6 buy" is genuinely open and is not the same question as "does the Gold
|
|
tile open the Gold group".
|
|
|
|
### 2.2 Should we sit at `FUT_STORE_DISPLAYGROUP=0`?
|
|
|
|
**Yes. Sit at 0 between experiments and switch it on only for the launch being
|
|
tested.** But the argument has to be made from mechanism, not from history,
|
|
because the history is uncited.
|
|
|
|
The mechanism is sound. With no `displayGroup` key, wire `+0x00` keeps its
|
|
constructor default on every pack, `FUN_180014380` matches the first group every
|
|
time, and all three packs land in one group with ordinal 1. There is then exactly
|
|
one legal value of `screen+0x290` besides 0, the six-tab bar is irrelevant, and
|
|
there is no group-to-pack round trip through the movie left to get wrong. The
|
|
packs are listed as rows in that one group and each row carries its own
|
|
`SERVER_ID`, which is the value the buy uses. Fewer moving parts, and the ones
|
|
that remain are the ones we have direct evidence work.
|
|
|
|
The uncited part is the claim that this configuration had "3 of 3 packs buyable"
|
|
against the current "2 of 3". No log line, no capture and no memory read supports
|
|
it, and the purchase log actively fails to support it: every purchase ever
|
|
recorded is `packId` 6. Do not let a decision rest on the 3-of-3 number. Rest it
|
|
on the mechanism, and treat "can the ungrouped layout buy pack 1" as an open
|
|
question that experiment 1 below answers for free.
|
|
|
|
Ugly and working still beats pretty and unbuyable. But we should stop saying
|
|
"working" until a `packId` other than 6 appears in the log.
|
|
|
|
### 2.3 Ranked experiments
|
|
|
|
One launch each unless marked otherwise. Ranking is the deliverable.
|
|
|
|
**#0. Free, no launch, do it before anything else while pid 134663 is alive.**
|
|
Ask the user to click the Gold Pack tile, then read `0x41934180 + 0x290`
|
|
(read-only, this pid only; re-resolve the pid by `comm` and re-check the two
|
|
controls first, and if the client has been relaunched this experiment needs a
|
|
fresh object address, found by scanning writable memory for the slid ctor vtable
|
|
`0x1801ff690 -> 0x6ffffc33f690` with `+0x138` and `+0x2d0` matching). Cost: zero.
|
|
Mechanism: `screen+0x290` is the category the resolver will use, written straight
|
|
from the movie's `CATEGORY_ID`. Outcomes: **2** means the movie sent the right
|
|
ordinal, the drill-down is correct and the reported symptom is something else
|
|
entirely, probably the tile captions or the detail panel, and the whole ranking
|
|
below is aimed at the wrong thing. **3** means the Gold tile really does produce
|
|
the Premium ordinal, and since `ASSET_ID` is eliminated, the fault is in the
|
|
movie's tile-to-click association or in the unbound tab path. Falsifier for the
|
|
whole exercise: any value outside {0, 1, 2, 3} would mean something writes that
|
|
field from outside CardsDLL and the ordinal model is incomplete.
|
|
|
|
**#1. Sit at `FUT_STORE_DISPLAYGROUP=0` and buy two different packs.** Cost: the
|
|
launch you were going to do anyway to get back to a usable store. Mechanism: as
|
|
in 2.2, one group, one legal category, no round trip. What it tests that has never
|
|
been tested: whether a `packId` other than 6 ever reaches the wire. Watch
|
|
`/tmp/utas_server.log` for `POST /purchased/items`. Falsifier: if buying the
|
|
Bronze row still sends `"packId":6`, the bug is not grouping at all, it is row
|
|
selection, and every experiment below is moot. This is ranked above the candidate
|
|
fixes precisely because it can invalidate them.
|
|
|
|
**#2. Serve `displayGroup.value` as three distinct canonical lowercase tokens:
|
|
`bronze`, `gold`, `special`.** Mechanism: those are three of the six literals
|
|
`FUN_180014580` hardcodes. Binding them gives three of the six panels a real
|
|
`PANEL_ID` instead of -1 and makes `GOLD_CATEGORY_ID` and friends carry the
|
|
group's ordinal, which means the category id can come from CardsDLL instead of
|
|
surviving a round trip through an unbound tab bar. It keeps one pack per group, so
|
|
it does not touch the never-exercised multi-pack-per-group path. Cost: the Premium
|
|
pack appears under the Specials tab, and tile captions become the client's own
|
|
localised tab labels rather than our strings, so the "unknown" cosmetics problem
|
|
comes back in a different form. Send them in exact lowercase; whether the compare
|
|
thunk `FUN_1800081c0` is case sensitive was never established. Falsifier: if the
|
|
tab bar still does not appear, or a category comes back empty
|
|
(`FUT_ZERO_PACKS_AVAILABLE`, `StoreFront::_OnEmptyCategory`), the tab theory is
|
|
dead and we are back to the movie-side selection hypothesis.
|
|
|
|
**#3. Only if #2 binds the tabs but you want Premium under Gold: merge to
|
|
`bronze`, `gold`, `gold`.** Mechanism: identical, one fewer group. This is how the
|
|
real FUT 17 catalogue was shaped. Cost and risk: it is the first time any group
|
|
has ever held two packs. That activates `FUN_1800159b0`'s `BEST_COINS_PRICE` and
|
|
`BEST_POINTS_PRICE` loops and the `sortPriority` merge sort in `FUN_180010890`,
|
|
neither of which has ever run with more than one element, and the second of those
|
|
price loops decompiles oddly enough that one agent flagged it. Falsifier: the gold
|
|
tab shows one pack instead of two, or a price of 0.
|
|
|
|
**#4. Do not spend a launch on these.** `displayGroupAssetId = ordinal` is
|
|
refuted (section 1). `displayGroupUseDefaultImage` only chooses whether the tile's
|
|
`OVERRIDE_ASSET_ID` is the assetId or -1, and note it is **not** the field that
|
|
gates that: the adapter's gate is `useDefaultImage` (atom 0x36a) at wire `+0xcc`,
|
|
a different atom, while `displayGroupUseDefaultImage` (0xdb) at wire `+0x38` only
|
|
picks the group's `packs_backgrounds_%d.dds` texture. `state`, `saleType`,
|
|
`quantity`, prices and contents are already correct in the live model, measured
|
|
field by field.
|
|
|
|
**A note on `sortPriority` and `displayGroup.priority`, which one pass called
|
|
inert dead ends.** That was a false absence, caught by a verifier searching the
|
|
whole `.text` listing rather than reading eight functions. `FUN_180016ef0` walks
|
|
the group vector and calls `FUN_180011480` on the groups, whose comparator reads
|
|
`group+0x100`, which is `displayGroup.priority`, as a median-of-3 introsort key;
|
|
then for each group it calls into `FUN_180010890`, whose merge key is
|
|
`model+0x1a0`, which is `sortPriority`. Both have readers. Neither explains the
|
|
current symptom, because an introsort permuting the group vector permutes each
|
|
group's caption and ordinal together, so the pairing a tile presents cannot break
|
|
that way, and the live vector is in creation order anyway. But an introsort on
|
|
all-equal keys is not order preserving, and we send neither field, so this is a
|
|
landmine: a future response could reorder the tiles without changing any ordinal.
|
|
If you ever want deterministic tile order, send distinct `displayGroup.priority`
|
|
values, and expect the order to change.
|
|
|
|
**A second landmine, found unprompted.** A group is only rendered as a tile if it
|
|
contains at least one pack whose tile `+0x00` visible byte is non-zero
|
|
(`FUN_180014610` gated on `FUN_18002c8b0`, and the per-row loop applies the same
|
|
test at `0x1800148d9`). That byte comes from wire `+0x138`, written only by atom
|
|
`0x37d` `visible`, a key we do not send, and it is currently 1 on all three packs,
|
|
so nothing is hidden today. If a future response ever makes it false, a whole tile
|
|
vanishes and every later tile shifts up while its `CHILD_CATEGORY` does not.
|
|
|
|
---
|
|
|
|
## 3. The store and pack wire model as now understood
|
|
|
|
Confidence is marked per element. CONFIRMED means an address plus either a live
|
|
read or two independent decompiles that agree. INFERRED means the code says so and
|
|
nothing has contradicted it. UNKNOWN means exactly that.
|
|
|
|
### 3.1 `GET ut/%s/store/purchasegroup/...` root
|
|
|
|
CONFIRMED. Deser `0x1801234e0`. Two keys: `purchase` (0x260) is an ARRAY of pack
|
|
objects each dispatched to `FUN_18013af30`; `timestamp` (0x31b) is an INT to
|
|
`[rdi+0x5c]`. The body we serve is 1908 bytes and the client's own trace ring
|
|
buffer logs it as `RS4::StorePackTypes sz:1908`.
|
|
|
|
### 3.2 The 0x158 wire pack record, deser `FUN_18013af30`
|
|
|
|
Dispatch in this function is a **jump table for atoms below 0x119 plus running-sum
|
|
`sub`/`cmp` ladders above it**. An immediate grep for an atom value returns zero
|
|
hits even for atoms that provably have arms. Do not make absence claims about this
|
|
parser without enumerating the table at `0x18013b001`/`0x18013b7ba` and decoding
|
|
the ladders.
|
|
|
|
CONFIRMED offsets, from the frame arithmetic of the stack struct plus the field
|
|
types recovered from the copy constructor `FUN_1801340e0`:
|
|
|
|
| key | atom | type | wire offset | notes |
|
|
|---|---|---|---|---|
|
|
| `displayGroup` | 0xd9 | flat OBJECT | | `value` (0x377, STR) to `+0x00`, `priority` (0x250, INT) to `+0x34` |
|
|
| `displayGroupAssetId` | 0xda | INT | `+0x30` | premise restored, see section 1 |
|
|
| `displayGroupUseDefaultImage` | 0xdb | BOOL | `+0x38` | group texture only |
|
|
| `currencies` | 0xc5 | ARRAY | `+0x40..+0x48` | 0x30-byte records via `FUN_180138bd0` |
|
|
| `extPrice` | 0x119 | OBJECT | | see 3.4 |
|
|
| (id) | see below | INT | `+0x70` | truncated to u16 into tile `+0x70` = `SERVER_ID` = purchase `packId` |
|
|
| `assetId` | 0x23 | INT | `+0x74` | tile `+0xac` = `ASSET_ID` |
|
|
| `firstPartyStoreId` | 0x127 | STR then `atoi()` | `+0x78` | an int here is the documented freeze |
|
|
| `sortPriority` | 0x2cb | INT | `+0x7c` | tile `+0x1a0`, merge-sort key |
|
|
| `description` | 0xd1 | STR | `+0x80` | tile caption |
|
|
| `state` | 0x2eb | STR vs `"active"` | `+0xb0` | ctor default 0, not active |
|
|
| `start` | 0x2e3 | INT, clamped | `+0xb4` | **top level, not inside packContentInfo** |
|
|
| `end` | 0x102 | INT | `+0xb8` | |
|
|
| `quantity` | 0x26b | INT | `+0xbc` | a sent 0 becomes -1 |
|
|
| `purchaseLimit` | 0x265 | INT | `+0xc0` | |
|
|
| `purchaseCount` | 0x261 | INT | `+0xc4` | copied to tile `+0x78` |
|
|
| `limitType` | | STR to enum | `+0xc8` | tile `+0x90`, Flash `SALE_TYPE`; `NONE` 0, `QUANTITY` 1, `TIME` 2, `TIME_QUANTITY` 5 |
|
|
| `saleType` | 0x298 | STR | | `promo` lands at tile `+0xbb` `IS_PROMO` |
|
|
| `dealType` | 0xcc | STR, lowercased | `+0xcf/+0xd0` | |
|
|
| `useDefaultImage` | 0x36a | BOOL, stored inverted | `+0xcc` | gates tile `+0xb0` `OVERRIDE_ASSET_ID` |
|
|
| `unopened` | 0x35d | BOOL | `+0xcd` | **top level**; tile `+0x69`; live 0 on all three |
|
|
| `isPremium` | 0x176 | BOOL | `+0xce` | tile `+0xb8` |
|
|
| `actionType` | 0x08 | STR | `+0xd8` | ctor default `"CREATEPACK"`; tile `+0x08` `POST_PURCHASE_ACTION` |
|
|
| `packType` | 0x20f | STR | `+0x108` | ctor default `"INVALID"`; tile `+0x38`; **no Flash consumer** |
|
|
| `visible` | 0x37d | presence only | `+0x138` | value never read; gates whether the tile renders |
|
|
| `points` | 0x240 | INT | `+0x140` | |
|
|
| `packContentInfo` | 0x20c | flat OBJECT | `+0x144..+0x154` | exactly five children |
|
|
|
|
`packContentInfo` has exactly five children and nothing else: `itemQuantity`
|
|
0x170 to `+0x144`, `goldQuantity` 0x149 to `+0x148`, `silverQuantity` 0x2c6 to
|
|
`+0x14c`, `bronzeQuantity` 0x63 to `+0x150`, `rareQuantity` 0x273 to `+0x154`.
|
|
The inner loop's default reaches the safe SKIP and jumps back into the **inner**
|
|
loop; the `unopened`/`start` ladder's default jumps back into the **outer** loop.
|
|
Two different back edges, which is how the nesting was proven. Our `_pack_body()`
|
|
sends `"unopened": false` inside `packContentInfo`, which is therefore inert, and
|
|
`packContentInfo` does reach the tile: the live tiles read 5/7/11 items.
|
|
|
|
UNKNOWN: which atom writes wire `+0x70`. It is almost certainly `id` (0x15c) and
|
|
the verifier calls it that, but we serve `id` and `assetId` identically so no live
|
|
read can separate them, and the arm was not located in the jump table. This
|
|
matters more than it looks: wire `+0x70` is `SERVER_ID`, and `SERVER_ID` is the
|
|
`packId` the purchase sends. `ENDPOINT_MAP.md` currently claims `id` is skipped
|
|
entirely, which cannot be right if `+0x70` is fed from anywhere.
|
|
|
|
`packType` deserves a note because two agents disagreed. One argued it is the
|
|
"strongest candidate lookup key" for the mis-resolution, on the grounds that it is
|
|
the only string field where Gold and Premium collide while Bronze differs. It is
|
|
not a key for anything: it lands at tile `+0x38`, and a full dump of
|
|
`FUN_180015d80`'s Flash push list (32 fields) does not contain `+0x38`. The
|
|
{Bronze} versus {Gold, Premium} partition is a coincidence of two packs sharing a
|
|
tier. The six store tabs key on `displayGroup.value`, not on `packType`.
|
|
|
|
### 3.3 Currencies, `FUN_180138bd0`, 0x30 stride
|
|
|
|
CONFIRMED, and this is the canonical example of the sub-ladder dispatch form:
|
|
`sub edx,0x124` then `sub edx,0x10` then `cmp edx,0x9c`, so no single immediate
|
|
equals any atom. `name` 0x1d0 STR to `+0x00`, `finalFunds` 0x124 INT to `+0x24`,
|
|
`funds` 0x134 INT to `+0x20`, both through the non-negative clamp
|
|
`FUN_1800d7b30` so a negative price silently becomes 0.
|
|
|
|
The asymmetry here is real and was verified twice. The **store tile** takes its
|
|
coin price from `finalFunds` (`elem+0x24`, adapter at `0x18002c75b`), which
|
|
matches tonight's live probe where `funds=15000, finalFunds=4321` rendered 4,321.
|
|
The **HUD balance** parser `FUN_180122c50` takes `funds` (`elem+0x20`) for all
|
|
three recognised names, `coins`, `points` and `DRAFT_TOKEN`. Same array, two
|
|
fields, two consumers.
|
|
|
|
The only server-side lever for a FIFA Points number on a tile is a second
|
|
currency entry named lowercase `points`, which the adapter maps to tile `+0xa4`
|
|
and `+0xa8` behind flag `+0xb7`, gated by a call into the commerce manager whose
|
|
return value is unknowable statically. INFERRED, medium. Note that the `mtx` arm
|
|
and the `points` arm **both write tile `+0xa4`**, so with both rows present the
|
|
later vector element silently wins. Never test a `points` row with `extPrice`
|
|
still in place.
|
|
|
|
### 3.4 `extPrice`, and why it should go
|
|
|
|
CONFIRMED, by two agents with different toolchains, one of them printing both
|
|
functions end to end from `.pdata`-derived bounds. `FUN_180139070` (finalPrice,
|
|
0x227 bytes) and `FUN_18013aae0` (originalPrice, 0x220 bytes) each contain
|
|
exactly one atom comparison, `cmp r,0x11a`, for `externalPriceId`, an INT.
|
|
Neither reads `amount` (0x1b) or `currency` (0xc4); both go to the value-SKIP.
|
|
Neither function contains an indirect jump, a sub ladder or a switch, and the atom
|
|
register is seeded to `0x38c`, a value with no atom, so nothing can fall through.
|
|
`finalPrice` writes its value to both `[rdi+0x2c]` and the pack's
|
|
`firstPartyStoreId` slot at `+0x78`, so `extPrice.finalPrice` and top-level
|
|
`firstPartyStoreId` are the same slot reached two ways.
|
|
|
|
The side effect is the whole story. Before parsing, both scan the pack's currency
|
|
vector for an entry named `mtx` and create one if absent, memmoving 3 bytes from
|
|
the literal at `0x1801efea0` (`"mtx\0coins\0"`). The adapter then sets tile
|
|
`+0xb5 = 1` at `0x18002c729`, the only write to that byte outside the bulk
|
|
zeroing, purely because the row exists. `FUN_18002cc90` would fill the price text
|
|
from the commerce catalogue, but its first statement is `if (tile+0x6c == -1)
|
|
return`, and `+0x6c` is pre-seeded to -1 by the adapter at `0x18002c4a2` and
|
|
copied from wire `+0x78` at `0x18002c685`. We send no `externalPriceId`, so it
|
|
stays -1, the formatter returns, and all four catalogue strings keep their
|
|
constructor defaults. Live on all three tiles: `+0x6c` = -1, `+0xb5` = 1,
|
|
`+0xb6` = 1, `+0xb7` = 0, `+0xa4` = 0, `+0x168` = `"0"`, `+0xd8`/`+0x108`/`+0x138`
|
|
= `" "`.
|
|
|
|
`or %1s` occurs exactly once in the entire 4.09 GiB process, at `0xb8697c20`, and
|
|
it is a Scaleform string-pool record: the eight preceding bytes are
|
|
`01 00 06 00 07 00 00 00`, where 6 is the length and 7 the length plus one, the
|
|
same header shape as the neighbouring `highbid`. There is no `%1s` anywhere in the
|
|
3,179,952-byte PE. Its argument can only be one of the four strings the formatter
|
|
never wrote.
|
|
|
|
A third safe shape exists that nobody had noticed: `"extPrice": {}` opens the
|
|
nested loop at `0x18013b59d`, immediately reads END_OBJECT and exits, so it
|
|
creates no `mtx` row. Useful if schema fidelity is wanted for some reason, but
|
|
deletion is simpler and strictly better.
|
|
|
|
### 3.5 Purchase
|
|
|
|
CONFIRMED from the wire. `POST /ut/game/fifa17/purchased/items`, body
|
|
`{"packId":6,"useCredits":1,"usePreOrder":0,"currency":"COINS"}`, six times, always
|
|
6. Serializer `FUN_180162530`, four keys from one mode integer: `packId` (0x20b),
|
|
`useCredits` (0x369) = mode 0, `usePreOrder` (0x36b) = mode 4, `currency` (0xc4) =
|
|
`MTX`/`POINTS`/`COINS` and omitted entirely when mode is 4. The request-side
|
|
currency vocabulary is UPPERCASE; the response-side currency names are lowercase.
|
|
Do not mix them.
|
|
|
|
The separate first-party path `PUT /ut/v2/game/fifa17/store/transaction/0` with
|
|
`{"state":"TRANSACTIONCANCEL"}` fires on every boot and is live code.
|
|
|
|
### 3.6 Quick sell, and the discard economy
|
|
|
|
This is the most complete part of the run.
|
|
|
|
**The wire.** `DELETE /ut/game/<sku>/item/<id>`, no body, confirmed live tonight
|
|
and now served. The URL suffix builder emits `/%llu`. The response is
|
|
balance-bearing: answering `{}` put 1,133,686,384 coins on screen. The real
|
|
response class is `FutDiscardCardServerResponse` (`RS4:` literal `0x180220540`,
|
|
vtable `0x180220488`, deser `FUN_180127300` at slot `+0x08`), and its shape is
|
|
`{"items":[{"id":N}], "totalCredits":N}`. `items` (0x171) is an array of OBJECTS
|
|
with an inner `id` (0x15c); there is no top-level `id`. `totalCredits` (0x326) is
|
|
a plain `MOV` into a freshly allocated 0x38-byte object at `+0x28`; an exhaustive
|
|
whole-DLL scan in cmp, sub-ladder, dec-ladder and switch-range forms finds exactly
|
|
two functions carrying that atom, both discard responses, and neither accumulates.
|
|
Whether the wallet assigns or adds is still unproven because the consumer is a
|
|
delegate registered outside CardsDLL. Keep sending the absolute balance; the field
|
|
is named `totalCredits` and `GET /user/credits` resyncs within seconds anyway.
|
|
|
|
**Bulk quick sell exists and our parser expects a shape that does not.**
|
|
`FUN_180126f40`, printed in full at 0x21a bytes, emits `{"itemId":[<int64>, ...]}`
|
|
using atom 0x16d over a vector of 8-byte elements. `quick_sell_route()` looks for
|
|
`itemData` and `itemIds`, which appear nowhere in that serializer. The URL and
|
|
method for the bulk call were not established; the decisive test is a live capture
|
|
of "Quick Sell All".
|
|
|
|
**The value the client displays is computed locally.** `FUN_18013fe00`, the shared
|
|
item deserializer, stores our `discardValue` (atom 0xd7) at item `+0x38`. Then, at
|
|
`0x180141025`, `cmp dword [rbp+0x198],0` followed by `ja` skips the entire local
|
|
computation if that value is non-zero. When it is zero, the client runs
|
|
|
|
```sql
|
|
SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==?
|
|
```
|
|
|
|
(literals at `0x1802231e4`, `0x1802231f0`, `0x180223208`, `0x180207848`,
|
|
`0x18022315c`) and then, at `0x180141119..0x180141140`:
|
|
|
|
```
|
|
value = (rating * price) / 100, rounded half up
|
|
```
|
|
|
|
stored at item `+0x3c`. If the query returns no row the price register stays 0 and
|
|
the value is 0.
|
|
|
|
`cardtype` comes from `cardsubtypeid` through `FUN_1800d8330`, decoded from its
|
|
raw two-level jump table and checked against every subtype 0..599 with zero
|
|
disagreements:
|
|
|
|
```
|
|
0..3 -> 1 (players)
|
|
4 -> 2
|
|
5 -> 3
|
|
6 -> 10
|
|
7 -> 5
|
|
8 -> 4
|
|
9..11 -> 7
|
|
30,31,145..150,231,232,233,236 -> 9
|
|
51..136, 201..220, 250..273, 300..341 -> 6
|
|
anything else -> 0 (no table row, value 0)
|
|
```
|
|
|
|
`level` is derived purely from rating at the tail of `FUN_180141660`
|
|
(`0x180141e8a..0x180141ea3`): 3 if rating >= 75, 2 if 65..74, else 1. It is not a
|
|
wire field; the slot at item `+0x54` is never written through the deserializer's
|
|
frame, which was confirmed by an operand census showing `rbp+0x1b4` appearing
|
|
exactly once in all 1801 instructions of `FUN_18013fe00`, as a read.
|
|
|
|
The table itself, 141 rows, decoded from the client's loaded DB at `0x4278e1a8`
|
|
with bit layout `level[0:7] id[7:17] cardtype[17:24] price[24:44] rare[44:51]`.
|
|
That layout is not merely self-consistent: an exhaustive search over all field
|
|
offsets and widths produced exactly one split satisfying sequential ids 1..141,
|
|
cardtype in 1..10, price under 200000 and unique keys. Prices are given per level
|
|
1 / 2 / 3:
|
|
|
|
```
|
|
cardtype 1 (players)
|
|
rare 0 30 / 150 / 400
|
|
rare 1 75 / 350 / 800
|
|
rare 7 1500 / 5000 / 9000
|
|
rare 2,3,10,13,17..31 2000 / 7000 / 12200
|
|
rare 4,8,9 6000 / 10000 / 18000
|
|
rare 11 10000 / 15000 / 24000
|
|
rare 5,6 20000 / 40000 / 80000
|
|
rare 12 120000 /120000 /120000
|
|
cardtype 2 rare 0 20 / 70 / 110 rare 1 25 / 120 / 320
|
|
cardtype 3 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300
|
|
cardtype 4 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300
|
|
cardtype 5 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300
|
|
cardtype 6 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70
|
|
cardtype 7 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70
|
|
cardtype 8 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70
|
|
cardtype 9 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70
|
|
cardtype 10 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300
|
|
```
|
|
|
|
Rare values 14, 15 and 16 are absent for cardtype 1, and cardtypes 2..10 carry
|
|
only rare 0 and 1. A key we do not have pays 0, so use a `.get(key, 0)`, not a
|
|
subscript.
|
|
|
|
Verified against 22 live club items, 22 of 22 exact. A gold rare player is
|
|
`8 * rating`: 75 gives 600, 94 gives 752. A gold common is `4 * rating`. A
|
|
50-rated bronze common is 15. Our placeholder underpays a 94 gold rare by 152 and
|
|
overpays a bronze common by 35, and is blind to both `rareflag` and card type.
|
|
|
|
One scope correction worth having, because it inverts the practical advice one
|
|
pass gave. For **cardtypes 2, 3, 4, 5 and 10** the client overwrites the rating
|
|
and rare flag we send with values from its own card database before computing,
|
|
querying `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and
|
|
`gkcoachcards` by `carddbid`. For **cardtypes 6, 7, 8 and 9** the jump table at
|
|
rva `0x141eb4` goes straight to the default arm with no DB query and no overwrite.
|
|
Every consumable in our profile (`cardsubtypeid` 52, 54, 92, 98, 100) maps to
|
|
cardtype 6, live-confirmed on the two resident consumables, so for exactly the
|
|
items the warning was aimed at, the server's rating and rare flag are
|
|
authoritative.
|
|
|
|
### 3.7 `duplicateItemIdList`
|
|
|
|
CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
|
|
0x20-byte records: `itemId` (0x16d, int64) at `+0x00`, `itemLoans` (0x16f, int32
|
|
narrowed) at `+0x08`, `duplicateItemId` (0xeb, int64) at `+0x10`,
|
|
`duplicateItemLoans` (0xed, int32 narrowed) at `+0x18`. It is an array of objects,
|
|
not an int list, in both places the old docs describe it.
|
|
|
|
Four deserializers accept the key (`FUN_180162880` CreatePack, `FUN_18013bd40`
|
|
shared purchased/massinfo, `FUN_1801293d0` FutViewCards, `FUN_18013e7f0` shared
|
|
IS-list, which reaches the item through the auction record at `+0xb0`). All four
|
|
run the identical fixup: for each record, walk the item list parsed from the same
|
|
response and where `item+0x08` equals the record's `itemId`, set `item+0x10` to
|
|
`duplicateItemId`. Only record indices 0 and 2 are ever touched; both loans fields
|
|
are parsed and discarded. An entry naming an item not in the same response has no
|
|
effect at all.
|
|
|
|
The only visible consequence is a boolean. Six sites publish the Scaleform key
|
|
`HAS_DUPLICATE` as `item+0x10 != 0` (the literal at `0x1801f6510` has exactly one
|
|
occurrence in the file and, per a live scan, one in the whole process; the six
|
|
functions are `FUN_180043880`, `FUN_180084720`, `FUN_180094220`, `FUN_180094ae0`,
|
|
`FUN_180096490`, `FUN_180096670`). One C++ flow branches on it, `FUN_18009bc40`,
|
|
the completion of the sign-loan-player chain: not duplicate means the client
|
|
auto-issues `PUT ut/%s/item` with `{"itemData":[{"id":N,"pile":"club","swap":0,
|
|
"tradeId":0}]}`, duplicate means it suppresses that call and navigates to
|
|
`GotoNewItems`. So the flag gates an endpoint we already serve rather than
|
|
unlocking a new one, and no request anywhere serialises any of the four atoms: a
|
|
census of all 257 mentions of the atom-name helper `FUN_180180cd0` (216 calls plus
|
|
41 tail calls) resolves 168 distinct atoms and none of them is 0xeb, 0xec, 0xed or
|
|
0x16f, and each of the four wire-name literals has exactly one reference and it is
|
|
the atom-name table.
|
|
|
|
Net server work: optional. Continuing to send `[]` is coherent. Implementing it
|
|
needs no new endpoint.
|
|
|
|
### 3.8 `FutCreateUserServerResponse`, deser `FUN_18014cc60`
|
|
|
|
CONFIRMED, 3662 characters decompiled in full by two agents independently.
|
|
`login` (0x1a5) is the `userInfo` record object into `response+0x28`;
|
|
`userData` (0x36d) is an object with exactly four keys parsed by `FUN_180142470`
|
|
(`onlineELORating` 0x1f2 INT, `onlineRatedUser` 0x1f3 BOOL, `accountResetCount`
|
|
0x1f4 INT, `winForm` 0x383 INT **narrowed to a single byte**); `squad` (0x2cd) is
|
|
an object to `FUN_18013d1f0`; `starterPack` (0x2e5) is an ARRAY of shared ITEM
|
|
objects into a 0x18-stride vector at `response+0x158`; `bonusPacks` (0x5d) is a
|
|
BOOL byte at `response+0x150`. `ENDPOINT_MAP.md` has `login` and `userData`
|
|
swapped and types `bonusPacks` as an array.
|
|
|
|
The `userInfo` deserializer's top-level arm set, recovered by decoding its 225-entry
|
|
byte table at `0x18013f2c8` and 11-entry dword table at `0x18013f29c` plus its
|
|
ladders, is `{0x6, 0xb, 0x59, 0x8d, 0x8e, 0x8f, 0xc5, 0xdd, 0xde, 0xe6, 0x110,
|
|
0x11c, 0x121, 0x122, 0x1a6, 0x1be, 0x21b, 0x262, 0x27e, 0x2bb, 0x2d4, 0x330, 0x340,
|
|
0x35e, 0x387}`. `0xbc` and `0x363` are children of `bidTokens` (0x59), not
|
|
top-level.
|
|
|
|
`unopenedPacks` (0x35e) reads only `preOrderPacks` (0x24b) and `recoveredPacks`
|
|
(0x27b); `count` (0xbc) has no arm and is skipped. The sum goes through model
|
|
vtable slot `+0x4e0` (`FUN_18011e120`) to `model+0x20950` and broadcasts UI event
|
|
`0x273d`. The same pair also arrives on the credits response (`FUN_180122c50`,
|
|
`resp+0x34`/`+0x38`) with two consumers, `FUN_180017390` and `FUN_1800ad390`, and
|
|
the client requests `/user/credits` fourteen times a session against five
|
|
`userMassInfo` calls, so that is the more-travelled carrier.
|
|
|
|
The claimable-items container is `model+0x5a38`, reached through vtable slot
|
|
`+0x160` (`FUN_18011b780`). One pass said nothing fills it but `starterPack`. It
|
|
is filled by the CreatePack deserializer `FUN_180162880`, pushing 0x18-stride
|
|
items directly, and `FUN_18009bc40` searches that same container, so the two
|
|
dimensions contradicted each other and the CreatePack reading is the right one.
|
|
Live it is empty with capacity 0x180 already allocated, i.e. it has been populated
|
|
and drained this session.
|
|
|
|
---
|
|
|
|
## 4. Server-authoritative versus client-side
|
|
|
|
Strict test: server-authoritative only if the server's response actually
|
|
determines the value. This is the table that decides what work exists.
|
|
|
|
| thing | authority | why |
|
|
|---|---|---|
|
|
| Which packs exist, their captions, prices, quantities, limits, dates | SERVER | every field lands in the wire record and reaches the tile; live-verified field by field |
|
|
| The purchase `packId` | SERVER | it is wire `+0x70` echoed back as `SERVER_ID`; we choose the number |
|
|
| Which display groups exist and what is in each | SERVER | membership keys only on `displayGroup.value`; verified live |
|
|
| The group **ordinal** | CLIENT | 1-based creation order assigned by `FUN_180012950`; the server can only influence it through array order |
|
|
| The six store tabs and their labels | CLIENT | six hardcoded lowercase literals in `FUN_180014580`; the server can only match them |
|
|
| Tile background art | CLIENT | `packs_backgrounds_%d.dds` chosen by `displayGroupAssetId`; the file must exist |
|
|
| Which tile the movie thinks you clicked | CLIENT | Scaleform, unread |
|
|
| The FIFA Points price line | CLIENT | Origin/Dime catalogue by `externalPriceId`; the catalogue is gone, so this is unfixable server-side |
|
|
| `or %1s` | CLIENT | AS string-pool constant; suppressed only by not creating the `mtx` currency row |
|
|
| Quick sell **displayed** value | CLIENT when `discardValue` is 0 or absent | `fcc_discardcoins` in the client's own game DB, scaled by rating |
|
|
| Quick sell displayed value | SERVER when `discardValue` is non-zero | but sending it **suppresses** the client's own correct number, and which field the UI renders is unproven |
|
|
| Quick sell **paid** value | SERVER | it is our arithmetic; today it disagrees with the display |
|
|
| `level` (bronze/silver/gold tier for pricing) | CLIENT | derived from rating alone |
|
|
| `cardtype` | SERVER | derived from the `cardsubtypeid` we send |
|
|
| `rating` and `rareflag` for cardtypes 6..9 (consumables) | SERVER | no DB override on that arm |
|
|
| `rating` and `rareflag` for cardtypes 2,3,4,5,10 (staff) | CLIENT | overwritten from local card DB before pricing |
|
|
| `totalCredits` after a discard | SERVER | assigned into the response object; wallet consumer unproven |
|
|
| Which items a pack contains | SERVER | `GET /purchased` fills the item manager |
|
|
| Declared pack contents (`packContentInfo`) versus delivered items | SERVER, unchecked | nothing compares them; cosmetic |
|
|
| Duplicate flagging | SERVER | `item+0x10` is written only by the four response deserializers; the client never derives it |
|
|
| CreatePack versus PurchaseItems | CLIENT | two separate ServerCall classes chosen client-side; no response selects between them |
|
|
| Unopened pack count | SERVER for the initial value, CLIENT thereafter | five CardsDLL functions read-modify-write `model+0x20950` |
|
|
| `packOpeningAnimationEnabled` and friends | CLIENT | settings struct defaults are already 1 |
|
|
|
|
---
|
|
|
|
## 5. Remaining unknowns and the cheapest experiment for each
|
|
|
|
### Needs decompiling (free, no launch, no live process)
|
|
|
|
1. **Which atom writes wire `+0x70`.** It feeds `SERVER_ID` and therefore the
|
|
purchase `packId`. Decode `FUN_18013af30`'s jump table at `0x18013b001` and find
|
|
the arm that stores to frame slot `record-0xb0+0x70`. High value for the price.
|
|
2. **`FUN_18013fe00`'s jump table.** Nobody has enumerated it, which is why the
|
|
`loans` (0x19b) to `item+0x90` attribution is only corroborated and why the atom
|
|
that writes `item+0x58` (rare flag) is unidentified. Same byte/dword table shape
|
|
as the `userInfo` one at `0x18013f2c8`/`0x18013f29c`.
|
|
3. **Is `FUN_180016ef0` reached on the store path?** Its only caller is
|
|
`FUN_180017420`. This decides whether the introsort landmine in 2.3 is live.
|
|
4. **The remaining `vt+0x4d8`/`vt+0x4e0` callers.** `FUN_1800706e0` does
|
|
`set(get() + delta)` and nobody knows where the delta comes from, nor which
|
|
response class feeds `FUN_180019780`'s five booleans. Do this before anyone ever
|
|
serves a non-zero unopened count.
|
|
5. **The bulk-discard action row.** Which action index owns `FUN_180126f40`, and
|
|
the URL and method. The pointer block at `0x1801f3110` mixes it with the
|
|
single-id URL builder and the three action factories at `0x180123cc0/cd0/ce0`
|
|
are not disassembled as functions in the current project.
|
|
6. **Re-run dimension 4's `item+0x10` write census with a raw-instruction matcher.**
|
|
The original was decompiler-based and the verifier declined to reproduce it, so
|
|
it currently carries only one agent's confidence. Low urgency, listed for
|
|
honesty.
|
|
|
|
### Needs a live probe (read-only, no launch, needs a running client)
|
|
|
|
1. **Which field the UI renders for quick sell, `item+0x38` or `item+0x3c`.** The
|
|
cheap version needs a throwaway server on a spare port serving one item with
|
|
`discardValue` 999. Until this is settled, do not start emitting `discardValue`.
|
|
2. **What `commerce_mgr->vt[0x140]()` returns.** It gates the `points` currency
|
|
arm. Its code is in Denuvo-decrypted rwx pages of `FIFA17.exe`, so it needs a
|
|
live read at the right moment, not a decompile.
|
|
3. **Does the movie hold pack captions at all?** "Gold Pack" has zero ActionScript
|
|
string nodes in the whole process while "Bronze Pack" has three; the only
|
|
rendered UTF-16 store caption anywhere is a doubled "Premium Gold". Re-run the
|
|
census after scrolling the store to the Gold tile to find out whether that
|
|
absence is a fact about the pool or just about what is on screen.
|
|
4. **The silver band.** No resident item is rated 65..74, though the profile
|
|
contains eight. Load one and confirm `item+0x54` reads 2 and the value matches
|
|
the table.
|
|
|
|
### Needs a launch the user must drive (the scarce resource, ranked)
|
|
|
|
1. **Click the Gold tile and read `screen+0x290`.** Strictly this needs no launch
|
|
at all if the current session is still alive, which is why it is experiment #0
|
|
in section 2.3 and the closing recommendation of this document.
|
|
2. **`FUT_STORE_DISPLAYGROUP=0` plus buy two different packs.** Answers the
|
|
never-asked question of whether any `packId` but 6 has ever gone out.
|
|
3. **Canonical lowercase category tokens** (`bronze`, `gold`, `special`).
|
|
4. **Drop `extPrice`** and confirm the `or %1s` line disappears and, critically,
|
|
that all tiles remain buyable. Can be combined with 2 or 3, at the cost of
|
|
ambiguity if something breaks.
|
|
|
|
---
|
|
|
|
## 6. Proposed patches
|
|
|
|
Not applied. Every new flag defaults off. Each carries its freeze risk and its
|
|
type-fidelity argument, because the last two rounds were broken by
|
|
technically-correct fields.
|
|
|
|
### P1. Real quick-sell values in `fut_store.py`
|
|
|
|
Zero wire change. This is the one patch I would argue for defaulting **on** after
|
|
a single verification, but it ships off.
|
|
|
|
```python
|
|
# tools/fut_store.py, near the top
|
|
|
|
# FUT_DISCARD_TABLE: pay the real FIFA 17 discard value instead of an invented
|
|
# tier. The table is fcc_discardcoins, read out of the running client's own
|
|
# loaded game DB (141 rows, pid 134663, 2026-08-05) and verified against 22 live
|
|
# club items, 22/22 exact. See docs/plan-2026-08-05-store-subsystem.md 3.6.
|
|
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
|
|
|
|
# (cardtype, level, rare) -> price. Compressed: prices are level 1/2/3.
|
|
_DP = {}
|
|
def _dp(ct, rares, p1, p2, p3):
|
|
for r in rares:
|
|
_DP[(ct, 1, r)] = p1; _DP[(ct, 2, r)] = p2; _DP[(ct, 3, r)] = p3
|
|
_dp(1, [0], 30, 150, 400)
|
|
_dp(1, [1], 75, 350, 800)
|
|
_dp(1, [7], 1500, 5000, 9000)
|
|
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
|
|
_dp(1, [4, 8, 9], 6000, 10000, 18000)
|
|
_dp(1, [11], 10000, 15000, 24000)
|
|
_dp(1, [5, 6], 20000, 40000, 80000)
|
|
_dp(1, [12], 120000, 120000, 120000)
|
|
_dp(2, [0], 20, 70, 110); _dp(2, [1], 25, 120, 320)
|
|
for _ct in (3, 4, 5, 10):
|
|
_dp(_ct, [0], 10, 55, 110); _dp(_ct, [1], 50, 100, 300)
|
|
for _ct in (6, 7, 8, 9):
|
|
_dp(_ct, [0], 5, 20, 40); _dp(_ct, [1], 20, 50, 70)
|
|
|
|
def _cardtype(sub):
|
|
"""FUN_1800d8330, exact. 0 means no table row, i.e. a value of 0."""
|
|
if sub is None: return 0
|
|
if 0 <= sub <= 3: return 1
|
|
if sub == 4: return 2
|
|
if sub == 5: return 3
|
|
if sub == 6: return 10
|
|
if sub == 7: return 5
|
|
if sub == 8: return 4
|
|
if 9 <= sub <= 11: return 7
|
|
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
|
|
return 9
|
|
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
|
|
or (300 <= sub <= 341): return 6
|
|
return 0
|
|
|
|
def discard_value(item):
|
|
"""round_half_up(rating * price / 100), price from fcc_discardcoins."""
|
|
ct = _cardtype(item.get("cardsubtypeid"))
|
|
r = int(item.get("rating") or 0)
|
|
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
|
|
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
|
|
n = r * price
|
|
return n // 100 + (1 if n % 100 >= 50 else 0)
|
|
```
|
|
|
|
and inside `quick_sell()`:
|
|
|
|
```python
|
|
def value(it):
|
|
dv = it.get("discardValue") or 0
|
|
if dv:
|
|
return int(dv)
|
|
if DISCARD_TABLE:
|
|
return discard_value(it)
|
|
r = it.get("rating") or 0
|
|
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
|
```
|
|
|
|
**Freeze risk: none.** Nothing on the wire changes; `discardValue` is still not
|
|
emitted. **Type fidelity: not applicable** for the same reason. The only failure
|
|
mode is arithmetic, and it is bounded: a missing key pays 0, which is exactly what
|
|
the client does. Verify with one gold rare 85, which must credit 680, not 600.
|
|
|
|
### P2. Stop sending `extPrice`
|
|
|
|
Invert the current unconditional emission into a flag that defaults to not
|
|
sending it.
|
|
|
|
```python
|
|
# tools/utas_server.py, with the other store flags near line 800
|
|
|
|
# FUT_STORE_EXTPRICE: send the extPrice object. DEFAULT OFF as of 2026-08-05.
|
|
# extPrice's two sub-parsers (0x180139070, 0x18013aae0) read exactly ONE key,
|
|
# externalPriceId (0x11a); amount and currency are SKIPped. Before parsing,
|
|
# both CREATE an "mtx" row in the pack's currency vector, and the tile adapter
|
|
# FUN_18002c3c0 sets tile+0xb5 = 1 purely because that row exists, which is what
|
|
# turns on the real-money price line. The number that should fill it comes from
|
|
# the Origin/Dime catalogue by externalPriceId, which does not exist offline, so
|
|
# FUN_18002cc90 early-returns on tile+0x6c == -1 and every string stays at its
|
|
# ctor default. That is the "or %1s" on the tile. There is no correct extPrice.
|
|
STORE_EXTPRICE = os.environ.get("FUT_STORE_EXTPRICE", "0") == "1"
|
|
```
|
|
|
|
and in `_pack_body()`, replace the unconditional `"extPrice": {...}` member with
|
|
|
|
```python
|
|
if STORE_EXTPRICE:
|
|
body["extPrice"] = {"finalPrice": {"amount": mtx, "currency": "mtx"},
|
|
"originalPrice": {"amount": mtx, "currency": "mtx"}}
|
|
```
|
|
|
|
**Freeze risk: strictly negative.** Omitting the key means atom 0x119 never
|
|
matches at `0x18013afc9`, the nested loop at `0x18013b59d` never opens and neither
|
|
sub-parser runs. No reader-state change of any kind. It also removes an unguarded
|
|
`call FUN_1801a0040` followed by `mov rdx,[rax]` at `0x18013ba98`, with no null
|
|
test, that currently executes on every store load because an `mtx` row exists.
|
|
**Type fidelity:** the genuine hazard in this area is sending `finalPrice` or
|
|
`originalPrice` as a scalar, since `FUN_180139070` runs its own loop until
|
|
END_OBJECT unconditionally; omitting the key removes even that.
|
|
|
|
**The untested part, stated plainly:** the step "tile `+0xb5` drives the observed
|
|
`or %1s` line" is inference, not proof. And the displayGroup precedent is exactly
|
|
this shape: a plausible field change made two packs unbuyable. Check buyability
|
|
after this change, not just the price line.
|
|
|
|
### P3. Canonical lowercase category tokens
|
|
|
|
```python
|
|
# tools/utas_server.py
|
|
|
|
# FUT_STORE_CATTOKENS: send displayGroup.value as the client's own hardcoded
|
|
# lowercase category tokens instead of our pack names. FUN_180014580 switches
|
|
# 0..5 over exactly mypacks/points/bronze/silver/gold/special and looks each up
|
|
# by the same caption strcmp (FUN_180014380) that builds groups. Today all six
|
|
# miss, FUN_18007e5e0 hides all six panels and the store runs with NO bound tabs,
|
|
# which is the degenerate state the drill-down bug lives in. Tile captions become
|
|
# the client's localised tab labels. DEFAULT OFF.
|
|
STORE_CATTOKENS = os.environ.get("FUT_STORE_CATTOKENS", "0") == "1"
|
|
_CAT_TOKEN = {1: "bronze", 5: "gold", 6: "special"} # keyed by pack id
|
|
```
|
|
|
|
in `_pack_body()`, inside the existing `if STORE_DISPLAYGROUP:` block:
|
|
|
|
```python
|
|
if STORE_CATTOKENS:
|
|
body["displayGroup"] = {"value": _CAT_TOKEN.get(p["id"], "special")}
|
|
else:
|
|
body["displayGroup"] = {"value": p["name"]}
|
|
```
|
|
|
|
**Freeze risk: none beyond what `displayGroup` already carries.** It is the same
|
|
key, the same flat object, the same single STR member into the same STR getter.
|
|
Only the string content changes. **Type fidelity: unchanged.** The risk is
|
|
behavioural, not structural: this is a field that selects a render path, and the
|
|
last time we changed one of those two packs became unbuyable. Send exact
|
|
lowercase; the case sensitivity of the compare thunk `FUN_1800081c0` was never
|
|
established.
|
|
|
|
### P4. Accept the real bulk-discard body
|
|
|
|
Additive. Do not remove the working single-item `DELETE` path.
|
|
|
|
```python
|
|
# in quick_sell_route(), before the existing itemData/itemIds handling
|
|
|
|
# The client's bulk discard serializer FUN_180126f40 emits exactly
|
|
# {"itemId": [<int64>, ...]}
|
|
# using atom 0x16d. It emits no itemData and no itemIds; those two names
|
|
# appear nowhere in that function. Kept additive because the URL and method
|
|
# for the bulk call are still unknown and the single-item DELETE works.
|
|
if isinstance(body, dict) and isinstance(body.get("itemId"), list):
|
|
ids = [int(x) for x in body["itemId"] if isinstance(x, (int, str))]
|
|
```
|
|
|
|
**Freeze risk: none.** Request parsing only; nothing changes on the response side.
|
|
**Type fidelity: not applicable.**
|
|
|
|
### P5. Reshape the CreateUser body
|
|
|
|
Behind a flag because the route has never been exercised and there is no captured
|
|
EA response to validate the reshape against.
|
|
|
|
```python
|
|
# FUT_CREATEUSER_FIX: POST /user currently sends login as a BOOL, bonusPacks as
|
|
# an ARRAY and starterPack as an OBJECT. FutCreateUserServerResponse's deser
|
|
# 0x18014cc60 wants the opposite in every case: login is the userInfo OBJECT,
|
|
# bonusPacks is a BOOL, starterPack is an ARRAY of shared ITEM objects, userData
|
|
# is a 4-key OBJECT. Each of the three mismatches is independently sufficient to
|
|
# desync the SAX reader into the freeze at 0x1801c7f1a. The client has never
|
|
# issued this POST (zero occurrences in any log; all GET /user hits are the
|
|
# Python suite), so this is latent, not live. DEFAULT OFF: shape derived from
|
|
# the deserializer alone, never validated against a real response.
|
|
CREATEUSER_FIX = os.environ.get("FUT_CREATEUSER_FIX", "0") == "1"
|
|
```
|
|
|
|
```python
|
|
if CREATEUSER_FIX:
|
|
body = {"login": _user_info(), # OBJECT, deser 0x18013ec10
|
|
"squad": _active_squad(), # OBJECT, deser 0x18013d1f0
|
|
"starterPack": [], # ARRAY of ITEM objects
|
|
"bonusPacks": False, # BOOL
|
|
"userData": {"onlineELORating": 0, "onlineRatedUser": False,
|
|
"accountResetCount": 0, "winForm": 0}}
|
|
```
|
|
|
|
**Freeze risk: the patch removes three freezes and adds none**, provided
|
|
`_user_info()` returns an object and `starterPack` stays an array. **Type
|
|
fidelity:** `login`, `squad` and `userData` reach object parsers; `starterPack`
|
|
reaches an array loop that calls the shared item element deser `0x18013fe00`, so
|
|
its elements follow the ordinary `itemData` element schema verbatim; `bonusPacks`
|
|
reaches the BOOL getter `0x1801c7620`. Note `winForm` is narrowed to a single byte
|
|
by `FUN_1800d7ab0`, so anything above 255 truncates silently.
|
|
|
|
---
|
|
|
|
## 7. Proposed `ENDPOINT_MAP.md` corrections
|
|
|
|
Paste-ready, in the existing entry format.
|
|
|
|
**Section 1, `FutStoreGetPackTypesServerResponse`, pack object field table.**
|
|
Replace the `displayGroup`, `extPrice` and `packContentInfo` rows and add the
|
|
missing offsets:
|
|
|
|
```
|
|
| `displayGroup` | 0xd9 | **flat OBJECT** | NOT an array. Exactly two members: `value` (0x377, STR) → record `+0x00`, `priority` (0x250, INT) → record `+0x34`. The `+0x00` slot's ctor default is the literal `"unknown"`, which is why untouched tiles read "unknown". `value` is the ONLY key group membership is decided by (`FUN_180014380`, exact strcmp against `group+0x70`). |
|
|
| `displayGroupAssetId` | 0xda | INT | record `+0x30` → `group+0x04` → tile `ASSET_ID` and the `packs_backgrounds_%d.dds` texture name. NOT a lookup key anywhere in CardsDLL. |
|
|
| `displayGroupUseDefaultImage` | 0xdb | BOOL | record `+0x38`. Chooses the GROUP tile texture only. It is NOT the gate on `OVERRIDE_ASSET_ID`; that is `useDefaultImage` (0x36a) at record `+0xcc`, a different atom. |
|
|
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice` (0x125, `0x180139070`) + `originalPrice` (0x205, `0x18013aae0`). **Each reads exactly ONE inner key, `externalPriceId` (0x11a, INT). `amount` (0x1b) and `currency` (0xc4) do NOT exist in either parser and are SKIPped.** `finalPrice` also writes the pack's `firstPartyStoreId` slot at `+0x78`. SIDE EFFECT, and this is the reason not to send the key at all: both parsers CREATE an `"mtx"` entry in the pack's currency vector if one is absent, and the tile adapter sets tile `+0xb5 = 1` purely because that row exists, enabling a real-money price line the client can never fill offline. **Recommended: omit `extPrice` entirely.** |
|
|
| `packContentInfo` | 0x20c | **flat OBJECT** | record `+0x144..+0x154`. EXACTLY five children: `itemQuantity` (0x170), `goldQuantity` (0x149), `silverQuantity` (0x2c6), `bronzeQuantity` (0x63), `rareQuantity` (0x273). **`unopened` (0x35d) and `start` (0x2e3) are TOP-LEVEL pack keys, NOT children of this object.** Sending them nested is inert, not harmful. |
|
|
| `unopened` | 0x35d | BOOL | **top level.** Record `+0xcd` → tile `+0x69`. Consumer is in packed FIFA17.exe; meaning unestablished. |
|
|
| `start` | 0x2e3 | INT | **top level.** Record `+0xb4`. |
|
|
| `packType` | 0x20f | STR | Record `+0x108` → tile `+0x38`. Ctor default `"INVALID"`. Pushed to NO Flash field; it is not a tab key and not a lookup key. |
|
|
| `actionType` | 0x08 | **STR, not INT** | Record `+0xd8` → tile `+0x08`, Flash `POST_PURCHASE_ACTION`. Ctor default is the literal `"CREATEPACK"` (`0x180223110`, written by `FUN_1801342d0`). |
|
|
| `sortPriority` | 0x2cb | INT | Record `+0x7c` → tile `+0x1a0`. Pushed to no Flash field, but it IS the merge key of `FUN_180010890`, which sorts each group's pack list. Not inert. |
|
|
```
|
|
|
|
**Section 1, delete the "none of these atoms exist in the pack deser" bullet.**
|
|
It is wrong for at least `quantity` (0x26b → `+0xbc`), `saleType` (0x298),
|
|
`packType` (0x20f → `+0x108`) and `isPremium` (0x176 → `+0xce`), all of which have
|
|
arms. `id` (0x15c) is unresolved, but record `+0x70` exists, feeds tile `+0x70` as
|
|
`SERVER_ID` and is the number the purchase sends as `packId`, so something writes
|
|
it. Replace with:
|
|
|
|
```
|
|
- **Dispatch in `0x18013af30` is a jump table (bound `cmp eax,0xfa`) for atoms below 0x119 plus running-sum `sub`/`cmp` ladders above it.** An immediate grep for an atom value returns ZERO hits even for atoms that provably have arms. No absence claim about this parser is valid without decoding the tables at `0x18013b001` / `0x18013b7ba` and the ladders.
|
|
- **The purchase identity is record `+0x70`**, copied to tile `+0x70` truncated to u16, published to Flash as `SERVER_ID` and sent back as `packId`. It is NOT `assetId` (record `+0x74` → tile `+0xac` → `ASSET_ID`) and NOT `displayGroupAssetId`. Which atom writes `+0x70` is an open question; `id` (0x15c) is the obvious candidate and we currently serve `id` == `assetId`, which is why no live read can separate them.
|
|
```
|
|
|
|
**Section 1, corrected minimal known-good:**
|
|
|
|
```json
|
|
{"purchase":[{"id":1,"assetId":1,"description":"Bronze Pack","sortPriority":1,
|
|
"packType":"BRONZE","state":"active",
|
|
"displayGroup":{"value":"bronze"},
|
|
"currencies":[{"name":"coins","funds":400,"finalFunds":400}],
|
|
"packContentInfo":{"bronzeQuantity":5,"silverQuantity":0,"goldQuantity":0,
|
|
"rareQuantity":0,"itemQuantity":5}}],
|
|
"timestamp":1596326400}
|
|
```
|
|
|
|
Note the deliberate absence of `extPrice`. `finalFunds` is the number the tile
|
|
renders; `funds` is not displayed on the tile but IS what the HUD balance reads
|
|
from the `/credits` currencies array. Same field name, two consumers, two
|
|
meanings.
|
|
|
|
**New subsection, display groups.** There is no entry for this today.
|
|
|
|
```
|
|
### 1a. Display groups (client-side, built from the store response)
|
|
|
|
Builder `FUN_1800150d0`. Walks `purchase[]` in ARRAY ORDER. For each pack it
|
|
finds-or-creates a group by exact strcmp of `displayGroup.value` against
|
|
`group+0x70` (`FUN_180014380`). A new group (`FUN_180012950`, 0x108 bytes) gets:
|
|
`+0x00` = a 1-BASED ORDINAL equal to (groups so far)+1, `+0x04` =
|
|
`displayGroupAssetId`, `+0x70`/`+0xa0`/`+0xd0` = three copies of the caption
|
|
(`+0x70` is the lookup key, `+0xa0` the tile NAME, `+0xd0` the DESCRIPTION and
|
|
CONTENT), `+0x100` = `displayGroup.priority`, `+0x104` = (value == "mypacks"),
|
|
`+0x40` = the group's vector of 0x1a8 tile models.
|
|
|
|
Resolution: `FUN_1800147f0(model, N, ...)` treats N == 0 as "list the group
|
|
tiles" and otherwise calls `FUN_180014420`, which EXACT-matches `group+0x00` and
|
|
returns NULL on a miss, after which the caller dereferences `[RAX+0x40]` with no
|
|
guard. **The only legal category values are 0 and the ordinals 1..N.** N arrives
|
|
from the Flash movie: `FUN_18007e7f0` case 0x7551 copies the message field
|
|
`CATEGORY_ID` verbatim into `screen+0x290`.
|
|
|
|
The store's tab bar is SIX hardcoded lowercase tokens, `mypacks`, `points`,
|
|
`bronze`, `silver`, `gold`, `special` (`FUN_180014580`, `FUN_180014df0`), each
|
|
looked up by the same caption strcmp. `FUN_18007e5e0` gives panel 0..5 a
|
|
`PANEL_ID` of the matching group's ordinal or HIDES the panel; `FUN_18007df60`
|
|
publishes `MYPACK_/BRONZE_/SILVER_/GOLD_/SPECIAL_/POINTS_CATEGORY_ID`. Serving
|
|
captions outside that vocabulary leaves all six at -1 and all six panels hidden.
|
|
|
|
A group renders only if it holds at least one pack whose tile `+0x00` visible byte
|
|
is non-zero (`FUN_18002c8b0`), which comes from `visible` (0x37d) at record
|
|
`+0x138`, presence-only.
|
|
```
|
|
|
|
**Section on discard, replace the `FutDiscardCardServerResponse` body.**
|
|
|
|
```
|
|
- **name VA** `0x180220540` · **vtable** `0x180220488` · **deserializer** `0x180127300` (slot +0x08)
|
|
- Wire: `DELETE ut/%s/item/<id>` (URL suffix builder emits `/%llu`), NO body. CONFIRMED live 2026-08-05.
|
|
- Response: `{"items":[{"id":123456789}], "totalCredits":15000}`
|
|
- `items` (0x171) is an **ARRAY OF OBJECTS**; the inner key is `id` (0x15c), int64. There is NO top-level `id`. Each parsed id calls the item manager's `vt[0xa30]`, which removes the card from the club.
|
|
- `totalCredits` (0x326) is an INT, plain assign into a freshly allocated 0x38-byte response object at `+0x28`. No accumulation anywhere in CardsDLL. Send the ABSOLUTE new balance.
|
|
- Bulk discard: the client's body serializer `FUN_180126f40` emits `{"itemId":[<int64>, ...]}` (atom 0x16d). It emits NO `itemData` and NO `itemIds`. URL and method not yet established.
|
|
- **The client computes the displayed value itself** from its own game DB table `fcc_discardcoins` whenever the server's `discardValue` (0xd7) is 0 or absent: `value = round_half_up(rating * price / 100)` with `price` keyed on `(cardtype, level, rare)`, `cardtype = FUN_1800d8330(cardsubtypeid)` and `level` = 3 if rating>=75, 2 if 65..74, else 1. Sending a non-zero `discardValue` SUPPRESSES that computation (guard `ja` at `0x180141025`). Table and formula in docs/plan-2026-08-05-store-subsystem.md 3.6.
|
|
```
|
|
|
|
**`duplicateItemIdList`, both places it appears (`:1095` and `:218`).**
|
|
|
|
```
|
|
| `duplicateItemIdList` | 0xec | **ARRAY OF OBJECTS**, not an int list | element deser `0x180138e10`, 0x20-byte records: `itemId` (0x16d, int64), `itemLoans` (0x16f, int32), `duplicateItemId` (0xeb, int64), `duplicateItemLoans` (0xed, int32). Only `itemId` and `duplicateItemId` are ever read; both loans fields are parsed and discarded. Effect: for each record, where an item in the SAME response has `id == itemId`, set that item's `+0x10` to `duplicateItemId`. Entries naming an item not in the same body do nothing. Accepted by `0x180162880` (CreatePack), `0x18013bd40` (purchased/massinfo), `0x1801293d0` (ViewCards) and `0x18013e7f0` (IS-list, item reached via auction `+0xb0`). The only C++ consequence is `HAS_DUPLICATE` (a `!= 0` test) plus one branch in the sign-loan-player completion that SUPPRESSES an automatic `PUT ut/%s/item`. No request ever serialises any of these four atoms. Serving `[]` is coherent. |
|
|
```
|
|
|
|
**`FutCreateUserServerResponse`.**
|
|
|
|
```
|
|
| `login` | 0x1a5 | **OBJECT** (the userInfo record, deser `0x18013ec10`) → `response+0x28` | was documented as a bool |
|
|
| `userData` | 0x36d | **OBJECT**, deser `0x180142470`, exactly four keys: `onlineELORating` (0x1f2, INT), `onlineRatedUser` (0x1f3, BOOL), `accountResetCount` (0x1f4, INT), `winForm` (0x383, INT **narrowed to one byte**) | was swapped with `login` |
|
|
| `squad` | 0x2cd | **OBJECT**, LoadActiveSquad `0x18013d1f0` | |
|
|
| `starterPack` | 0x2e5 | **ARRAY of shared ITEM objects** (`0x18013fe00`) → 0x18-stride vector at `response+0x158` | was documented as an object |
|
|
| `bonusPacks` | 0x5d | **BOOL** → `response+0x150` | was documented as an array |
|
|
```
|
|
|
|
**`userInfo.unopenedPacks`.**
|
|
|
|
```
|
|
| `unopenedPacks` | 0x35e | OBJECT | exactly two children, `preOrderPacks` (0x24b) and `recoveredPacks` (0x27b), both INT. **`count` (0xbc) has no arm here and is SKIPped.** Their SUM goes to `model+0x20950` via model vtable slot `+0x4e0` and broadcasts UI event 0x273d. The SAME pair also arrives on `FutUpdateCreditsServerResponse` (`FUN_180122c50`, `resp+0x34`/`+0x38`), which the client requests ~14x per session against ~5 userMassInfo calls. The counter is CLIENT-MUTABLE: at least five CardsDLL functions read-modify-write it, and `FUN_180019780` increments it and then issues `GET ut/%s/purchased`. Do not serve a non-zero value until that subsystem is audited. |
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Coverage, honestly
|
|
|
|
Two dimensions came back thick and hold up: the quick-sell economy and the price
|
|
model. Both were reproduced end to end by a verifier using a different toolchain
|
|
(`.pdata`-bounded objdump instead of Ghidra, brute-force table relocation instead
|
|
of the original pointer chain) and 20 of 23 claims survived with no refutations.
|
|
The discard table's bit layout was upgraded from self-consistent to provably
|
|
unique.
|
|
|
|
The grouping dimension came back confident and was the one that took damage. Its
|
|
top-ranked recommendation is refuted, its "sortPriority and displayGroup.priority
|
|
are inert" claim was a false absence found by searching the whole `.text` listing
|
|
rather than eight functions, and its ActionScript evidence is constant-pool string
|
|
order, which is suggestive and not proof. What survives is the mechanism, which is
|
|
solid and well controlled: the group builder, the ordinal, the exact-match
|
|
resolver, the missing null guard and the six-tab vocabulary were all read in raw
|
|
disassembly with same-form controls.
|
|
|
|
Dimension 6 (live state) contradicted dimension 1 twice and lost both times: it
|
|
claimed `packType` was the likely lookup key, and that the store tabs key on
|
|
`packType` tier. Both are wrong; the tabs key on `displayGroup.value` and
|
|
`packType` has no Flash consumer at all. Its other work, the process-wide sweeps
|
|
and the telemetry reconstruction, was not re-verified but is presence-only
|
|
evidence from direct reads, which is the lowest-risk category. Its author also
|
|
caught and logged one of its own filter bugs mid-run, which is worth more than the
|
|
findings it corrected.
|
|
|
|
The duplicates dimension is thorough and entirely static. It predicts a flow (loan
|
|
signing) that has never been exercised, and its one exhaustive-census claim was
|
|
explicitly not reproduced by the verifier, so treat that claim as carrying one
|
|
agent's confidence rather than two.
|
|
|
|
The unopened-packs dimension had the single largest error of the run, calling a
|
|
counter display-only when six functions write it and one of them fires an HTTP
|
|
request. That was caught only because a verifier grepped for vtable-slot call
|
|
sites rather than for the raw field displacement. The lesson generalises: a field
|
|
reached exclusively through a vtable slot is invisible to a displacement search.
|
|
|
|
**The unresolved contradiction I would flag hardest** is which Flash field becomes
|
|
`CATEGORY_ID`. `ASSET_ID` is eliminated by live memory. Between `CHILD_CATEGORY`
|
|
and `GetActiveTabId()`, nobody read the AS2 bytecode and nobody should pretend
|
|
otherwise. I would bet on `CHILD_CATEGORY`, because 3 is a valid ordinal and an
|
|
unbound tab id is -1, and because a tab-derived id would break Bronze too and
|
|
Bronze works. If that bet is right, no field we send fixes the bug directly and
|
|
the value of binding the tabs is that it removes a degenerate subsystem, not that
|
|
it addresses the carrier. I would not spend more than one launch on it before
|
|
someone unpacks the FUT SWF.
|
|
|
|
The whole ActionScript layer is unread. That is the thinnest and most consequential
|
|
coverage gap in this document, and every remaining store mystery lives there.
|
|
|
|
---
|
|
|
|
## Next action
|
|
|
|
Before anything else, and before the process dies: while pid 134663 is still in
|
|
the store, have the user click the **Gold Pack** tile, then read
|
|
`0x41934180 + 0x290` out of `/proc/134663/mem` read-only, having first
|
|
re-resolved the pid by `comm` and re-checked both slide controls. That single
|
|
number decides which half of this document matters. If it reads 2, the drill-down
|
|
is correct, the bug is not group resolution at all, and the entire ranked
|
|
experiment list in section 2.3 is aimed at the wrong layer. If it reads 3, the
|
|
movie is echoing the wrong ordinal and the tab-binding experiment becomes the
|
|
first launch worth spending. It costs nothing, it needs no restart, it touches no
|
|
server, and it is the only observation available right now that can invalidate a
|
|
whole branch of planned work.
|