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:
funman300
2026-08-17 23:26:06 +00:00
parent f9ca901a50
commit a57f4930f0
4 changed files with 158 additions and 19 deletions
+19 -10
View File
@@ -79,9 +79,14 @@ fn trade_id_from_path(path: &str) -> Option<String> {
/// snapshot persisted at listing time; a row written before snapshots existed
/// degrades to the stub (honest, not fabricated).
///
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
/// distinguishes these, so the caller passes the one its screen needs.
/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the
/// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its
/// 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 {
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
// 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
/// `active` set). Returns the oracle's tradePile shape.
///
/// This is the SELLER's own pile, so each card carries `itemState: "listFS"`
/// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct
/// from the `forSale` used for market search results.
/// Each card carries `itemState: "forSale"` (5) — FIFA 17's own token for an item
/// offered for sale. The oracle's `listFS` is not a FIFA 17 value at all and
/// decoded to `-1`; see [`auction_record_as`].
///
/// 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`
@@ -406,7 +411,7 @@ pub async fn handle_market_query(
};
let auctions: Vec<Value> = listings
.iter()
.map(|l| auction_record_as(l, "listFS"))
.map(|l| auction_record_as(l, "forSale"))
.collect();
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
@@ -495,7 +500,7 @@ pub async fn handle_market_status(
};
let auctions: Vec<Value> = listings
.iter()
.map(|l| auction_record_as(l, "listFS"))
.map(|l| auction_record_as(l, "forSale"))
.collect();
eprintln!(
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
@@ -989,10 +994,14 @@ mod tests {
assert_eq!(snap["rating"], 84);
assert_eq!(snap["preferredPosition"], "ST");
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 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"]["id"], 100004617i64);
assert_eq!(rec["itemData"]["resourceId"], 169193);
@@ -51,7 +51,12 @@
//! | market list POST /ah | PARITY | 200; `{"id":<tradeId>}` (a positive listing id). Id SPACES |
//! | | | differ (Rust MarketStore vs oracle 900500000+seq) — a |
//! | | | 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 |
//! | | | Rust's MarketStore is STATEFUL single-debit (a second buy of |
//! | | | 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::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::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
use openfut_identity::JsonIdentityStore;
use openfut_utas_host::async_bridge::AsyncBridge;
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"
);
// 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!(
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],
"own-pile itemState parity (listFS)"
o_rec["itemData"]["itemState"], "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!(
r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
"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 ─────────
// Shared invariant: the first buy debits exactly buyNowPrice and closes the