cb32fe9b84
Return to Club is durable across a full FUT exit/re-entry — the last claim the forSale promotion could only make server-side. Same disposable card as Gap 1 (75 ST, res 212188, wire 100000178, tradeId 1000000178), after the 1h auction expired NATURALLY. No timestamp was mutated in either gap; a read-only sampler watched the whole hour (55 samples), because `expires` is derived from created_at + duration and watching is the only honest way to see expiry: active expires 3175 -> counting down -> expired expires 0, itemState forSale (absent) total 0 <- Return to Club itemState stayed forSale across active -> expired, the one state change Fix B had never been watched through live. The host log then shows the coupled transition and TWO session boundaries: route=move-items wire=100000178 pile=club auction_cancelled=1 route=auth-delete route=auth ... sid_opened=true (fresh session, x2) route=hub clubPlayers=1966 auctionCount=0 route=club total=1986 emitted=1966 auction_cancelled=1 is cancel_active_for_core_item firing, so pile membership and auction lifecycle cannot disagree. Two independent fresh sessions each rebuilt the state from durable storage and the operator confirmed the card was still in My Club; one boundary was the requirement. 18/18 server checks pass IDENTICALLY before and after re-entry: /tradePile total 0, counts all zero, tradeId -> closed with expires 0 (still resolves, correctly terminal), /club 1966 with itemState free, 0 duplicate ids, market store cancelled with 0 active and 0 reserved, coins 29,843,976 unchanged throughout. No code change was required for EITHER gap. Both tests existed to find out whether the promoted implementation was already correct on paths it had not been exercised on, and it was. Also freezes the full CLUB -> list -> active -> expired -> Return to Club -> CLUB lifecycle as the reference baseline, with the eight invariants it pins, so a future change that alters any line is a regression until proven otherwise. Explicitly NOT established: the SOLD path, /tradePile/counts semantics, the AVM1 gate.
888 lines
45 KiB
Markdown
888 lines
45 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`.
|
||
|
||
---
|
||
|
||
## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17) — **SUPERSEDED, see the final section**
|
||
|
||
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 — **SUPERSEDED, see the final section**
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## Q2 status downgrade + the "Expired" differential EXPLAINED (2026-08-17) — **SUPERSEDED, see the final section**
|
||
|
||
Phase C was a PARTIAL pass. Corrected conclusions:
|
||
|
||
```text
|
||
tradeState "inactive" = CONFIRMED section/lifecycle discriminator
|
||
(rows persist across a fresh session and land under
|
||
TRANSFER LIST, not LISTED ITEMS)
|
||
|
||
complete ACTIONABLE unlisted representation = was UNKNOWN; see below
|
||
```
|
||
|
||
### The visual differential, and its cause
|
||
|
||
| | one-item probe | generalized rows |
|
||
|---|---|---|
|
||
| Start Price | 0 | 0 |
|
||
| Buy Now / Current Bid | `-` | `-` |
|
||
| Time Remaining | `-` | **`Expired`** |
|
||
|
||
Cause: **route coverage, not a field.** `/tradePile` advertised the unlisted
|
||
tradeIds, but `GET …/trade/status` (ISVIEWTRADE) answered them from the market store
|
||
ONLY — and an unlisted pile member has no listing row, so the poll returned an empty
|
||
`auctionInfo`. Observed live as `route=market-status requested=1 returned=0` repeating
|
||
for the row the operator had selected (`tradeIds=1000000122`), while the same id was
|
||
present in `/tradePile`.
|
||
|
||
The client polls `/trade/status` for the row it is displaying, and an empty answer
|
||
degrades it: Time Remaining renders `Expired` and no actions are offered. The earlier
|
||
probe showed `-` simply because the client had not yet polled that id — the logs at
|
||
the time show only `tradeIds=1000000097`. So `expires`, `tradeState`,
|
||
`itemData.itemState` and `pile` were all innocent, and NO field was changed.
|
||
|
||
This is the same defect class as the original `trade/status` bug: a route the client
|
||
polls being answered with an empty set. The corpus predicted it — `tradeId` must
|
||
resolve across `/transfermarket`, `/tradePile`, `/watchList` AND `/trade/status`. We
|
||
had stability but not coverage.
|
||
|
||
Fix: both routes now share ONE pile enumeration (`Server::resolve_trade_pile`), so an
|
||
id advertised by `/tradePile` always resolves on `/trade/status`. Verified live: the
|
||
six inactive ids went from `returned=0` to `returned=6`. `/trade/status` still answers
|
||
only the ids actually asked about, and a real auction always wins over an inactive row
|
||
for the same id.
|
||
|
||
Regression test: `trade_status_resolves_the_unlisted_ids_tradepile_advertises`.
|
||
|
||
### Investigations NOT needed as a result
|
||
|
||
`itemData.itemState` and numeric `itemData.pile` were queued for PE recovery on the
|
||
assumption the encoding was incomplete. The cause was route coverage, so neither was
|
||
touched and neither is implicated. If the actions still do not appear, those remain
|
||
the next candidates — and `itemState`'s vocabulary should be dumped statically with
|
||
`fifa17-recon/tools/vocab_dump.py` (already validated against the tradeState table)
|
||
rather than guessed.
|
||
|
||
---
|
||
|
||
## Q2 — RETRACTED, and CLOSED as unrepresentable (2026-08-17, RE of the FUT front-end)
|
||
|
||
**Everything in the two Q2 sections above is superseded.** The `tradeState:"inactive"`
|
||
projection has been removed from `GET …/tradePile` and `…/trade/status`, and the
|
||
`UnlistedCandidate` machinery deleted. Live after the fix: `total` 7 → 1, only the real
|
||
auction, `inactive` rows 0, coins unchanged, and all six former rows still present in
|
||
`/club` (invariant intact).
|
||
|
||
### Why the earlier conclusion was wrong
|
||
|
||
It was `LIVE-CONFIRMED` that the row **renders**. That was never in doubt, and it is
|
||
not the same claim as the row being usable. The rows were never actionable, and could
|
||
not have been:
|
||
|
||
| Fact | Confidence |
|
||
|---|---|
|
||
| `tradeState` reaches ONE native branch, `cmp …,0x4` at `0x18013e619` ("is it `closed`?"). `inactive`(2) and `expired`(3) take the same edge → bit-identical `flagA`/`flagB`. Exhaustive 22-site census of `[reg+0x88]` reads across the PE. | CONFIRMED |
|
||
| `tradeState` is handed to the movie verbatim as the Flash property `STATE` (publisher `0x1801bf030`); the action gate is in the APT/ActionScript FUT front-end, not CardsDLL. | CONFIRMED |
|
||
| The trade-pile movie class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`), and only auction rows reach `PreCheckCardOptions` → `handleTradeCardAction`. | CONFIRMED (interned AS symbols + trace strings, live) |
|
||
| A client rule excludes non-auction pile rows from the action path (Category C). | STRONGLY SUPPORTED |
|
||
| The gate term is `STATE == 2`. | **NOT PROVEN** — `STATE`, `CARD_OFFERSTATE` and the `RESERVEDPRICE`/`MAX_CREDITS` pair all differ together in every row we can observe |
|
||
| In the shipped game, unlisted pile rows were a CLIENT-SIDE transient built by the movie from a `TO_TRADEPILE` Flash message (`FUN_1801be6a0`), never server-delivered. | STRONGLY SUPPORTED |
|
||
|
||
This fires the corpus's own pre-registered falsifier E3
|
||
(`plan-2026-08-06-transfer-market.md:368-373`): the native predicates all pass
|
||
(`IS_TRADING_ENABLED=1`, `TRADE_PILE_SIZE=100`, `tradeable=1`) and an `expired` row in
|
||
the same section IS actionable, so "the Flash layer has its own gate and CardsDLL is no
|
||
longer the right place to look" is now an observation.
|
||
|
||
**The corpus said so before any of this was written**, at
|
||
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path
|
||
treats it specially; **do not emit it**." The projection was built anyway, and the
|
||
earlier reasoning above (that the warning "was written about the PRESENTATION
|
||
function") was motivated rather than evidenced.
|
||
|
||
**Consequence for the product:** a server cannot put an unlisted item on the Transfer
|
||
List in an actionable state. `/tradePile` carries only twelve-atom auction records and
|
||
`pile` has no deserializer arm, so anything rendered there must be a real auction.
|
||
Unlisted trade-pile membership is now internal state with no wire expression; those
|
||
cards live in `/club`, which is where the client can act on them.
|
||
|
||
### Open: `itemData.itemState: "listFS"` is not a FIFA 17 token
|
||
|
||
Independent of the gate, and CONFIRMED: `listFS` occurs **0 times** in
|
||
`CardsDLL_Win64_retail.dll` (md5 `4de3493131d7d2ff7f8b360c5ac9b655`) and 0 times in
|
||
4.26 GiB of live process memory; it decodes to **−1** through `FUN_180166660`, observed
|
||
live on our listed row. FIFA 17's value for a listed item is `forSale` (5), from the
|
||
12-row table at `0x180229cc0`. We emit `listFS` on `/tradePile` and `/trade/status`, and
|
||
so does the Python oracle (`utas_server.py:3921`) — which is exactly why the
|
||
differential never caught it: **oracle parity is necessary but not sufficient.**
|
||
|
||
### Fix B — `forSale` PROMOTED, live-confirmed on the expired path (2026-08-17)
|
||
|
||
Shipped alone, then validated by an operator FIFA 17 session against the already-expired
|
||
row `1000000155` (res 158023, 93 RW).
|
||
|
||
| Claim | Status |
|
||
|---|---|
|
||
| `listFS` is not a FIFA 17 token and decodes to −1 | **CONFIRMED** (binary + 4.26 GiB process scan + live row) |
|
||
| `forSale` is FIFA 17-native, value 5 | **CONFIRMED** (12-row table at `0x180229cc0`) |
|
||
| An expired row carrying `forSale` is still ACTIONABLE, and Return to Club works | **LIVE-CONFIRMED** — operator returned the card; server verified below |
|
||
| The result is durable (a fresh session reconstructs it) | **LIVE-CONFIRMED** (Gap 2, 2026-08-17) — the operator exited FUT and re-entered; the host log shows `auth-delete` then two fresh `sid_opened=true` sessions, each re-fetching `/club` and `/tradePile`, and the card was still in My Club. 18/18 server checks passed identically before and after. See the Gap 2 closure section. |
|
||
| An ACTIVE seller row carrying `forSale` renders correctly | **LIVE-CONFIRMED** (Gap 1, 2026-08-17) — see the closure section below; the client decoded `CARD_OFFERSTATE = 5` out of its own auction record |
|
||
| `CARD_OFFERSTATE` is the Flash action-gate term | **STILL UNPROVEN** — this change does not test it, and the result does not bear on it |
|
||
|
||
What this rules out: `CARD_OFFERSTATE` is not a gate term that *requires* −1. An expired
|
||
row decoding `CARD_OFFERSTATE = 5` remained actionable, so the earlier worry — that every
|
||
actionable row we had ever seen carried `itemState = −1` — was a coincidence of our own
|
||
bad token, not a client rule. It narrows nothing else: `STATE` and the
|
||
`RESERVEDPRICE`/`MAX_CREDITS` pair are untouched and still confounded with each other.
|
||
|
||
Wire A/B (`docs/evidence/market-lifecycle-2026-08-17/itemstate-forsale-ab.json`): on the
|
||
same expired row, **exactly one field changed** — `itemState` `listFS` → `forSale`.
|
||
`tradeId`, `tradeState`, `expires`, `startingBid`, `buyNowPrice`, `currentBid`,
|
||
`bidState`, `sellerName`, `sellerEstablished`, `watched`, `coinsProcessed`, the atom
|
||
count (12), and the whole `itemData` card are byte-identical; coins unchanged; Fix A's
|
||
zero `inactive` rows intact.
|
||
|
||
Risk was lower than it first appeared, for a reason worth recording: the market SEARCH
|
||
route (`/transfermarket`) **already** emitted `forSale`, so the client has been decoding
|
||
`forSale` (5) on a live path all along — only the seller's own pile carried the invalid
|
||
token.
|
||
|
||
Differential consequence: `market query tradePile` moves from `PARITY` to
|
||
`DIFFERENT-BY-DESIGN`, pinning `oracle == "listFS"` and `rust == "forSale"` so the
|
||
divergence stays visible. That assertion previously demanded parity and passed while
|
||
**both** sides were wrong — the mechanism by which this defect survived every
|
||
differential run. **Where the FIFA 17 binary contradicts the Python oracle, the binary
|
||
wins.**
|
||
|
||
Revert is one line if the live A/B fails: both `auction_record_as(l, "forSale")` calls in
|
||
`market.rs` back to `"listFS"`, rebuild, restart `prod-host`. On failure the rule is to
|
||
revert and go to the AVM1 gate work — NOT to compensate with another field change.
|
||
|
||
**Claim boundary:** this is protocol correctness of one token. It is not evidence about
|
||
the Flash decision tree; `STATE`, `CARD_OFFERSTATE` and `RESERVEDPRICE`/`MAX_CREDITS`
|
||
remain confounded.
|
||
|
||
### Gap 1 CLOSED — an ACTIVE seller row under `forSale` (2026-08-17)
|
||
|
||
The one thing the Fix B promotion could not test, because no active listing existed
|
||
during that session. Closed with a deliberately disposable card: a **75 ST**, resourceId
|
||
`212188`, wire id `100000178` — one of **three identical owned copies**, not in the
|
||
11-man squad, `untradeable: false`, discardValue 600. Listed **through the real FIFA 17
|
||
client** at Start 150 / Buy Now 200 for the shortest offered duration (1h), so expiry
|
||
would arrive naturally. No timestamp was touched.
|
||
|
||
Listing creation was entirely Rust-owned:
|
||
`PUT …/item` (club → pile) → `route=market-list POST item_id=100000178 listed=true
|
||
trade_id=1000000178` → `POST …/auctionhouse 200`, then the client polling
|
||
`GET …/trade/status?tradeIds=1000000178` (`requested=1 returned=1`) for the row it draws.
|
||
|
||
Wire, active row `1000000178`
|
||
(`docs/evidence/market-lifecycle-2026-08-17/gap1-active-forsale.json`):
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| `itemState` | **`forSale`** |
|
||
| `tradeState` | `active` |
|
||
| `startingBid` / `buyNowPrice` / `currentBid` | 150 / 200 / 0 |
|
||
| `bidState` | `none` |
|
||
| `expires` | **3562 → 3556** over a 6-second sample — a live clock, not a frozen scalar |
|
||
| atom count | **12**, exactly what `0x18013e410` reads |
|
||
| `itemData` | full card, 6-entry `attributeList` |
|
||
| `/tradePile/counts` | `count 1, selling 1, offered 0, sold 0` |
|
||
| coins | 29,843,976 unchanged |
|
||
| Fix A | 0 `inactive` rows |
|
||
|
||
`/trade/status` stayed coherent with `/tradePile` throughout, agreeing on tradeId,
|
||
`active`, `forSale` and a decreasing `expires`.
|
||
|
||
**The decisive observation** is not the wire — it is what the client made of it. A
|
||
read-only `/proc/<pid>/mem` decode of the live trade-pile auction record (slide proven by
|
||
FNV control MATCH first; `O_RDONLY` + `pread` only) returned:
|
||
|
||
```text
|
||
tradeId=1000000178 state=1(active) bid=0(none) buyNow=200 start=150 cur=0 expires=3457
|
||
item CARD_ID=212188 cardType=1 tradeable=1 itemState=5(forSale) pile=0 rating=75
|
||
```
|
||
|
||
`itemState = 5(forSale)`. The same field read `-1(<unrecognised>)` under `listFS`. This is
|
||
the direct A/B on the only changed field, taken from the client's own memory rather than
|
||
from our response — the strongest form of this evidence available without AVM1 work.
|
||
`pile = 0` again, confirming pile is still never set from the wire.
|
||
|
||
Operator UI acceptance: card under **LISTED ITEMS** (not the TRANSFER LIST section), Start
|
||
150 / Buy Now 200 correct, **Time Remaining visibly counting down**, normal card art, no
|
||
malformed row, no duplicate, no premature Expired, no crash, and correctly
|
||
**non-actionable while active**. The client's container populating with exactly this row
|
||
(`rows=1`) independently corroborates that the Transfer List screen bound and drew it.
|
||
|
||
**No code change was required** — the promoted implementation was already correct on the
|
||
active path, which is what the test was designed to find out.
|
||
|
||
Two process notes worth keeping. First, an early capture reported "countdown NO"; that was
|
||
a bug in the capture script (it re-read a hardcoded copy id rather than the row it had
|
||
resolved), not a server fault — the same run's `/trade/status` already showed a lower
|
||
`expires` than `/tradePile`. Verify the instrument before believing a negative. Second,
|
||
the client's auction-record containers read **0 rows** while the operator was on any other
|
||
screen; they populate only while the Transfer List is bound. An empty container is not
|
||
evidence of an empty pile.
|
||
|
||
**Claim boundary unchanged:** this proves the client *decodes* the token. It says nothing
|
||
about the Flash action-gate term.
|
||
|
||
|
||
### Proving the gate (not yet done)
|
||
|
||
Disassemble the AVM1 action buffer of `tradepile.isInActiveAuction` (and
|
||
`PreCheckCardOptions`): either walk from the interned symbol at `0xb84d7498` to its APT
|
||
function object in the live process, or extract
|
||
`external/ion_fut/screens/trading/tradepile` from `ui.sb` + the `cas` archives offline.
|
||
Nothing else in reach separates `STATE` from `CARD_OFFERSTATE` from the price pair,
|
||
because all three differ together in every observable row.
|
||
|
||
---
|
||
|
||
## Gap 2 CLOSED — Return to Club survives a full exit/re-entry (2026-08-17)
|
||
|
||
The claim the Fix B promotion could only make server-side. Closed on the same
|
||
disposable card as Gap 1 (75 ST, res `212188`, wire id `100000178`, tradeId
|
||
`1000000178`) after it expired **naturally** — the 1h auction was allowed to run
|
||
out. No timestamp was mutated at any point in either gap.
|
||
|
||
### The whole lifecycle, observed rather than forced
|
||
|
||
A read-only sampler polled `/tradePile` once a minute for the auction's full hour
|
||
(55 samples). `expires` is derived from `created_at + duration`, so watching is the
|
||
only honest way to see expiry happen:
|
||
|
||
```text
|
||
t=1787012091 active expires 3175 itemState forSale
|
||
... counting down, one sample a minute ...
|
||
t=1787015271 expired expires 0 itemState forSale <- natural expiry
|
||
t=1787015301 (absent) total 0 <- Return to Club
|
||
```
|
||
|
||
`itemState` stayed `forSale` across the `active -> expired` transition, which is the
|
||
Fix B behaviour under the one state change we had never watched live.
|
||
|
||
### Return to Club, then two session boundaries
|
||
|
||
The host log records the coupled transition and the re-entries:
|
||
|
||
```text
|
||
route=move-items wire=100000178 pile=club auction_cancelled=1
|
||
route=auth-delete
|
||
route=auth ... ip=10.10.0.105 persona=33068179 sid_opened=true
|
||
route=hub clubPlayers=1966 auctionCount=0
|
||
route=club total=1986 emitted=1966 dropped_no_asset=0
|
||
... the full auth -> userMassInfo -> hub -> club -> tradePile -> trade/status
|
||
cycle then repeats for a SECOND fresh session ...
|
||
```
|
||
|
||
`auction_cancelled=1` is `cancel_active_for_core_item` firing: the pile move to
|
||
`club` ends the auction that put the card there, which is the coupled transition
|
||
that keeps pile membership and auction lifecycle from disagreeing.
|
||
|
||
Two independent fresh sessions each rebuilt the state from durable storage, and the
|
||
operator confirmed the card was still in My Club. One session boundary was the
|
||
requirement; two is stronger.
|
||
|
||
### Server state — 18/18, byte-identical before and after re-entry
|
||
|
||
| Surface | Result |
|
||
|---|---|
|
||
| `/tradePile` | `total 0`, 0 rows, no reference to the item or the tradeId |
|
||
| `/tradePile/counts` | `count 0, selling 0, sold 0, offered 0` |
|
||
| `/trade/status` | tradeId `1000000178` → **`closed`**, `expires 0` — still resolves, correctly terminal |
|
||
| `/club` | **1966** items; `id=100000178 res=212188 rating=75 ST itemState=free untradeable=False` |
|
||
| Duplicate owned ids | **0** across all 1966 |
|
||
| Market store | listing `1000000178` = `cancelled`; **0 active, 0 reserved** anywhere |
|
||
| Coins | **29,843,976** unchanged for the entire test |
|
||
| Fix A | 0 `inactive` rows |
|
||
|
||
The `1965 → 1966` club count is the pile-aware exclusion working in both directions:
|
||
the card left `/club` while actively listed and returned on cancel. A tradeId that
|
||
can still be asked about still answers — terminally — rather than vanishing.
|
||
|
||
**No code change was required for either gap.** Both tests were designed to find out
|
||
whether the promoted implementation was already correct on paths it had not been
|
||
exercised on. It was.
|
||
|
||
---
|
||
|
||
## FROZEN BASELINE — the known-good transfer-list lifecycle (2026-08-17)
|
||
|
||
With both gaps closed, this lifecycle is the reference. A future change that alters
|
||
any line here is a regression until proven otherwise.
|
||
|
||
```text
|
||
CLUB
|
||
itemState free, present in /club, absent from /tradePile
|
||
| PUT /item {pile:"trade"} + POST /auctionhouse (ISStart)
|
||
v
|
||
ACTIVE AUCTION
|
||
/tradePile: tradeState active, itemState forSale, 12 atoms,
|
||
expires counting down from duration, currentBid 0, bidState none
|
||
/tradePile/counts: count 1, selling 1
|
||
/club: EXCLUDED while listed
|
||
/trade/status: agrees with the pile on every field
|
||
client: renders under LISTED ITEMS, CARD_OFFERSTATE = 5, non-actionable
|
||
| wall clock reaches created_at + duration
|
||
v
|
||
EXPIRED
|
||
/tradePile: tradeState expired, expires 0, itemState still forSale
|
||
client: row becomes ACTIONABLE, offers Return to Club
|
||
| PUT /item {pile:"club"} -> cancel_active_for_core_item
|
||
v
|
||
CLUB (terminal)
|
||
/tradePile: total 0 /tradePile/counts: all zero
|
||
/club: present again, itemState free market store: cancelled
|
||
/trade/status: old tradeId -> closed, expires 0
|
||
coins: unchanged throughout
|
||
survives: FUT exit + re-entry, repeatedly
|
||
```
|
||
|
||
Invariants this baseline pins:
|
||
|
||
* an item is never in limbo — it is in `/club` or in an active auction, never neither;
|
||
* `expires` is SECONDS REMAINING and must evolve with wall-clock;
|
||
* pile membership and auction lifecycle are separate facts that must be changed
|
||
together;
|
||
* a tradeId resolves on every route that can be asked about it, even when terminal;
|
||
* only real active auctions appear in `/tradePile` (Fix A);
|
||
* a listed item carries `itemState forSale` (Fix B), and the client decodes 5;
|
||
* an active own auction is intentionally non-actionable;
|
||
* coins move only when coins should move — this whole lifecycle moves none.
|
||
|
||
What is still NOT established: the SOLD path (no counterparty exists yet; settlement
|
||
is implemented and unit-tested in Core but never client-validated — see
|
||
`MARKET_SOLD_SETTLEMENT.md`), `/tradePile/counts` exact FIFA semantics, and the AVM1
|
||
action gate.
|
||
|