Files
OpenFUT/docs/FIFA17_TRANSFER_MARKET_WIRE.md
T
funman300 b6398c44e6 docs: record the LIVE-CONFIRMED inactive UI contract and the expired->Club transition
Operator confirmed in the real client that a tradeState "inactive" row lands under the
right-hand TRANSFER LIST section and renders Start Price 0 with Buy Now, Current Bid
and Time Remaining all absent, while an active row in the same body continued to
render separately under LISTED ITEMS. That is the Q2 representation confirmed live,
with the token itself dumped from the client's own string table rather than guessed.

Records the observed wire->UI contract as a table plus a fixture
(inactive-row-live-confirmed.json), and names the regression tests that pin it,
including the two guards that the row is never emitted for an item outside the trade
pile and never duplicates a real auction.

Also records the expired->Club coupled transition verified server-side: the listing
went to `cancelled`, the pile went to `club`, /tradePile dropped the item, /club
regained it, and clubPlayers went 1964 -> 1965. That is durable store state rather
than a client-local view, so it survives a session boundary by construction; tagged
pending the operator's final exit/re-enter confirmation.

Adds the counts observation table. `count` currently tracks AUCTION entries and not
total Transfer List membership; semantics deliberately left unchanged until the full
state set has been observed.

No behaviour change in this commit.
2026-08-17 20:31:20 +00:00

534 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
---
## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17)
Re-entry discriminator result: **CONFIRMED BUG.** After fully leaving and re-entering
FUT, the active auction reconstructs correctly under LISTED ITEMS, but the unlisted
TRANSFER LIST section is **empty** — item `100000059` does not survive. The
immediate post-move visibility was client-local only, so our representation cannot
durably reconstruct trade-pile membership.
The representation is now pinned by elimination over PROVEN facts, not chosen:
1. **Only one route can own a trade-pile list.** The complete CardsDLL route table
(`.rdata 0x18021df80`, 45 routes + 3 empty admin slots) is dumped verbatim at
`docs/evidence/market-lifecycle-2026-08-17/cardsdll-route-table.txt` via
`fifa17-recon/tools/route_table_dump.py` (static, read-only, VA→offset resolved
through the real PE section table). Row 30 `ut/%s/tradePile` is the ONLY
trade-pile route. There is no trade-pile *items* route.
2. **That route carries only auction records.** `FutGetTradePile` (`0x180170810`)
deserializes the shared IS-list body (`0x18013e7f0`), whose `auctionInfo`
elements go through `0x18013e410` — the twelve-atom auction record.
3. **`pile` cannot be set from the wire.** `pile` (atom 0x226) has NO arm in the item
deserializer `FUN_18013fe00` (checked in all four dispatch forms); `item+0x60` is
assigned by the OWNING LIST, reading 1 for every `/club` item and 6 for every
`/purchased` item. So membership is conferred by *arriving in the list*, never by
a field we can add.
4. **Of the twelve atoms, only `tradeState` can express lifecycle.**
5. **`tradeState`'s vocabulary is closed and has exactly one unused value.** The table
walk at `0x180229e40` decodes `active=1 inactive=2 expired=3 closed=4`, anything
else `-1`. `active`, `expired` and `closed` are all already spoken for by the
observed lifecycle.
**Therefore an unlisted trade-pile item can only be an `auctionInfo` record with
`tradeState: "inactive"`.** That is the sole encoding the client's own parser can
accept for "in the pile, no auction" — reached by elimination, not invention.
Status: **INFERRED-BY-ELIMINATION**, not yet CONFIRMED. One binary question remains,
and it is about client *rendering*, not encoding: does the Flash Transfer List place
an `inactive` record in the unlisted section? The corpus's warning that "`inactive`
decodes but no client path treats it specially" was established for the market
PRESENTATION function (`flagA`/`flagB`), where `inactive` is indeed unremarkable —
that is consistent with, and does not contradict, using it for list membership.
Ruled out on evidence, do not retry: adding `itemData.pile` (inert, no deserializer
arm), a second route (none exists), and `tradeId: 0` / invented `itemState` values
(unnecessary — `tradeState` alone carries the distinction).
### Acceptance test for whatever lands
```text
move item Club -> Transfer List
leave FUT entirely
re-enter FUT
item appears in the unlisted TRANSFER LIST section
item can be returned to Club
no auction exists unless explicitly listed
```
Plus the active lifecycle must remain unchanged: active listing under LISTED ITEMS,
`expires` counting down, active non-selectable, expiry making it actionable, relist
working.
### Revised domain invariant
Core pile membership is NOT equivalent to `/tradePile` visibility. Core ownership/pile
state and auction lifecycle are separate authoritative facts; the wire may expose them
through different FIFA 17 resources. **A transition is complete only when a fresh FIFA
session can reconstruct the same user-visible state** — re-entry is the acceptance
test, not the immediate post-mutation response.
---
## Q2 — LIVE-CONFIRMED: `tradeState: "inactive"` is the unlisted representation
Operator-observed in the real FIFA 17 client, 2026-08-17, with the bounded one-item
probe (`OPENFUT_FIFA17_UNLISTED_PROBE`). Fixture:
`docs/evidence/market-lifecycle-2026-08-17/inactive-row-live-confirmed.json`.
### The observed `inactive` UI contract
| Wire | Client presentation |
|---|---|
| `tradeState: "inactive"` | row placed under the right-hand **TRANSFER LIST** section, NOT under LISTED ITEMS |
| `startingBid: 0` | Start Price = `0` |
| `buyNowPrice: 0` | Buy Now Price = `-` (absent) |
| `currentBid: 0` | Current Bid = `-` (absent) |
| `expires: 0` | Time Remaining = `-` (no timer) |
| `bidState: "none"` | no bid presentation |
| `itemData.itemState: "free"` | renders as a normal owned card |
An `active` row in the same body continued to render separately under LISTED ITEMS,
so the two sections are driven by `tradeState` and the experiment did not disturb the
known-good active path.
Token provenance: dumped from the client's own `{const char*, int}` table at
`0x180229e40` (`active=1 inactive=2 expired=3 closed=4`) — see
`cardsdll-vocab-tables.txt` and `fifa17-recon/tools/vocab_dump.py`. Nothing here was
guessed.
Regression test: `market::tests::unlisted_candidate_is_a_non_active_pile_row` pins the
whole tuple, plus that the row is never emitted for an item outside the `trade` pile
and never duplicates a real auction
(`unlisted_candidate_never_duplicates_a_real_auction`).
### Expired -> Club coupled transition, verified
Operator returned the naturally-expired Ronaldo to the club. Server state after:
```
listing 1000000097 state = cancelled (the coupled transition fired)
pile Ronaldo = club
/tradePile Ronaldo absent
/club Ronaldo present, itemState free
hub clubPlayers 1964 -> 1965, auctionCount 1
counts {count 1, selling 1, sold 0}
```
All of that is durable store state rather than a client-local view, so it survives a
session boundary by construction. Pending the operator's final exit/re-enter
confirmation before this is tagged CONFIRMED.
### Counts observations so far (semantics still UNCHANGED and unresolved)
| State | `count` | `selling` | `sold` |
|---|---|---|---|
| 1 active | 1 | 1 | 0 |
| 2 active | 2 | 2 | 0 |
| 1 active + 1 inactive (probe) | 1 | 1 | 0 |
| unlisted pile items (no probe) | not counted | not counted | 0 |
So `count` currently tracks AUCTION entries, not total Transfer List membership. Do
not change this until the full state set (empty / unlisted / active / expired / sold /
mixed) has been observed — it remains an open question whether FIFA 17 expects
`count` to include non-active pile members.