022634704a04bc57026c11280ec97e201b8db8e4
330 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
022634704a |
fix(scripts): staging squad now matches production's known-good shape exactly
Adds the manager reference, the last remaining difference from the squad the same client demonstrably accepts. Staging's squad shape is now identical to production's: zero missing keys, zero type differences, zero empty-vs-populated mismatches. The manager looked unfixable. Production points at instance 100000427 while the staging club holds 11 players and zero staff, so there was apparently nothing to reference, and inventing an id would have pointed at a non-existent item. Checking production properly dissolved the problem: 100000427 is absent from production's OWN club listing too. /club/staff returns 1975 items spanning ids 100000001..100004826 and 100000427 is not among them, and the type=staff/type=manager filters are ignored (200 players either way). Production's manager reference is dangling and the client accepts that squad anyway, which proves the client does not validate the manager id against the club -- only a populated array matters. So the reference is mirrored verbatim, dangling id included. That replicates the known-good state exactly and is better than pointing the manager slot at a player, which would have been a guess dressed up as a fix. Method note: every step here came from diffing against production rather than reading the client. The host reported squad-active 200 outcome=ok throughout, and 200 with the right players was never evidence the client accepted the body. |
||
|
|
96ca7c0484 |
fix(scripts): the seeded squad was structurally valid but the client still refused it
First seed sent only squadName/formation/captain/players. The host logged squad-active 200 outcome=ok and /squad/0 showed 11 occupied slots, yet the client still threw a FUT squad update error -- a 200 with the right players is not proof the client accepts the body. Diffed against production's known-good squad, which the same client accepts, comparing key presence and JSON TYPES rather than values. Staging returned null for exactly the five fields the PUT never carried, because the extension stored nothing for them: squadType (a string enum), chemistry, rating, starRating (ints) and custom (the opaque 33-int tactics array the client definitely parses). kicktakers was empty where production carries five. Now sends all of them: squadType REGULAR_SQUAD, chemistry, rating/starRating derived from the XI's mean rating, production's custom array verbatim (opaque server-side, only its shape matters), and five kicktakers. Re-diff leaves exactly one difference -- manager, which production points at owned staff instance 100000427 while the staging club holds 11 players and zero staff. Left empty rather than inventing an id that references a non-existent item; recorded in the code as the one known remaining gap. Also fixes a KeyError from the rewrite dropping the players key. |
||
|
|
202366611e |
test(scripts): front-load the hub preconditions instead of finding them one restart at a time
The sold A/B stalled twice on preconditions no headless check exercised: the staging identity had no squad (the hub refuses to open, showing a squad update error), and any route without a Rust owner falls through to a deliberately dead Python upstream and answers 502. Each cost a full operator cycle. Sweeps the routes the client is observed to request and separates three failure classes that need different fixes: 502/PYTHON_FALLBACK (no Rust owner), missing_integrity (200 but the underlying state is absent -- exactly 'no extension stored' before the squad was seeded), and 200-but-unusable (a squad with zero occupied slots). A 200 is not proof the client is satisfied, so squad responses are judged on occupied slots. Also reads the host's own classification for the requests just made, since the host is the authority on ownership and integrity rather than the response body. Every path is verified against what the client actually sends. A first pass flagged five 'fatal' routes that were my own guesses -- /accountinfo (client uses /user/accountinfo), bare /squad (uses /squad/active), and /watchlist (camelCase watchList). Crying wolf about the stack is worse than not checking, so the list now carries only observed paths and that trap is written down in the comment. Current result: 14 ok, 0 integrity warnings, 0 fatal. |
||
|
|
ea92057e53 |
test(scripts): seed the staging seller an XI so the FUT hub will open
The A/B identity had owned items but no squad, because the sold-row work only ever
needed the tradePile wire. Every headless check passed -- none of them asks for a
squad -- but the real client refuses to enter the FUT hub with an empty one and shows
a squad update error. A squad is a hub precondition, not a Transfer-List detail.
Seeds via the real PUT /ut/game/fifa17/squad/0, the same request the client sends, so
parse_squad_put/build_squad_write produce exactly what a genuine save would. Writing
Core rows by hand could yield a shape the live path never emits, which is the kind of
divergence that quietly invalidates an experiment.
Picks one owned player per 4-3-3 slot, best rating first, without reusing an instance;
fills the fixed 23-slot array with 0..=10 as the pitch and empty slots as
itemData.id == 0; refuses to write a partial XI and reports any out-of-position
substitution loudly rather than silently reproducing the broken state. Verified
0 -> 11 occupied with no substitutions and a {"id":0} ack.
|
||
|
|
c71593b286 |
test(scripts): enable the hook on the deployed launcher without a rebuild
Bridges openfut-launcher c542415 onto the already-built launcher on .105 by writing WINEDLLOVERRIDES=version=n,b into game_profile.env, which that build does apply. Forward-compatible: the fixed launcher defers to a profile that already pins version=, so this value simply wins. Records the prior value -- including its absence, as the literal <absent> -- to a sidecar before mutating, so revert restores the real previous state instead of assuming the key was missing. Refuses to edit while the launcher runs, since it holds its config in memory and would write the stale value back. |
||
|
|
413ad901fb |
launcher: bump gitlink to c542415 (hook WINEDLLOVERRIDES fix)
Without version=n,b the launcher's own launch path never loaded the version.dll hook proxy, so the Blaze ports it writes to openfut.cfg were ignored and the client silently reached production via /etc/hosts instead of the configured server. |
||
|
|
e0e46d8a57 |
fix(scripts): the port switcher was editing a derived file, so the A/B ran on production
openfut.cfg is not the source of truth for the client's Blaze ports -- the launcher
is. It reconciles openfut.cfg from ~/.config/openfut-launcher/config.json,
fail-closed, immediately before every launch. So `staging` set the ports, verified
them, and the next launch silently reverted them.
Caught only because the capture harness cross-checks instead of trusting the screen.
The operator reported "the Transfers tile does not show Sold" -- which looked like a
clean negative result about the sold counter, and was in fact a reading of their
PRODUCTION club, where sold:0 is correct. Evidence chain:
* route-log delta contained 5 lines, all of them the harness's own GETs; the
client issued nothing to staging at all;
* `ss -tnp` on the client showed FIFA17.exe pid 39482 ESTAB to 10.10.0.120:42130
(production Blaze) plus TIME-WAIT to :8099 (production UTAS);
* the hook logged `blaze_redir=42127 blaze_main=42130`;
* openfut.cfg mtime was 2s before process start, sha back to the production value.
Had the harness reported the tile at face value, the sold counter recovered from
CardsDLL would now be recorded as refuted by a run that never reached the code.
Fixes: own the launcher config (source) before openfut.cfg (derived), with the same
record-before-mutate sidecar discipline on both; refuse to edit while the launcher is
running, since it holds config in memory and would write the stale values back;
report both files and both guards in `show`. launcher_running() matches the
kernel-truncated comm "openfut-launche" -- the full name exceeds 15 chars, which has
bitten this project before.
No production change; staging stack and its variant-A sold row untouched.
|
||
|
|
fbe29da05b |
fix(scripts): sold-client-ports guard self-matched its own shell, wedging it ON
`pgrep -f FIFA17.exe` matched the remote shell executing it -- the SSH command line contains the literal pattern -- so fifa_running() always returned True and the port switcher could never edit openfut.cfg. It refused with "REFUSING to edit ... while a FIFA client is running" moments after FIFA had actually exited. Fail-closed, so nothing unsafe happened, but the guard was permanently stuck and blocked the A/B entirely. Now matches /proc/<pid>/comm exactly, which is the executable name: the invoking shell reads as zsh and cannot self-match, while a genuine FIFA process still does. Validated both directions with the same loop -- it found pid 36958 while FIFA was up, and reports gone once it exited. Still fail-closed on read errors. The lesson generalises: a pattern-matching process guard checked over a transport that carries the pattern in its own argv is self-satisfying, and a guard that can only ever say "yes" is not a guard. |
||
|
|
9ffbd651b1 |
test(market): one-command live capture for the sold A/B, with in-run validation
Turns the operator's job into "navigate, say go" and removes any chance of a half-recorded variant. One command captures and labels: the staging wire surfaces, the client's OWN auction record decoded read-only from /proc/<pid>/mem (STATE, YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE), and the staging host route-log DELTA since the last capture -- which is how a client-issued DELETE .../trade/sold gets OBSERVED rather than assumed. The part that matters is the wire-vs-memory cross-check. It validates the observation mechanism against a known-positive in the SAME run: if the wire says bidState "highest" and the client's memory decodes 2(highest), the probe is demonstrably reading the right struct this time. It also recomputes the native IS_GLOW/INBOX formulas from the wire and compares them to what the client stored. Proven honest on first run: with the client attached to PRODUCTION and not on the Transfer List, it reported the staging sold row on the wire, 0 client records, and INSTRUMENTATION NOT VALIDATED -- refusing to draw a conclusion from an empty read. Two earlier sessions were misled by exactly that (a sampler bug printing "countdown NO", and auction containers read while the screen was unbound), so an empty container is explicitly not treated as an empty pile. Probe base-address discovery was separately confirmed against the live client (pid 36958, FNV control=MATCH, model resolved, containers read cleanly), and production's wire independently agreed at total=0. No production change. Client config untouched (still production Blaze ports). |
||
|
|
aa5fb2cc40 |
test(market): make the sold A/B one-field attributable, add classified differential
The brief's gate: if the harness varies bidState AND coinsProcessed together, the client's reaction is attributable to neither. The env knobs were already orthogonal (--variant and --coins-processed are independent, cp defaults to 0), but sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly the contaminated A/B the brief forbids. Fixed: the primary pair now holds coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState']. New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic sale, then re-reads every seller-facing surface under each variant by restarting only the host (same Core, same DBs, same sale), and diffs with explicit classification -- MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?". Two orthogonal pairs: PRIMARY bidState highest vs buyNow, coinsProcessed held at 0 ORTHOGONAL coinsProcessed 0 vs 1, bidState held at highest Result, 36/36: the ONLY finding on /tradePile is VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs in exactly the same one path; counts are byte-identical. The orthogonal pair's only finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the capture is reproducible rather than merely consistent. Counts states the live run has to interpret, measured not guessed: S1 0 active + 1 sold -> count 0, selling 0, sold 1 S2 1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode) That divergence IS the open question for the client; production is unchanged. scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is built so restoration cannot depend on memory: it records the production values to a sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing if it is absent. It rewrites only known keys (a missing key is an error, never a silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA client is running because the hook reads the file at connect time. Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface, one file per variant so A can never overwrite B. Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105 (pid 32188), and live-session mutual exclusion applies. The client config was NOT touched -- the switcher's guard refused, as designed. Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0, counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened. |
||
|
|
468bc0fba9 |
feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.
PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.
OPENFUT_FIFA17_SOLD_EXPERIMENT = highest | buyNow (else OFF)
OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1 (else 0)
OPENFUT_FIFA17_SOLD_COUNT_MODE = active_plus_sold (else active)
WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.
CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.
FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.
STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
* scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
responder and asserts every substitution applied, so a silent no-op cannot leave
it pointing at production. Kills only recorded pids whose cmdline contains the
staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
* docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
* src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
(CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
leaves the listing live with nothing moved. Refuses any path containing
openfut-promotion or the production ports.
* scripts/sold-wire-check.py -- proves the whole flow headless before any operator
time is spent.
WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.
Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.
Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.
The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
|
||
|
|
571c5f9261 |
docs(market): recover the FIFA17 sold wire contract from CardsDLL (Ghidra)
Task A, static phase. Ghidra 12.1.2 headless via the repo's own pyghidra harness
over CardsDLL_Win64_retail.dll (13,382 functions). Queries and raw decompiler
output committed under docs/evidence/market-sold-re-2026-08-17/.
RECOVERED FROM THE BINARY
1. No sold token, now EXHAUSTIVELY: both vocabularies dumped to their sentinels
rather than sampled. tradeState is exactly 4 rows; itemState is exactly 12
(invalid/free/WAITING_FOR_GAME/inGame/forSale/offered/activeBadge/
activeHomeKit/activeAwayKit/activeBall/activeStadium/active=255). A sold row
MUST therefore be a combination of existing atoms.
2. What closed does, complete, from the auctionInfo deserializer 0x18013e410:
IS_GLOW = (tradeState==closed) ? bidState != none
: bidState in {outbid, buyNow}
INBOX = bidState in {highest, buyNow}
3. The full record -> Flash map from the publisher 0x1801bf030, superseding the
partial list. The prize: record +0xbf is published as COINS_AWARDED, fed by the
coinsProcessed atom 0x2f4. The corpus had recorded that atom's type and noted
its consumer was never found; it is now traced. DURATION also renders the
localised FUT_AUCTION_EXPIRED when expires underflows.
4. highest vs buyNow on a closed row is UNDECIDABLE from CardsDLL, by proof: both
yield IS_GLOW=1/INBOX=1, bit-identical. But bidState is ALSO published verbatim
as YOURBID alongside STATE and COINS_AWARDED, so the movie does receive the raw
values - the discrimination exists and lives entirely in unread ActionScript.
This retires the question as a static target, and it contradicts the
third-party lore that a seller's sold row is closed+buyNow (the corpus's own
lifecycle table says closed+highest and assigns buyNow to the buyer).
5. The clear-sold verb EXISTS. Builder 0x1801647c0 emits "/sold" when the tradeId
field is zero and "/%lld" otherwise, on route base ut/delete/%s/trade, response
class RS4 FutISRemoveTradeServerResponse. Confirmed by the client's own
request-name table entry RemoveAllSoldFromTradePile. A BULK clear-sold verb only
makes sense if sold rows PERSIST in the seller's pile until cleared, which is
incompatible with our Fix A invariant - so the sold path will require revisiting
it under live validation.
6. The seller's SOLD counter is real, proven end to end with no inference: the hub
tradePile sub-deserializer 0x18013ead0 writes atom sold 0x2c9 to +0x1d8, and the
tile publisher 0x1800b1dc0 renders +0x1d8 as Flash TEXT3 under the localised
caption FUT_TF_SOLD. Siblings: selling -> +0x1d2 -> FUT_TF_SELLING,
count -> +0x1d4 -> FUT_UC_ITEMS, plus FUT_TF_WINNING/FUT_TF_OUTBID on the
Transfer Targets tile. We and the Python oracle both hardcode sold:0, so that
bucket can never fill.
7. Reusable method: an atom id is the INDEX into the alphabetical atom-name pointer
table at base 0x1802d2760. Validated 12/12 against the known auctionInfo atoms
and cross-checked against fifa17-recon/docs/fut_atoms.tsv. Documented gotcha:
resolve a name by the pointer slot INSIDE the table, never by the first matching
string in the binary, or you get confident nonsense.
8. An auction-outcome vocabulary exists (auctionSoldBid 0x39, auctionSoldBuyNow
0x3a, auctionWon*/auctionLost*) but NO deserializer consumes it - every
candidate function was checked for the value-SKIP/atom-loop signature and none
qualifies. Server-side or telemetry only; it does not carry sold state here.
TASK B IS UNDECIDABLE FROM THE CLIENT, and this is a proof of absence: no 0.95 or
0.05 constant of either width, no tax/fee/net/proceeds caption, and no fee
arithmetic anywhere. The client never computes or displays a net, so no experiment
against our own server can measure the rounding - whatever we credit is what it
displays, and there is no oracle. Only an original EA-era seller-balance capture
could settle it. The rule stays an explicit CHOICE (floor the fee, so
fee + proceeds == gross exactly) and is now pinned at the requested boundaries
100/101/119/120/149/150/151/199/200 plus 15,000 and i64::MAX.
Settlement NOT promoted. No production process, port or database was touched.
|
||
|
|
cb32fe9b84 |
docs(market): close Gap 2 and freeze the transfer-list lifecycle as known-good
Return to Club is durable across a full FUT exit/re-entry — the last claim the forSale promotion could only make server-side. Same disposable card as Gap 1 (75 ST, res 212188, wire 100000178, tradeId 1000000178), after the 1h auction expired NATURALLY. No timestamp was mutated in either gap; a read-only sampler watched the whole hour (55 samples), because `expires` is derived from created_at + duration and watching is the only honest way to see expiry: active expires 3175 -> counting down -> expired expires 0, itemState forSale (absent) total 0 <- Return to Club itemState stayed forSale across active -> expired, the one state change Fix B had never been watched through live. The host log then shows the coupled transition and TWO session boundaries: route=move-items wire=100000178 pile=club auction_cancelled=1 route=auth-delete route=auth ... sid_opened=true (fresh session, x2) route=hub clubPlayers=1966 auctionCount=0 route=club total=1986 emitted=1966 auction_cancelled=1 is cancel_active_for_core_item firing, so pile membership and auction lifecycle cannot disagree. Two independent fresh sessions each rebuilt the state from durable storage and the operator confirmed the card was still in My Club; one boundary was the requirement. 18/18 server checks pass IDENTICALLY before and after re-entry: /tradePile total 0, counts all zero, tradeId -> closed with expires 0 (still resolves, correctly terminal), /club 1966 with itemState free, 0 duplicate ids, market store cancelled with 0 active and 0 reserved, coins 29,843,976 unchanged throughout. No code change was required for EITHER gap. Both tests existed to find out whether the promoted implementation was already correct on paths it had not been exercised on, and it was. Also freezes the full CLUB -> list -> active -> expired -> Return to Club -> CLUB lifecycle as the reference baseline, with the eight invariants it pins, so a future change that alters any line is a regression until proven otherwise. Explicitly NOT established: the SOLD path, /tradePile/counts semantics, the AVM1 gate. |
||
|
|
fbe9804d3e |
core: quick-sell FK fix (gitlink 637a21e)
Quick-selling a card that was in any squad failed with SQLite 787 FOREIGN KEY constraint failed, on the live FIFA 17 path (economy_store -> econ.sell_item -> POST /economy/sell-item). Reproduced, then fixed by evicting the item from every lineup inside sell_item's existing transaction. routes/cards.rs::delete_owned_card folded into the same authority, which also gives it the transaction it never had. Not deployed. |
||
|
|
f6606accb3 |
feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live here. FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and match_reward_total because 5% is a game policy constant and Core must stay game-neutral — Core only validates 0 <= fee <= gross and never computes a rate: TRANSFER_MARKET_FEE_PERCENT = 5 transfer_market_fee(gross) = floor(gross * 5 / 100), i128 intermediate seller_proceeds(gross) = gross - fee Integer only. Floating point is never used for coin settlement: 0.05 is not representable in binary and a f64 round trip can create or destroy a coin at large prices. Widening to i128 makes overflow unreachable for any i64 price, so no price ceiling has to be assumed. ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller keeps the fractional coin, chosen because it makes fee + proceeds == gross hold exactly at every input — the property the accounting invariant rests on. The discriminating case against flooring the seller's 95% instead is a gross of 150: this rule pays 143, the alternative 142. Nothing in the corpus or the client binary settles which the real server did (the client is only ever told the gross; no tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/ 150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep. HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on HttpCoreClient as POST /economy/settle-sale. Request field names were checked against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club ids are OMITTED from the body (not null), which is what Core's Outside/active-club defaults depend on, so a unit test pins that body shape. handle_market_buy is deliberately untouched: the synthetic buy path has no counterparty, so minting there is correct. HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a hard deny-list checked in three places), seeds the canonical two-party fixture, prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31 pass. It found the rejection-precedence bug fixed in Core, and that Core's content preflight aborts startup on an owned card whose CardDefinitionId no pack defines. Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code fmt-clean. Nothing deployed; no production process, port or database was touched. |
||
|
|
0a007f4941 |
docs(market): close Gap 1 — active seller row under forSale is live-confirmed
The one claim the Fix B promotion left open: no active listing existed during
that session, so only the expired path had been exercised.
Closed with a disposable card (75 ST, res 212188, wire id 100000178 — one of
three identical copies, not in the squad) listed through the real FIFA 17 client
at 150/200 for 1h, so expiry arrives naturally. No timestamp touched.
Wire: itemState=forSale, tradeState=active, 12 atoms, prices intact, expires
3562 -> 3556 over a 6s sample (live clock), /trade/status coherent, counts
{count:1, selling:1}, coins unchanged, zero inactive rows (Fix A intact).
The decisive evidence is client-side, not ours: a read-only /proc/<pid>/mem
decode of the live trade-pile auction record returned
itemState=5(forSale)
on the very field that read -1(<unrecognised>) under listFS. Direct A/B on the
only changed field, taken from the client's own memory.
Operator confirmed the row renders under LISTED ITEMS with correct prices, a
counting-down timer, normal art, and correctly non-actionable while active.
No code change required — the promoted implementation was already correct on the
active path. Claim boundary unchanged: this proves the client DECODES the token
and says nothing about the Flash action-gate term.
|
||
|
|
0c4aee6164 |
market: promote forSale — live-confirmed on the expired path
Operator drove the expired row 1000000155 (res 158023, 93 RW) in FIFA 17 with the candidate deployed: the row was still actionable, Return to Club was offered, and it worked — "the card is back in my club". Server-verified durable afterwards, which is what a fresh session reconstructs: /tradePile total 0 with zero rows, /tradePile/counts all zero, the card present in /club (1965 -> 1966 items, itemState "free"), zero duplicate ids, no stale active listing anywhere in the store (both rows cancelled), and the ended auction projecting as `closed` (4) on /trade/status. Fix A intact: zero `inactive` rows. So `listFS` -> `forSale` is protocol-correct AND behaviour-preserving on the path that matters, and `listFS` is gone from production serialization. Also worth recording what the result rules out: CARD_OFFERSTATE is NOT a gate term that requires -1. Every actionable row we had ever seen carried itemState -1, which looked like a possible client rule; it was a coincidence of our own invalid token. An expired row decoding CARD_OFFERSTATE = 5 stayed actionable. Deliberately NOT claimed: anything about the Flash action gate itself. STATE and the RESERVEDPRICE/MAX_CREDITS pair are untouched and still confounded, so the gate remains Category C / STRONGLY SUPPORTED / not proven, and AVM1 disassembly of tradepile.isInActiveAuction is still the separate next investigation. One gap left open honestly: no ACTIVE seller row existed during the session, so active-row rendering under `forSale` is unverified. /transfermarket has always emitted `forSale` on active rows, so it is expected-safe, but it has not been seen. |
||
|
|
a57f4930f0 |
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. |
||
|
|
f9ca901a50 |
market: stop advertising unlisted pile members as tradeState:"inactive"
RE of the FUT front-end closed the question the Actions-panel investigation left open, and the answer retracts Q2 rather than completing it. `tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at `0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of `[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0 inbox=0). The value is then handed to the movie verbatim as the Flash property `STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders and can never be acted on, which is exactly what the operator saw. So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER, which is not the same claim, and I treated it as if it were. The corpus said this before any of it was built — `plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path treats it specially; do not emit it." The earlier note explaining that the warning "was written about the PRESENTATION function" was motivated reasoning. This also fires the corpus's own pre-registered falsifier E3 (:368-373). Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`, `UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`, `Server::resolve_trade_pile`, and the two helpers that existed only to feed them (`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`). Unlisted trade-pile membership is now internal state with no wire expression. Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an unlisted pile member stays visible in the club, which is where the client can act on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive` rows, `/trade/status` resolving only the real auction, coins unchanged at 29,843,976, and all six former rows present in `/club` (1965 items). Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and `trade_status_answers_only_about_real_auctions`. NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17 token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in 4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and the Python oracle emits `listFS` too — which is why the differential never caught it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and every actionable row observed carried -1, so that change ships alone with its own live A/B. |
||
|
|
3ce69f8951 |
launcher: one-button launch flow with an explicit state machine (gitlink 3174fe4)
Normal users press Launch FIFA 17; LSX, autopatch, client preparation and the pre-launch checks are orchestrated automatically, reusing whatever is already healthy, and every manual control moves under Advanced / Diagnostics. Service ownership is tracked so a service the launcher did not start is never killed. |
||
|
|
acd1def00d |
launcher: machine-independent preflight tests (gitlink 504ceee)
Bumps openfut-launcher past two test-hygiene fixes found by running the suite on the game machine (.105) instead of only on the server host: the new hook-config check added a second warning on any box with a hook deployed, and the shadowed-hostname test was asserting, via backend_reachable's live sockets, that the local machine has the OpenFUT ports open. Suite now passes on both hosts. |
||
|
|
5ec9c7f8bf |
launcher: guided first-run flow + hook-config reconcile (gitlink 357501f)
Bumps openfut-launcher to
|
||
|
|
11c028e6eb |
fix(market): /trade/status must resolve the unlisted ids /tradePile advertises
Explains and fixes the Phase C partial failure WITHOUT changing a single wire field. The operator saw a difference between the one-item probe (Time Remaining "-") and the generalized rows (Time Remaining "Expired"). Cause: route coverage, not encoding. /tradePile advertised the unlisted tradeIds while ISVIEWTRADE (GET .../trade/status) resolved ids from the market store only -- and an unlisted pile member has no listing row, so the poll returned an empty auctionInfo. Observed live as `route=market-status requested=1 returned=0` repeating for the row the operator had selected, while that same id was present in /tradePile. The client polls status for the row it displays and degrades it when the answer is empty, which is also why no actions were offered. The probe showed "-" only because the client had not yet polled that id (logs of the time show only tradeIds=1000000097). So expires, tradeState, itemData.itemState and pile were all innocent. Nothing was guessed and no field changed: both routes now share one pile enumeration (Server::resolve_trade_pile), so an id advertised by /tradePile always resolves on /trade/status. The corpus predicted exactly this -- tradeId must resolve across /transfermarket, /tradePile, /watchList AND /trade/status; we had stability but not coverage. Same defect class as the original empty-trade/status bug. Status still answers only the ids actually asked about, and a real auction always wins over an inactive row for the same tradeId. Regression test covers all four cases. Records the downgraded conclusion: "inactive" is a CONFIRMED section/lifecycle discriminator; whether the full actionable contract is now complete is the operator's next test. itemState/pile recovery was queued on the assumption the encoding was incomplete -- neither was touched, and both remain the next candidates if actions are still absent. 342 tests pass, 0 failed, clippy clean. Verified live: the six inactive ids went from returned=0 to returned=6. |
||
|
|
afadb13de4 |
feat(market): PHASE C — expose every unlisted trade-pile item as tradeState "inactive"
Q2 is LIVE-CONFIRMED (operator saw the inactive row under TRANSFER LIST with Start Price 0 and no Buy Now / Current Bid / timer, active rows still separate under LISTED ITEMS, and the state survived a full FUT exit/re-entry). Promoting from the bounded one-item probe to the real behaviour: the env gate is gone and /tradePile now enumerates the whole trade pile. Mechanism: read the pile (async), resolve each member to a shaped card (sync, because the identity/Core resolvers are not `Send`), then build the response (async). The core->wire lookup is `wire_for_owned_id`, which uses the identity store's NON-allocating `external_for` -- enumerating a pile is a READ and must never mint a wire id for an item the client has not seen. Items with no mapping, no Core record or no resolvable FIFA identity are skipped, never faked. Includes a bug the DIFFERENTIAL caught and unit tests did not: a pile row OUTLIVES its auction, so after a sale the seller's `trade` row is stale, and filtering only on ACTIVE listings re-advertised a SOLD card as an owned unlisted item. Suppression is now by listing state via `blocking_core_items()` -- active (real auction shown instead), reserved (sale in flight) and sold (card gone) -- while `cancelled` is deliberately NOT suppressed, because a cancelled listing means the card came back to the pile. New test covers all three plus the store-level rule. counts semantics deliberately unchanged: `count`/`selling` still track auctions only. 341 tests pass, 0 failed, clippy clean. Deployed: the 6 previously stranded pile items now render, alongside the 1 active listing, with Ronaldo correctly in /club and out of the pile. Body preserved as phase-c-full-pile-exposed.json. |
||
|
|
b6398c44e6 |
docs: record the LIVE-CONFIRMED inactive UI contract and the expired->Club transition
Operator confirmed in the real client that a tradeState "inactive" row lands under the right-hand TRANSFER LIST section and renders Start Price 0 with Buy Now, Current Bid and Time Remaining all absent, while an active row in the same body continued to render separately under LISTED ITEMS. That is the Q2 representation confirmed live, with the token itself dumped from the client's own string table rather than guessed. Records the observed wire->UI contract as a table plus a fixture (inactive-row-live-confirmed.json), and names the regression tests that pin it, including the two guards that the row is never emitted for an item outside the trade pile and never duplicates a real auction. Also records the expired->Club coupled transition verified server-side: the listing went to `cancelled`, the pile went to `club`, /tradePile dropped the item, /club regained it, and clubPlayers went 1964 -> 1965. That is durable store state rather than a client-local view, so it survives a session boundary by construction; tagged pending the operator's final exit/re-enter confirmation. Adds the counts observation table. `count` currently tracks AUCTION entries and not total Transfer List membership; semantics deliberately left unchanged until the full state set has been observed. No behaviour change in this commit. |
||
|
|
a2bd048ace |
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.
|
||
|
|
2e97ff1461 |
docs+tools: dump the CardsDLL route table; narrow Q2 to one candidate by elimination
Re-entry discriminator came back a CONFIRMED BUG: an unlisted transfer-list item does not survive a fresh FUT session, so our representation cannot reconstruct trade-pile membership. Evidence acquisition per instruction, corpus and PE first, no guessing. Adds route_table_dump.py: static read-only dump of CardsDLL's route table from the on-disk PE, resolving VA->file offset through the real section table instead of assuming a single .text mapping. Output preserved as evidence. It settles "is /tradePile the only relevant route?" -- the table holds 45 routes plus 3 empty admin slots, and row 30 `ut/%s/tradePile` is the ONLY trade-pile route. There is no trade-pile items route. That plus three existing PE facts narrows the representation to exactly one candidate by ELIMINATION rather than choice: the route carries only twelve-atom auction records; `pile` (0x226) has no arm in the item deserializer so membership is conferred by the owning list and cannot be added as a field; of the twelve atoms only tradeState expresses lifecycle; and tradeState's closed vocabulary (active=1 inactive=2 expired=3 closed=4) has exactly one value not already spoken for. So an unlisted item can only be an auctionInfo record with tradeState "inactive". Tagged INFERRED-BY-ELIMINATION, not CONFIRMED: the remaining unknown is whether the Flash Transfer List RENDERS such a record in the unlisted section. Records the acceptance test (survive a full FUT reload) and the revised invariant that a transition is complete only when a fresh session reconstructs the same visible state. No behaviour change in this commit. |
||
|
|
7f37b37be3 |
docs: capture the unlisted transfer-list state and model the pile/auction boundary
Q2 measured, not guessed. The operator moved a card Club -> Transfer List without listing it (PUT /item, no POST /auctionhouse) and the state was captured read-only. Finding: we do not represent the unlisted state on the wire AT ALL. Such an item is byte-identical to a club item -- itemState `free`, no `pile` field emitted, still returned in /club and counted in clubPlayers -- while an actively-listed item is correctly excluded from /club and present in tradePile. Only the host's own pile store knows the difference. Trade pile held 6 items: 1 listed, 5 unlisted. The FIFA 17 ENCODING of that state stays UNKNOWN on purpose: returned itemData.pile is numeric with an unrecovered mapping, and tradeState is a closed table walk where an unrecognised bidState is silently swallowed as `none`, so a wrong enum produces a plausible-looking but wrong UI. The corpus warns `inactive` decodes but no client path treats it specially. One client-only discriminator is recorded instead. Also models the domain boundary both limbo bugs came from: pile membership and auction lifecycle are separate facts requiring coordinated transitions. States the testable invariant -- an item must never be simultaneously excluded from /club and absent from /tradePile -- with the two ways it was reachable and the commits that closed each. |
||
|
|
4e31fb98a2 |
fix(market): returning an item to the club ends its auction; close the panel probe
CLOSES the active-own-auction Actions-panel investigation. Live client plus the RE corpus plus historical FUT behaviour all agree: an active auction is COMMITTED until sale or expiry and is not seller-actionable, while an expired unsold item becomes actionable (relist / return to club). Every observation fits that lifecycle -- active+frozen expires was non-selectable, expired was selectable and relisted fine, relisting made it active and non-selectable again, and the client never emits a cancel. Documented with confidence tags, and the dead ends are named so they are not retried: MAY_BE_REMOVED is a constant 1, and the eight-flag array is the CLUB-CARD menu with no auction-cancellation flag in it. Implements the return-to-club transition that closure exposes. A pile move to `club` now cancels any ACTIVE listing on that item, because the auction that put the card in the pile has to end with it. Otherwise the pile reads `club` while the row stays `active`, so the card is filtered out of /club (exclusion keys on active listings) AND still rendered in the Transfer List: the move appears to do nothing. This is the same limbo class as the earlier pile-vs-listing bug, found by reasoning about the transition rather than by another live failure. Scoped to `active` only: a `reserved` row is mid-sale and a `sold` row is already gone, so cancelling either would let one card be both sold and returned. Two tests cover exactly that boundary. 338 tests pass, 0 failed, clippy clean. |
||
|
|
6cc22e5cc5 |
docs: freeze the known-good auction state and mark tradeOwner DISPROVEN
Records the live-client resolution so the market shape is now a fixture rather than folklore, and so a future agent cannot burn deployments on tradeOwner again. Confidence notes updated: tradeOwner remains FIFA17-HISTORICAL (it does exist in the FIFA 17-era API) but "required by FIFA17.exe Transfer List Actions" is now DISPROVEN for this client path -- it is not among the twelve atoms the client's auctionInfo deserializer reads, and it was implemented, deployed, observed inert and removed. The real blockers are recorded as CONFIRMED live-client findings: trade/status polling is load-bearing, `expires` must EVOLVE with wall-clock time (a frozen value is structurally valid and behaviourally broken), and relist must persist through the PK conflict that FIFA's re-sent ISStart necessarily causes. Freezes the known-good bodies under docs/evidence/market-lifecycle-2026-08-17/ with a machine-checkable countdown proof (_index.json._countdown_proof records expires decrementing, frozen:false) rather than asserting the clock in prose. States the general rule this cost us: a response can pass differential parity and render perfectly while still being wrong, because FIFA expects an evolving server-side state machine, not a static object that resembles one. |
||
|
|
b1d7ed2570 |
fix(market): relisting an expired auction actually relists it
The client's relist arrives as a fresh ISStart (`POST /auctionhouse`) for an item that ALREADY has a listing row, so `create_listing` hit a primary-key conflict. The handler treated `Err(Conflict)` as success: it logged `listed=true`, handed the client its trade id, and persisted nothing. The stale row kept its old `created_at`, so the card stayed expired and the relist appeared to do nothing -- observed live, with the client's price-limits fetch and the ISStart POST both in the log. The PK conflict IS the relist path. `relist_listing` now resets `created_at` to now and takes the new prices and duration, so the auction actually returns to the market with a fresh countdown. Refuses to revive a `sold` or `reserved` row: re-opening a sold auction would sell the same card twice. `cancelled` rows ARE relistable (the card is back in the pile). Missing rows report NotFound rather than silently succeeding. The failure paths still ack so the screen cannot wedge, but they now say `relisted=false reason=...` in the log instead of claiming success. Three store tests: the clock/price reset, the sold+reserved revival guard (plus the cancelled-is-relistable case), and NotFound. 336 tests pass, 0 failed, clippy clean. |
||
|
|
dcbef721f2 |
docs+tools: measure the FIFA 17 market gate bytes in the live client
Adds trade_gate_probe.py (read-only: /proc/<pid>/mem O_RDONLY + pread, slide proven
against the on-disk FNV prologue), extending gate_byte_probe.py to vtable slot
+0x270 exactly as the transfer-market analysis asked for.
Measured: IS_TRADING_ENABLED=1 (was 0 in the Python era), TRADE_PILE_SIZE=100
(was 0), watchListSize=50 (was 0), with four controls reading 1. So every
CardsDLL-supplied input that analysis named as a market blocker is now OPEN, which
the Rust host achieves by construction -- it emits userInfo.feature as {} so the
kill switch at 0x180174f19 never arms, and it already sends pileSizeClientData
keys 2 and 4.
This narrows the Actions-panel question to the exe-side UI script term, and rules
out ownership fields, the gate bytes, the cancel route and the state vocabularies
as candidates -- each on measured or PE-derived evidence rather than inference.
|
||
|
|
772f8a615a |
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.
|
||
|
|
bf9ae20367 |
docs: FIFA 17 transfer-market wire findings with confidence tags
Records the auction-record field set, route spellings, pile encoding and the four open UNKNOWNs so future agents neither reopen settled questions nor re-guess enum values. Each claim tagged CONFIRMED / FIFA17-HISTORICAL / INFERRED / UNKNOWN. Captures the key methodological lesson: oracle parity is necessary but NOT sufficient for a flow the oracle itself never served -- our auction record matched the oracle key-for-key while both omitted the FIFA 17 ownership fields. |
||
|
|
58d1f9426f |
fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE
Three defects behind "selecting my own Transfer List listing opens no dialog".
Pressing the card emits NO HTTP at all, so the gate is a field in what we already
return -- the client decides locally from the auction record.
1. OWNERSHIP FIELDS (FIFA17-HISTORICAL). FIFA 17 auctionInfo carries `tradeOwner`
(bool), `sellerId` and `offers`; we emitted none of them. `tradeOwner` is the
purpose-built "this auction is mine" flag, and without it the Transfer List has
nothing to key owner actions (Remove / Re-list) on. `sellerId` now carries the
configured persona so it agrees with `tradeOwner` and `sellerName` instead of
telling three different stories. Persona is threaded from config, never baked in.
2. `GET …/trade/status` ANSWERED EMPTY (CONFIRMED from our own live logs). The
Transfer List polls this continuously to refresh live auction state. The tail has
no numeric id, so it fell through `t.starts_with("trade")` into the buy/view arm,
where `trade_id_from_path` fails and the reply is `{"auctionInfo": []}`. The
client asked for the state of its own listings and was repeatedly told there was
none. Now a real handler: `tradeIds` filter, or the whole active pile unfiltered;
unknown ids are absent rather than an error, so a poll never fails closed.
3. PLAIN `DELETE …/trade/<id>` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients
cancel via `DELETE /ut/game/<sku>/trade/<id>`; only the oracle's
`/ut/delete/game/…` spelling mapped to MarketCancel, so the plain form landed in
the buy/view arm and "cancelled" nothing while returning 200. Both spellings now
map to MarketCancel. Kept the oracle spelling: the differential exercises it.
Why the differential missed all of this: our record's key set was IDENTICAL to the
oracle's, so parity was green. The oracle omits the ownership fields too, because
its own remove flow was never driven by a real client either. The differential now
asserts we COVER every oracle key and that our extra keys are EXACTLY
{offers, sellerId, tradeOwner} -- so an unexplained new divergence still fails,
while the deliberate superset is pinned.
Deliberately NOT changed (no evidence): itemState stays "listFS", expires stays
3600 seconds-remaining, bidState stays "none" for active/unbid, counts stays
count=1, and no FIFA 18+ price fields were added.
332 tests pass, 0 failed, clippy clean. Deployed and verified live: tradeOwner=true
sellerId=33068179 sellerName='CAGE' offers=0 on /tradePile AND /trade/status
(filtered and unfiltered).
|
||
|
|
3cd31c4322 |
fix(market): stamp the player's persona as sellerName, not EA's house name
A card listed on the Transfer Market rendered correctly in the Transfer List but pressing it opened NO Actions panel, so Remove / Re-list were unreachable. The one field where our auction record diverged from the oracle was the seller: we stamped "EASFC" while the oracle stamps the account's persona name. `fut_account.py` annotates that very property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName", so EA's house name on the player's OWN listing is simply wrong, whether or not it proves to be the gate on the Actions panel. Introduces `non_economy::PERSONA_DISPLAY_NAME` as the single source of truth and uses it both for the `account/sync` default (previously a bare "CAGE" literal) and as the market seller. Every listing in this store is the player's own -- there is no NPC seller in a single-account emulator -- so the fallback is the player. Also strengthens the differential: it compared only auctionInfo LENGTH and tradeState, so it was structurally blind to this. It now compares the record key set and each shared field against the live Python oracle, asserts the seller is the persona rather than EA, and asserts itemData is the full card rather than a stub. That strengthened comparison passes against the real oracle subprocess, which establishes two things: our record's key set is IDENTICAL to the oracle's (we are missing no field relative to it), and sellerName was the only divergence. NOTE the limit of that evidence: the oracle's own Transfer List remove flow has never been confirmed against a real client either (the only live datapoint is a counts-tile bug), so parity is necessary but may not be sufficient. If the client still offers no dialog, the missing field is missing on BOTH sides and must come from client instrumentation, not from the oracle. 14 targets green, clippy clean. Deployed and verified live: sellerName='CAGE', listing intact, coins unchanged. |
||
|
|
ae5feb05b7 |
docs: record the external FIFA 17 FUT hub behavioural spec + cross-check
Operator-supplied research document (authored outside this repo) describing the
player-visible FUT hub state machine. Stored verbatim so it cannot drift, with a
provenance header pinning its standing: it is a BEHAVIOUR target, never a protocol
reference. Its own §43 already forbids inventing route/field/sentinel/empty-state
details from it, which matches project policy (guessing wire spellings is the
documented client-freeze class).
Appended a repo-grounded cross-check that tags each relevant claim CONFIRMED /
CONFLICT / GAP / UNVERIFIED, so a future agent cannot mistake the aspirational
parts for observed behaviour. Notably it CONFLICTS with the recovered client
tables twice (Manager League is deliberately excluded from the consumable
overlay; there is no apply-consumable endpoint upstream at all), and it usefully
confirms that "sent to the Transfer List but not currently listed" is a real FUT
state -- which is exactly the limbo
|
||
|
|
f2c4927ea6 |
fix(club): hide only ACTIVELY-LISTED cards, not the whole trade pile
Keying the club exclusion on the `trade` pile put cards in limbo: the pile can hold cards with no active listing (a bare "Place on Transfer Market" move, or a listing later cancelled/sold), and `/tradePile` renders ONLY active listings — so those cards were invisible in BOTH views. Live prod had 5 trade-pile rows but 1 active listing, so 4 owned cards had no reachable screen (clubPlayers 1966->1961). Key on the ACTIVE LISTING instead (market store `core_item_id` of `state=active`). This is self-healing: the moment a listing stops being active the card is back in the club, with no extra transition to maintain and no need to invent an "unlisted transfer-list" wire shape (`tradeState` has no verified spelling for that state, and guessing enum spellings is the documented client-freeze class). A bare pile move therefore no longer hides a card. That is deliberate: our `/tradePile` shows only active listings, so hiding on the move alone would reintroduce the limbo it is meant to prevent. Verified live: clubPlayers 1961 -> 1965 (exactly the one listed card hidden, the 4 stranded cards recovered); listed wire still absent from /club; counts and tradePile unchanged. 14 targets green + clippy clean. |
||
|
|
aa2abc2772 |
fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:
1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
client greyed out "Place/List on Transfer Market" for the whole club. Owned
and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
for owned copies too (item_def keeps `true`; instances do not).
2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
the handler fail-closed and returned 200 while persisting NOTHING. It now
resolves server-side: wire id -> Core owned instance -> its card_id (minted on
a synthetic buy) + FIFA resourceId (the auction record). This also enforces
that a listing can only name a card the club actually owns.
3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
row the client could not draw -> "1 item listed" but no visible sale. A
listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
additive migration) built by the same `shape_item` shaper `/club` and the
squad projection use, so the auction card renders identically to the club
card. The seller's own pile stamps `itemState: listFS`; market search keeps
`forSale` (the oracle distinguishes these).
4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
deserializers: `/counts` is FutGetAuctionCount, five scalar ints
(count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
everything else. Served the `auctionInfo` body it left every count at 0, so
the Transfer List screen showed no active sale while the hub tile showed one.
New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
also accepts the /counts path).
Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.
Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.
Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.
Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
|
||
|
|
1aa84afa9a |
feat(host): migrate item-defs + marketdata UTAS reads to Rust
Two more real client-hit reads move off the Python proxy:
- GET /item/resource, /defid (Route::ItemDefs): build {itemData:[item_def…]}
for every >=3-digit id in the query, replicating the oracle's item_def
(assetId = resourceId & 0xffffff; hardcoded Ronaldo asset 20801 + a generic
"Player" 75 CM placeholder). The client renders the real card from its local
DB, so the placeholder is exact parity.
- GET /marketdata (+ /marketdata/pricelimits) (Route::MarketData): suggested
pricing, constant band 150..15000. /pricelimits returns a BARE ARRAY (one
{defId,minPrice,maxPrice} per queried defId); plain /marketdata returns an
OBJECT {minPrice,maxPrice}. The container type is load-bearing — object-where-
array froze a live client at the listing screen, so the handler picks it from
the path.
Adds extract_long_ints / extract_defid_param query parsers, shape+parser unit
tests (incl. the freeze-critical container-type assertions), and classify-table
coverage. Deployed to prod-host 2026-08-17; verified owner=RUST 200 for all four
(Ronaldo/placeholder resolve, pricelimits=array, marketdata=object).
Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated. Remaining
Python tail is now only mutation (user/club), no-Core-model (squad/<n>), and
unimplemented modes (draft/leaderboards/sbs).
|
||
|
|
33e9118329 |
feat(host): migrate flag-off UTAS reads (season/tournament/champion/clubUser/user-list) to Rust
FUT modes (Seasons/Tournaments/FUT Champions) and the club-identity service are
disabled in this emulator, so these GET reads return {} verbatim from the Python
oracle. Serve them directly from Rust via a new Route::FeatureOffEmpty +
non_economy::feature_off_body() -> {} (byte-identical to the flag-off oracle),
reducing the proxied Python surface.
- The mutating club rename (user/club) stays on Python (needs a Core write).
- Enabling a mode later requires a real Rust handler here, never a Python
fallback (no split authority).
- classify tests: 5 routes owned + user/club/wrong-method lookalikes stay
Passthrough; updated the stale clubUser assertion.
- Deployed to prod-host 2026-08-17; verified owner=RUST 200 {} for all five.
Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated.
|
||
|
|
e06fd57211 |
feat(host): migrate non-economy UTAS routes to Rust + launcher redesign
Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
(nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.
Launcher: bump gitlink to
|
||
|
|
42fd3c7e90 |
core: deploy correctness fixes (SBC exploit + economy TOCTOU) + docs
Bump openfut-core gitlink to 68d1065 (correctness fixes: SBC duplicate-card exploit, non-atomic economy CAS guards, season/checkin panics, sbc_submissions club_id migration 0019). Deployed to prod-core (DB migration ver 18 -> 19). Add docs/CORE_CORRECTNESS_ISSUES.md (audit + Resolution) and docs/OVERNIGHT_HANDOFF_2026-08-17.md. |
||
|
|
0bc71dbd74 |
docs(evidence): capture full-length UTAS responses + userMassInfo envelope
Fix extractor truncation (bound each HTTP message by Content-Length): userMassInfo (8 KB) and purchasegroup responses are now complete in the committed corpus, not cut at 4 KB. Document the userMassInfo envelope contract (target shape for a future full-Rust migration; needs the clubAbbr/established account triad Core lacks). |
||
|
|
2ecd830d75 |
docs(evidence): commit fresh sanitised UTAS wire captures (2026-08-15 live A/B)
Rebuilds the primary-capture corpus lost to .gitignore (Known Issues #200): 10 real-client requests + 12 responses captured during the post-P1 staging A/B on a real FIFA 17 client, sanitised (SID/authCode/deviceId/MAC/tokens redacted; raw pcap withheld). Documents the account/sync, empty-My-Packs 65534 sentinel, and userMassInfo contracts, incl. the finding that account/sync is coupled to Python active-profile selection (so it can't migrate standalone from the userMassInfo hybrid). |
||
|
|
3a51b0ebd4 |
docs: correct wrong Fire2 header traps in heat2.py + fifa-blaze frame.rs
Both files documented a wrong Fire2 header layout as authoritative, the reader trap called out in Known Issues: - heat2.py's module docstring labelled its >IHHHHB3s header 'VALIDATED'. The round-trip only validates the payload length + TDF body; decode->encode with the same mislabelled header trivially reproduces the capture, so it never tested the [10:16] field boundaries. Marked superseded; cite the proven layout; warn at build_fire2_frame. Code unchanged (dead tooling). - fifa-blaze frame.rs: see submodule commit f4f3396. Bumps fifa-blaze submodule eccd46f -> f4f3396 (FIFA23 stub; not in the prod container; no prod impact). |
||
|
|
ad406f21bd |
fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
A reachability probe (TcpStream::connect then drop; the launcher preflight makes them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the certificate mismatch that cost three live gates. The redirector classified the opening before the acceptor to keep a benign probe from forging a TLS fault, but the roster host (the second FIFA-facing TLS host) did not, so the documented hazard 'remains in any other TLS host that has not adopted it' was live there. Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream) peek helper into the shared openfut-tls crate (game-independent; +unit tests). The redirector now re-exports them (public API + its probe_classification test unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of failing the handshake. New roster probe_classification integration test (3 cases: bare probe classified, real client after a probe still served 200, speaks-then- fails still reported as a fault). Full workspace tests green; clippy -D clean. |
||
|
|
12fb9fc38b |
chore: update workspace Cargo.lock after excluding openfut-hook
openfut-hook (now its own workspace root) and its windows-sys deps are no longer part of this workspace's lockfile. |
||
|
|
7b580a0070 |
chore: bump openfut-hook clippy -D warnings cleanup (0d3f33c -> d1a71bd)
|
||
|
|
22443a3810 |
build(hook): exclude openfut-hook from workspace so its release profile applies
openfut-hook (Windows version.dll injected into the FIFA client) declared a [profile.release] with panic=abort/strip/opt-level=s that Cargo silently ignored because it was a non-root workspace member (per-package `panic` overrides are forbidden). Move it out of `members` into `exclude`; the submodule now carries a matching empty [workspace] table so it builds as its own root. Fixes cross-FFI panic-unwind UB in the injected DLL, shrinks it 1200126 -> 861696 B, and lands the artifact in openfut-hook/target/ (matching launcher config.rs hook_dll_path). Bumps openfut-launcher submodule |