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

```python
# 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:

```python
            # "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.

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

```python
# 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.

```python
@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.

```python
# 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.
