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

72 KiB

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:

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)

  1. 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.
  2. 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.
  3. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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

-  | `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

-  | `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

-  | `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

-  - `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

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

 // 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

-  | `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 duplicateItemIdListarray to duplicateItemIdListarray 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

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

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

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

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:

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:

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.