market: emit FIFA 17's own forSale itemState, not the oracle's listFS
Single-field protocol-correctness fix, deployed as a candidate for a live A/B. `itemData.itemState: "listFS"` on the seller's own auction rows is not a FIFA 17 token at all: zero occurrences in `CardsDLL_Win64_retail.dll` (md5 4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory, and it decodes to -1 through `FUN_180166660` — so the client was handed an unrecognised `CARD_OFFERSTATE`. FIFA 17's value for an item offered for sale is `forSale` (5), from the 12-row table at 0x180229cc0. Changed only where the invalid token was emitted: `handle_market_query` (GET …/tradePile) and `handle_market_status` (GET …/trade/status). The market search path already emitted `forSale` and is untouched — which is also why the risk here was lower than it looked: the client has been decoding `forSale` on a live route all along, and only the seller's own pile carried the bad value. Wire A/B on the same expired row: EXACTLY one field differs. tradeId, tradeState, expires, startingBid, buyNowPrice, currentBid, bidState, sellerName, sellerEstablished, watched, coinsProcessed, the twelve-atom count and the whole itemData card are byte-identical; coins unchanged at 29,843,976; Fix A's zero `inactive` rows intact. The differential asserted PARITY on this field and therefore passed while BOTH sides were wrong — the exact mechanism by which the defect survived every run. `market query tradePile` is now DIFFERENT-BY-DESIGN, pinning oracle == "listFS" and rust == "forSale" so the divergence cannot silently close again. Where the FIFA 17 binary contradicts the Python oracle, the binary wins. Gates: 126 host tests, 214 adapter tests, fmt clean, clippy clean. NOT claimed: that this preserves the list -> expire -> Return-to-Club lifecycle. That needs an operator FIFA 17 session and has NOT been observed yet. Also not claimed: anything about the Flash action gate — `CARD_OFFERSTATE` is one of three still-confounded candidates and this change does not test it. Revert is one line if the live test fails.
This commit is contained in:
@@ -642,10 +642,43 @@ live on our listed row. FIFA 17's value for a listed item is `forSale` (5), from
|
|||||||
so does the Python oracle (`utas_server.py:3921`) — which is exactly why the
|
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.**
|
differential never caught it: **oracle parity is necessary but not sufficient.**
|
||||||
|
|
||||||
Deliberately NOT fixed in the same change: `CARD_OFFERSTATE` (itemState) is one of the
|
### Fix B — `forSale` CANDIDATE DEPLOYED, live A/B outstanding (2026-08-17)
|
||||||
three unresolved gate candidates, and every actionable row observed so far carried
|
|
||||||
`itemState = −1`. Flipping it to `forSale` could perturb the one flow that currently
|
Shipped as a code+deploy change, deliberately alone, and **not yet live-confirmed**.
|
||||||
works (expired → Return to Club), so it ships alone and gets its own live A/B.
|
|
||||||
|
| 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`) |
|
||||||
|
| Emitting `forSale` preserves the list → expire → Return-to-Club lifecycle | **NOT YET OBSERVED** — needs an operator FIFA 17 session |
|
||||||
|
| `CARD_OFFERSTATE` is the Flash action-gate term | **STILL UNPROVEN** — this change does not test it |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Proving the gate (not yet done)
|
### Proving the gate (not yet done)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"change": "itemData.itemState on the seller's own auction rows: listFS -> forSale",
|
||||||
|
"sites": [
|
||||||
|
"openfut-utas-host/src/market.rs handle_market_query (GET /tradePile)",
|
||||||
|
"openfut-utas-host/src/market.rs handle_market_status (GET /trade/status)"
|
||||||
|
],
|
||||||
|
"why": {
|
||||||
|
"listFS": "not a FIFA 17 token: 0 occurrences in CardsDLL_Win64_retail.dll (md5 4de3493131d7d2ff7f8b360c5ac9b655), 0 in 4.26 GiB of live client memory, decodes to -1 via FUN_180166660",
|
||||||
|
"forSale": "FIFA 17 native, value 5, from the 12-row itemState table at 0x180229cc0",
|
||||||
|
"oracle": "the Python oracle emits listFS (utas_server.py:3921); the differential asserted PARITY and so passed while both sides were wrong"
|
||||||
|
},
|
||||||
|
"supporting_evidence_the_token_is_safe": "the market SEARCH route (/transfermarket) already emitted forSale before this change, so the client has been decoding forSale=5 on a live path all along",
|
||||||
|
"wire_ab": {
|
||||||
|
"baseline": {
|
||||||
|
"tradeId": 1000000155,
|
||||||
|
"tradeState": "expired",
|
||||||
|
"bidState": "none",
|
||||||
|
"expires": 0,
|
||||||
|
"startingBid": 150,
|
||||||
|
"buyNowPrice": 15000,
|
||||||
|
"currentBid": 0,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"watched": false,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"atom_count": 12,
|
||||||
|
"itemState": "listFS",
|
||||||
|
"item_id": 100000155,
|
||||||
|
"resourceId": 158023,
|
||||||
|
"untradeable": false,
|
||||||
|
"rating": 93,
|
||||||
|
"preferredPosition": "RW"
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"tradeId": 1000000155,
|
||||||
|
"tradeState": "expired",
|
||||||
|
"bidState": "none",
|
||||||
|
"expires": 0,
|
||||||
|
"startingBid": 150,
|
||||||
|
"buyNowPrice": 15000,
|
||||||
|
"currentBid": 0,
|
||||||
|
"sellerName": "CAGE",
|
||||||
|
"sellerEstablished": 1,
|
||||||
|
"watched": false,
|
||||||
|
"coinsProcessed": 0,
|
||||||
|
"atom_count": 12,
|
||||||
|
"itemState": "forSale",
|
||||||
|
"item_id": 100000155,
|
||||||
|
"resourceId": 158023,
|
||||||
|
"untradeable": false,
|
||||||
|
"rating": 93,
|
||||||
|
"preferredPosition": "RW"
|
||||||
|
},
|
||||||
|
"fields_changed": [
|
||||||
|
"itemState"
|
||||||
|
],
|
||||||
|
"fields_identical": 17,
|
||||||
|
"credits_before": 29843976,
|
||||||
|
"credits_after": 29843976,
|
||||||
|
"fix_a_inactive_rows_before": 0,
|
||||||
|
"fix_a_inactive_rows_after": 0
|
||||||
|
},
|
||||||
|
"offline_gates": {
|
||||||
|
"host_tests": "126 pass",
|
||||||
|
"adapter_tests": "214 pass",
|
||||||
|
"fmt": "clean",
|
||||||
|
"clippy": "clean",
|
||||||
|
"differential": "relabelled market-query tradePile PARITY -> DIFFERENT-BY-DESIGN; now pins oracle=listFS and rust=forSale so the divergence stays visible"
|
||||||
|
},
|
||||||
|
"status": "CANDIDATE DEPLOYED to prod-host, awaiting the live lifecycle A/B",
|
||||||
|
"live_status": "NOT YET OBSERVED \u2014 requires an operator FIFA 17 session",
|
||||||
|
"revert": "single-line: set both auction_record_as(l, \"forSale\") calls in market.rs back to \"listFS\", rebuild, hub restart prod-host",
|
||||||
|
"claim_boundary": "This validates PROTOCOL CORRECTNESS of the token only. It does NOT prove the Flash action-gate term; STATE / CARD_OFFERSTATE / RESERVEDPRICE+MAX_CREDITS remain confounded and the gate stays Category C / STRONGLY SUPPORTED / not proven."
|
||||||
|
}
|
||||||
@@ -79,9 +79,14 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
|||||||
/// snapshot persisted at listing time; a row written before snapshots existed
|
/// snapshot persisted at listing time; a row written before snapshots existed
|
||||||
/// degrades to the stub (honest, not fabricated).
|
/// degrades to the stub (honest, not fabricated).
|
||||||
///
|
///
|
||||||
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
|
/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the
|
||||||
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
/// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its
|
||||||
/// distinguishes these, so the caller passes the one its screen needs.
|
/// value for an item offered for sale. The Python oracle stamps `listFS` on the
|
||||||
|
/// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero
|
||||||
|
/// occurrences in `CardsDLL_Win64_retail.dll`, zero in 4.26 GiB of live process
|
||||||
|
/// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client
|
||||||
|
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
|
||||||
|
/// oracle, the binary wins.
|
||||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||||
@@ -380,9 +385,9 @@ pub async fn handle_market_list(
|
|||||||
/// Query listings in a given `state` (e.g. the user's own sale pile is the
|
/// Query listings in a given `state` (e.g. the user's own sale pile is the
|
||||||
/// `active` set). Returns the oracle's tradePile shape.
|
/// `active` set). Returns the oracle's tradePile shape.
|
||||||
///
|
///
|
||||||
/// This is the SELLER's own pile, so each card carries `itemState: "listFS"`
|
/// Each card carries `itemState: "forSale"` (5) — FIFA 17's own token for an item
|
||||||
/// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct
|
/// offered for sale. The oracle's `listFS` is not a FIFA 17 value at all and
|
||||||
/// from the `forSale` used for market search results.
|
/// decoded to `-1`; see [`auction_record_as`].
|
||||||
///
|
///
|
||||||
/// ONLY real auctions appear here. An item sitting in the trade pile with no
|
/// ONLY real auctions appear here. An item sitting in the trade pile with no
|
||||||
/// auction is deliberately absent: `plan-2026-08-06-transfer-market.md:731-733`
|
/// auction is deliberately absent: `plan-2026-08-06-transfer-market.md:731-733`
|
||||||
@@ -406,7 +411,7 @@ pub async fn handle_market_query(
|
|||||||
};
|
};
|
||||||
let auctions: Vec<Value> = listings
|
let auctions: Vec<Value> = listings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| auction_record_as(l, "listFS"))
|
.map(|l| auction_record_as(l, "forSale"))
|
||||||
.collect();
|
.collect();
|
||||||
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
||||||
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
|
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
|
||||||
@@ -495,7 +500,7 @@ pub async fn handle_market_status(
|
|||||||
};
|
};
|
||||||
let auctions: Vec<Value> = listings
|
let auctions: Vec<Value> = listings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| auction_record_as(l, "listFS"))
|
.map(|l| auction_record_as(l, "forSale"))
|
||||||
.collect();
|
.collect();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||||
@@ -989,10 +994,14 @@ mod tests {
|
|||||||
assert_eq!(snap["rating"], 84);
|
assert_eq!(snap["rating"], 84);
|
||||||
assert_eq!(snap["preferredPosition"], "ST");
|
assert_eq!(snap["preferredPosition"], "ST");
|
||||||
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||||
// tradePile embeds that full card and stamps the seller-pile state.
|
// tradePile embeds that full card and stamps the seller-pile state. This
|
||||||
|
// asserted `listFS` until FIFA17.exe disproved it: that token appears
|
||||||
|
// nowhere in CardsDLL or in 4.26 GiB of live process memory and decoded to
|
||||||
|
// -1, so the client was handed an unrecognised CARD_OFFERSTATE. `forSale`
|
||||||
|
// (5) is the value in FIFA 17's own itemState table.
|
||||||
let pile = handle_market_query("active", &econ, &store).await;
|
let pile = handle_market_query("active", &econ, &store).await;
|
||||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
assert_eq!(rec["itemData"]["itemState"], "forSale");
|
||||||
assert_eq!(rec["itemData"]["rating"], 84);
|
assert_eq!(rec["itemData"]["rating"], 84);
|
||||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||||
|
|||||||
@@ -51,7 +51,12 @@
|
|||||||
//! | market list POST /ah | PARITY | 200; `{"id":<tradeId>}` (a positive listing id). Id SPACES |
|
//! | market list POST /ah | PARITY | 200; `{"id":<tradeId>}` (a positive listing id). Id SPACES |
|
||||||
//! | | | differ (Rust MarketStore vs oracle 900500000+seq) — a |
|
//! | | | differ (Rust MarketStore vs oracle 900500000+seq) — a |
|
||||||
//! | | | wire-insignificant server-private handle. |
|
//! | | | wire-insignificant server-private handle. |
|
||||||
//! | market query tradePile | PARITY | after list -> `auctionInfo` len 1, `tradeState:"active"`. |
|
//! | market query tradePile | DIFFERENT-BY-DESIGN| after list -> `auctionInfo` len 1, `tradeState:"active"`. |
|
||||||
|
//! | | | ONE field diverges deliberately: `itemData.itemState`. The |
|
||||||
|
//! | | | oracle emits `listFS`, which does not exist in FIFA 17 (0 in |
|
||||||
|
//! | | | CardsDLL, 0 in 4.26 GiB of client memory) and decodes to -1; |
|
||||||
|
//! | | | Rust emits `forSale` (5), the client's own token. Parity here |
|
||||||
|
//! | | | passed while BOTH were wrong, which is why it survived. |
|
||||||
//! | market buy POST /trade | DIFFERENT-BY-DESIGN| first buy debits exactly `buyNowPrice` & closes on BOTH, but |
|
//! | market buy POST /trade | DIFFERENT-BY-DESIGN| first buy debits exactly `buyNowPrice` & closes on BOTH, but |
|
||||||
//! | | | Rust's MarketStore is STATEFUL single-debit (a second buy of |
|
//! | | | Rust's MarketStore is STATEFUL single-debit (a second buy of |
|
||||||
//! | | | a sold listing is a no-op: 0 delta, empty `auctionInfo`) |
|
//! | | | a sold listing is a no-op: 0 delta, empty `auctionInfo`) |
|
||||||
@@ -91,8 +96,8 @@
|
|||||||
|
|
||||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
|
||||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||||
|
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
use openfut_utas_host::async_bridge::AsyncBridge;
|
||||||
use openfut_utas_host::market_store::MarketStore;
|
use openfut_utas_host::market_store::MarketStore;
|
||||||
@@ -942,15 +947,32 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
"an active auction has positive seconds remaining"
|
"an active auction has positive seconds remaining"
|
||||||
);
|
);
|
||||||
// itemData must be the full shaped card on both sides; a stub cannot render.
|
// itemData must be the full shaped card on both sides; a stub cannot render.
|
||||||
|
//
|
||||||
|
// DELIBERATE DIVERGENCE — the one field on this route where the oracle is
|
||||||
|
// WRONG. It stamps `listFS`, which is not a FIFA 17 token at all: zero
|
||||||
|
// occurrences in `CardsDLL_Win64_retail.dll` (md5
|
||||||
|
// 4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory,
|
||||||
|
// and it decodes to -1 through the itemState table walk, so the client is
|
||||||
|
// handed an unrecognised `CARD_OFFERSTATE`. FIFA 17's value for an item
|
||||||
|
// offered for sale is `forSale` (5), from the 12-row table at 0x180229cc0.
|
||||||
|
//
|
||||||
|
// This assertion used to demand parity, and passed while BOTH sides were
|
||||||
|
// wrong — the reason the defect survived every differential run. Oracle parity
|
||||||
|
// is necessary but not sufficient; where the binary contradicts the oracle,
|
||||||
|
// the binary wins.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],
|
o_rec["itemData"]["itemState"], "listFS",
|
||||||
"own-pile itemState parity (listFS)"
|
"pins what the oracle actually emits, so this divergence stays visible"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r_rec["itemData"]["itemState"], "forSale",
|
||||||
|
"Rust emits FIFA 17's own token, not the oracle's non-existent one"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
|
r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
|
||||||
"rust tradePile itemData is the full card, not a stub"
|
"rust tradePile itemData is the full card, not a stub"
|
||||||
);
|
);
|
||||||
matrix.push(("market query tradePile", "PARITY"));
|
matrix.push(("market query tradePile", "DIFFERENT-BY-DESIGN"));
|
||||||
|
|
||||||
// ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ─────────
|
// ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ─────────
|
||||||
// Shared invariant: the first buy debits exactly buyNowPrice and closes the
|
// Shared invariant: the first buy debits exactly buyNowPrice and closes the
|
||||||
|
|||||||
Reference in New Issue
Block a user