feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"

PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:

    'active' = 1   'inactive' = 2   'expired' = 3   'closed' = 4

The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.

PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.

Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.

Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.

340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
This commit is contained in:
funman300
2026-08-17 20:25:46 +00:00
parent 2e97ff1461
commit a2bd048ace
5 changed files with 602 additions and 6 deletions
@@ -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 --
@@ -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
}
+78
View File
@@ -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("<I", pe, 0x3C)[0]
coff = e_lfanew + 4
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
opt = coff + 20
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
sec_off = opt + opt_size
sections = []
for i in range(num_sections):
b = sec_off + i * 40
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
sections.append((vaddr, vsize, rawptr, rawsize))
def va2off(va):
rva = va - image_base
for vaddr, vsize, rawptr, rawsize in sections:
if vaddr <= rva < vaddr + max(vsize, rawsize):
off = rva - vaddr + rawptr
if 0 <= off < len(pe):
return off
return None
def cstr(va, limit=64):
off = va2off(va)
if off is None:
return None
end = pe.find(b"\0", off, off + limit)
if end < 0:
return None
try:
s = pe[off:end].decode("ascii")
except UnicodeDecodeError:
return None
return s if s.isprintable() else None
for table_va, name in TABLES:
base = va2off(table_va)
print("\n=== %s VA %#x -> 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("<Qi", pe, base + i * 16)
if ptr == 0:
print(" -- NULL terminator after %d rows --" % i)
break
s = cstr(ptr)
if s is None:
print(" row %d: ptr %#x does not resolve to a string; stopping" % (i, ptr))
break
print(" %-28s = %d" % (repr(s), code))
+28 -2
View File
@@ -2347,13 +2347,39 @@ impl Server {
})
}
EconomyRoute::MarketQuery => {
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::<i64>().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
})
+199 -4
View File
@@ -260,6 +260,86 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
})
}
/// 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<String>,
}
/// 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<E: ReverseEntityResolver>(
item_id: i64,
reverse: &dyn SquadWireResolver,
resolver: &dyn ItemIdentityResolver,
items: &dyn OwnedItemLookup,
ent: &E,
) -> Option<UnlistedCandidate> {
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::<Value>(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<Value> = listings
let mut auctions: Vec<Value> = 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();