fix(market): pin auctionInfo to FIFA 17's twelve atoms, add the real auction clock

Corrects the record against the CLIENT BINARY rather than library hearsay, using
the project's own reverse-engineering record
(fifa17-recon/docs/plan-2026-08-06-transfer-market.md, read out of the on-disk PE).

REVERTED (refuted): `tradeOwner`, `sellerId`, `offers`. FIFA 17's auctionInfo
deserializer (0x18013e410) reads exactly TWELVE atoms -- bidState, buyNowPrice,
currentBid, expires, itemData, sellerEstablished, sellerName, startingBid,
coinsProcessed, tradeId, tradeState, watched -- and value-SKIPs everything else at
0x180135ff0. Those three fields were added last commit on the strength of
contemporaneous FIFA 17 libraries; the PE says the client never reads them, so they
were inert and could not have been the Actions-panel gate. A preservation emulator
must not emit fields the client does not consume. New test pins the exact set.

ADDED: the auction clock. `expires` is SECONDS REMAINING (never an epoch) and the
client renders a LIVE COUNTDOWN it expects to reach 0. We hardcoded 3600, so no
auction ever aged or ran out. Now `duration` is taken from the ISStart body
(additive `duration_secs` column, defaulting to 3600) and `expires` is derived from
created_at + duration - now, clamped at 0. An active listing whose clock has run
out projects as `expired`/`none`/`expires: 0` -- FIFA 17's relistable state, per the
lifecycle table (active=1 inactive=2 expired=3 closed=4; none=0 outbid=1 highest=2
buyNow=3, both closed vocabularies). Pure projection: no row is mutated, so no
sweeper and no race with the economy.

ADDED: `duplicateItemIdList: []` on GetTradePile, which shares one deserializer
(0x18013e7f0) with ISSearch/ISWatchList over four members and we were omitting one.

CONFIRMED by the same source, so kept: `GET ut/{ns}/trade/status?tradeIds=a,b,c` is
real (ISVIEWTRADE) and my handler matches it exactly, including the comma list.
`ISREMOVETRADE` is `DELETE ut/delete/{ns}/trade/{tradeId}` -- our ORIGINAL spelling
was right. The plain-DELETE arm stays because the same source advises dispatching
on path and being method-agnostic (HTTP verbs are not statically recoverable).

Differential returns to strict key-set parity, with a comment recording WHY parity
is not sufficient: a field absent from both sides is invisible to it.

333 tests pass, 0 failed, clippy clean. Verified live: the twelve-atom record, the
four-member envelope, and the listing correctly reading expires=0 / expired after
aging past its hour.
This commit is contained in:
funman300
2026-08-17 19:06:33 +00:00
parent bf9ae20367
commit 772f8a615a
4 changed files with 229 additions and 129 deletions
+15 -22
View File
@@ -910,34 +910,21 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
k.sort();
k
};
// Our record is a deliberate SUPERSET of the oracle's. The oracle omits the
// FIFA 17 ownership fields entirely, which is exactly why parity could not
// catch the Actions-panel bug: the field was missing on BOTH sides, because the
// oracle's own remove flow was never driven by a real client either. So assert
// (a) we cover every key the oracle emits, and (b) the extra keys are precisely
// the ownership set we added on purpose — a NEW unexplained divergence still
// fails here.
let (ok, rk) = (keys(o_rec), keys(r_rec));
for k in &ok {
assert!(rk.contains(k), "rust tradePile record is missing oracle key `{k}`");
}
let extra: Vec<&String> = rk.iter().filter(|k| !ok.contains(k)).collect();
// Both sides emit exactly FIFA 17's twelve auctionInfo atoms, so this is a
// strict key-set equality. NOTE the limit of that: parity here proves we match
// the oracle, NOT that either side is complete -- a field absent from BOTH is
// invisible to this check. That is exactly how the Transfer List Actions-panel
// bug hid, and the client binary's atom table is the authority that settled it
// (see docs/FIFA17_TRANSFER_MARKET_WIRE.md).
assert_eq!(
extra,
vec!["offers", "sellerId", "tradeOwner"],
"the ONLY keys we add beyond the oracle are the FIFA 17 ownership fields"
);
// The ownership story must be internally consistent on our side.
assert_eq!(r_rec["tradeOwner"], true, "own pile listing is owned by us");
assert_eq!(
r_rec["sellerId"], PERSONA_ID,
"sellerId agrees with tradeOwner"
keys(o_rec),
keys(r_rec),
"tradePile auction-record key set parity"
);
for f in [
"sellerName",
"bidState",
"currentBid",
"expires",
"sellerEstablished",
"watched",
"coinsProcessed",
@@ -948,6 +935,12 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
r_rec["sellerName"], PERSONA_DISPLAY_NAME,
"the player's own listing is sold BY the player, never by EA"
);
// `expires` is seconds remaining on a live clock, so it need not equal the
// oracle's constant; it must be a positive 64-bit count for an active auction.
assert!(
r_rec["expires"].as_i64().is_some_and(|e| e > 0),
"an active auction has positive seconds remaining"
);
// itemData must be the full shaped card on both sides; a stub cannot render.
assert_eq!(
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],