7f37b37be3
Q2 measured, not guessed. The operator moved a card Club -> Transfer List without listing it (PUT /item, no POST /auctionhouse) and the state was captured read-only. Finding: we do not represent the unlisted state on the wire AT ALL. Such an item is byte-identical to a club item -- itemState `free`, no `pile` field emitted, still returned in /club and counted in clubPlayers -- while an actively-listed item is correctly excluded from /club and present in tradePile. Only the host's own pile store knows the difference. Trade pile held 6 items: 1 listed, 5 unlisted. The FIFA 17 ENCODING of that state stays UNKNOWN on purpose: returned itemData.pile is numeric with an unrecovered mapping, and tradeState is a closed table walk where an unrecognised bidState is silently swallowed as `none`, so a wrong enum produces a plausible-looking but wrong UI. The corpus warns `inactive` decodes but no client path treats it specially. One client-only discriminator is recorded instead. Also models the domain boundary both limbo bugs came from: pile membership and auction lifecycle are separate facts requiring coordinated transitions. States the testable invariant -- an item must never be simultaneously excluded from /club and absent from /tradePile -- with the two ways it was reachable and the commits that closed each.
399 lines
20 KiB
Markdown
399 lines
20 KiB
Markdown
# 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/<sku>/trade/<id>` | **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/<sku>/trade/<id>` | **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/<pid>/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.
|
||
|
||
---
|
||
|
||
## INVESTIGATION CLOSED — active own auction is not seller-actionable
|
||
|
||
**Do not reopen without new direct FIFA17.exe evidence contradicting the lifecycle
|
||
below.** The correct FIFA 17 lifecycle is:
|
||
|
||
```text
|
||
ACTIVE AUCTION tradeState=active, expires>0/counting down
|
||
-> seller CANNOT withdraw it through Transfer List actions
|
||
-> the item is not seller-actionable while the auction runs
|
||
|
||
EXPIRED UNSOLD tradeState=expired, expires=0
|
||
-> the item becomes actionable again
|
||
-> relist / return-to-club / other expired-item actions
|
||
```
|
||
|
||
### Confidence tags
|
||
|
||
```text
|
||
CONFIRMED — live FIFA17.exe:
|
||
active listing with ticking expires is NON-selectable
|
||
expired listing IS selectable
|
||
relisting makes it active and therefore non-selectable again
|
||
no cancel request is ever emitted by the client
|
||
expires must advance with the wall clock
|
||
no button prompt is offered on the Transfer List for an active auction
|
||
|
||
CONFIRMED — local RE corpus:
|
||
every CardsDLL-side prerequisite passes (IS_TRADING_ENABLED=1,
|
||
TRADE_PILE_SIZE=100, watch-list=50, item+0x49 tradeable)
|
||
MAY_BE_REMOVED is a CONSTANT 1 — it cannot be the gate and the server
|
||
cannot move it
|
||
the eight-flag array FUN_18003e370 publishes is the CLUB-CARD Actions menu
|
||
and contains NO transfer-auction cancellation flag
|
||
the auction parser is limited to the known twelve atoms
|
||
|
||
HISTORICAL FUT / FIFA17-era:
|
||
active auctions are committed until sale or expiry
|
||
expired Transfer List items expose relist/return actions
|
||
FIFA 17 trading guidance tells players to relist once auctions expire
|
||
|
||
UNKNOWN, and no longer required for backend fidelity:
|
||
the exact Flash/ActionScript branch that makes active cards non-selectable
|
||
```
|
||
|
||
### Explicitly out of scope now
|
||
|
||
Do NOT add auction fields, change `itemState`, revisit `tradeOwner`, probe
|
||
`MAY_BE_REMOVED`, add an active-auction cancel feature, or disassemble Flash in
|
||
order to make active auctions selectable. Three of those were already tried and
|
||
refuted; the rest are ruled out above.
|
||
|
||
### Return-to-club transition (implemented)
|
||
|
||
A pile move to `club` now ENDS any `active` auction on that item. Without it the
|
||
pile reads `club` while the listing row stays `active`, so the card is filtered out
|
||
of `/club` (exclusion keys on active listings) AND still rendered in the Transfer
|
||
List — the move silently appears to do nothing. `reserved` (mid-sale) and `sold`
|
||
rows are never touched, so a card can never be both sold and returned.
|
||
|
||
---
|
||
|
||
## Q2 — the unlisted transfer-list item, MEASURED 2026-08-17
|
||
|
||
The operator moved a card Club -> Transfer List **without listing it**
|
||
(`PUT /ut/game/fifa17/item`, no `POST /auctionhouse`). State captured immediately,
|
||
read-only. Fixture: `docs/evidence/market-lifecycle-2026-08-17/`.
|
||
|
||
Server truth at capture: trade pile held **6** items, of which **1** had an active
|
||
auction and **5** were unlisted.
|
||
|
||
| Surface | Unlisted item | Actively-listed item (contrast) |
|
||
|---|---|---|
|
||
| `/tradePile` `auctionInfo` | **absent** | present, `tradeState: active` |
|
||
| `/tradePile/counts` | **not counted** (`count 1, selling 1`) | counted |
|
||
| `/club` | **present**, `clubPlayers` unchanged at 1965 | **absent** (excluded) |
|
||
| `itemData.itemState` | `free` | `listFS` |
|
||
| `itemData.pile` | **field not emitted at all** | not emitted |
|
||
| `itemData.untradeable` | `false` | `false` |
|
||
|
||
**Finding: we do not represent the unlisted state anywhere on the wire.** Such an
|
||
item is byte-identical to a club item; only the host's own pile store knows it is in
|
||
the trade pile, and nothing the client receives says so.
|
||
|
||
Still **UNKNOWN** (do not guess): the FIFA 17 encoding of that state. The PE
|
||
documents returned `itemData.pile` as NUMERIC with an unrecovered mapping, and
|
||
`tradeState` decodes through a closed table walk (`active=1 inactive=2 expired=3
|
||
closed=4`) where an unrecognised `bidState` is silently swallowed as `none` — so a
|
||
wrong enum yields a plausible-looking but WRONG UI. The corpus explicitly warns that
|
||
`inactive` decodes but "no client path treats it specially; do not emit it".
|
||
|
||
Open discriminator, needs the client only: after backing out of FUT and re-entering,
|
||
does the Transfer List still show an unlisted item? If it does not, the move is not
|
||
durable from the client's point of view and the state must be represented; if it
|
||
does, `tradePile` is auctions-only by design and there is nothing to fix.
|
||
|
||
---
|
||
|
||
## Domain boundary: pile membership vs auction lifecycle
|
||
|
||
Two SEPARATE facts, but several mutations require a COORDINATED transition. Both
|
||
limbo bugs this session came from conflating them, so this is modelled explicitly
|
||
and covered by tests rather than patched route by route.
|
||
|
||
| Mutation | Pile | Auction |
|
||
|---|---|---|
|
||
| Club -> Transfer List | becomes `trade` | none necessarily exists |
|
||
| list item | stays `trade` | becomes `active` |
|
||
| clock runs out unsold | stays `trade` | becomes `expired` (projection; no row mutated) |
|
||
| relist | stays `trade` | `active` again, clock restarted, new prices |
|
||
| return expired item to Club | becomes `club` | any `active` association MUST end |
|
||
| sold / reserved | — | MUST NOT be ended by a generic move-to-club |
|
||
|
||
Invariant, stated so it can be tested rather than remembered: **an item must never be
|
||
simultaneously excluded from `/club` and absent from `/tradePile`.** That is the limbo
|
||
state, and it is reachable in two ways, both now closed:
|
||
|
||
1. Excluding by *pile* rather than by *active listing* (fixed in `f2c4927`) — the
|
||
pile can hold items with no auction, and `/tradePile` renders auctions only.
|
||
2. Moving to `club` while leaving the auction `active` (fixed in `4e31fb9`) — the
|
||
pile says club, the exclusion still fires, and the card shows in neither place.
|
||
|
||
Tests: `returning_an_expired_listing_to_the_club_ends_its_auction`,
|
||
`a_pile_move_never_disturbs_a_sale_in_flight`,
|
||
`club_excludes_listed_items_and_paginates_the_visible_set`.
|