# FIFA 17 Transfer Market — wire findings Reverse-engineering record for the FIFA 17 UTAS transfer-market surface, kept so future agents do not reopen settled questions or re-guess enum spellings. Every claim carries a confidence tag: | Tag | Meaning | |---|---| | **CONFIRMED** | Observed from our own FIFA17.exe client or live host capture | | **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 implementations (`lorenzh/fut-api`, `futapi/fut` v0.2.18 — the last pre-FIFA-18 release) | | **INFERRED** | Best explanation, not directly captured | | **UNKNOWN** | Requires instrumentation; do NOT implement from guesswork | Authority reminder: FIFA 17 field names, enum spellings, sentinel ids and empty-state shapes come from captures or the Python oracle — never from a modern FUT toolkit. Later-FIFA API drift is a known hazard, and reversing a container type or inventing an enum is the documented client-freeze class. --- ## The auction record (`auctionInfo[]`) What we emit today, on `/tradePile`, `/trade/status` and market browse: ```json { "tradeId": 1000000097, "itemData": { "...full shaped card...": "", "itemState": "listFS" }, "tradeState": "active", "buyNowPrice": 15000, "startingBid": 150, "currentBid": 0, "offers": 0, "bidState": "none", "expires": 3600, "tradeOwner": true, "sellerId": 33068179, "sellerName": "CAGE", "sellerEstablished": 1, "watched": false, "coinsProcessed": 0 } ``` | Field | Confidence | Note | |---|---|---| | `tradeOwner` (bool) | **FIFA17-HISTORICAL** | Exists in FIFA 17 auctionInfo. That it is *the* Actions-panel gate is **UNKNOWN** pending live confirmation. | | `sellerId` | **FIFA17-HISTORICAL** exists; type numeric is **INFERRED** | Set to the configured persona so it agrees with `tradeOwner`. Never baked in. | | `sellerName` | **CONFIRMED** it must be the player | `fut_account.py` annotates the persona property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / **UTAS sellerName**". EA's `"EASFC"` here is wrong for an own listing. | | `offers` | **FIFA17-HISTORICAL** | `0` valid for active/unbid. | | `bidState: "none"` | **FIFA17-HISTORICAL** | Valid for active/unbid. Other observed concepts: `highest`, `buyNow`. Do NOT "fix" this. | | `expires` | **FIFA17-HISTORICAL** | **SECONDS REMAINING, not an epoch.** Historical durations: 3600, 10800, 21600, 43200, 86400, 259200. | | `itemData.itemState: "listFS"` | **UNKNOWN** | Plausible and unchanged. Public FIFA 17 material gives no trustworthy enumeration. Do not guess replacements — capture. | | `itemData.untradeable` | **FIFA17-HISTORICAL** field; our blanket `false` is **INFERRED** | See "Known debt" below. | | `marketDataMinPrice` / `marketDataMaxPrice` | **do NOT add** | These entered the public parser only after its FIFA 18 migration. | ### Why the differential could not catch the missing fields Our record's key set was **identical to the Python oracle's**, so field-for-field parity was green. The oracle omits `tradeOwner` / `sellerId` / `offers` as well, because *its* remove flow was never driven by a real client either — the only historical live datapoint is a counts-tile bug. Oracle parity is therefore **necessary but not sufficient** for any flow the oracle never actually served. The differential now asserts we cover every oracle key AND that our extra keys are exactly `{offers, sellerId, tradeOwner}`, so the deliberate superset is pinned while a new unexplained divergence still fails. --- ## Routes | Route | Confidence | Behaviour | |---|---|---| | `GET …/trade/status` | **CONFIRMED** the client polls it continuously | Live auction-state refresh. It previously fell through `starts_with("trade")` into the buy/view arm, where the tail has no numeric id, so **every poll returned `{"auctionInfo": []}`**. Now a real handler: optional `tradeIds` filter, else the whole active pile. Unknown ids are absent, never an error. | | `DELETE /ut/game//trade/` | **FIFA17-HISTORICAL** | The spelling contemporaneous FIFA 17 clients use, no body, no meaningful response body. Previously landed in the buy/view arm and **silently cancelled nothing while returning 200.** Now maps to MarketCancel. | | `DELETE /ut/delete/game//trade/` | **CONFIRMED** (oracle) | The oracle's spelling; retained because the differential exercises it. Whether FIFA17.exe ever uses it is **UNKNOWN**. | | `POST …/auctionhouse` | **CONFIRMED** | List for sale. The client sends only `itemData.id`; the server resolves wire id → Core instance → `card_id`/`resourceId` and enforces ownership. | | `GET …/tradePile/counts` | **INFERRED** | Five scalar ints (`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`); a DISTINCT deserializer from `/tradePile`. Exact FIFA 17 semantics of `count` (active auctions vs whole pile) is **UNKNOWN** — we report active auctions and deliberately did NOT speculate. | | `PUT …/item` (move) | **FIFA17-HISTORICAL** | `{"itemData":[{"pile":"trade"|"club","id":ID}]}` → `{"itemData":[{id,pile,success}]}`. Transfer-List membership is a **separate operation from creating an auction**. | ### Pile encoding * MOVE commands take a **string** pile (`"trade"`, `"club"`) — **FIFA17-HISTORICAL**. * Returned `itemData.pile` is documented **numeric** in FIFA 17 auction data — **FIFA17-HISTORICAL**. * The numeric mapping is **UNKNOWN**. Do not unify the two representations, and do not derive a mapping from unrelated `pileSize` keys. --- ## Q2 — Transfer List item that is not currently auctioned A real FUT state: an item in the Transfer List with no active auction (freshly moved, or expired unsold). **CONFIRMED** to exist as a concept (the external hub spec §27, and move-vs-list being separate operations). Its wire representation is **UNKNOWN**: `tradeId` 0 / omitted / null, `tradeState` value or omission, and `itemData.itemState` are all unestablished. Consequence, and the reason this matters: our `/tradePile` renders only `active` listings, so keying the `/club` exclusion on the `trade` **pile** stranded 4 cards in no screen at all (hidden from the club, absent from the Transfer List). Commit `f2c4927` keys exclusion on the **active listing** instead, which is self-healing. That is a workaround, not fidelity — the faithful model needs the unlisted state represented. **Required capture** (four states, full structural diff, not just a shortlist): ``` A. moved Club -> Transfer List, NEVER listed B. actively listed C. listing expired unsold D. listing sold ``` Diff at least: `tradeId`, `tradeOwner`, `tradeState`, `bidState`, `expires`, `offers`, `currentBid`, `startingBid`, `buyNowPrice`, `sellerId`, `sellerName`, `itemData.id`, `itemData.itemState`, `itemData.pile`, `itemData.untradeable`. Do NOT drop the unlisted state from the model just because its encoding is unknown. --- ## Deferred, with reasons * **5% transfer tax** — **INFERRED** architecture only: auction closes → Core settles → seller credited gross × 0.95, with `auctionInfo` continuing to carry gross. No trustworthy FIFA 17 field named `tax`/`netPrice`/`sellerProceeds` was recovered, and no separate settle operation. Not blocking; do not couple settlement to clearing the sold auction without a capture. * **Bid / Transfer Targets** — not implemented. Watched / active bid / winning / outbid / won / expired are distinct states and must not collapse to a flat list. * **Unassigned** — FIFA 17 had a dedicated Unassigned service; the exact FIFA 17 URL is **UNKNOWN**. Our 29-item `purchased` pile is this state and is currently rendered inside `/club`. Do not manufacture a route from a modern toolkit. * **Match CREATE / READY / PLAY** — **UNKNOWN** and explicitly not portable from public FUT web-app work (the web app could not start matches). Instrument the real client from Play Match to kickoff before implementing. --- ## Known debt `shape_item` reports `untradeable: false` for **every** owned instance. Correct today (Core models no untradeable items) and necessary — a hardcoded `true` greyed out both list buttons — but it will misrepresent SBC / promo / loan rewards once those exist. `untradeable` belongs on the owned-item instance as authoritative state, not inferred from definition, resourceId or rarity. --- ## MEASURED in the live client — 2026-08-17 Read out of the running `FIFA17.exe` (pid-resolved, CardsDLL slide proven against the on-disk FNV prologue) with `fifa17-recon/tools/trade_gate_probe.py`, which extends `gate_byte_probe.py` to vtable slot `+0x270` as the transfer-market analysis asked for. Read-only: `/proc//mem` `O_RDONLY` + `pread`. | Gate | Python era (2026-08-06) | Now | Owner | |---|---|---|---| | `IS_TRADING_ENABLED` `model+0x1fd2e` (slot `+0x270`) | **0** | **1** | settings struct `+0x28`; was zeroed by `userInfo.feature.trade` | | `TRADE_PILE_SIZE` `model+0x1fd1c` | **0** | **100** | `userMassInfo.pileSizeClientData` key 2 | | watch-list size `model+0x1fd20` | **0** | **50** | same member, key 4 | | `storeEnabled` `model+0x1fd2f` | 1 | 1 | control | | `IS_FRIENDLY_SEASON` / `IS_DRAFT_MODE` / `packOpeningAnimation` | 1 | 1 | controls | **CONFIRMED: every CardsDLL-supplied input the transfer-market analysis named as a blocker is now open.** The Rust host does this by construction — it emits `userInfo.feature` as `{}` (no `trade` member, so the kill switch at `0x180174f19` never arms: it fires only when atom `0x330` inside `0x11c` parses as exactly 1) and it already sends `pileSizeClientData` keys 2 and 4. Serving `tradingEnabled: 1` in the settings `configs` array would NOT have worked, because that tail runs after every member is parsed and would overwrite it. ### What this rules out The Transfer List Actions panel not opening on an own listing is therefore **not**: * an ownership field — FIFA 17's auctionInfo has no `tradeOwner`/`sellerId` atom; * `IS_TRADING_ENABLED`, `TRADE_PILE_SIZE` or the watch-list size — all measured open; * the cancel route — `DELETE ut/delete/{ns}/trade/{tradeId}` is the PE's spelling and is what we serve; * `tradeState` / `bidState` / `expires` spellings — all three are the PE's own vocabularies and values. Per the analysis's own falsifier ("if the byte reads 1 and the screen still refuses, the exe-side predicate has a term we have not enumerated"), the remaining term is **exe-side UI script**, which CardsDLL does not own and the server cannot set. Status: **UNKNOWN**, and it is now the narrowest it has ever been. ### Confirmed fidelity bug found on the way `expires` was a frozen `3600` on every poll, so the client's live countdown never moved and an auction could never run out. Now derived from `created_at + duration` (duration taken from the `ISStart` body), clamped at 0, with an aged-out active listing projecting as `expired`/`none` — FIFA 17's relistable state. Verified live: the standing listing correctly reads `expires: 0` once past its hour. --- ## RESOLVED against the live client — 2026-08-17 (supersedes the tags above) Driven by the real FIFA 17 client end to end: list → expire → **relist** → active, with the operator confirming each UI state. Live-client behaviour OUTRANKS both the Python oracle and contemporaneous Web App implementations wherever they disagree. ### `tradeOwner` — do not re-litigate this ```text tradeOwner exists in the FIFA 17-era FUT API: FIFA17-HISTORICAL tradeOwner required by FIFA17.exe Transfer List Actions: DISPROVEN for the current client path ``` It is absent from the twelve atoms FIFA 17's `auctionInfo` deserializer (`0x18013e410`) reads, so the client value-SKIPs it at `0x180135ff0`. It was implemented, deployed, observed to change nothing, and REMOVED. A future agent rediscovering the old Web App sources will find this note before spending deployments on it again. ### What actually gated the Actions panel — all lifecycle/state bugs ```text CONFIRMED — FIFA17.exe live client Own Transfer List interactivity does NOT depend on tradeOwner in this path. trade/status polling is LOAD-BEARING for Transfer List state. The tail carries no numeric id, so it fell through the `trade…` buy/view arm and answered every poll with an empty auctionInfo. Route is ISVIEWTRADE: GET ut/{ns}/trade/status?tradeIds=a,b,c auctionInfo.expires is SECONDS REMAINING *and must evolve with wall-clock time*. A structurally valid but FROZEN value breaks lifecycle behaviour: the auction never ages into expired/relistable, which is the state where Re-list appears. Relisting must PERSIST. FIFA 17 relists by re-sending ISStart (POST /auctionhouse) for an item that already has a listing row, so the primary-key conflict IS the relist. Swallowing it as success left the stale expired row intact and produced a client-visible lifecycle failure behind an HTTP 200. ``` ### Known-good own active auction (frozen fixture) Captured at `docs/evidence/market-lifecycle-2026-08-17/`, including `_index.json._countdown_proof` which records `expires` decrementing (`frozen: false`) so the live clock is machine-checkable rather than asserted in prose. ```text tradeState = active bidState = none expires = decrementing (seconds remaining) sellerName = CAGE auction record = 12 atoms response envelope = 4 members (auctionInfo, credits, duplicateItemIdList, total) ISViewTrade body = 2 members (auctionInfo, credits) pricelimits = BARE ARRAY (container type is load-bearing) ``` **Do not "improve" this shape without a live-client retest.** ### The development rule this established A response can be structurally plausible, pass differential parity, and render perfectly while still being behaviourally wrong, because FIFA expects an **evolving server-side state machine** rather than a static object that looks like one. The frozen `expires` is the canonical example: every field was the right name, type and vocabulary, and the feature was still broken.