diff --git a/docs/evidence/market-lifecycle-2026-08-17/cardsdll-vocab-tables.txt b/docs/evidence/market-lifecycle-2026-08-17/cardsdll-vocab-tables.txt new file mode 100644 index 0000000..cc98631 --- /dev/null +++ b/docs/evidence/market-lifecycle-2026-08-17/cardsdll-vocab-tables.txt @@ -0,0 +1,107 @@ + +=== tradeState (table walk) VA 0x180229e40 -> off 0x229240 === + 'active' = 1 + 'inactive' = 2 + 'expired' = 3 + 'closed' = 4 + -- NULL terminator after 4 rows -- + +=== type VA 0x180229c30 -> off 0x229030 === + 'any' = -1 + 'player' = 1 + 'staff' = 2 + 'clubInfo' = 3 + 'training' = 4 + 'development' = 5 + 'stadium' = 6 + 'ball' = 7 + -- NULL terminator after 8 rows -- + +=== zone VA 0x1802296e0 -> off 0x228ae0 === + 'goalKeeper' = 0 + 'defense' = 1 + 'midfield' = 2 + 'attacker' = 3 + -- NULL terminator after 4 rows -- + +=== lev VA 0x180229a60 -> off 0x228e60 === + 'any' = 0 + 'gold' = 3 + 'silver' = 2 + 'bronze' = 1 + -- NULL terminator after 4 rows -- + +=== pos VA 0x1802295c0 -> off 0x2289c0 === + 'GK' = 0 + 'RWB' = 2 + 'RB' = 3 + 'CB' = 5 + 'LB' = 7 + 'LWB' = 8 + 'CDM' = 10 + 'RM' = 12 + 'CM' = 14 + 'LM' = 16 + 'CAM' = 18 + 'RF' = 20 + 'CF' = 21 + 'LF' = 22 + 'RW' = 23 + 'ST' = 25 + 'LW' = 27 + -- NULL terminator after 17 rows -- + +=== cat VA 0x180229ab0 -> off 0x228eb0 === + 'any' = -1 + 'playerGK' = 1 + 'playerRD' = 2 + 'playerRM' = 3 + 'playerRA' = 4 + 'manager' = 5 + 'headCoach' = 6 + 'GKCoach' = 7 + 'fitnessCoach' = 8 + 'physio' = 9 + 'badge' = 10 + 'kit' = 11 + 'leagueLogo' = 12 + 'playerTraining' = 13 + 'GKTraining' = 14 + 'position' = 15 + 'playStyle' = 16 + 'managerLeagueModifier' = 17 + 'contract' = 18 + 'fitness' = 19 + 'healing' = 20 + 'stadium' = 21 + 'ball' = 22 + -- NULL terminator after 23 rows -- + +=== form VA 0x180229880 -> off 0x228c80 === + 'f3412' = 23 + 'f3421' = 24 + 'f343' = 25 + 'f352' = 27 + 'f41212' = 14 + 'f4231' = 3 + 'f4222' = 13 + 'f4312' = 6 + 'f4321' = 7 + 'f433' = 8 + 'f4411' = 19 + 'f442' = 16 + 'f451' = 21 + 'f5212' = 29 + 'f5221' = 30 + 'f532' = 31 + 'f541' = 32 + 'f41212a' = 15 + 'f4141' = 2 + 'f4231a' = 4 + 'f433a' = 9 + 'f433b' = 10 + 'f433c' = 11 + 'f433d' = 12 + 'f442a' = 17 + 'f451a' = 20 + -- NULL terminator after 26 rows -- diff --git a/docs/evidence/market-lifecycle-2026-08-17/three-state-tradepile-active-expired-inactive.json b/docs/evidence/market-lifecycle-2026-08-17/three-state-tradepile-active-expired-inactive.json new file mode 100644 index 0000000..00188c0 --- /dev/null +++ b/docs/evidence/market-lifecycle-2026-08-17/three-state-tradepile-active-expired-inactive.json @@ -0,0 +1,190 @@ +{ + "auctionInfo": [ + { + "bidState": "none", + "buyNowPrice": 15000, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 20801, + "attributeList": [ + { + "index": 0, + "value": 92 + }, + { + "index": 1, + "value": 92 + }, + { + "index": 2, + "value": 81 + }, + { + "index": 3, + "value": 91 + }, + { + "index": 4, + "value": 33 + }, + { + "index": 5, + "value": 80 + } + ], + "cardassetid": 20801, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 20801, + "discardValue": 1500, + "fitness": 99, + "id": 100000097, + "itemState": "listFS", + "itemType": "player", + "leagueId": 53, + "nation": 38, + "owners": 1, + "playStyle": 250, + "preferredPosition": "LW", + "rareflag": 1, + "rating": 94, + "resourceId": 20801, + "teamid": 243, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 150, + "tradeId": 1000000097, + "tradeState": "expired", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 15000, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 2996, + "itemData": { + "assetId": 158023, + "attributeList": [ + { + "index": 0, + "value": 89 + }, + { + "index": 1, + "value": 90 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 96 + }, + { + "index": 4, + "value": 26 + }, + { + "index": 5, + "value": 61 + } + ], + "cardassetid": 158023, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 158023, + "discardValue": 1500, + "fitness": 99, + "id": 100000155, + "itemState": "listFS", + "itemType": "player", + "leagueId": 53, + "nation": 52, + "owners": 1, + "playStyle": 250, + "preferredPosition": "RW", + "rareflag": 1, + "rating": 93, + "resourceId": 158023, + "teamid": 241, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 150, + "tradeId": 1000000155, + "tradeState": "active", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 158023, + "attributeList": [ + { + "index": 0, + "value": 89 + }, + { + "index": 1, + "value": 90 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 96 + }, + { + "index": 4, + "value": 26 + }, + { + "index": 5, + "value": 61 + } + ], + "cardassetid": 158023, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 158023, + "discardValue": 1500, + "fitness": 99, + "id": 100000059, + "itemState": "free", + "itemType": "player", + "leagueId": 53, + "nation": 52, + "owners": 1, + "playStyle": 250, + "preferredPosition": "RW", + "rareflag": 1, + "rating": 93, + "resourceId": 158023, + "teamid": 241, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000000059, + "tradeState": "inactive", + "watched": false + } + ], + "credits": 29843976, + "duplicateItemIdList": [], + "total": 3 +} diff --git a/fifa17-recon/tools/vocab_dump.py b/fifa17-recon/tools/vocab_dump.py new file mode 100644 index 0000000..f3ae77d --- /dev/null +++ b/fifa17-recon/tools/vocab_dump.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Dump CardsDLL's NULL-terminated {const char*, int} vocabulary tables from the +ON-DISK PE. READ-ONLY, static. + +The transfer-market analysis records tradeState as decoding through a table walk at +0x180229e40 and lists sibling vocabularies (type/zone/lev/pos) as tables of the same +shape. This prints the exact token spellings and their integer codes, so the accepted +strings come from the client rather than from inference. +""" +import struct + +DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll" +TABLES = [ + (0x180229E40, "tradeState (table walk)"), + (0x180229C30, "type"), + (0x1802296E0, "zone"), + (0x180229A60, "lev"), + (0x1802295C0, "pos"), + (0x180229AB0, "cat"), + (0x180229880, "form"), +] +MAX_ROWS = 64 + +pe = open(DLL, "rb").read() +e_lfanew = struct.unpack_from(" off %s ===" % (name, table_va, hex(base) if base else None)) + if base is None: + print(" (VA did not resolve)") + continue + for i in range(MAX_ROWS): + ptr, code = struct.unpack_from(" { - let (bridge, market, econ) = - (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); + // BOUNDED Q2 EXPERIMENT (off unless the env names one wire item id): + // expose a SINGLE unlisted trade-pile item as a non-active auction + // record. Resolved synchronously here because the identity/Core + // resolvers are not `Send`, exactly like the market-list path. + let unlisted = std::env::var("OPENFUT_FIFA17_UNLISTED_PROBE") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|id| *id > 0) + .and_then(|id| { + let lookup = CoreItemLookup { + core: self.core.as_ref(), + }; + crate::market::resolve_unlisted_candidate( + id, + self.resolver.as_ref(), + self.resolver.as_ref(), + &lookup, + self.entities.as_ref(), + ) + }); + let (bridge, market, econ, piles) = ( + svc.bridge.clone(), + svc.market.clone(), + svc.econ.clone(), + svc.piles.clone(), + ); bridge.block_on(async move { crate::market::handle_market_query( "active", econ.as_ref(), market.as_ref(), + piles.as_ref(), + unlisted.as_ref(), ) .await }) diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 31ea198..64afcf9 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -260,6 +260,86 @@ pub fn resolve_market_list( }) } +/// A single unlisted trade-pile item to expose on `/tradePile` as a NON-ACTIVE +/// auction record — the bounded Q2 experiment (see +/// docs/FIFA17_TRANSFER_MARKET_WIRE.md). +/// +/// Why this shape is forced rather than chosen: the CardsDLL route table has exactly +/// ONE trade-pile route, it carries only twelve-atom auction records, `pile` has no +/// deserializer arm (membership comes from the owning list), and of those atoms only +/// `tradeState` can express lifecycle. Its vocabulary is a four-row `{const char*, +/// int}` table dumped from the PE — `active=1 inactive=2 expired=3 closed=4` — so +/// `"inactive"` is the client's own token for "in the pile, not on the market". +pub struct UnlistedCandidate { + /// FIFA wire item id. + pub item_id: i64, + /// Core owned-instance id, used to confirm the pile and the absence of an auction. + pub core_id: String, + /// The full shaped card, so the row renders like any other. + pub item_json: Option, +} + +/// Resolve ONE wire item id to an [`UnlistedCandidate`]. Pure identity plus a single +/// Core inventory read, so it runs OFF the async runtime exactly like +/// [`resolve_market_list`] (the trait-object resolvers are not `Send`). +pub fn resolve_unlisted_candidate( + item_id: i64, + reverse: &dyn SquadWireResolver, + resolver: &dyn ItemIdentityResolver, + items: &dyn OwnedItemLookup, + ent: &E, +) -> Option { + let core_id = reverse.owned_id_for_wire(item_id)?; + let owned = items.owned_item(&core_id)?; + let item_json = resolver + .resolve(&owned) + .map(|id| shape_item(&owned, id, ent)) + .and_then(|card| serde_json::to_string(&card).ok()); + Some(UnlistedCandidate { + item_id, + core_id, + item_json, + }) +} + +/// The auction record for an item sitting in the trade pile with NO auction. +/// +/// `tradeState: "inactive"` is the whole point; everything else is deliberately the +/// zero/neutral value so the row cannot render a countdown or a price. `tradeId` +/// reuses the item's stable auction id, because the client keys its record store on +/// `tradeId` and re-parents `itemData` onto an existing record — so listing this +/// item later UPDATES this row rather than creating a duplicate ghost. +fn unlisted_record(c: &UnlistedCandidate) -> Value { + let item_data = c + .item_json + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .filter(Value::is_object) + .map(|mut card| { + card["id"] = json!(c.item_id); + // `free` is what /club emits for an unheld card; there is no evidence of a + // distinct itemState for a pile-resident item, so do not invent one. + card["itemState"] = json!("free"); + card["untradeable"] = json!(false); + card + }) + .unwrap_or_else(|| json!({ "id": c.item_id, "itemState": "free", "untradeable": false })); + json!({ + "tradeId": TRADE_ID_BASE + c.item_id, + "itemData": item_data, + "tradeState": "inactive", + "buyNowPrice": 0, + "startingBid": 0, + "currentBid": 0, + "bidState": "none", + "expires": 0, + "sellerName": non_economy::PERSONA_DISPLAY_NAME, + "sellerEstablished": 1, + "watched": false, + "coinsProcessed": 0, + }) +} + /// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT). /// /// * GET returns the durable active auctions plus the FutGetAuctionCount ints, @@ -383,15 +463,41 @@ pub async fn handle_market_query( state: &str, econ: &dyn CoreEconomy, store: &MarketStore, + piles: &PileStore, + unlisted: Option<&UnlistedCandidate>, ) -> WireResponse { let listings = match store.query_listings(state).await { Ok(l) => l, Err(_) => return json_body(503, &json!({ "error": "market_store" })), }; - let auctions: Vec = listings + let mut auctions: Vec = listings .iter() .map(|l| auction_record_as(l, "listFS")) .collect(); + + // BOUNDED Q2 EXPERIMENT: expose ONE unlisted trade-pile item as a non-active + // record. Every precondition is re-checked here so the row can never contradict + // the auction state: the item must actually be in the `trade` pile, and it must + // NOT already own a listing (otherwise it would duplicate a real auction). + if let Some(c) = unlisted { + let in_trade_pile = matches!(piles.get(&c.core_id).await, Ok(Some(p)) if p == "trade"); + let already_listed = listings + .iter() + .any(|l| l.core_item_id.as_deref() == Some(c.core_id.as_str())); + if in_trade_pile && !already_listed { + auctions.push(unlisted_record(c)); + eprintln!( + "utas-host owner=RUST route=market-query unlisted_candidate item_id={} state=inactive", + c.item_id + ); + } else { + eprintln!( + "utas-host owner=RUST route=market-query unlisted_candidate item_id={} skipped in_pile={in_trade_pile} listed={already_listed}", + c.item_id + ); + } + } + // GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and // ISWatchList, over exactly four members: `auctionInfo` (array), `credits` // (int), `duplicateItemIdList` (array of objects) and `total` (int). We were @@ -868,6 +974,95 @@ mod tests { (store, db) } + /// An empty pile store on its own temp file, for tradePile tests that do not + /// exercise the unlisted candidate. + async fn empty_piles() -> (PileStore, TempDb) { + let db = TempDb::new("piles"); + let piles = PileStore::open(db.path()).await.unwrap(); + (piles, db) + } + + #[tokio::test] + async fn unlisted_candidate_is_a_non_active_pile_row() { + // Q2: an item in the trade pile with NO auction. The route table has exactly + // one trade-pile route and it carries only auction records, `pile` has no + // deserializer arm, and of the twelve atoms only `tradeState` expresses + // lifecycle — whose PE string table is `active/inactive/expired/closed`. So + // the row must be `inactive`, with no timer and no prices. + let (store, _d) = store_at("unlisted").await; + let (piles, _pd) = empty_piles().await; + let econ = CountingEconomy::with_balance(10_000); + let c = UnlistedCandidate { + item_id: 100_000_059, + core_id: "core-unlisted".into(), + item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()), + }; + + // Not in the trade pile yet -> deliberately NOT exposed. + let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await); + assert_eq!( + body["auctionInfo"].as_array().unwrap().len(), + 0, + "an item that is not in the trade pile must never be advertised as one" + ); + + piles.set("core-unlisted", "trade").await.unwrap(); + let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await); + let recs = body["auctionInfo"].as_array().unwrap(); + assert_eq!(recs.len(), 1, "now in the pile, so it is exposed"); + let r = &recs[0]; + assert_eq!(r["tradeState"], "inactive", "the PE's token for not-on-market"); + assert_eq!(r["expires"], 0, "no countdown on an item that is not listed"); + assert_eq!(r["buyNowPrice"], 0); + assert_eq!(r["startingBid"], 0); + assert_eq!(r["currentBid"], 0); + assert_eq!(r["bidState"], "none"); + assert_eq!(r["itemData"]["id"], 100_000_059i64); + assert_eq!(r["itemData"]["itemState"], "free"); + assert_eq!(r["itemData"]["rating"], 93, "the full card still renders"); + // Stable tradeId: the client keys its record store on it, so listing this + // item later must UPDATE this row rather than add a duplicate ghost. + assert_eq!(r["tradeId"], TRADE_ID_BASE + 100_000_059); + // Still exactly the twelve atoms. + let mut keys: Vec<&str> = r.as_object().unwrap().keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys.len(), 12, "no extra atoms on the inactive row"); + } + + #[tokio::test] + async fn unlisted_candidate_never_duplicates_a_real_auction() { + // If the item already owns a listing, advertising it as `inactive` too would + // put two records with the same tradeId in one body. + let (store, _d) = store_at("unlistedduped").await; + let (piles, _pd) = empty_piles().await; + let econ = CountingEconomy::with_balance(10_000); + store + .create_listing( + &(TRADE_ID_BASE + 100_000_059).to_string(), + "169193", + Some("core-unlisted"), + Some(100_000_059), + Some(169193), + 150, + 2500, + None, + None, + None, + ) + .await + .unwrap(); + piles.set("core-unlisted", "trade").await.unwrap(); + let c = UnlistedCandidate { + item_id: 100_000_059, + core_id: "core-unlisted".into(), + item_json: None, + }; + let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await); + let recs = body["auctionInfo"].as_array().unwrap(); + assert_eq!(recs.len(), 1, "the real auction only"); + assert_eq!(recs[0]["tradeState"], "active"); + } + async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) { store .create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None) @@ -919,7 +1114,7 @@ mod tests { 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. - let pile = handle_market_query("active", &econ, &store).await; + let pile = handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await; let rec = parse(&pile)["auctionInfo"][0].clone(); assert_eq!(rec["itemData"]["itemState"], "listFS"); assert_eq!(rec["itemData"]["rating"], 84); @@ -1016,7 +1211,7 @@ mod tests { let (store, _d) = store_at("query").await; seed_listing(&store, "900000005", 2500).await; let econ = CountingEconomy::with_balance(50); - let resp = handle_market_query("active", &econ, &store).await; + let resp = handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await; let b = parse(&resp); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64); @@ -1066,7 +1261,7 @@ mod tests { let econ = CountingEconomy::with_balance(10_000); seed_listing(&store, "900000030", 2500).await; - let body = parse(&handle_market_query("active", &econ, &store).await); + let body = parse(&handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await); let rec = body["auctionInfo"][0].clone(); let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect(); got.sort_unstable();