0a007f4941
The one claim the Fix B promotion left open: no active listing existed during
that session, so only the expired path had been exercised.
Closed with a disposable card (75 ST, res 212188, wire id 100000178 — one of
three identical copies, not in the squad) listed through the real FIFA 17 client
at 150/200 for 1h, so expiry arrives naturally. No timestamp touched.
Wire: itemState=forSale, tradeState=active, 12 atoms, prices intact, expires
3562 -> 3556 over a 6s sample (live clock), /trade/status coherent, counts
{count:1, selling:1}, coins unchanged, zero inactive rows (Fix A intact).
The decisive evidence is client-side, not ours: a read-only /proc/<pid>/mem
decode of the live trade-pile auction record returned
itemState=5(forSale)
on the very field that read -1(<unrecognised>) under listFS. Direct A/B on the
only changed field, taken from the client's own memory.
Operator confirmed the row renders under LISTED ITEMS with correct prices, a
counting-down timer, normal art, and correctly non-actionable while active.
No code change required — the promoted implementation was already correct on the
active path. Claim boundary unchanged: this proves the client DECODES the token
and says nothing about the Flash action-gate term.
768 lines
40 KiB
Markdown
768 lines
40 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) | **CONFIRMED server-side**: `/tradePile` `total:0`, `/tradePile/counts` all zero, card present in `/club` (1965 → 1966 items, `itemState:"free"`), zero duplicate ids, listing `cancelled` in the store and projected as `closed` (4) on `/trade/status` |
|
||
| 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.
|