Files
OpenFUT/fifa17-recon/docs/plan-2026-08-05-pack-opening.md
T
funman300 89da7b7609 fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.

THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.

The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.

Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.

HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.

Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.

TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.

ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
  * duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
    list at :1095. Bare ints where the element parser expects objects is a tokenizer
    desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id.

No behaviour change. utas_server.py is comment-only. 439 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:35:27 -07:00

1106 lines
72 KiB
Markdown

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