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>
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
.rdataat0x18021df80..0x18021e280holds 48(path, token)pairs. The only pack-relevant entries areut/%s/store,ut/v2/%s/store,ut/%s/purchasedandut/%s/item. A regex forut/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 arePurchasePack(urlIndex 0x1a),PurchasedItems(0x1a),PurchaseItems(0x1b),StorePackTypes(0x1b) andStorePackQuantities(0x1b). No claim, no list, no open. - The pending-pack surface that does exist is entirely inside responses we
already serve.
userInfo.unopenedPacksis a counter pair that lights a game-hub tile, and "My Packs" is a filter over the ordinary store catalogue: entries whosedisplayGroup.valuestring equalsmypacks(FUN_1800150d0, literal at0x1801ec008). 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_1801c7f10returns token 7 and consumes no input. It is a once-only start-of-document freebie, guarded by the flag atparser+0xdatogether with the zero character counter atparser+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== 10test 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 /hubis 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:clubPlayerscame back as 205, the value the server sent, atmodel+0x1fd70+0x3c(slide proven against the FNV prologue first). It reached its arm. The model was wrong.Consequences of the correction.
POST /useris fine as it stands.loginis 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.createPackResponseis now DOUBTFUL rather than confirmed. Atom0xbehas no arm inFUN_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 throughPOST /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_18023dd40and the switch in the classifierFUN_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 == 0xbthen atom-hashes+0xf812 START_ARRAY case 0x66, pushes state 3 13 END_ARRAY case 0x67, pops state 3 1 error the caseD_78sinkSo 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 atom0xbeand does not need one. Coverage for that absence: the ladder has exactly four arms (0xec,0x16e,0x1dd,0x264) and0xbedoes not occur anywhere in the full 4702-char decompile, printed in full.The error in reading B. It claimed the
/purchasedroot "spends the same three tokens".FUN_180124ee0spends two, then hands off toFUN_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/purchasednever 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 /hubreturns{"clubPlayers":205,"auctionCount":0}and is the obvious thing to check against this, but I could not close it here: there is noRS4:FutGetHubServerResponseliteral in the image, andclubPlayersandauctionCountappear only as atom-table entries with no code xref, so/hubis 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):
- a best item was found,
(item->resourceId & 0xffffff) != 0,item->cardtype == 1,- the data provider has an
ASSET_ID, 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 localfcc_discardcoinstable keyed oncardtype,levelandrare, scaled by rating and divided by 100 with round-half-up. Sending a non-zerodiscardValuetherefore 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_1800aa330returns true whenloans < 1and (rating > 87or the playerid appears in the localfcc_GrandStandPlayerstable). 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_1800a96a0is a stable descending sort ondiscardValue; the best-item scan uses strict greater-than so the earliest item wins ties. The underlying vector keeps wire order, sorandom.shuffle()inopen_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/itemplus/%lluappended byFUN_180127570, no body; - bulk:
ut/delete/%s/itemwith{"itemId":[id, id, ...]}(atom 0x16d) built byFUN_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)
The envelope rule.DONE 2026-08-05 evening. See the resolution box in section 2. Token enum decoded, reading A confirmed,/purchasedshown not to be a counterexample.POST /userandGET /purchasedare 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 toGET /hub, which we answer with a flat two-key body? There is noFutGetHubServerResponseclass and no code xref to either atom, so/hubmay not go through a generated root at all. UNKNOWN, and worth one query.NUM_*_IN_PACKauthority. DecompileFUN_1800147f0, the sole caller ofFUN_180015d80, and see whether itsparam_4is the JSON-filled pack record. One function. Until then those five numbers are UNKNOWN, not SERVER.- Whether anything reads
FutCreatePackServerResponse+0x28(numberItems). The original evidence was a misread of an adjacent vtable:0x180228260is a two-slot response vtable,{0x1801624e0 dtor, 0x180162880 deser}, and0x180228270is 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. - The three unread consumers of the
mypacksliteral,FUN_180014df0,FUN_180014580andFUN_1800147f0. OnlyFUN_1800150d0was read. - 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.
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)
- 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
CREATEPACKliteral, and a scan for theFUT Vector/FUT Stringpointer 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. data/store/dimecfg.xml. Thefile://path string is live at0x79049a0but the parsed content was not located. It is the sibling ofstorecfg.xmland probably carries the group/category structure thatdisplayGroupmaps onto, which is exactly the field that froze the store once. Worth a targeted hunt before this process exits.- The
FIFA17.exeside of the reveal gate. CardsDLL exposespackOpeningAnimationEnabledas a vtable slot; whether the packed exe also reads it is unknown, and would need a sweep of the decrypted arena at0x1450f3000..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.
- 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.
- Whether
finalFundsorfundsis 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. - 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.
- Lower value, do not spend a launch on these alone: whether a populated
displayGroupis safe; whetherunopenedPacksalone lights the unclaimed-pack tile or whether the store must also return amypacksgroup; whetherpurchaseLimitpluspurchaseCountis 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
preadand 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.xmlcontent is unresolved. It is not in CardsDLL, not in the packed exe, not instp-origin_emu.dll, not in the repo, and not in 256 files totalling 3.24 GiB under the install. ButData/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]thenMOV 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 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
### 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
- Store. THE CONTROL. Open it and read the Gold Pack price.
- Buy the Gold Pack. Note the coin balance before and after.
- 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.
- Quick Sell exactly one card from the reveal, then leave the rest.
- Send to Transfer List exactly one card, if the option is offered.
- Send to Club the remainder, which is the live-proven path and doubles as the regression check.
- 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:
login: truehas never reached the client. Whether that is harmful is UNKNOWN, since FUT plainly works without it.- The key order of that dict is load-bearing and nothing in the code says so. Any
reordering that puts
userDatafirst would swallowuserDatainstead, which would be a spectacular and very hard to diagnose failure. This is a landmine sitting inutas_server.pyright 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:
- I searched for
RS4:FutGetHubServerResponseand reported that no hub class existed. The class isFutGetHubDataServerResponse(literal0x18022ce40, vtable0x18022cd48, deserializer0x1801738b0, resolved withFutSquadSave -> 0x180171a60matching as the control in the same run). An absence found by guessing a name is not an absence. - I then scanned 152 deserializers for
clubPlayers, found zero hits, and had a control pass. The guard isif (iVar6 != 0x90)and my pattern only matched== 0x. The control passed only becauseauctionCounthappens 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.