Files
OpenFUT/fifa17-recon/docs/plan-2026-08-06-transfer-market.md
T
funman300 43557989f5 fifa17-recon: the transfer market works -- listed a card end to end, no freeze
The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.

1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
   not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
   q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
   the massinfo END_OBJECT the client runs
     cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
   feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
   and unconditionally, which is why the gate read 0 all day regardless of /settings or
   the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
   0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).

2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
   0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
   Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
   (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
   code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
   key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.

3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
   OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
   (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
   0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
   Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
   0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
   requested defId. Live: price screen opened and Submit succeeded.

Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.

Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.

Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).

Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:33:10 -07:00

65 KiB

The transfer market: why it refuses, the gate-byte block, and the market wire protocol

Written 2026-08-06. Six parallel reversing passes over the trading gate, the trade-pile capacity, the listing predicate, the market wire protocol, the online state machine and the live gate-byte block, plus three adversarial verification rounds that refuted six claims and corrected a dozen more. FIFA 17 was running throughout as pid 260692, in Ultimate Team, and was read strictly read-only. No server was restarted, no server code was changed, and tools/fifa17_profile.json was never opened for write.

Slide for every live read: live = static - 0x180000000 + 0x6ffffc140000, re-derived from /proc/260692/maps by six agents independently and proved each time against bytes read from the on-disk PE in two different sections. The controls used while writing this document were the FNV hasher prologue at 0x180180d00 (.text, 32 bytes, disk == live), the single-occurrence .rdata literal RS4:FutSquadSaveServerResponse at 0x18022c618 (48 bytes, disk == live), and a negative control one page high that mismatches. All three passed at the moment of writing.

Two premises the brief carried into this run are wrong and are not inherited below. The trading gate byte is not 1, it is 0, on four independent measurements across two pids. And the settings applier is not unreachable: it is virtual method +0x988 of the FutDataManagerImpl vtable, it has two callers, and it has been running on every session for the whole history of this project. "Ghidra shows no caller" was a search-form miss, the seventh instance of the absence trap on this project, and this time the dispatch form was a virtual call.


1. What we now know that we did not know this morning

The transfer market is switched off by our own server, by one JSON member, and the member is inverted so that sending it as true is what disables trading. tools/utas_server.py line 255 puts "feature": {"trade": True} inside userInfo. The FIFA 17 userMassInfo deserializer reads that as a trade ban. The chain is six hops and every one is an instruction I read out of the on-disk PE rather than a decompile:

18013f016:  cmp    esi,0x330            ; atom 0x330 = "trade", inside 0x11c = "feature"
18013f01c:  je     18013f02a
18013f02a:  mov    rcx,rbx
18013f02d:  call   1801c79d0            ; the INT primitive
18013f032:  cmp    eax,0x1
18013f035:  jne    18013f03d
18013f037:  mov    BYTE PTR [rdi+0xa4],al     ; userInfo+0xa4 == response+0x17c
...
180174f07:  cmp    eax,0xa                    ; top-level END_OBJECT of the whole body
180174f0a:  jne    180174720
180174f10:  cmp    BYTE PTR [rsi+0x17c],0x0
180174f17:  je     180174f20
180174f19:  mov    DWORD PTR [rsi+0x50],0x0   ; force settings.tradingEnabled to 0
...
18011dc8a:  cmp    DWORD PTR [rbx+0x28],0x1   ; rbx = copy of response+0x28, so +0x28 == response+0x50
18011dc8e:  sete   al
18011dc91:  mov    BYTE PTR [rdi+0x1fd2e],al  ; the IS_TRADING_ENABLED gate byte

userInfo is parsed at response+0xd8 (lea rcx,[rsi+0xd8]; call 0x18013ec10 at 0x180174b63), so [rdi+0xa4] is response+0x17c. The settings sub-struct is at response+0x28 (lea rdx,[rsi+0x28]; call 0x18013c6d0 at 0x180174ab2), so response+0x50 is settings field index 10, which is tradingEnabled, atom 0x336. Live right now: model+0x1fd2e = 0, model+0x1fd2f = 1.

The gap every previous agent left open is closed: JSON true reads as 1 through the INT primitive. This mattered because the whole finding depends on cmp eax,1 passing when we send a boolean, and three agents flagged it as unverified while ranking an action that rests on it. FUN_1801c79d0 is a sub/dec ladder on the token at reader+0xd0, and the token-4 arm is:

1801c7a38:  xor    eax,eax
1801c7a3a:  cmp    BYTE PTR [rbx+0x108],al
1801c7a40:  setne  al
1801c7a43:  ret

Token 4 is the boolean token and the INT getter returns it as 0 or 1. true arrives at cmp eax,1 as 1. The kill switch fires. Note the elegance of the bug: mov [rdi+0xa4],al stores al, which is the getter's return value, and the branch above guarantees it is exactly 1.

The kill switch runs last, unconditionally, so serving tradingEnabled: 1 in the configs array cannot beat it. The test at 0x180174f07 is on the top-level END_OBJECT of the entire massinfo body, and the jne at 0x180174f0a loops back to 0x180174720, the member-dispatch loop. Every member, settings included, is fully parsed before the tail runs. So a tradingEnabled arm would write response+0x50 and the tail would then zero it. Removing feature.trade is not the cleanest fix, it is the only fix that works through /userMassInfo. This point was made by exactly one agent and it is the difference between a working experiment and a wasted relaunch.

This resolves a contradiction that an adversarial pass had correctly identified and could not explain. That verifier mechanically parsed the applier and the settings-struct constructor FUN_18014e320 and built an expected-from-defaults table: 37 of 37 determinable fields match the live model exactly, including three unique values at three unrelated offsets, and tradingEnabled alone deviates. I re-read the constructor head myself:

18014e344:  mov    QWORD PTR [rcx+0x10],0x1e0    ; -> model+0x1fd54, live 480
18014e353:  mov    DWORD PTR [rcx+0x1c],0x3c     ; -> model+0x1fd28, live 60
18014e35a:  mov    DWORD PTR [rcx+0x20],0x4ec0   ; -> model+0x1fd60, live 20160
18014e361:  mov    DWORD PTR [rcx+0x28],0x1      ; tradingEnabled default is ONE
18014e368:  mov    DWORD PTR [rcx+0x2c],0x1      ; storeEnabled, identical form

There is no default asymmetry between store and trading. One agent claimed at HIGH confidence that "the response object defaults storeEnabled to 1 but tradingEnabled to 0, and that single asymmetry is the entire bug." That is refuted by the instruction at 0x18014e361, and that agent's own gap list concedes it never read the constructor and inferred the default from the live byte it was trying to explain. The verifier who caught the circularity was right to reopen it, and could not close it because it did not look at feature.trade. The kill switch closes it: 37 fields keep their constructor defaults because our configs array is empty, and the 38th is zeroed by our own userInfo member after the fact. Every measured number in the block is now accounted for.

"/settings cannot reach the gate bytes" is dead, and so is the measurement that seemed to prove it. The applier is virtual slot +0x988, called from 0x180173f0b (the userMassInfo completion callback FUN_180173e00) and from 0x18011e21a (the standalone /settings callback FUN_18011e180, which I confirmed: cmp DWORD PTR [rdx+0x1c],0x0 at 0x18011e19e, then a movups copy of rdx+0x28, then the call). Both endpoints apply. The historical probe that served maximumTradePileSize=77 and found no gate field carrying 77 was aimed at the wrong destination: atom 0x1c0 lands in settings field [0], and the applier's first act is add rcx,0x15f00; mov edx,[rdx]; call 0x18011f380, a slot vector at model+0x15f00. It was never going to appear in the 0x1fd block. The brief's proposed "clean falsifier" was a false falsifier and would have produced a null result read as a second confirmation of a wrong conclusion.

TRANSFER LIST 0/0 is a separate, fully decoded path, and it is not fed by maximumTradePileSize. model+0x1fd1c is published as TRADE_PILE_SIZE and has exactly one writer, FUN_18011dbf0 at 0x18011dc05, reached only through virtual slot +0x998, called from exactly one site, 0x18017405b, with a copy of response+0xc8. That block is written only by the pileSizeClientData parser FUN_18013adb0, whose entire key vocabulary is two constants:

18013aeaf:  cmp    esi,0x2
18013aeb2:  je     18013aec7          ; -> mov [r14+0x8],eax   -> model+0x1fd1c
18013aeb4:  cmp    esi,0x4
18013aeb7:  jne    18013aed3          ; -> mov [r14+0xc],eax   -> model+0x1fd20

Key 2 is the trade pile, key 4 is the watch list, every other key is read and discarded. FUT_PILESIZES=probe should not be run: it would burn a relaunch to rediscover a two-value enum that is now decoded, and its fallback would write the club item count (99) into the transfer-list capacity.

The Blaze client-config store is not a lever for any of this. The client demonstrably received IS_TRADING_ENABLED=1 and tradingEnabled=1 this session (the merged config blob is readable in the client's heap at 0x43c50aa7 and contains both), and the gate byte is still 0. IS_TRADING_ENABLED is an output name: FUN_18006cc60 calls the accessor at vt+0x270, takes the byte, and hands it out under that literal. The only rip-relative reference to 0x1801fc118 in all of .text is that lea. FUT_TRADING in blaze_responder_v3b.py is inert with respect to these bytes. This kills the brief's premise that we have "a PROVEN way to set the gate bytes via the Blaze client-config store." We do not. The proven way is the settings sub-struct, and its constructor already sets most of them.

Every market route currently implemented in utas_server.py is at a path the client never requests. The route table at .rdata 0x18021df80 (45 rows) and the action table at .data 0x1802caa20 (125 rows of 0x30) resolve twelve market operations to twelve path templates, none of which are the ones we serve. Section 5 has the full spec. One route is empirically confirmed rather than inferred: the game itself issued GET /ut/game/fifa17/tradePile at 10:34:24 and 10:42:35 with its own ProtoHttp 1.3/DS 15.1.2.1.0 (Windows) user agent.

The reconnect banner is a red herring and the market refusal is not a connectivity decision. PRESS q TO RE-CONNECT belongs to powdll_Win64_retail.dll and the EASFC widget. CardsDLL does not import powdll (its import directory names exactly four DLLs: sysdll, MSVCR120, KERNEL32, USER32) and contains no POW or EASFC connection literal. The Blaze session is alive and ping-replying. Section 4 has what this does and does not mean for Seasons.

Be precise about what this document claims. It explains, in instructions, why IS_TRADING_ENABLED publishes 0. It does not prove that flipping it opens the Transfer Market screen. That link is inference from the published input set plus a same-mechanism control, and section 2 grades it honestly.


2. Why the market refuses

The refusal is client-side UI logic, and CardsDLL only supplies its inputs

No CardsDLL code path raises the caption. The full text "The Transfer Market is currently unavailable. Please try again later." exists in memory only as EA string objects in the Wine heap, preceded by a three-u16 header and followed by 0xcd fill, with no adjacent identifier and no loc key. MARKET_UNAVAILABLE, TRANSFERMARKET and TRANSFER_HUB return zero hits across all readable memory. The FIFA17.exe image contains no FUT market identifier at all: every exe-only TRANSFER_* string is Career Mode (ScreenControllerTransferHistory::TransferList, cm_ap_addtotransferlist), and the one "currently unavailable" hit is the CRT debug string next to Callstack:. The CARDS_CB_ERR_* lead is dead as well: all forty-plus of those literals have exactly one xref each, all inside FUN_1800d7b70, which is a single enum-to-string table. They are formatting names, not raise sites. Note the brief's addresses for them were file offsets; the VAs are 0x1802142d0 and 0x180214360.

What CardsDLL does is publish a small named context map to the UI script layer. FUN_18006cc60 is a straight-line publisher over a contiguous .rdata name run at 0x1801fc118..0x1801fc228, and the complete family is ten names: IS_TRADING_ENABLED, IS_STORE_ENABLED, IS_FRIENDLY_SEASON_ENABLED, IS_TOURNAMENT_QUIT_ENABLED, IS_PROCESSING_STATE_ENABLED, IS_RETURNING_USER_REWARDS_SCREEN_ENABLED, IS_STORY_MODE_REWARD_ENABLED, IS_DRAFT_MODE_ENABLED, IS_USER_CHAT_RESTRICTED, IS_USER_UGM_RESTRICTED. FUN_18000d550 publishes TRADE_PILE_SIZE and FUN_1800377c0 publishes NUM_CURRENT_AUCTIONS, NUM_MAX_AUCTIONS and IS_MAX_AUCTIONS. There is no online or connected name in the published surface.

The strongest evidence that the market screen reads IS_TRADING_ENABLED is a control, not an assertion. In the client's interned-symbol heap the strings FUTGameHubDataHelper::_GotoGoldPlayerSearch(), IS_TRADING_ENABLED, _startAuctionSearch and FUT_TRADING_DISABLED are contiguous, and the same neighbourhood carries the exact structural twin IS_STORE_ENABLED / FUT_STORE_DISABLED around the _GotoStore_* handlers. IS_STORE_ENABLED evaluates to 1 live and the store screen works this session (13 requests to /store and 24 to /store/purchasegroup/all). IS_TRADING_ENABLED evaluates to 0 and the market refuses without asking. Same publisher, same script-symbol pattern, one on and working, one off and refusing.

One agent called that a constant pool; it is not. Each string is a separately allocated interned symbol with a 0x20-byte header, so the adjacency is allocation order while one script chunk loaded. That is weaker than a constant pool and still meaningful, but it is not control flow. Nobody disassembled the script bytecode. Grade the link MEDIUM-HIGH, and note the falsifier is now cheap: if the byte reads 1 and the screen still refuses, the exe-side predicate has a term we have not enumerated.

The condition, as a boolean expression over named inputs

For the per-card menu entry, this is exact. FUN_1801a7260 is 190 bytes, disassembled in full from .pdata-derived bounds, and it returns 1 only if:

TO_TRADE_PILE =
      model+0x1fd2e != 0                                  (IS_TRADING_ENABLED)
  AND item+0x49    != 0                                   (tradeable; untradeable is stored INVERTED)
  AND ( is_player                                          (item+8 != 0 AND item+0x4c == 1)
        ? item+0x145 == 0 AND FUN_1801a8900(item) == 0     (no injury; stats slots 4 and 5 both < 1)
        : item+0x50 NOT IN {0xe7, 0xe8, 0xe9, 0xec} )      (subtype blacklist for non-players)

Polarity is proven by the consumer: FUN_1800e2a40 stores the return at param_3[3] and FUN_18003e370 publishes that byte array to the UI in index order as (DISCARD, MODIFY, TO_ACTIVE_SQUAD, TO_TRADE_PILE, TO_STICKER_BOOK, MAY_BE_REMOVED, QUICK_SEARCH, DREAM_REPLACE). Index 3 is TO_TRADE_PILE and 0 means greyed.

Two corrections to how this predicate has been described. The item is [rsi+0x18], not rsi, and FUN_1801a8900 is called with rsi (the outer wrapper), not the item pointer. Neither changes the logic. The brief's claim that "both documented gates are satisfied so there must be a third condition" was based on the gate byte being 1. It is 0. There is no third condition to hunt, and FUN_1801a7260 contains no pile term at all, so "send it to the club first" is not the missing step either.

For the market screen itself the expression is not known, only its input set:

market_screen_opens = f( IS_TRADING_ENABLED,          [0 live]
                         IS_STORE_ENABLED,            [1 live]
                         TRADE_PILE_SIZE,             [0 live]
                         NUM_MAX_AUCTIONS,            [-1 live]
                         NUM_CURRENT_AUCTIONS,        [0 live]
                         IS_MAX_AUCTIONS,             [0 live, computed]
                         ...exe-internal terms unknown... )

Of the CardsDLL-supplied terms, exactly one is off and exactly one is zero: trading and the pile size.

Every input, classified

Input Live Who owns it Can we set it
IS_TRADING_ENABLED = model+0x1fd2e 0 settings struct +0x28, then zeroed by our feature.trade YES. Remove userInfo.feature. Constructor default is already 1
TRADE_PILE_SIZE = model+0x1fd1c 0 userMassInfo.pileSizeClientData entry key: 2 YES. One member we have never sent
watch-list size = model+0x1fd20 0 same member, key: 4 YES
item+0x49 (tradeable) 1 our untradeable: false, stored inverted YES, already correct with FUT_TRADEABLE=1
item+0x4c, item+0x50, item+0x145, stats slots 4/5 not measured per-card fields of the item record we serve YES, but we do not know today whether any live card fails them
NUM_MAX_AUCTIONS = model+0x54f8+0x30 -1 GET /tradePile/counts, maxAuctionsAllowed YES. -1 is the unlimited sentinel, so it is not blocking
IS_MAX_AUCTIONS 0 (not maxed) computed as !(max < 0 or cur < max) YES indirectly. Not currently a gate
IS_STORE_ENABLED 1 settings +0x2c / +0x30 by region YES, and it is already right. Do not disturb it
the exe-side script predicate unknown FIFA17.exe UI script NO
POW / EASFC reachability unreachable FUT_POW unset in the responder YES, but CardsDLL cannot read it, so it is not an input here
Blaze IS_* config rows delivered Blaze client-config store NO EFFECT. Output names, never read

The single fact that determines the ranking

The client fetches /settings and /userMassInfo exactly once each, at UT entry, and never again. Two agents disagreed about this, one citing repeated polls at 12:56, 13:01, 13:03, 13:34 and 13:39. I checked the log myself and every one of those carries User-Agent: curl/8.21.0; they are our own agents' probe scripts. Filtered to the game's ProtoHttp user agent, pid 260692 issued ten requests in its whole 79-minute lifetime, all between 12:40:05 and 12:40:16, in a fixed bootstrap:

accountinfo -> POST /ut/auth -> GET /settings -> phishing x3 -> match/reset
            -> GET /userMassInfo -> store/transaction/0 -> GET /hub

and nothing since. The same bootstrap appears at 10:41:34 and at 10:33 on earlier pids, always within about 35 seconds of process start. So a utas-only restart preserves the Blaze session but buys no test, because the body is never re-read. The unit of experimental cost is a relaunch, and the advice from two agents to "do the trade fix first, rank the capacity fix second" is wrong: two sequenced fixes cost two relaunches for readouts that do not confound each other.

Note also the ordering that falls out of the same log: /settings at 12:40:10 precedes /userMassInfo at 12:40:16. Both apply the gate struct, so the last one wins, and it is massinfo, which is the one carrying the kill switch. That is consistent with the live 0 and it is why fixing only the standalone /settings body would be overwritten six seconds later.

Ranked experiments

E0. Ask the user to back out to the main menu and re-enter Ultimate Team while watching /tmp/utas_server.log. Cost: zero, no restart of anything. Mechanism: if UT re-entry re-runs the bootstrap, a ProtoHttp-UA GET /user/accountinfo and GET /userMassInfo will appear. Falsifier: no new ProtoHttp line within a minute means the bootstrap is process-scoped. This is ranked first because it is free and because a positive result collapses the cost of every experiment below from a relaunch to a menu round-trip, which would change the whole plan. Nobody has ever tested it.

E1. The bundle. One utas edit, one relaunch, four independent readouts. Apply P1 through P5 from section 6 together: drop userInfo.feature, set FUT_SETTINGS=keep with the corrected probe, serve pileSizeClientData keys 2 and 4 with distinctive values, serve GET /tradePile/counts, and re-path the market routes with correctly shaped empty bodies. Mechanism: the four changes write four different destinations and are read out separately, so bundling costs no diagnostic power.

Readout Where Expected if the mechanism holds
model+0x1fd2e via vt+0x270 tools/gate_byte_probe.py 1
model+0x1fd28 via the applier's +0x1c source live probe 77, not 60. This is the control that proves the configs array was parsed at all
model+0x1fd1c via vt+0xa58 live probe 77
the hub header on screen 0/77 in white, not 0/0 in red
"Place on Transfer List" per-card menu no longer greyed
Transfers screen on screen opens, or refuses with a different story

Falsifiers, in order of what they would tell us. If model+0x1fd28 stays 60, the configs array never reached the applier and response+0x1c is the real blocker, which is the one thing three agents could not rule out. If +0x1fd28 becomes 77 but +0x1fd2e stays 0, the feature.trade chain is wrong despite six confirmed hops. If +0x1fd2e becomes 1 and the menu ungreys but the market screen still refuses, the exe-side predicate has a term outside the published map, and section 4's model of how the UI is gated is wrong. If +0x1fd1c does not become 77, the key: 2 mapping is wrong. Use 77 and 33 rather than 100 and 50: 100 is the stock-looking transfer-list size and reading x/100 in game would prove nothing.

E2. Only if the market screen opens: exercise the real routes. Cost: no restart, the client drives it. Mechanism: the screen will issue GET ut/game/fifa17/transfermarket?type=..&start=..&num=.. and we log the real type value, which is the one piece of the search spec that cannot be recovered statically. Falsifier: if the client requests a path outside the twelve in section 5, the route-table reading is wrong. The highest freeze risk in the whole project is here, and it is the marketdata/pricelimits response, which must be a bare top-level JSON array.

E3. Only if the menu is still greyed after +0x1fd2e reads 1: measure the item terms. Cost: a live probe, no relaunch. Mechanism: read item+0x4c, +0x50, +0x145 and stats slots 4 and 5 on a resident purchased-pile record and evaluate the second and third terms of FUN_1801a7260 by hand. Falsifier: if all four terms pass and the entry is still greyed, the Flash layer has its own gate and CardsDLL is no longer the right place to look.

E4. Seasons only, and it costs a Blaze restart: set FUT_POW=1. Ranked last because it drops the session and because section 4 argues it cannot affect the market. Mechanism: OSDK_POW is an empty list unless FUT_POW is set, so FIFA_POW_URL and POW_IS_ON have never been served and powdll is falling back to unresolvable EA endpoints. Falsifier: a request line appearing in /tmp/pow_server.log, and the PRESS q TO RE-CONNECT banner clearing.

Not ranked, and specifically do not run. FUT_PILESIZES=probe: the key enum is decoded, the probe would cost a relaunch to rediscover it, and its fallback writes the club count into the transfer-list capacity. Serving maximumTradePileSize as a capacity control: it lands in settings field [0], feeds a slot vector at model+0x15f00, and the pile applier runs afterwards on the same vector, so it is inert twice over. Adding TRADE_PILE_SIZE or IS_TRADING_ENABLED to FUT_RS4_CONFIG: both are output names, and it would cost a Blaze restart for nothing.


3. The gate-byte block

Raw, read live at model = 0xb80c84c0 while writing this, with all three slide controls passing:

+0x1fd10  00000000 00000000 01000000 00000000
+0x1fd20  00000000 00000000 3c000000 01010001
+0x1fd30  01010101 01010101 01010101 01010101
+0x1fd40  00000000 01010100 01960000 00000000
+0x1fd50  00000000 e0010000 00000000 00000000
+0x1fd60  c04e0000 00000000

Every byte in +0x1fd14..0x1fd48 and the dwords at +0x1fd4c, +0x1fd54, +0x1fd58, +0x1fd5c, +0x1fd60 are written by FUN_18011dc50 and by nothing else. That exclusivity is established form-independently: a scan of all 1,982,464 bytes of .text for the raw four-byte little-endian displacement, which catches mov, movzx, cmp, lea and setcc in every encoding, gives exactly two sites per field, one accessor stub and one write. A second scan for any imm32 in 0x1fc80..0x1fe00 rules out a lea-a-block-base bulk writer, with 0x15f00 found correctly as the same-form control. A third scan over all 451 MB of live executable memory, including the 288 MB unpacked FIFA17.exe body that the Denuvo-packed on-disk file cannot answer for, finds no writer outside CardsDLL.

The applier is byte = (struct_field == 1) in every case. The struct is initialised by FUN_18014e320, and with our empty configs array the live block is the constructor default set, with exactly one exception.

Offset Live Settings atom Struct off Published as / gates
+0x1fd14 0 clubCreateThreshold 0x8c +0x04 club-creation threshold
+0x1fd18 1 tokenRedemptionEnabled +0x08 token redemption
+0x1fd1c 0 not settings. pileSizeClientData key 2 resp+0xd0 TRADE_PILE_SIZE, the red 0/0
+0x1fd20 0 not settings. pileSizeClientData key 4 resp+0xd4 watch-list size (by position)
+0x1fd28 60 squadBuildingSetsGracePeriodMinutes 0x2d0 +0x1c grace period. Use as the parse control
+0x1fd2c 1 allowUntradeableForSquadBuildingSets +0x18 SBC untradeables
+0x1fd2e 0 tradingEnabled 0x336 +0x28 IS_TRADING_ENABLED
+0x1fd2f 1 storeEnabled 0x2f1 +0x2c IS_STORE_ENABLED (non-JP)
+0x1fd30 1 storeEnabled_JP 0x2f2 +0x30 IS_STORE_ENABLED (region 4)
+0x1fd31..39 1 coin / cardpack / points store family +0x34..0x54 store sub-features
+0x1fd3a 1 friendlySeasonsEnabled 0x133 +0x58 IS_FRIENDLY_SEASON_ENABLED
+0x1fd3b 1 tournamentQuitEnabled 0x32d +0x80 IS_TOURNAMENT_QUIT_ENABLED
+0x1fd3c 1 processingStateEnabled 0x257 +0x84 IS_PROCESSING_STATE_ENABLED
+0x1fd3d 1 enableDraftMode 0xf9 +0x5c IS_DRAFT_MODE_ENABLED
+0x1fd3e 1 enableSinglePlayerDraftMode / enableOfflineDraftMode (shared arm) +0x60 offline draft
+0x1fd3f 1 storyModeRewardEnabled 0x2f3 +0x7c IS_STORY_MODE_REWARD_ENABLED
+0x1fd40 0 returningUserRewardsScreenEnabled 0x28a +0x64 IS_RETURNING_USER_REWARDS_SCREEN_ENABLED
+0x1fd41 0 unnamed arm +0x68 unknown
+0x1fd42 0 allowGracePeriodForSquadBuildingSets 0x18 +0x6c SBC grace period
+0x1fd43 0 enableLiveMessaging 0xfb +0x88 live messaging
+0x1fd44 1 enableObjectives (shared, CLEAR-only arm) +0x70 objectives
+0x1fd45 1 packOpeningAnimationEnabled +0x74 pack animation
+0x1fd46 1 couchPlayEnabled +0x78 couch play
+0x1fd47 0 enableFloatPointSquadRating 0x30f +0x8c float squad rating
+0x1fd48 1 enableLegacyYearInfoInItemResourceId +0x90 legacy resourceId years
+0x1fd49 150 not applier-written n/a owner unknown
+0x1fd4c 0 itemDbVersion 0x16c +0x0c item DB version
+0x1fd54 480 tunable +0x10 ctor 0x1e0
+0x1fd58 0 numEndMatchRetriesAllowed 0x1de +0x14 end-match retries
+0x1fd5c 0 constrainGracePeriod 0xa3 +0x24 ctor never writes +0x24; this 0 is allocation, not a default
+0x1fd60 20160 tunable +0x20 ctor 0x4ec0

Which zeros are worth setting, and which are dangerous

The brief asked which zero bytes are candidate blockers and stated we have a proven way to set them via the Blaze store. That premise is false, as established above, so the question becomes: which zeros are worth setting via the configs array.

Worth setting: exactly one, and it is not even a config row. +0x1fd2e is the only zero in the block that is a known gate on a broken feature, and the way to set it is to stop cancelling it, not to send a row. Its constructor default is already 1.

Worth setting, but not through settings: +0x1fd1c and +0x1fd20, via pileSizeClientData. These are the red 0/0.

Leave alone. +0x1fd40, +0x1fd41, +0x1fd42, +0x1fd43, +0x1fd47, +0x1fd58, +0x1fd5c are all zeros the client chose. None gates a feature we are trying to open, and returningUserRewardsScreenEnabled in particular would put a rewards screen in front of the user at boot. +0x1fd14 and +0x1fd4c are thresholds and versions, not gates.

Actively dangerous, and each for a reason established in code. enableObjectives and enableObjectivesAsManagerTasks share an arm that can only clear the field (if value == 0 then field = 0), so 1 is a no-op and 0 turns objectives off. checkServerDbVersion makes the applier go parse a string config into model+0x1fd50 and touches the DB-version path. clientKeepAliveResetTimeoutSec and getOperationTimeoutSec do not set a field at all, they reprogram client timers with value * 1000. enableSquadBuildingSetsFeature is a real atom with no arm and does nothing. The existing comment block in utas_server.py gets all five of these right and should be kept.

One safety question, settled

utas_server.py's own comment says that populating the configs array is "what makes the applier run" and that omitting a flag "could turn the working store off." Both halves are wrong, and the second one is the reason the flag has never been flipped. The applier already runs on every session with configs: []. Every flag we do not name keeps its constructor default, and the constructor sets storeEnabled and storeEnabled_JP to 1 at 0x18014e368 and 0x18014e36f. The live block is the default set, so re-asserting the defaults changes nothing and overriding one flag changes one byte.

One verifier graded this a MEDIUM risk and recommended sending the full enable set. It reached that grade by inferring "the applier never ran" from the live 1s, which is refuted by +0x1fd28 = 60, +0x1fd54 = 480 and +0x1fd60 = 20160 matching the constructor's 0x3c, 0x1e0 and 0x4ec0 at three unrelated offsets with sole writers. Its own gap list concedes it did not read the constructor. I would bet on safe. That said, _SETTINGS_KEEP re-asserts the store flags at zero cost and there is no reason to argue about it, so section 6 keeps them.


4. Is there a shared root cause with the Seasons refusal?

No, and the evidence is a measurement rather than an argument. Seasons and the market both refuse with zero requests, which is a shared shape, and that shape is what made a common cause attractive. But the gate bytes separate them cleanly: IS_FRIENDLY_SEASON_ENABLED reads 1 live, in the same walk, off the same object, with the same instruction form as the trading gate that reads 0. I measured model+0x1fd3a = 1 again while writing this. Seasons does not refuse because its published gate is false. Its gate is true. The market's is false. Whatever is stopping Seasons is not in the CardsDLL published context map, because every name in that map either reads 1 or is not a Seasons term.

This also corrects docs/plan-2026-08-05-settings-gate.md, which already carries the correction but is worth restating here: that document's original claim that IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED "have never been set to true by anything" was wrong, and wrong for the same reason the brief was wrong about /settings being dead. The applier runs, the struct defaults those fields to 1, and the bytes have been 1 all along.

What the two refusals do share is a mechanism class, and if E1 works that class becomes proven rather than hypothesised: a UI script in FIFA17.exe reads a named boolean out of a context map that CardsDLL publishes, and refuses locally without a network call when it is false. Confirming that model on the market is worth more than the market itself, because it tells us exactly where to look for Seasons: Seasons' term is not in CardsDLL's published surface, so it is either exe-internal state or another DLL's. The complete CardsDLL surface is now enumerated (ten booleans, TRADE_PILE_SIZE, and the auction triple), so this is an exhaustion argument, not a guess.

The best remaining Seasons candidate is POW/EASFC, and it is a genuinely different subsystem rather than a shared root. PRESS q TO RE-CONNECT is produced by powdll_Win64_retail.dll: EASFCWidget::EnableButtonReconnect, EASFCWidget::ReconnectToServers, POWService::PowReconnect, TXT_EASFC_RECONNECT_PROMPT, POW_RECONNECT_TIMER_MS. It is truthful rather than cosmetic, because blaze_responder_v3b.py sets OSDK_POW = [] unless FUT_POW is set, FUT_POW is not in the running responder's environment, no FIFA_POW_URL or POW_IS_ON was ever served, the client's live merged config blob contains zero POW keys, and /tmp/pow_server.log has no request lines. So powdll is falling back to https://fifa17.service.easports.com/..., which does not resolve here. Seasons is an online mode and EASFC is the online service layer, so a Seasons refusal that follows from POW being unreachable is coherent.

The market cannot be affected by any of that. CardsDLL's import directory names exactly four DLLs and powdll is not among them, so it cannot call into powdll directly. Its only POW-shaped literals are three pow/imgAssets/store_*.dds paths, two coin-boost reward names, and %s/pow/mm/, whose single reference sits in the UTAS base-URL assembly cluster next to FUT_RS4_APIURL_%s and game/%s. There is no EASFC status read anywhere in the FUT layer.

Honest limits on this section. The Seasons caption was never located: "Ultimate Team serv" has zero occurrences in the whole process, ASCII or UTF-16, so the answer here is by elimination on the market side rather than by finding and diffing the Seasons predicate. To do it properly the user has to reproduce the Seasons refusal and leave it on screen, at which point the caption can be located and its interned-symbol neighbours read exactly the way the market's were. That is one relaunch and it is the cheapest path to the project's biggest open problem.


5. The market wire protocol

{ns} is game/fifa17. Everything below is from the on-disk PE. The route table at .rdata 0x18021df80 walks to exactly 45 {char*, char*} rows and stops cleanly; the action table at .data 0x1802caa20 walks to exactly 125 rows of 0x30, with the route index at +0x08. That index reading is controlled: ClubSearch, ClubStats, StaffStats and ConsumablesSearch all carry 3 and route 3 is ut/%s/club; DreamSquadSearch carries 4 and route 4 is ut/%s/defid.

The twelve operations

Action Path Request body Response deser Grade
ISSEARCH GET ut/{ns}/transfermarket?type=%s&start=%d&num=%d + filters none 0x180163420 CONFIRMED
ISOFFERTRADE POST ut/{ns}/trade/{tradeId}/offer {"bid":N} 0x180165410 CONFIRMED
ISSTART POST ut/{ns}/auctionhouse {"itemData":{"id":N},"startingBid":N,"buyNowPrice":N,"duration":N} 0x180165df0 CONFIRMED
RELISTALL PUT ut/{ns}/auctionhouse/relist none 0x1801642c0 (no-op) CONFIRMED
ISREMOVEWATCH DELETE ut/delete/{ns}/watchList?tradeId=a,b,c or .../watchList/expired none 0x1801642c0 CONFIRMED
ISWATCHTRADE PUT ut/{ns}/watchList?tradeId=N {"auctionInfo":[{"id":N}]} 0x1801642c0 CONFIRMED
ISWATCHLIST GET ut/{ns}/watchList?offset=%d&count=%d none 0x180166240 CONFIRMED
ISVIEWTRADE GET ut/{ns}/trade/status?tradeIds=a,b,c none 0x1801644d0 CONFIRMED
GETTRADEPILE GET ut/{ns}/tradePile none 0x180170810 CONFIRMED ON THE WIRE
GETAUCTIONCOUNT GET ut/{ns}/tradePile/counts none 0x180163770 CONFIRMED
ISREMOVETRADE DELETE ut/delete/{ns}/trade/{tradeId} or .../trade/sold none 0x1801642c0 CONFIRMED
GETSUGGESTEDPRICING GET ut/{ns}/marketdata/pricelimits?defId=a,b,c none 0x180163ee0 CONFIRMED

GETTRADEPILE is the only one the game has actually requested (10:34:24 and 10:42:35, ProtoHttp UA). Everything else rests on the two tables plus the per-operation URL builders, located by rip-relative lea xrefs to the literal sitting immediately after each RS4:Fut...ServerResponse name in .rdata.

Two corrections to what has been written about these before. /auctionhouse is a real client path, it is ISStart's and RelistAll's; what is wrong in utas_server.py is using it for search. And several deserializer VAs in docs/ENDPOINT_MAP.md point at constructors, factories or completion handlers rather than deserializers; the corrected set is in the table above, resolved by RS4 name to installed vtable to slot +0x08, with FutISSearch and FutGetTradePile as the control pair that had to come back 0x180163420 and 0x180170810 and did.

HTTP verbs are not statically recoverable. The action table has no verb field and the handler table's first slot shows no verb pattern. The only structural signal is that deletions are encoded as a ut/delete/... path prefix rather than a DELETE method, and that the three ops with body serializers must be POST or PUT. Make the emulator method-agnostic and dispatch on path. That removes the risk entirely and costs nothing.

ISSearch filters

Appended only when set, in emission order, all from FUN_180162c90: &maskedDefId=%d &definitionId=%d &micr=%d &macr=%d &minb=%d &maxb=%d &pos=%s &zone=%s &pos=%s &form=%s &playStyle=%d &lev=%s &rare=SP &nat=%d &leag=%d &team=%d &amount=%d &cardsubtype=%d &cat=%s. Note the two different pos enums.

Vocabularies, all NULL-terminated {const char*, int} tables: type (0x180229c30) any=-1 player=1 staff=2 clubInfo=3 training=4 development=5 stadium=6 ball=7; zone (0x1802296e0) goalKeeper=0 defense=1 midfield=2 attacker=3; lev (0x180229a60) any=0 bronze=1 silver=2 gold=3; pos (0x1802295c0) GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21 LF=22 RW=23 ST=25 LW=27; the second pos (0x180229730) is a 20-entry dual-position table LWB-LB=0 ... ST-CF=19; cat (0x180229ab0) 23 entries from any=-1 through ball=22; form (0x180229880) 26 formation codes.

Paging: start is request field 0x68 and num is field 0x6c plus one. This was graded MEDIUM by the agent that found it, on the grounds that the +1 rested on Ghidra's vararg slot ordering. It is upgraded to CONFIRMED by reading the stores directly: mov ebx,[rcx+0x28]; inc ebx at 0x180162cc0, then at the sprintf the two stack varargs are mov [rsp+0x20],eax with eax = [rsi+0x68] and mov [rsp+0x28],ebx. In the Win64 layout [rsp+0x20] is the first %d and [rsp+0x28] is the second. The client asks for one more row than it will show, to detect a next page. Return at most num rows and let the client infer the end from a short page; do not pad.

The auctionInfo record

Twelve fields, all optional, deserializer 0x18013e410.

Atom Key Type Note
0x57 bidState string enum see below
0x65 buyNowPrice int32
0xc1 currentBid int32
0x116 expires int 64-bit SECONDS REMAINING, not epoch
0x16b itemData OBJECT the only freeze risk in the record
0x2b6 sellerEstablished int32
0x2b7 sellerName string bounded copy, max 30 chars
0x2e6 startingBid int32
0x2f4 coinsProcessed int truncated to u8
0x331 tradeId int 64-bit the client-side primary key
0x335 tradeState string enum see below
0x380 watched bool

Everything else falls through to value-SKIP 0x180135ff0. After the field loop the deserializer calls model->vt+0xa00(ctx, tradeId) to find an existing record and re-parents the parsed itemData onto it, so tradeId must be stable across /transfermarket, /tradePile, /watchList and /trade/status or the client will hold duplicate ghost auctions. Allocate from a persisted monotonic counter, never from an array index.

A methodological note worth adding to the absence-trap list: this deserializer dispatches through a compare tree of mixed < and == tests, not a ladder and not a switch. A first mechanical extraction of it found only 10 of the 12 atoms for exactly that reason. The enumerated dispatch forms are now: == 0xNN, != 0xNN, switch { case 0xNN }, sub/dec ladders, jump tables, and compare trees.

The shared IS-list body

Deserializer 0x18013e7f0, used by ISSearch, ISWatchList and GetTradePile: auctionInfo (0x35, array), credits (0xc0, int), duplicateItemIdList (0xec, array of objects, [] is safe and recommended), total (0x325, int).

Per-response minimal bodies

ISSearch          {"auctionInfo":[...], "credits":N, "total":N, "duplicateItemIdList":[]}
ISWatchList       same shape
GetTradePile      same shape
ISViewTrade       {"auctionInfo":[...], "credits":N}
ISOfferTrade      {"auctionInfo":[...], "credits":N, "errorState":"..."}     (0x10d errorState)
ISStart           {"id": <new tradeId>}                                       (0x15c only, 64-bit)
GetAuctionCount   {"count":N,"maxAuctionsAllowed":N,"offered":N,"selling":N,"sold":N}
GetSuggestedPricing   [ {"defId":N,"minPrice":N,"maxPrice":N}, ... ]          BARE TOP-LEVEL ARRAY
RelistAll / ISWatchTrade / ISRemoveTrade / ISRemoveWatch    {}                parse nothing

GetAuctionCount stores count, offered, selling and sold as u16 and maxAuctionsAllowed as i32 pre-set to -1.

Two of these are type-fidelity hazards and both are new. GetSuggestedPricing is a bare top-level array, which ENDPOINT_MAP.md currently specifies as an object. FUN_180163ee0's outer loop terminates on token 0xd (END_ARRAY), and the same-form control FutISStart at 0x180165df0 is structurally identical but terminates on 10 (END_OBJECT), so the distinction is real and not an artefact of reading the loop. Serving an object there is exactly the shape mismatch that hard freezes the tokenizer at 0x1801c7f1a. Conversely, the four ack responses install vtable 0x18022cb58 whose deserializer is literally return 1;, so any body is safe for them.

Lifecycle

tradeState decodes through a table walk at 0x180229e40: active=1 inactive=2 expired=3 closed=4, anything else -1. bidState decodes through a strcmp ladder, not a table: none=0 outbid=1 highest=2 buyNow=3, anything else silently 0. Those are the complete vocabularies; there is no won, lost or sold. Reading each in its own dispatch form rather than grepping one pattern is what made this reliable.

The entire market UI presentation is a pure function of those two strings: flagA = bidState in {highest, buyNow} and flagB = (tradeState == closed) ? bidState != none : bidState in {outbid, buyNow}.

listed by user       active  / none     currentBid 0, expires > 0
outbid               active  / outbid
currently winning    active  / highest
bought now           closed  / buyNow
won at auction end   closed  / highest
lost at auction end  closed  / outbid
ran out unsold       expired / none     expires 0, relistable
sold, coins paid     closed  / highest  coinsProcessed 1, clearable via DELETE .../trade/sold

Never invent a state string: an unrecognised bidState is swallowed as none, which produces a plausible-looking but wrong UI. inactive decodes but no client path treats it specially; do not emit it.

Errors

FUN_1801844c0 maps one transport status to an internal code: 200->0 204->99 400->7 403->403 459->407 460->7 461->3 465->1 467->0x1f 470->5 475->4 476->8 478->0x10 480->480 481->0x6b 483->0x6c 485->0x6d 489..492->identity 503->0x6f, the bucket {401,402,404..417,462,463,466,468..474,477,479,500,501,502,504,505} -> 998, default 999, and -1 -> 0x68. Note 500 lands in the generic bucket, so a lazy 500 produces a generic failure message; pick a code from the table.

ISOfferTrade overrides this at request vtable 0x180228ea0+0x10 (FUN_180165050): when the code is exactly 461 it searches the response text for five literals and sub-classifies. It is a substring search (FUN_180009ee0(begin, end, needle, "")), not an equality test, so a JSON envelope carrying the phrase works:

"You are not allowed to bid on this trade"  -> 0x14
"Auction state is invalid for bidding"      -> 0x14
"Incorrect parameters"                      -> 999
"Permission Denied"                         -> 0x12
"Watchlist is full"                         -> 0x21
anything else                               -> 7

The market error vocabulary present in .rdata is NO_TRADE_EXISTS, TRADE_CANCELED, TRADE_CLOSED, TRADE_YOUR_CARD, TRADE_INACTIVE, TRADE_CARD_ALREADY_OFFERED, TRADE_MISMATCH, TRADE_LOCKED, WATCHLIST_FULL, AUCTION_LIMIT_REACHED.

Authority split

Server-authoritative, with no client fallback. credits: every IS-list response writes it straight into the global model via vt+0x5b8, so omitting it leaves the balance stale and sending 0 visibly zeroes the player's coins. The server owns the coin balance on every market response, not only on purchases. tradeId allocation. tradeState, bidState, currentBid, expires, coinsProcessed: the client derives its whole UI from these and never recomputes them, so bid outcome, win/lose and expiry are entirely server decisions. The auction cap: maxAuctionsAllowed and selling from /tradePile/counts are the only inputs to IS_MAX_AUCTIONS, so the server decides whether the player may list. Bid validation: no client-side minimum-increment check exists, rejection is 461 plus text. Price bands from /marketdata/pricelimits.

Client-side, do not implement. The derived flagA/flagB presentation booleans; the search-filter enum-to-string mapping; the num = count + 1 paging convention; the duplicateItemIdList id-rewrite merge.

What a single-player market must actually simulate

Five stateful pieces and nothing else. A listing store keyed by a monotonic 64-bit tradeId holding {itemId, startingBid, buyNowPrice, duration, createdAt, state, bidState, currentBid, sellerName}. A clock: expires = max(0, createdAt + duration - now), flipping active to expired when unsold or closed plus coinsProcessed when sold. That clock is the only simulation genuinely required, because the client renders a live countdown and expects it to reach 0. A synthetic seller pool for /transfermarket, generated from the card pool with a rating-derived price and a sellerName of at most 30 characters: the search endpoint is the other players. Coin arithmetic on buy and on sale settlement, echoed as credits in every response. And /tradePile/counts.

Stub outright: /marketdata/pricelimits (a static band per defId or a rating formula), /watchList (an empty list is valid), the four ack bodies, and rival bidder simulation. bidState going none to buyNow on purchase is a complete and internally consistent story.


6. Proposed patches

None of these are applied. All are utas-only: no Blaze restart, so the session survives. But per section 2 the client re-reads none of these bodies without a UT bootstrap, so they land on the next relaunch (or on the next UT entry if E0 comes back positive).

P1 -- FUT_NO_TRADE_BAN: stop switching trading off

In tools/utas_server.py, near the other feature flags:

# FUT_NO_TRADE_BAN: omit userInfo.feature entirely.
#
# `"feature": {"trade": True}` is a TRADE BAN, not a trade grant. The massinfo
# userInfo deser (FUN_18013ec10) has one arm under feature(0x11c): atom 0x330
# `trade`, read with the INT primitive 0x1801c79d0. Token 4 (JSON boolean) returns
# 0/1 from that getter (0x1801c7a38: xor eax,eax / cmp [rbx+0x108],al / setne al),
# so `true` arrives as 1 and passes `cmp eax,1` at 0x18013f032. It then does
# `mov [rdi+0xa4],al` -> response+0x17c. At the TOP-LEVEL END_OBJECT of the whole
# massinfo body, 0x180174f10 does `cmp byte [rsi+0x17c],0 / je / mov dword
# [rsi+0x50],0` -- forcing settings.tradingEnabled to 0. The applier FUN_18011dc50
# then does `cmp [rbx+0x28],1 / sete al / mov [rdi+0x1fd2e],al`, and 0x1fd2e is
# IS_TRADING_ENABLED, the first term of the TO_TRADE_PILE predicate FUN_1801a7260.
#
# The settings-struct ctor FUN_18014e320 sets +0x28 to 1 at 0x18014e361, i.e. the
# client's own default for trading is ON. We are the only thing turning it off.
#
# The kill switch runs at END_OBJECT, AFTER every member including `settings`, so
# serving tradingEnabled:1 in configs CANNOT beat it. Removing the member is the
# only fix that works through /userMassInfo.
#
# Freeze risk: NONE. Removing a member cannot desync a reader; the 0x11c arm
# recognises only 0x330 and everything else in `feature` is already SKIP'd. The
# member has no other consumer: userInfo+0xa4 has exactly one writer in the whole
# 1945-byte body of FUN_18013ec10 (disp32 scan; 0xa4 has no disp8 encoding, so
# there is no alternative form to miss), and its initialiser FUN_18010ea80 sets
# the byte to 0.
_NO_TRADE_BAN = os.environ.get("FUT_NO_TRADE_BAN", "0") == "1"

and at line 255, inside the _UI != "min" block:

            # "feature": {"trade": True},        <- REMOVED, see _NO_TRADE_BAN
            **({} if _NO_TRADE_BAN else {"feature": {"trade": True}}),

Default off means today's behaviour, unchanged.

P2 -- correct the /settings probe control

_SETTINGS_PROBE currently sends maximumTradePileSize = 77 as a positive control on the theory that transfer-list capacity is readable in game. It is not that field. Atom 0x1c0 lands in settings field [0], which the applier hands to FUN_18011f380(model+0x15f00, value). Worse, the pile applier FUN_18011dbf0 calls FUN_18011f380 on the same sub-object afterwards (vt+0x988 at 0x180173f0b, then vt+0x998 at 0x18017405b), and with pileSizeClientData absent it passes 0, which takes the requested <= current path at 0x18011f48b and clears the container. So the control is inert twice over.

# The positive control. CORRECTED 2026-08-06: maximumTradePileSize was the WRONG
# control -- atom 0x1c0 lands in settings field [0], which the applier passes to
# FUN_18011f380(model+0x15f00), a slot vector, NOT model+0x1fd1c. And the pile
# applier resizes the same vector afterwards, so with pileSizeClientData absent it
# gets cleared to 0 regardless. The historical "we served 77 and nothing carried
# 77" measurement was aimed at a destination this field never reaches.
#
# squadBuildingSetsGracePeriodMinutes (atom 0x2d0) lands in settings field [7]
# (struct+0x1c), which the applier copies verbatim to model+0x1fd28. The ctor
# default is 0x3c (0x18014e353) and model+0x1fd28 reads exactly 60 live, so a 77
# there is unambiguous and is readable by tools/gate_byte_probe.py without a UI.
# 77 on purpose: a number FUT would never pick by itself.
_SETTINGS_PROBE = (("squadBuildingSetsGracePeriodMinutes", 77),)

Also fix the block comment above _SETTINGS_MODE: "Populating the array is what makes the applier run at all" is false. The applier runs on every response, including with configs: [], which is why the live gate block matches the constructor defaults exactly at 37 of 38 fields. Omitting a flag keeps its constructor default, and storeEnabled and storeEnabled_JP default to 1 at 0x18014e368 and 0x18014e36f. _SETTINGS_KEEP is harmless belt-and-braces, not a necessary guard.

Freeze risk: none. value goes through 0x1801c79d0, which coerces int/float/bool/string to int64. Never put an object or array in value.

P3 -- pileSizeClientData: the real key enum

# pileSizeClientData -- parser FUN_18013adb0, called as FUN_18013adb0(reader,
# response+0xc8) from massinfo arm 0x227.
#
# CORRECTED 2026-08-06. The key enum IS recoverable and is now recovered. The
# parser has exactly TWO store arms and no default arm:
#     18013aeaf:  cmp esi,0x2 / je  -> mov [r14+0x8],eax    -> response+0xd0
#     18013aeb4:  cmp esi,0x4 / jne -> mov [r14+0xc],eax    -> response+0xd4
# FUN_180173e00 copies response+0xc8..+0xd7 and calls model->vt+0x998 =
# FUN_18011dbf0, which does [model+0x1fd1c] = p+0x8 and [model+0x1fd20] = p+0xc.
# model+0x1fd1c is published by FUN_18000d550 as TRADE_PILE_SIZE -- the red
# "TRANSFER LIST 0/0". So key 2 is the TRADE PILE and key 4 is the WATCH LIST.
#
# There is NO club-pile key. Every key other than 2 and 4 is read and discarded,
# so FUT_PILE_KEY_CLUB does not exist and FUT_PILESIZES=probe must NOT be run --
# it would cost a relaunch to rediscover a two-value enum and its 16-key fallback
# would write the club item count into the TRANSFER-LIST CAPACITY.
#
# Values must be POSITIVE: both key and value go through FUN_1800d7b30, which is
# `test rcx,rcx / jle -> return 0`, so -1 cannot mean unlimited here.
#
# Freeze risk: LOW. Documented member of the boot-critical massinfo parser,
# int-only, skip-safe on unrecognised fields. Instant fallback: FUT_MASSINFO=squad.
_PILESIZES = os.environ.get("FUT_PILESIZES", "")

PILE_KEY_TRADEPILE = 2
PILE_KEY_WATCHLIST = 4


def pile_size_body():
    """massinfo.pileSizeClientData -- capacities, not counts."""
    if _PILESIZES == "probe":
        # Distinctive values for the first run: 77 and 33 are numbers FUT would
        # never pick. Read back at model+0x1fd1c (vt+0xa58) and +0x1fd20 (vt+0xa60).
        return {"entries": [{"key": PILE_KEY_TRADEPILE, "value": 77},
                            {"key": PILE_KEY_WATCHLIST, "value": 33}]}
    return {"entries": [{"key": PILE_KEY_TRADEPILE, "value": 100},
                        {"key": PILE_KEY_WATCHLIST, "value": 50}]}

The stock FIFA 17 values of 100 and 50 are convention, not derived from the binary: nothing in CardsDLL carries a default and the response constructor zeroes both at 0x180173a86.

P4 -- GET ut/{ns}/tradePile/counts

Never omit this route. If the object is never loaded, its loaded byte at +0x28 stays 0 and the completion handler FUN_180163670 takes a different branch.

@route("GET", "/ut/game/fifa17/tradePile/counts")
def auction_counts():
    """GetAuctionCount, deser FUN_180163770 (vtable 0x180228638 slot +0x08).
    Atoms: 0xbc count -> +0x34 (u16), 0x1bf maxAuctionsAllowed -> +0x30 (i32,
    pre-set to -1), 0x1e5 offered -> +0x3a (u16), 0x2b8 selling -> +0x36 (u16),
    0x2c9 sold -> +0x38 (u16).

    These are the ONLY inputs to IS_MAX_AUCTIONS, published by FUN_1800377c0 as
    !(max < 0 || cur < max) off the embedded sub-object at model+0x54f8
    (vt+0x130 = `lea rax,[rcx+0x54f8]; ret`). Live today max = -1 and selling = 0,
    so IS_MAX_AUCTIONS is 0 and the auction cap is NOT currently blocking anything.
    Keep maxAuctionsAllowed >= 1 and selling < it.

    Freeze risk: NONE. All five fields are scalars read with the int primitive."""
    return {"count": len(STORE.listings()),
            "maxAuctionsAllowed": 100,
            "offered": 0,
            "selling": len(STORE.listings()),
            "sold": 0}

P5 -- FUT_MARKET_ROUTES: correctly shaped empty market responses

Rationale: if E1 opens the Transfers screen, the client will immediately request routes we do not serve. A 404 becomes transport status 404, which the mapper buckets to 998 and produces a generic failure. That is survivable. A wrongly shaped body is not: it desyncs the SAX reader and hard freezes at 0x1801c7f1a. Serving correct-shaped empty bodies is strictly safer than either alternative, and it is what lets E2 observe the real type parameter.

# FUT_MARKET_ROUTES: serve the twelve real market paths with correctly shaped
# EMPTY bodies. Every existing market handler in this file is at a path the client
# never requests (route table .rdata 0x18021df80 x action table .data 0x1802caa20).
# Empty is deliberate: shape fidelity first, contents once the screen is proven to
# open. Note ut/delete/... is a PATH PREFIX in this client, not an HTTP method, and
# verbs are not statically recoverable -- dispatch on path only.
_MARKET_ROUTES = os.environ.get("FUT_MARKET_ROUTES", "0") == "1"

_IS_LIST_EMPTY = {"auctionInfo": [], "credits": 0,
                  "total": 0, "duplicateItemIdList": []}
# ^ credits MUST be the real balance: atom 0xc0 in the shared IS-list body
#   FUN_18013e7f0 writes it to the GLOBAL model via vt+0x5b8, so serving 0 here
#   visibly zeroes the player's coins. Fill it from the profile before shipping.

_MARKET_EMPTY = {
    "/ut/game/fifa17/transfermarket":            _IS_LIST_EMPTY,
    "/ut/game/fifa17/tradePile":                 _IS_LIST_EMPTY,
    "/ut/game/fifa17/watchList":                 _IS_LIST_EMPTY,
    "/ut/game/fifa17/trade/status":              {"auctionInfo": [], "credits": 0},
    "/ut/game/fifa17/auctionhouse/relist":       {},   # deser is `return 1;`
    "/ut/delete/game/fifa17/trade/sold":         {},   # 0x1801642c0, parses nothing
    "/ut/delete/game/fifa17/watchList/expired":  {},
    # marketdata/pricelimits is a BARE TOP-LEVEL ARRAY, not an object. Its deser
    # FUN_180163ee0 terminates its outer loop on token 0xd (END_ARRAY); the
    # same-form control FutISStart 0x180165df0 terminates on 10 (END_OBJECT).
    # Serving an object here is the exact shape mismatch that hard freezes at
    # 0x1801c7f1a. One row per requested defId, parsed from the comma list.
    "/ut/game/fifa17/marketdata/pricelimits":    [],
}

ISStart and ISOfferTrade need real handlers rather than empty ones once listing works, because ISStart's response is {"id": <tradeId>} and the server mints that id.

Not proposed, and why

Removing tradingEnabled or IS_TRADING_ENABLED from FUT_RS4_CONFIG in blaze_responder_v3b.py. They are inert for the gate bytes, removing them costs a Blaze restart, and they may be doing something elsewhere that nobody has checked. Leave them.


7. Remaining unknowns

Needs decompiling only

FUN_18011f380 and the slot vector at model+0x15f00. Both the settings applier and the pile applier write it, the pile applier runs second, and maximumTradePileSize is its other input. Where that value surfaces in the UI is unknown, and until it is known there is a small chance that maximumTradePileSize matters for something after all.

The owner of model+0x1fd49 (live 150). It sits inside the gate block, has zero disp32 sites in .text, and is not applier-written. The implication is worth noting: if it is allocator residue then the model object is not zero-initialised, which weakens any argument of the form "value X cannot be a zero-init". None of the arguments in this document depend on that, because they rest on sole-writer scans instead.

The writer of response+0x1c, the field that makes both completion callbacks skip the appliers. It is outside every deserialised region (settings starts at 0x28, pile sizes at 0xc8, userInfo at 0xd8), so the transport layer sets it, and the failure branch hands it straight to the observers as an error code (mov r8d,[rbp+0x1c] at 0x180174123, message id 0x754d). This is moot if E1's control lands and becomes the top priority if it does not.

The subsystem at 0x18018b000..0x180199fff. vt+0x270 (the trading gate) is read from 34 call sites, not the two that have been discussed, and 21 of them cluster in that contiguous range. A string-literal harvest over those function bodies came back empty, so it is a lead and not an identification. If the market screen is gated on trading, this is probably where the native half of the transfer subsystem lives.

The FutGetSettings standalone response's +0xd0, i.e. whether anything other than massinfo can set the trade-pile capacity. The destination is proven; the source is not found.

Needs a live probe (read-only, no relaunch)

The item-side terms of FUN_1801a7260 on a live purchased-pile card: item+0x4c, item+0x50, item+0x145 and stats slots 4 and 5. item+0x49 was measured 1 on a club record on an earlier pid, but the other four have never been read. This is E3 and it only matters if the menu stays greyed after the gate flips.

Whether model+0x1fd1c is the trade pile's current count or its capacity. It is published as TRADE_PILE_SIZE and it also sizes the slot vector, and the hub shows a count/capacity pair, so one of the two numbers comes from elsewhere. E1's distinctive 77 answers this by showing which half of the header changes.

The name of model+0x1fd20. "Watch list" is inference from its position next to TRADE_PILE_SIZE and from pileSizeClientData key 4; its publisher at 0x1800e06f5 clamps to 255 and stores a byte into a UI struct with no adjacent literal. MEDIUM at best.

Needs a relaunch

Everything in section 2's ranked list, unless E0 comes back positive.

The Seasons caption and its interned-symbol neighbours. The user has to reproduce the Seasons refusal and leave it on screen. This is the cheapest path to the project's largest open problem and it is one relaunch.

The FUT UI script bytecode. The IS_TRADING_ENABLED to _startAuctionSearch link is interned-symbol adjacency plus a same-form control, not disassembled control flow. Extracting the FUT chunk from Data/Win32/ui.sb would settle it without any relaunch at all, but nobody has established whether the scripting layer is Scaleform ActionScript or EAWebKit JS. EAWebKit.dll is present, so the "Scaleform ActionScript" label used in one report is unsupported either way.


Coverage, honestly

Six dimensions ran in parallel and three adversarial passes attacked them. The attacks refuted six claims, four of them graded HIGH by the agent that made them: that the response object defaults tradingEnabled to 0; that only userMassInfo drives the applier and /settings does not; that the per-action byte at row+0x21 is a kill switch (all 125 read 0 live, and the agent's own falsifier said that settles it); and that the brief's "zero market requests" premise had failed (the cited requests were Python-urllib). Two more were corrected in mechanism while their conclusions stood: the slot-vector "shrink branch" is actually a resize(0) clear at 0x18011f48b, and maximumTradePileSize and pileSizeClientData share the model+0x15f00 sink rather than being unrelated.

The verifiers also disagreed with each other, and I settled the ones that mattered myself rather than reporting both. On whether the client re-polls userMassInfo during play, one verifier said yes with five timestamps and one said no; I grepped the log and the five timestamps are curl, so the answer is no and the unit of cost is a relaunch. On whether serving a partial configs array would turn the store off, one verifier said MEDIUM danger and another said safe; I disassembled FUN_18014e320 and the constructor sets storeEnabled and storeEnabled_JP to 1 seven and fourteen bytes after it sets tradingEnabled to 1, so it is safe, and the verifier that warned had explicitly not read the constructor. On whether the applier has ever run, one verifier argued it never had (because the live bytes are 1); that is refuted by +0x1fd28 = 60, +0x1fd54 = 480 and +0x1fd60 = 20160 matching the constructor's 0x3c, 0x1e0 and 0x4ec0 at three unrelated offsets with sole writers, which I measured live and read out of the constructor.

One disagreement is not settled and I am flagging it rather than picking. Whether flipping IS_TRADING_ENABLED opens the Transfer Market screen, as opposed to ungreying the per-card menu entry. The menu entry is certain: it reads that byte as the first term of a 190-byte predicate I disassembled in full. The screen is inference from the published input set plus the IS_STORE_ENABLED control. I would bet on it, at roughly four to one, and the bet is cheap because both surfaces are separately observable in E1: if the menu ungreys and the screen still refuses, we learn something specific rather than nothing.

What was verified independently while writing this document: the slide, with three controls; model+0x1fd2e = 0, +0x1fd1c = 0, +0x1fd2f = 1, +0x1fd28 = 60, +0x1fd54 = 480, +0x1fd60 = 20160; the whole 0x1fd10..0x1fd68 block; the model+0x15f00 slot vector empty; the auction object at model+0x54f8+0x30 reading max = -1, selling = 0; the settings-struct constructor head; the massinfo kill switch; the feature.trade arm; the INT primitive's boolean token; the applier's tradingEnabled write; the /settings callback's apply; the pileSizeClientData key arms; the live wire bodies for /settings and /userMassInfo; and the complete ProtoHttp-filtered request history of pid 260692.

What was not verified: anything requiring a response to be served to the live client. Every server-side claim in this document is falsifiable and untested.

Safety: /proc/260692/mem was opened O_RDONLY and only pread. No ptrace, no writes, no patches. No server was started, stopped, reconfigured or bound to a port; the only traffic to 8099 was read-only GETs. tools/fifa17_profile.json was never opened. No git operation. No server code was edited. All disassembly was of the on-disk PE at /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll via objdump. FIFA17.exe pid 260692 was confirmed alive at the end of the run.


The next action

Before spending a relaunch, ask the user to back out of Ultimate Team to the main menu and walk straight back in, while tail -f /tmp/utas_server.log runs. If a ProtoHttp-UA GET /user/accountinfo appears, the UT bootstrap is not process-scoped, the cost of every experiment in this document drops from a relaunch to a menu round-trip, and the ranking in section 2 becomes far less important than it currently is. That observation is free and has never been made. Then, either way, apply P1 through P5 as one edit, restart utas only, and take the relaunch once: read model+0x1fd28 first, because if it is still 60 the configs array never reached the applier and response+0x1c is the real story; if it is 77, read model+0x1fd2e, and if that is 1 the two-day-old greyed menu entry is fixed and we finally learn whether the Transfer Market screen was ever about trading at all.