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
+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
})