Runs the REAL roster_server.py under `sudo unshare -n` (so port 8081 is free and
production is never touched) and validates its certificate BY THE DIALED IP, proving the
before/after the SAN fix (fbc0da2) targets:
* OLD cert (DNS-only, production's current shape) -> a by-IP-verifying client is
rejected with "IP address mismatch, certificate is not valid for '127.0.0.1'" — the
certificate_unknown class the FIFA client hit.
* NEW cert (fixed generator, DNS + IP SANs) -> verifies through the actual roster
server and returns the roster XML (200, application/xml).
roster-cert-verify.py is the client probe (trusts the served self-signed cert as CA,
checks it against the dialed IP, then GETs /fifa17/fut/rosterupdate.xml).
roster-cert-iso-test.sh drives the real server with each cert and asserts new=pass,
old=fail. Two harness bugs were found and fixed while writing it (a shared /tmp log the
production run owns, and a subshell pid that left the first server alive so the "old"
probe hit a stale server presenting the new cert — the tell was "self-signed" instead
of "IP mismatch"), so the final before/after is clean.
Complements the in-process check: this exercises the production server code path, not a
hand-rolled server. Live production confirmation still needs the container rebuilt with
OPENFUT_ADVERTISE set (operator-gated).
The entire docs/ tree (24 top-level notes, 68 evidence captures, 2 plans, research) has
been migrated into ~/OpenFUT-Vault, the curated Obsidian vault, which is now the sole
home for project documentation. Merges preserved all detail (obsolete material kept
under "Superseded" sections); evidence/plans were copied byte-identical; every migrated
note records its Source: docs/<original>.md provenance. Vault commit f55a5ba.
Documentation lives in the vault from here on. Code/script comments that still reference
docs/ paths are stale pointers only (no build dependency); they can be repointed at the
vault opportunistically. References to fifa17-recon/docs/ are a different tree and are
unaffected.
Updates status from root-caused to fixed, and records that option 1 (IP SAN) was
taken across the three cert generators, with the verification and the operator-gated
production rebuild that remains.
The FUT hub failed to load with "An error occurred downloading the FUT Squad
Update" because the client dials the roster (https://<advertise>:8081) and the
redirector BY IP, while the served certificate carried DNS SANs only
(winter15.gosredirector.ea.com + wildcards). The client aborts that handshake with
fatal certificate_unknown. Root cause and evidence in
docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md (commit 082246c): a wire capture shows the client
offering TLS1.2 with RSA suites, the server selecting them, then rejecting the cert —
and autopatch demonstrably patched both ProtoSSL gates in that process, so this
validation path is NOT one of the two the client-side patch covers. The SAN is the fix.
Three generators produced the cert and none put the advertised IP in the SAN:
* docker entrypoint.sh — the production path. The advertised IP is a RUNTIME value
(OPENFUT_ADVERTISE), unknown at image-build time, so the cert is now reconciled at
startup: reissued with IP:$ADV,IP:127.0.0.1 in the SAN only when the current cert
lacks it. That makes a restart reuse the same cert (no per-start fingerprint churn,
which would otherwise recreate the Aug-13 surprise) and self-heal if $ADV changes.
* Dockerfile — installs openssl unconditionally so the entrypoint can reissue at
runtime (previously it was dropped with the apt lists), and bakes a loopback-IP
baseline cert so a plain `docker build` still yields a usable image.
* openfut-fut.sh — the local orchestrator. ensure_cert now defaults the SAN IP to this
host's primary LAN IP (OPENFUT_ADVERTISE overrides) and reissues when the cert lacks
it, instead of only generating when the file is absent.
Verified without the client, which is the strongest evidence obtainable here: a
verifying TLS client checking the cert BY IP rejects the old DNS-only cert ("IP address
mismatch, certificate is not valid for '10.10.0.120'") and accepts the new
IP-bearing cert; and the entrypoint reconcile is idempotent end to end — an old cert is
reissued to carry IP:$ADV, a simulated restart leaves the fingerprint unchanged, and
the final SAN carries both the advertised and loopback IPs.
Live confirmation needs the production container rebuilt with OPENFUT_ADVERTISE set
(operator-gated); production is otherwise untouched.
entrypoint.sh carries unrelated pre-existing uncommitted work (env-based component
selection) that is not on any branch; only the cert-reconcile block is committed here,
and that work is left intact in the working tree.
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).
openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.
openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').
The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.
VERIFICATION — the ports are checked against the Python, not against themselves:
* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
constants, which proves consistency, not parity, and the Python cannot run here
(pycryptodome absent) with the client host unreachable. So the LCG and key derivation
were transcribed from the Python and run as plain arithmetic, and every AES value came
from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
netns, so the smoke test runs under `unshare -n` — the real binary on the port the
client actually dials, with no port-override hack and no risk to production. A
hand-written client read the unprompted <Challenge>, completed the handshake, and
decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
the exact Python message, dual stdout+logfile output, and a missing value rejected
with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
every constant by executing the Python module and drove the binary against a synthetic
client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
own parser fixture, so backend capability registration still works.
Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
Recovered the client's own dialog text from memory rather than inferring from the
server, which is what finally identified the subsystem: "An error occurred downloading
the FUT Squad Update" is the ROSTER update, not the player's lineup. Four squad-shaped
fixes before that were aimed at the wrong thing.
Wire capture shows the client aborting the handshake itself: it offers TLS1.2 with RSA
suites, the server selects TLS1.2 and sends its certificate, and the client replies
fatal certificate_unknown. So protocol and ciphers are compatible and the certificate
is the problem. That certificate is DNS-SAN-only while the advertised ROSTERUPDATE_URL
is an IP literal, and it was regenerated Aug 13 -- after the Aug 12 session being used
as the known-good control, which therefore says nothing about the current cert.
Notably autopatch DID patch both ProtoSSL gates in the failing process (log line plus
live bytes reading back patched) and the client still rejected, so those gates do not
govern this path -- contradicting roster_server.py's standing comment that they make
self-signed certs acceptable.
Documents what was ruled out with evidence (hub route shapes, squad shape, squad
round-trip, advertised hosts, TLS version, Blaze health), the probing gotcha that a
default modern TLS context misreports this server as broken, the unresolved question of
why production appears unaffected, and three fix options with a recommendation. No fix
applied.
Three diagnostics from chasing a FUT error that four server-side fixes failed to
resolve, kept because the technique generalises.
client-error-string.py recovers FIFA's on-screen message from /proc/<pid>/mem,
read-only, scanning ASCII and UTF-16LE (FIFA UI strings are wide). This ended the
guessing: the dialog reads "An error occurred downloading the FUT Squad Update.
Please try again." -- a CONTENT DOWNLOAD failure, not the player's lineup. Every
squad fix before it was aimed at the wrong subsystem, because "squad update" in FIFA
means the roster update, and the server-side symptom (a squad the client would not
accept) was consistent with both readings. When the server says 200 and the client
says no, the client's own words are the cheapest evidence available and should have
been the FIRST thing recovered, not the fifth.
hub-dump.py + hub-diff-prod-staging.py diff every hub route between production
(known-good, same client accepts it) and staging, comparing key presence and JSON
types rather than values, since values legitimately differ. Result: 0 structural
differences across 14 routes, which retired the whole "a missing field breaks
bootstrap" line of investigation in one run instead of one restart at a time.
Also ruled out with evidence: cert gates ARE patched (autopatch logs
"pid 56298: PATCHED cert gates", and the gate bytes read back as the patched
patterns); the roster server serves the FUT Squad Update fine (TLS1.2
AES256-GCM-SHA384, HTTP/1.0 200, application/xml) once probed with
ALL:@SECLEVEL=0 -- a default modern context gets SSLV3_ALERT_HANDSHAKE_FAILURE and
would have been a false alarm; production and staging Blaze advertise identical
roster/POW hosts; the Blaze session is healthy and answering PINGs; and the squad
round-trips exactly through PUT/GET.
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.
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.
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.
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.
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.
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.
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.
`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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Bumps openfut-launcher to 357501f: Welcome/"Get started" onboarding, Settings as
the single owner of the server address, server-authoritative account claiming,
and `openfut.cfg` reconciled before every launch so a settings change can no
longer leave FIFA pointed at the previous server. Cargo.lock picks up
parking_lot for the launcher (already used by openfut-utas-host and
openfut-identity).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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 f2c4927 worked around.
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.
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.
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).
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.
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 c277213 (shareholder-grade redesign + live account panel).
Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
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).
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).
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).
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.
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 ca7ce26 -> 0d3f33c.
Global MY-CLUB stat set now Rust (staging-verified RUST route=club-stats
owned=1982 players=1962); only club/stats/{country,league,team} nation-bucket
context sub-screens remain proxied (low value, inert atoms).
Migrate the MY CLUB stat set from the Python proxy to a Rust handler computing
Core-accurate counts: player tiers + rare from the collection, staff/consumable
families from catalog kind+subtype, per-nation buckets via the reverse entity
resolver. Faithful port of fut_club_stats.py (VOCAB + global_counts +
context_rows). Unlike the oracle (stale profile + synthetic consumable shelf),
this reflects the real imported content (incl. the content-gap consumables/staff).
Fail-closed 503 on Core error. club/stats/country|league|team sub-screens remain
Python (documented). Adds adapter club_stats module (5 tests), host handler +
classify arm + resolver subtype_of/rareflag_of, ownership + integration tests;
reachability tool splits club/stats global(migrated) vs context(residual).
Migrate GET /hub from the Python proxy to a Rust handler deriving counts from
authoritative state: clubPlayers = owned PLAYER cards in Core (consumables/staff
excluded via catalog kind; may be lower than Python's profile count by the
deferred Legend instances = DIFFERENT-BY-DESIGN), auction/tradePile counts from
the durable market store via the async bridge. Fail-closed 503 on Core error;
market read failure degrades cosmetic counts to 0. Adds classify arm, handler,
ownership + integration tests; reachability tool marks hub migrated.
Migrate GET club/stats/staff from the Python proxy to a Rust static handler.
The production oracle returns {} for the staff-bonus stat set (deliberately
empty); the Rust host now owns it (owner=RUST route=club-stats-staff). Updates
classify(), the pre-existing near-miss test (now club/stats/year), the ownership
matrix test, and the no-fallback integration test. club/stats/{year,consumables}
remain Python (aggregation) pending the Core-derived club-stats migration.
Through real handle_with_ip dispatch against live Core:
- pure_economy_routes gains the retail v2 Store family (transaction/0,
purchasegroup, purchased GET/POST) so NEVER-BOTH + no-fallback assert them
Rust-owned (Python proxy count 0) and fail-closed 503 on dead Core.
- Part-7 repro: PUT /ut/v2/game/fifa17/store/transaction/0 returns a Rust
createPackResponse + debits Core (NOT the Python TRANSACTIONCANCEL no-op).
- retail_v2_store_flow_matches_v1_through_dispatch: v1 and v2 BUY of the same
pack debit + mint identically; v2 purchasegroup + reveal Rust-owned.
Live staging (S2) showed the retail FIFA17 client issues the Store family
under /ut/v2/game/<sku>/... (PUT /ut/v2/game/fifa17/store/transaction/0),
which escaped Rust economy authority to Python. Fix classify_economy:
- ut_tail() normalizes both /ut/game/<sku>/ and /ut/v2/game/<sku>/ to the
same tail (generic sku, never hard-coded fifa17); delete family likewise
accepts /ut/v2/delete/game/.
- StoreBuy matches store/transaction and store/transaction/<digits> via a
bounded is_store_transaction_tail (never store/transactions, ...foo, or
.../<id>/extra), mirroring the Python bare /store/transaction regex.
Adds table-driven ut_tail + is_store_transaction_tail + classify_economy v2
unit tests (lib 74).
barrier_never_both_no_fallback_and_stale_reader drives the REAL post-barrier
handle_with_ip against a live in-process Core + a mock Python upstream that
counts every request and answers with a coins=111 marker:
- STALE READER: credits + userMassInfo show the Core balance, never 111
(userMassInfo proxies the Python envelope but the Rust economy overlay wins).
- NEVER BOTH (Core up): every pure economy route returns a Rust body (no
__python__ marker) and the Python proxy call-count stays 0.
- NO FALLBACK: a server pointed at a dead Core port (built without probing Core:
empty catalog + empty pool) fails closed (credits/match -> 503) and STILL never
proxies to Python (call-count unchanged).
Also parametrizes build_econ_server's pass URL so the mock upstream can be
injected. host 71 lib + 4 integration + differential + concurrency + failure +
24 host_test all green; clippy -D warnings + fmt clean.
The economy authority barrier. `handle_with_ip` now dispatches every
economy-touching route to Rust/Core via `try_handle_economy` BEFORE consulting
`classify()`, so a migrated route can never also reach the Python passthrough
(NEVER BOTH). With economy services wired (production `from_config`) an economy
route ALWAYS returns Some — fail-closed 503 on any Core error — so there is no
Python economy fallback. `userMassInfo` stays a hybrid by design: Python
supplies the non-economy envelope; Rust overlays BOTH the squad and the economy
fields (coins + unopened-pack count from Core), so no stale Python economy value
is visible.
Routing only — no handler/test changes buried here. Routes now Rust-owned:
/user/credits, /store/purchasegroup, /store/transaction, POST+GET /purchased,
PUT /item, item DELETE forms, /match (ut/delete), /auctionhouse, /tradePile,
/trade, ut/delete trade; plus the userMassInfo economy overlay.
openfut-import-fifa17/tests/durable_import.rs drives the REAL import pipeline
(analyze -> Report dry-run; emit_content; plan_apply -> GenericImportRequest +
deterministic identity mappings + watermark; the staging/preflight/seed/
post-validate gates over a real JsonIdentityStore; openfut_core::services::
import::apply_profile_import in one Core SQLite tx) against a disposable
temp-file Core DB, from a small sanitized in-test fixture (750000 coins, 3
resolvable base players, unopenedPackIds [70,70,101], one squad).
Five ordered steps on one durable target, all green:
A dry-run: report exposes persona/coins/inventory/unopened/fingerprint; ZERO
DB mutation (all Core tables COUNT=0, identity store empty).
B apply: coins=750000 exact, owned=3, packs=3 (opened=0), squad_players=2,
deterministic owned ids, import_fingerprint recorded, identities reverse-
resolve both ways, watermark=100000600.
C restart: close+reopen the SAME sqlite file -> identical state.
D re-apply same source -> AlreadyImported (fingerprint), no doubling.
E conflict (coins 750000->750001 flips the fingerprint for the same game)
-> apply fails closed ('different source'); DB unchanged.
Fingerprint = FNV-1a-64 hex of the source snapshot, carried into
ProfileImportRequest.source_fingerprint = Core profiles.import_fingerprint, the
per-game rerun-identity key. dev-deps added to openfut-import-fifa17
(openfut-core path, tokio, sqlx). No production/live data.
economy_differential.rs boots the REAL Python oracle (fifa17-recon/tools/
utas_server.py) as an isolated subprocess (env FUT_PROFILE/FUT_ACCOUNT_PATH/
FUT_PORT into a temp dir + loopback port; no production container/port/save;
killed on Drop) AND the real Rust stack (seeded in-process Core + a real Server
with EconomyServices), seeds a semantically-aligned fixture on both, and drives
16 ops through the REAL surfaces (oracle over HTTP; Rust via
Server::try_handle_economy on the off-runtime thread).
Result: 15 PARITY, 1 DIFFERENT-BY-DESIGN.
- PARITY: credits, userMassInfo economy, purchasegroup (pack70/sentinel/clean-v1
incl. the real SessionStore capability handshake), Store BUY, POST /purchased
open, GET /purchased reveal (VERIFIED: durable single-profile purchased pile
on BOTH — the hypothesised per-SID cache does NOT exist, so PARITY not
DIFFERENT-BY-DESIGN), quick-sell (both forms), move, match WIN (+400 byte
shape), market list/query/cancel.
- DIFFERENT-BY-DESIGN: market second-buy. First buy debits buyNowPrice + closes
on both. Rust's MarketStore is a crash-consistent single-debit ledger (second
buy of a sold listing = no-op, pinned by assertion); the oracle's buyable
market is a stateless PACK_POOL sample that re-debits on repeat. Compat impact
NONE (buy-now is one-shot); Rust is a strict correctness improvement.
No Python source changes; no classifier changes. Deterministic (3 runs).
Two real host-dispatch test files (no fakes) driving Server::try_handle_economy
against a live in-process Core over the real blocking client + durable
MarketStore/PileStore + JsonIdentityStore, each racer its own OS thread
(off-runtime pattern).
economy_concurrency.rs — 8 races x 50 iterations:
A two BUYs (coins for one) -> exactly one 200 + one 461, final 0, one debit.
B duplicate owned-pack open -> one redemption, +11 once, entitlement once.
C duplicate quick-sell -> one sell + one credit + one removal.
D two market buyers -> one win, one debit, one mint, sold once.
E reward+BUY -> no lost update (Core relative UPDATE under BEGIN IMMEDIATE).
F move+quick-sell / G list+quick-sell -> one coherent transition.
H 1000 concurrent mints -> unique + reversible wire ids, monotonic watermark.
economy_failure.rs — 10 fault-injection sub-cases, all fail-closed:
BUY/open-redeem/generator/pile/identity, quick-sell, move, market
reserve/purchase/complete. CRITICAL complete-sale-after-commit = SAFE: the
listing is left `reserved` (not active), so the active->reserved reserve CAS
can never win again -> not buyable, exactly one debit + one mint. No E3.
Fault injection uses test-file CoreEconomy/ExternalIdentityStore doubles plus a
NARROW, inert-by-default `StoreFault` seam in market_store.rs + pile_store.rs
(the concrete stores have no trait boundary; 3 `tripped()` checks + a field,
zero behaviour unless a test arms it). `parking_lot` promoted to a normal dep
(the seam's Mutex is used at lib scope). Classifier/ROUTE_AUTHORITY/Python
untouched. host lib 71/71; both new tests pass.
Wire the PRODUCTION constructor so the economy authority is not test-only.
Server::from_config now builds one process-lifetime AsyncBridge, opens the
durable MarketStore + PileStore (paths from config), shares one HttpCoreClient
as both CoreAccess and CoreEconomy, builds the content pool from Core, and
attaches EconomyServices via with_economy. Stores/bridge are host-lifetime, never
per request.
- config.rs: required OPENFUT_MARKET_DB / OPENFUT_PILE_DB (durable file paths;
must survive host restart — no temp defaults).
- Fail-closed startup: a bridge/store that cannot initialize returns Err from
from_config (host refuses to start) — NEVER a silent omission or a Python
economy fallback.
Test: from_config_constructs_and_serves_economy — builds the Server via the REAL
from_config (disposable config: temp market/pile/identity + a catalog file
derived from seeded content + the real tables dir) against a live Core, drives
credits / purchasegroup / Store BUY / market list-query-buy through it, then
rebuilds from the SAME config after a Core restart and asserts the balance
persisted. host 71 lib + 3 integration + 24 host_test green; clippy/fmt clean.
Closes the reveal contract gap: POST /purchased opens a pack and returns
metadata; the client then polls GET /purchased for the opened items. Store BUY
returns items inline, but owned reward-pack (e.g. pack 70) opens had no reveal
read path, so a real FIFA session would show nothing after opening.
Faithful to the Python oracle (fut_store.last_pack / purchased pile): the reveal
is the set of owned items currently in the FIFA "purchased" pile — durable,
idempotent on repeat GET, cleared per-item when a card is moved to the club, and
appended-to by each open. Not a replay cache; presentation state derived from
the durable pile store + Core inventory.
- pile_store.rs: `list_by_pile(pile) -> Vec<core_item_id>` (reveal membership).
- economy_store.rs: `PurchasedPileSink` trait + optional `StoreDeps.purchased`;
handle_store_buy/handle_pack_open record each minted item into the "purchased"
pile. `shape_purchased_reveal` (pure): filter Core inventory to the purchased
pile, shape with the SAME `shape_club_response` /club uses. Grants nothing,
consumes no entitlement, allocates no id, moves no coins.
- lib.rs: EconomyRoute::PackReveal + classify_economy (GET purchased);
BridgedPurchasedSink (records via the runtime bridge from the sync dispatch
thread); dispatch reads the pile async + Core inventory sync + pure-shapes.
Scoping: single fifa17 profile/club (like the Python oracle), so all sessions
share one purchased pile — DIFFERENT-BY-DESIGN vs a per-SID cache, matching the
oracle's single-profile model.
Tests: pile_store::list_by_pile_filters_and_reflects_moves; and the dispatch E2E
now opens pack 70 (entitlement seeded via the Core economy API) and asserts GET
/purchased reveals the opened items and is idempotent on repeat. host 71 lib +
2 integration + 24 host_test green; clippy -D warnings + fmt clean.
Closes the market correctness gap: handle_market_list recorded listing.card_id
from the raw FIFA wire resourceId, so a synthetic buy minted a card_id Core
could not resolve — it survived the immediate response but Core's content
preflight rejected it on reboot.
- catalog.rs: keep the by_resource reverse index (was built then discarded) and
expose `card_id_for_resource(resource_id) -> Option<&str>` — exact reverse of
the card_id->asset catalog, no heuristics, unknown => None.
- lib.rs: `impl MarketCardResolver for Fifa17IdentityResolver` delegates to the
same catalog /club shaping uses; Core never sees a FIFA resource id.
- market_store.rs: listings now carry BOTH `card_id` (authoritative Core content,
what a buy MINTS) and `wire_resource_id` (the FIFA wire id, echoed in the
auction record). New column; create_listing takes both; row/Listing updated.
- market.rs: `MarketCardResolver` trait; handle_market_list resolves resourceId
-> Core card_id and fails closed (persists nothing) on an unmappable resource;
auction_record emits `resourceId` from wire_resource_id. Dispatch passes the
resolver.
Tests: list_unknown_resource_fails_closed_no_listing (B),
list_persists_core_card_and_wire_resource_across_reopen (C), catalog reverse
lookup; and the dispatch E2E now RESTORES the full Core+store restart
(economy_full_sequence_through_dispatch_and_restart) — the synthetic buy mints a
real reverse-mapped card_id, so Core's content preflight passes on reboot (A+D).
market 23 lib + catalog 15 + 2 integration green; clippy -D warnings + fmt clean.
Records the AsyncBridge + classify_economy + try_handle_economy dispatch
(unrouted) and the economy_full_sequence_through_dispatch E2E, the
reqwest-blocking-in-async fix (off_runtime), and narrows Remaining to: Python
differential, host concurrency matrix, failure injection, importer, then the
from_config attachment + classifier barrier + reachability proofs. Flags the
two market-handler gaps (resourceId->card_id mapping; GET /purchased reveal
cache).
Bridges the synchronous thread-per-connection host to the async
transfer-market/pile handlers WITHOUT flipping the classifier. classify()
is untouched; production still proxies every economy route to Python. The
new dispatch is exercised only by the integration harness via
Server::try_handle_economy — handler wiring, not authority cutover.
async_bridge.rs: AsyncBridge owns ONE process-lifetime multi-threaded Tokio
runtime, shared by every connection via Arc. block_on() runs a future from
the sync dispatch thread; if invoked from within an ambient runtime it
offloads onto its own runtime + a std channel instead of panicking
("cannot start a runtime from within a runtime"). 4 unit tests incl. the
nested-runtime-safety case and concurrent multi-thread drivers.
lib.rs: EconomyRoute + classify_economy (mirrors the Python route table:
credits, purchasegroup, store/transaction, purchased, item DELETE/PUT,
ut/delete match/item/trade, auctionhouse/transfermarket, tradePile, trade).
EconomyServices (Core econ transport + durable MarketStore/PileStore + the
bridge + the pack-content pool), attached via Server::with_economy (kept out
of `new`/`from_config` so existing tests build a DB-less Server; production
from_config attachment is the barrier step). Server::try_handle_economy
dispatches: sync handlers (credits/purchasegroup/store-buy/pack-open/
quick-sell/match) inline; async handlers (market list/query/buy/cancel,
move) on the bridge via owned `async move` blocks. build_content_pool
derives the resolvable FIFA∩Core candidate pool from Core content.
market.rs: FIX the load-bearing hazard the FakeEconomy tests missed — the
async market handlers call the BLOCKING reqwest Core client, which panics
(reqwest::blocking::wait::enter) when run while a Tokio runtime is entered.
off_runtime() hops each Core call to a fresh OS thread with no runtime
entered, so blocking is legal. handle_move_items resolver gains `+ Sync`
(future must be Send for the bridge).
tests/economy_integration.rs: economy_full_sequence_through_dispatch drives
the WHOLE cluster through the REAL Server dispatch + bridge against a live
in-process Core (seeded with fifa17 dev content: 100k coins + owned cards),
on a plain OS thread (direct bridge path), over the real blocking
HttpCoreClient — no fakes: Store BUY (pool draw + shape + debit 400 + mint 5),
credits, quick-sell (reverse-resolve + credit), match WIN (+400), market
list->query->buy->query(sold)->second-buy-fails(no double debit), cancel
(cancelled not buyable), move-items, then reopen the durable market/pile
stores from disk (sold + pile persist). Deterministic. start_core_seeded
loads fifa17 dev content so /collection renders real definitions.
Tests: host 68 lib (+4 bridge) + 2 economy_integration + 24 host_test, all
green; adapter unchanged-green; clippy -D warnings + fmt clean.
Update cutover progress: Store BUY/pack-open/quick-sell, market
list/query/cancel/buy + move-items, durable listing/pile stores, and the
pack-content generator are implemented + tested (4d2b8b9) but NOT routed.
Remaining before the single classifier barrier: sync<->async Server wiring +
classify() routes, host<->Core writer E2E, Python differential, host
concurrency matrix, importer restart/idempotency, then the barrier + no-fallback
proofs.
The fresh-DB write-lock race is fixed (core fbb54ea: BEGIN IMMEDIATE writes),
so the E2E+restart harness now uses max_connections=5; 10/10 deterministic.
Complete the transport contract: purchase_items (atomic debit + mint N) on the
CoreEconomy trait + HttpCoreClient (POST /economy/purchase-items) + FakeEconomy.
This is the open-on-buy primitive the Store BUY handler will use (createPackResponse
returns the minted itemList). Fail-closed like the rest of the client.
WAL-establish-once + busy_timeout (core 75b1830) reduced but did not eliminate a
brand-new-DB multi-connection warm-up 'database error'; the E2E+restart harness
stays on a single serialized connection for determinism, and the residual
multi-connection concurrency issue is documented as the remaining Part-T blocker.
Serialize Core access with a single pooled connection (the harness drives Core
sequentially via the blocking client) to avoid a WAL-mode-establishment race
across connections warming up on a brand-new DB file, and raise the readiness
ceiling for heavy parallel test-binary load. 10/10 deterministic. (Fixed
alongside a real Core robustness fix: per-connection pragmas + busy_timeout,
core 0360135.)
Spawn Core (axum) on an ephemeral loopback port backed by a disposable temp-file
SQLite, seed a fifa17 profile via the real Core HTTP API, then drive the HOST's
REAL transport (HttpCoreClient: CoreEconomy) + handlers against it — no fakes:
credits reads Core balance; match-reward writer credits via Core grant_reward;
purchasegroup full-gen renders the owned pack from a Core entitlement (no
sentinel); userMassInfo overlay derives coins from the same Core state
(credits==massinfo==Core invariant). Restart phase reboots Core from the same
on-disk DB and proves coins + entitlements persist. Temp dir + 127.0.0.1:0 only;
no prod DB/ports/containers/.105. dev-deps: openfut-core, tokio, axum.
Carry unopenedPackIds through the importer: Profile model -> Report ->
ApplyPlan. GenericImportRequest now emits entitlements[] (one definition_id
per unopened pack instance, order+duplicates preserved), which Core's import
seeds as unconsumed packs rows in the same transaction. Closes the economy
import gap so a migrated profile's unopened packs become Core entitlements
(feeding purchasegroup/credits/userMassInfo). Idempotency unchanged (import
fingerprint). +1 test; 26 pass, clippy -D warnings clean.
All Core-backed, fail-closed (503, never Python), NOT yet classifier-routed
(coherent barrier pending full cluster + Core seed):
- handle_purchasegroup: full Rust body from Core entitlements + StoreMode via
the oracle-fixture-tested build_purchasegroup (no Python body dependency).
- overlay_massinfo_economy: set userInfo.currencies coins + unopenedPacks
recoveredPacks from Core, preserving all other fields.
- handle_match_end + build_match_reward_body: derive outcome from endReason,
credit via Core grant_reward, oracle-shaped destroy_match_body.
Invariant test: credits == userMassInfo == purchasegroup all read one Core state.
7 new host tests (+ FakeEconomy write methods honor fail flag).
Add CoreEconomy transport (trait + HttpCoreClient impl over Core /economy/*):
balance, entitlements, purchase_entitlement, redeem_entitlement, sell_item,
grant_reward, purchase_item. Fail-closed by contract: any transport/status/parse
error surfaces a controlled error and NEVER falls back to Python (a fallback
would be a second writer).
Add the credits reader vertical: build_credits_body (byte-shape-identical to the
Python oracle: credits + currencies[].funds/finalFunds + optional
unopenedPacks.recoveredPacks) and handle_credits (coins = Core balance,
recoveredPacks = Core entitlement count; 503 fail-closed on Core error). Not yet
classifier-routed: the coins cluster flips as one coherent barrier once every
writer+reader moves together and Core is seeded. FakeEconomy double + 3 tests
(oracle shape, Core-backed read, fail-closed).
Advance openfut-core gitlink to d32dc6e: generic /economy/* HTTP routes over
services::economy, server-side club resolution (game-scoped active profile, no
client-supplied club id), + list_unopened_entitlements reader. This is the
transport the FIFA17 economy cutover binds to. Core matrix 43 lib + 115
integration green; clippy clean; boundary audit clean.
Machine-auditable ownership table for every FIFA17 UTAS route touching the
economy cluster (coins/inventory/entitlements), plus the writer->Core-primitive
map and the single-writer rule. Grounded in the Python economy writer audit:
4 coin-mutation routes (match reward, pack BUY, quick-sell, market buy-now),
synthetic-seller market (no sale-credit/expiry/fee), dead grant_coins/
grant_unopened_pack, no points writer. This is the deployment gate: no proxied
Python route may touch Core-owned state before R1.
Advance openfut-core gitlink to c8269d0, which adds services::economy::purchase_item
(atomic debit + mint) — the generic Core primitive the FIFA17 synthetic-seller
transfer market needs. Evidence: the Python economy audit proved market buy-now
mints a new item with no real counterparty, so debit+mint (not two-party transfer)
is the correct generic model. Core matrix + clippy green.
Advance the openfut-core gitlink 3084a46 -> ee2caa0. This does two things:
1. Reconciliation: moves the canonical Core lineage onto the committed,
validated migration trunk (66c88fb: game-scoped opaque extension,
inventory service, squad-ext routes, content-pack loader, generic
transactional profile-import service). The divergent local Core refactor
that was dirtying the eab522a checkout is preserved verbatim on branch
wip/core-local-development (f70cf44) for separate reconciliation; nothing
is lost.
2. Economy foundation: ee2caa0 adds services::economy, a generic atomic
profile-economy authority (currency/inventory/entitlements over the
existing durable tables, single-transaction compound ops, fail-closed).
The FIFA17 adapter economy engine sits on top of these primitives.
Core builds green; full matrix 41 lib + 111 integration + 16 = all pass;
clippy clean.
Adds openfut-adapter-fifa17 fut::economy — the single-writer FIFA 17 economy engine
the eventual cluster cutover needs: coins + unopened-pack entitlements + owned
inventory + stable item ids, with all-or-nothing transactional mutations faithfully
ported from the Python oracle's fut_store.Store primitives.
Atomic ops: debit (fail-closed), credit, grant_pack/consume_pack (consume-once),
allocate_item_id (unique/monotonic), add_item, and composed transactions buy_pack,
open_pack, quick_sell, market_buy_now, grant_reward. Fail-closed everywhere; the
65534 sentinel can never be bought/granted/opened (defense at the grant primitive,
mirroring grant_unopened_pack rejecting non-catalogue ids). from_fut_profile importer
round-trips coins/unopenedPackIds/items/nextItemId and floors nextItemId past the
highest existing id so re-import cannot mint a duplicate. 10 unit tests (atomicity,
sentinel safety, consume-once, quick-sell, item-id uniqueness, import round-trip).
NOT wired (R3): the live coin balance is one indivisible writer set spanning Store
BUY, pack-open, quick-sell, match rewards AND the transfer market, all in
fut_profile.json; and the generic home (OpenFUT Core) is a preserved-dirty/frozen
submodule. So a safe single-writer cutover cannot be wired yet — this engine +
importer is the coherent prerequisite. No dual-write introduced. No deployment.
Adds openfut-adapter-fifa17 fut::store_catalog — a pure, faithful Rust port of the
Python oracle's PACK_CATALOG + _pack_body + store_catalog assembly at production
flag defaults (FUT_STORE_DISPLAYGROUP=1, GROUPID=0, PRICE_PROBE=0):
- PackDef + PACK_CATALOG (ids 1/5/6/7/70; economy numbers are OpenFUT PLACEHOLDER,
wire shape is oracle-verified; 65534 deliberately absent).
- pack_body() (_pack_body port), sentinel_body() (id-65534 compatibility shim),
build_purchasegroup(unopened_ids, StoreMode) mirroring store_catalog(3627).
- Differential parity: fixtures generated from the Python oracle
(tests/fixtures/purchasegroup_{zero_sentinel,zero_clean,pack70}.json); Rust output
matches semantically (6 tests). Adapter 140 tests, host 24, fmt/clippy clean,
Python A-R oracle green.
PURE wire shaping — NOT wired into the live host. Serving purchasegroup from Rust
requires an authoritative Rust owner of unopenedPackIds, which is blocked on the
economy-authority prerequisite (R3): coins are one shared balance written by many
Python-oracle routes (BUY spend, quick-sell credit, SBC/match/objective rewards)
persisted to fut_profile.json, so no single coin-touching route can move without a
whole-cluster migration. No dual-write introduced; no production deployment.
openfut-utas-host now classifies and owns three routes, wiring the landed
adapter store_session state machine while keeping the Store economy Python's:
- POST /ut/auth: proxy to Python (which mints X-UT-SID, adopts persona, refreshes
save), OBSERVE the returned sid, and open a Rust session bound to peer IP +
configured persona. Account/economy authority stays Python.
- POST /openfut/fifa17/capability: Rust-owned, no proxy — validate + register into
SessionStore (bound/pending/ignored-late); fail-closed 400 on unsupported.
- GET .../store/purchasegroup: proxy to Python for the authoritative economy body,
then overlay ONLY the empty-My-Packs topology from the frozen session mode —
strip the 65534 sentinel for a verified clean-v1 SID, keep it otherwise. Rust
never writes economy state.
Session state (Arc<Mutex<SessionStore>> + monotonic clock) lives on Server; new()
and from_config() initialise it (signatures unchanged). handle() gains a peer-IP
variant (handle_with_ip) threaded from handle_conn. Strict never-both routing is
preserved. Pure helpers (observe_sid, parse_capability_request, overlay_empty_mypacks)
+ classifier are unit-tested; adapter+host tests + Python A-R oracle all pass.
STOP-GATE: Store BUY / coins / unopenedPackIds NOT migrated — Rust has no
authoritative FIFA17 economy-mutation path (Python fut_profile.json is the source;
Core's economy is separate/unwired), so moving BUY would split store authority.
That cluster migration is the remaining R2 gap. No production deployment.
Ports the novel per-session empty-My-Packs capability negotiation — proven live
on staging and currently Python-only (fifa17-recon/tools/utas_server.py) — into
the production Rust FIFA17 adapter as a pure, dependency-free state machine
(openfut-adapter-fifa17 fut::store_session).
It owns: per-login X-UT-SID session table, single-use (ip,persona) launcher
capability hand-off (pending), capability binding (bound/pending/ignored-late),
the once-per-session clean-v1 vs sentinel freeze, TTL reaping, and fail-closed
rules (unknown/expired/ambiguous/late/cross-session/sid-ip-mismatch -> sentinel).
The clock and SID entropy are injected so it is fully unit-testable.
The full Python capability-negotiation matrix A-R is ported as Rust unit tests
(21 pass). Python remains the behavioural oracle. The delicate _pack_body UTAS
wire shaping, /ut/auth persona-adoption, /store/purchasegroup catalogue assembly
and the store BUY path are deliberately NOT ported here (documented gap); wiring
the three routes into openfut-utas-host without splitting store authority is the
remaining bounded slice toward full Rust authority. No production deployment.
Point the launcher gitlink at the reconciled merge ca7ce26
(integration/fifa17-launcher-capability-sbc), which retains BOTH launcher lineages:
- 13339c1 FIFA 17 verified patched-client capability reporting
- 958ff245 openfut-hook SBC request tracing / RE instrumentation
The previously-uncommitted openfut-hook WIP that blocked this move is preserved on the
submodule branch wip/openfut-hook-local (commit 4e44a37) + /tmp/openfut-hook-wip-preserved.patch
(sha256 8e65de2c…) + the untracked server.rs copy. Gitlink-only change; no other superproject
dirt staged. Backend/production unchanged.
Record the overnight launcher-lineage reconciliation (merge ca7ce26 retaining both
feat/launcher-arming 13339c1 and feat/sbc-hook-tracing 958ff24; only src/process.rs
conflict, resolved keep-deleted), the deferred superproject gitlink bump (blocked by
uncommitted openfut-hook WIP overlapping the merged hook content), the validated
deployment-candidate commit tuple + local build artifacts, and the controlled A/B/C
deployment sequence. Production stays P2 active-sentinel until the A/B passes.
Case R makes the F3 session-topology invariant explicit alongside the A-Q matrix:
for a single X-UT-SID the frozen empty-My-Packs mode never flips in either
direction (Sentinel stays Sentinel even if a capability later appears; Clean stays
Clean even if the capability is wiped), while a fresh SID from the same IP decides
independently. Complements F/G/K.
Record the per-IP -> per-session correction: why source-IP-only was unsafe (two
FIFA processes share an IP), the authoritative per-login X-UT-SID key with IP and
persona as auxiliary, the Capability/StoreMode state machine, the single-use
short-TTL launcher->session pending hand-off, activity-based session cleanup, and
the documented fail-closed residual for genuinely simultaneous same-(ip,persona)
logins. Design history is retained; the per-IP prototype is marked superseded.
Harden the empty-My-Packs capability binding so a verified FIFA process can never
enable clean/no-sentinel Store topology for another unverified process that merely
shares its source IP. The prototype keyed the decision by source IP alone; two FIFA
processes (concurrent, or a relaunch) share an IP, so an unpatched process could
inherit a patched one's clean-v1 mode and crash. Source IP is now auxiliary only.
- Authoritative key = the per-login UTAS session id (X-UT-SID). /ut/auth now mints
a fresh unique SID per login (was a shared constant) and opens a session record
keyed by that SID; the client echoes it on every later call incl.
/store/purchasegroup (live-confirmed). The legacy constant is still accepted by
the retired security-question gate only, never to grant clean-v1.
- Session state: _FIFA17_SESSIONS[sid] = {ip, persona, resolver, mode, created,
last_seen}. Store mode freezes at the first /store/purchasegroup of the session
and is immutable thereafter. Fail-closed: unknown SID, or a SID presented from a
different source IP than it was opened on, resolves to the sentinel.
- Launcher capability (out-of-band; cannot know the SID) is matched by (ip, persona)
as a SINGLE-USE, short-TTL pending, bound to exactly one session at whichever comes
first: its login (pending predates auth), the registration (session already live),
or its first store request. Ambiguous same-(ip,persona) concurrent registration is
ignored-late -> both sentinel (never a wrong clean).
- Session cleanup: activity-based TTL sweep (sessions 3600s idle, pendings 120s);
reaping only removes expired entries and never affects another live session.
- account_sync now clears only stale pending for the machine (pre-launch hygiene);
it no longer resets a per-IP mode (there is no per-IP mode any more).
Backend-only: the launcher registration payload (already carries personaId) is
unchanged. Additive; P2 sentinel remains the else-branch and the default.
Tests: matrix A-Q incl. same-IP concurrent (K), same-IP+persona relaunch (L),
same-IP failed-patch (M), late-registration-vs-frozen-sessions (N), TTL expiry (O),
duplicate/idempotent registration (P), and register-before-login pending (Q).
Design + cross-component contract for verified patched-client capability
negotiation: architecture inventory, transport choice (autopatch stdout ->
launcher, sibling /openfut/fifa17/capability endpoint), the versioned capability
and its VERIFIED semantics, autopatch verification states, launcher per-process
state, source-IP binding, the session-stable freeze point, the additive store
switch, the trust model (local preservation, not attestation), the fail-closed
matrix, and P2 retention.
Backend side of the handshake: suppress the synthetic 65534 My-Packs sentinel
ONLY for a session whose client has registered a verified resolver-guard
capability. Additive; the P2 active-sentinel path is retained as the else-branch
and the universal default. Fail-closed everywhere.
- Per-client state keyed by source IP (client_address[0]; the only per-connection
discriminator in this single-account, stateless backend): _FIFA17_STORE[ip] =
{resolver, mode}; mode in {None, "sentinel", "clean-v1"}, guarded by a lock.
- New POST /openfut/fifa17/capability endpoint: accepts only
{"capability":"empty_mypacks_resolver","version":1,...}; unknown capability or
version => 400 and records nothing (=> sentinel).
- account_sync (the launcher's required per-launch call) resets the per-ip record
=> a new FIFA process starts unfrozen with no inherited capability.
- Store topology is frozen at the FIRST /store/purchasegroup per session:
clean-v1 iff a v1 capability is registered, else sentinel; immutable thereafter
(late capability logged + ignored this session; a disappeared capability does
not un-freeze a clean session). This enforces the SESSION-STABLE invariant.
- store_catalog zero-owned-packs branch: clean-v1 emits NO mypacks group (the
client guard routes category -1 to Browse); every other case emits the existing
active 65534 sentinel verbatim. PACK_CATALOG / pack 70 / normal packs / profile
untouched. FIFA-17 only; not lifted into game-independent Core.
- Tests: full matrix A-J incl. concurrency isolation (two IPs, no global leak) and
no cross-process capability leak.
Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
autopatch side of the verified patched-client capability handshake: prove, at
runtime, that the empty-My-Packs resolver guard is active for a specific FIFA
process, and advertise it once on stdout for the launcher to relay.
- Add a per-pid guard verification state derived by a pure, testable
guard_state_after(cur_before, orig, patch, wrote_ok, cur_after) returning one
of VERIFIED / UNSUPPORTED_BUILD / WRITE_FAILED / VERIFY_FAILED (NOT_ATTEMPTED
is the pre-evaluation constant). VERIFIED means the live bytes at RVA 0x14858
are 7f 0f (JG) after enforcement (from an applied 75 0f->7f 0f, or already
patched). The existing fail-closed byte guard (guarded_action / STORE_PATCHES_
GUARDED) is unchanged — this only observes the outcome.
- Emit exactly once per FIFA pid: on VERIFIED,
[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
otherwise a non-advertising
[store-guard] guard status=<STATE> fifa_pid=<pid> (no capability advertised)
- Capability constants: EMPTY_MYPACKS_RESOLVER_VERSION=1, fully-qualified name
"fifa17.empty_mypacks_resolver".
- Tests: 5 guard-state cases + capability-constant assertions (standalone-runnable).
The capability = "the guard was verified in THIS FIFA process", never merely
"the code is present". Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
Land the client-side empty-My-Packs resolver evidence and the session-stability
invariant established by the F3/R1 experiments.
- PART III (F3, CONFOUNDED CRASH): a mid-process sentinel -> no-sentinel flip
left a stale POSITIVE My-Packs ordinal that still took the resolve branch and
crashed at 0x180014882. Preserved verbatim (not a guard failure).
- PART IV (R1, SUCCESS): backend set no-sentinel first, then a FRESH FIFA
process; genuine purchasegroup response ids [1,5,6,7] is byte-identical to the
F3 capture, so client process lifetime is the only changed variable. Store
opens on Browse Packs, no crash, no dialog. Guard PROVEN on the tested build.
- New INVARIANT: empty-My-Packs capability MUST be session-stable -- the server
must not switch a running client between sentinel-present and sentinel-absent
for the My Packs group within one FIFA process, because the client caches the
group ordinal and a stale positive ordinal still crashes the resolver.
- Both no-sentinel captures kept: client_guard (F3) and freshretest (R1).
Backend P2 active-sentinel (65534) remains production default; no capability
handshake is implemented yet.
Port the PROVEN empty-"My Packs" resolver crash-guard into the canonical
autopatch.py /proc-mem patcher. When no `mypacks` purchase group exists, a
fresh FIFA 17 client resolves category id -1; CardsDLL FUN_1800147f0 at RVA
0x14858 (`JNZ 0x14869`, bytes 75 0f) treats every non-zero category as
resolvable, calls FUN_180014420, gets NULL, and dereferences [NULL+0x48] at
0x180014882 (0xC0000005). Rewriting JNZ->JG (7f 0f) preserves positive-category
resolution (EDI>0) while routing zero/negative categories to the existing
Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
- STORE_PATCHES_GUARDED table pins RVA 0x180014858 orig 75 0f -> patch 7f 0f.
- Applied every tick, fail-closed via guarded_action(): apply only when the
live bytes are the known original; no-op when already patched; SKIP+log an
unrecognised CardsDLL build (never blindly overwritten).
- Runtime watch loop moved under `if __name__ == "__main__"` so the module
imports cleanly for unit testing; script behavior is unchanged. Existing
ProtoSSL cert-gate and STORE_PATCHES enforcement are byte-identical (indent
only).
- test_autopatch_guard.py: pure test covering PATCH/NOOP/SKIP and pinning the
exact guarded RVA/bytes.
Proven on the tested build (CardsDLL 4706a881...) by a clean fresh-process
no-sentinel A/B (R1). Dormant while the backend active-sentinel is present.
See docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV.
Investigation + design only (no client/backend/binary changes, no live
Store experiment). Reconfirmed CardsDLL_Win64_retail.dll (4706a881..,
unpacked) against a freshly rebuilt Ghidra project on .105; FIFA17.exe
(29c31cef..) is Denuvo-packed so the Scaleform decision is unreadable.
PART II added to docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md:
- Native category path traced: FUN_18007dab0 (store render, RVA 0x7dab0)
reads screen+0x290; My Packs funnels through FUN_1800147f0 (0x147f0) ->
FUN_180014420 (0x14420, NULL on ordinal miss) -> crash MOV [RDX+0x8] at
0x14882 ([NULL+0x48]), matching the Exp-B minidump. Tab->ordinal map
FUN_180014580 (0=mypacks..5=special); category 0 = list-all (Browse).
- Unopened-pack count is a data-manager singleton (vtbl[0x4d8] get /
[0x4e0] set), reachable from the store resolver.
- Vehicle: existing openfut-hook -> version.dll proxy (already deployed);
reuse ssl_patch signature-scan + connect_hook inline detour. No new loader.
- Preferred strategy A: entry-hook FUN_18007dab0; when the requested
category is My Packs and unopened count==0, force screen+0x290=0 (Browse).
Removes crash + fake 65534 tile + dialog + nav gate; count>0 untouched.
- Ranked B (resolver NULL fallback, higher risk) and C (null-guard, crash-only).
- Build guard: module gate + SHA/PE + signature scan; unknown build -> no
patch, backend sentinel remains fallback.
- First experiment design (needs a later, separately-authorized backend
empty-no-sentinel test mode) + client rollback (config flag / dll swap).
- Keep backend 65534 sentinel deployed until strategy A is verified.
Describes the FIFA 17 client/data model only; not OpenFUT Core assumptions.
Single source of truth for FIFA 17 FUT card families, reconciled against
the authoritative shipped fcc_*.json + staff tables (verified byte-identical
between .105 and this repo, 36/36 sha256).
- docs/CARD_TAXONOMY.md: family -> table/rowcount/subtype/carddbid/cardassetid,
with OBSERVED/INFERRED/HYPOTHESIS/UNKNOWN labels. Corrects four superseded
claims (chem styles are 250-273 not 91-136; 6300/6400xxx are kits not badges;
5004xxx misc and 8010xxx league logos exist). Manager-league precision kept
distinct: shipped table 300-340 (41 rows) vs client enum 300-341 (341 defined,
unshipped). Club-item wire subtype->family mapping preserved as UNKNOWN.
- docs/evidence/fifa17-recon/table-hashes.sha256: 36-file provenance manifest
(31 fcc_*.json + 5 staff tables), combined hash 10f239ad...
Describes the FIFA 17 data/client model only; not OpenFUT Core assumptions.
Full investigation record for bug 6c: baseline + Experiments A/B/C', Candidate F (contradicted), the explicit active-placeholder selection test, the minidump-confirmed CardsDLL crash, and the P2 decision. Marks ROOT CAUSE ESTABLISHED and documents the known UX limitations and the client-side follow-up.
Files: docs/evidence/STORE_TILE_6C.md, docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md, the four genuine /store/purchasegroup captures (baseline, mypacks70, empty_no_sentinel, active_placeholder), and docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md (client-side design/research).
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".
Root cause (bug 6c): FIFA 17's Store/Scaleform path resolves the `mypacks` category even with zero unopened packs (category chosen client-side via the movie's CATEGORY_ID -> screen+0x290; no server field gates it). CardsDLL FUN_1800147f0 then dereferences the resolved group with no null guard, so an absent group crashes the client (CardsDLL+0x14882, [NULL+0x48], minidump-confirmed). An inactive placeholder avoids the crash but makes the Store report the pack unavailable on entry and bounce to the Hub; an active placeholder lets the Store open normally.
65534 stays economy-safe: pack_by_id() returns None, so store_buy()/purchased_items() cannot open it or grant items/coins, and grant_unopened_pack() rejects it. Explicit selection is rejected client-side ("This pack is no longer available") and sends no backend request. This is a FIFA-17 client-compatibility shim (P2), not an EA-authentic representation, confined to the FIFA-17 backend (not OpenFUT Core). A clean zero-pack UX needs a client-side fix (docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md).
Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
Records the operator-assisted live A/B on the REAL imported club (1949/1962, 13
Legends deferred): real club render, clean pagination, Special filter (1665, 0
base leaked), squad edit persistence + cold relaunch, and rollback->Python->Rust
re-enable. Documents the three real-data fidelity fixes (versioned resourceId
e187cd4, rareflag 626c972, rare=SP filter 6f16a23) and the nation/league/team
model correction (44fcf24). Marks PRODUCTION NOT READY pending .105 Legends name
data for the 13 deferred assets. Aggregate counts only; no private identity.
The club search 'Quality = Special' sends rare=SP, which was a deliberate no-op
('semantics UNKNOWN'), so it returned every card — base golds included. The
rareflag work now grounds it: a special is rareflag > 1 (base rare = 1),
evidence-backed by the FIFA17 taxonomy + the observed profile (base Ronaldo/Messi
rareflag 1; their informs 11/24).
rareflag lives in the FIFA catalog, not Core, so Core cannot filter it:
- map_to_core: rare=SP now sets CoreOwnedQuery.special (host-applied), not
'unsupported'; any OTHER rare value stays unsupported. special is NEVER a Core
/collection param. is_special_rareflag(rf)=rf>1 lives in the adapter.
- handle_club special path: fetch all items matching the OTHER filters (offset/
limit stripped), shape (resolves rareflag), then special_filter_page() keeps
rareflag>1 and paginates the FILTERED set locally (start/count over specials,
not Core's unfiltered page) — no base leakage, no post-pagination drops.
Verified live on the real staged club: rare=SP -> 1665 items (=1949-284 base),
rareflag distribution all >1, zero base leaked; pagination page0==full[0:50],
page1==full[50:100], no overlap. Tests: adapter map rare=SP->special, unknown
rare stays unsupported, is_special_rareflag predicate; host special_filter_page
filter+paginate. adapter 113 + host 25 + importer 25 green; clippy -D clean.
shape_item hardcoded rareflag=1, so all 1949 cards shaped as basic rare gold
regardless of type; informs/specials lost their card art. The dev fixture is
base-only, so this was invisible until the real profile (10 distinct rareflag
values) exposed it on .105.
rareflag is definition-level FIFA identity metadata OBSERVED from the profile
(the raw wire integer, never a guessed marketing label), so it lives in the
FIFA catalog like asset_id/version, not in generic Core:
- ObservedDefinition.rareflag + emitted into the production catalog entry.
- Fifa17CardCatalog RawCard/Fifa17CardIdentity gain rareflag (default 1 when a
base-only catalog omits it, preserving prior wire behaviour).
- Fifa17Identity.rareflag; host resolver populates it from the catalog.
- shape_item emits id.rareflag instead of a hardcoded 1.
Verified on the real staged /club: wire rareflag distribution == source exactly
(0 per-item mismatches across 1949; e.g. rareflag 3 x591, 24 x302, 21 x256).
adapter 111 + host 24 + importer 25 tests green; clippy -D clean. rareflag lives
in the host catalog, not Core, so no re-import was needed.
Evidence (resourceId 169193): its 4 owned copies are IDENTICAL in asset/rating/
position/all attributes and differ ONLY in nation/team/league (and those resolve
inconsistently, e.g. team 240 'Atletico Madrid' under league 16 'Ligue 1'). A
player's club affiliation is an instance-time snapshot, not part of the card
DEFINITION identity.
Correct the model (not a special-case): the definition-consistency gate now
compares a DefIdentity projection (asset_id/version/rating/position/attrs/
rareflag) and EXCLUDES nation/league/team. A club-only difference between copies
of one resourceId is no longer a conflict; a real identity disagreement
(rating/position/attrs/asset) still trips it. The definition's display
nation/league/club use the first-observed copy (deterministic; display-only,
never identity). No --defer-conflict allowlist entry is needed for 169193 now.
On the real profile: conflicts 1->0, 169193 reclassified conflict->NoName
(still deferred, unnameable), supported still 1681, deferred instances still 13,
BLOCKERS none without any --defer-conflict flag. 2 new tests (club-only diff is
not a conflict; rating diff still is). crate suite 25 green; clippy -D clean.
shape_item emitted resourceId/definitionId = asset_id (base), collapsing every
versioned (special) card onto its base definition on the /club and squad wire.
The dev 32-card fixture is base-only (version 0), so Slice 7 never exposed it;
the real profile (1531 versioned cards) did.
Fifa17Identity now carries resource_id (= (version<<24)|asset_id, == asset_id
for a base card). shape_item emits resourceId/definitionId from resource_id and
assetId/cardassetid from asset_id — versioned and base stay distinct. The host
resolver populates resource_id from the catalog's reconstructed resource_id
(the catalog already parsed version; it was dropped before shaping).
Regression test: versioned 117617092 (v7 of asset 176580) shapes resourceId/
definitionId=117617092, assetId/cardassetid=176580.
Verified on the real staged /club: 1949 items, wire-id set exact, 0
wire->resourceId mismatches, 0 duplicate-multiplicity mismatches vs the source
manifest. adapter 111 + host 24 tests green; clippy -D warnings clean.
FIFA17-specific orchestration that installs the real profile across BOTH durable
stores (openfut-identity + Core SQLite) recoverably and idempotently, handing
Core only a GENERIC request (all FIFA17 semantics stay in this adapter layer).
apply module:
- owned_item_id(persona, wire) = deterministic UUIDv5 from a private namespace;
identical in BOTH stores (identity core_id AND Core owned_cards.id), so the
running host's wire->owned reverse lookup resolves exactly what Core stored.
- plan_apply(report, raw_profile, fp): pure translation to a GenericImportRequest
(card_id = fifa17_<resourceId>) + the preserved (owned_item_id <-> source wire)
mappings + watermark. Canonical squad + Fifa17SquadExtensionV1 are built by the
SAME adapter code (parse_squad_put + build_squad_write) the retail-validated
live squad path uses. Refuses if the report has blockers.
- Two-store protocol (apply): staging gate -> local Core-preflight mirror ->
identity dry-preflight -> idempotent seed (insert_existing_mapping per instance
+ set_watermark) -> ONE generic Core import transaction (spawned binary) ->
cross-store post-validation -> completion record. A crash after identity
seeding re-converges on re-run (idempotent mappings + Core already_imported):
no cleanup, no reminting.
- gate_staging: deferred players are ABSENT from an import; allowed only for a
staged run behind --allow-deferred-players-for-staging (never a silent default;
prints an INCOMPLETE banner). Production requires zero deferred instances.
CLI: --apply (with --emit-content, --core-bin, --core-db, --core-data,
--identity-store, --allow-deferred-players-for-staging). Depends on
openfut-adapter-fifa17 + openfut-identity + uuid(v5).
Proven end-to-end on the real 33068179/CAGE profile (staged): first apply
imports 1949 supported instances (293 base + versioned), 11/11 f433 squad +
opaque extension, coins 28,112,944, fingerprint 8dc5582d2414af28; re-run is an
idempotent no-op (already_imported, DB unchanged); staging-not-default refuses
before any write; every OwnedItemId is an opaque UUID; identity wire-id set ==
source supported set exactly (0 minted, 0 dropped); 13 deferred instances leak 0.
10 new apply tests (determinism, request/mapping/squad translation, blocker
refusal, staging gate both ways, local preflight, identity seed/dry/postvalidate/
idempotency, conflict detection, graceful spawn failure). clippy -D warnings
clean; crate suite 23 tests green.
Fold entity resolution (nation/league/club id->name via committed tables,
mirroring seed_fifa17_cards.py) and quality-tier rarity into the analysis, so
the supported set is honest about unresolved entities too. Add an explicit
--defer-conflict <rid> allowlist: a reviewed conflict (169193) defers, any NEW
conflict still hard-fails (defer never becomes a silent conflict suppressor).
--emit-content writes three files, PUBLIC content separated from PRIVATE account
state: fifa17-production-cards.json (Core CardDefinition[] keyed fifa17_<resourceId>,
base+versioned, tier rarity, profile-derived, no promo labels), a versioned host
identity catalog {card_id:{asset_id,version}}, and a private import manifest
(supported instances' wire ids + deferred set with reasons + preserved watermark
+ target profile + snapshot fingerprint). Emit refuses while blockers exist.
Real profile (33068179/CAGE): 1681 supported defs (150 base + 1531 versioned),
9 NoName deferred, 1 approved-deferred conflict (169193, 4 copies), 1949
importable instances, watermark 100004617 -> next 100004617, active squad f433
11/11 supported. fmt + clippy -D warnings clean; 13 tests.
openfut-identity:
- insert_existing_mapping(game,kind,core_id,external_id): preserve an existing
external wire id instead of minting; idempotent for an identical mapping,
rejects conflicting forward/reverse with IdError::Conflict, persists atomically.
- persisted per-scope allocator watermark (set_watermark/watermark_for) so a
future mint continues past the source high-water even across burned-id gaps;
next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk
format (legacy bare [Row] still loads). +4 tests (10 total).
openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python
profile for a faithful Core import. Enforces disjoint item-class balance;
proposes profile-derived CardDefinitions keyed fifa17_<resourceId> (base vs
versioned never collapse) with a resourceId-group consistency gate (hard-fail on
disagreement, never pick a winner) and honest buildability (roster name +
version formula + metadata, never fabricated); plans owned-instance identity
(preserve Python wire ids, preserve nextItemId watermark); checks active-squad
coverage. --apply/--emit-content refuse to write in this phase. 11 tests.
Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17
consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9
NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a
divergent nation/team/league); 1949 importable player instances, watermark
100004617 -> first new alloc 100004617; active squad f433 fully supported.
fmt + clippy -D warnings clean.
Record the staged retail A/B: FIFA consumed the Rust squad path end-to-end
(userMassInfo overlay, in-game swap -> squad-replace {"id":0}, formation
f442->f433 persisted to Core, cold relaunch returned the persisted squad),
with Python rollback / Rust re-enable proven by host-log presence. Note the
OPENFUT_DEV_CONTENT_GAMES=fifa17 startup requirement (silent empty /collection
if omitted) as a needed deployment/preflight assertion.
"TLS HANDSHAKE FAILED: ... unexpected EOF" is exactly how the certificate
mismatch presented -- the defect that cost three live gate attempts and was
invisible everywhere else. It is the one line this project has learned to
treat as serious.
A reachability probe forges it for free: TcpStream::connect followed by a
drop opens the connection and closes without sending a byte, and the
acceptor reports that as "unexpected EOF". The launcher's preflight makes
two such probes per run. On 2026-08-12 they produced ten of these lines and
sent a whole session diagnosing a client-side fault that did not exist --
autopatch, ptrace_scope and client_arm.sh were all investigated before the
pairing of the timestamps gave it away.
Classify before the acceptor sees the connection: peek one byte, and treat
EOF-before-any-byte as a probe with its own quiet line. A timeout is
deliberately NOT a probe -- a slow or broken client must still reach the
acceptor and produce a real diagnostic, since misclassifying a fault as
benign would defeat the point.
The counter exists because the test needs it. A probe produces no response
either way, so a test written against client-visible behaviour passes with
the classification deleted; asserting on a count is what makes the mutation
detectable. Verified: all three mutations (drop the classification, treat
undetermined as a probe, treat a speaking client as a probe) are killed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trailing rustfmt reflow of two catalog unit-test assertions (no logic change); clears the adapter dirty state so verify-build-identity.sh passes for the UTAS A/B.
Migrate the active-squad READ off the Python oracle to the existing Core-backed projector, completing the squad authority (read + write + /squad/list + userMassInfo overlay) on one projector.
- classify: GET /ut/game/<t>/squad/active -> Route::SquadActive. Numeric GET /squad/<n> stays on Python (no Core multi-squad model yet).
- handle_squad_active returns the projector object via user_mass_info_squad(v, persona) — byte-identical to userMassInfo.squad; degrades to an empty overlay on stale/missing/Core-error, never falls back to Python.
- persona: new REQUIRED OPENFUT_PERSONA_ID (non-zero) on HostConfig, injected not baked, must match LSX/Blaze/POW/UTAS identity.
- tests: squad_active parity test; classify updated; README config table + A/B command.
fmt + clippy -D warnings + tests (24 host + adapter) green.
Formatting reflowed two mutation target lines onto multiple lines; update
the battery anchors (adapter #14 kit lookup, host #19 Content-Length push)
to the new unique substrings. Both batteries kill all mutants again
(adapter 14/14, host 20/20).
Formatting-only. Runs the project formatter over the files authored/edited
this session (host lib+tests, adapter item/squad_ext/squad_projection/mod +
projection test). The intentionally-preserved dirty catalog.rs and
pre-existing /club-era host drift beyond these files are out of scope.
The capture sanitizer redacted tokens/device ids but left the lab Host
address (10.10.0.x) in three committed UTAS fixtures, tripping
scripts/check-no-lab-addresses.sh. Replace with an RFC 5737 TEST-NET
address (192.0.2.120) per the guard's own doctrine. Host header is
plaintext metadata only (tests decode body_b64), so no test is affected.
Pre-existing leak (fixtures committed at 0b66662/eb8311a); no lab address
was introduced this session.
mutation-battery.sh injects each of the 20 required wrong behaviours into
the committed host (or the adapter it composes) source, runs the one host
test that must catch it, and requires a non-zero exit (killed), reverting
via git after each. Adds a duplicate-definition host round-trip test.
Kills: authz-skipped, authz-loop-empty, failure-masked-as-success,
PUT-as-partial-diff, extension-dropped, stale-accepted, missing-fabricated,
overlay-clobbers-{userInfo,settings,pile}, list-separate-shaping,
captain-as-resourceId, kit-by-slot, client-eval-dropped, host-trusts-fp,
per-slot-N+1, squad/active-to-Rust, duplicate-collapse, stale-content-length,
python-squad-after-failure. 20/20 killed.
Extend the UTAS migration host to own the squad slice, reusing the exact
production identity path (Fifa17CardCatalog + ExternalIdentityStore) that
/club uses, so /club, /squad/list, userMassInfo and PUT all agree on
wire<->owned identity.
Routing (classified ONCE, no try-Rust-then-Python):
PUT …/squad/<n> -> Rust (numeric id; …/squad/active stays Python)
GET …/squad/list -> Rust
GET …/userMassInfo-> Python proxy, ONLY .squad overlaid
everything else -> Python verbatim
CoreAccess gains read_squad_ext / replace_squad / all_owned (HTTP to the new
Core /squad/ext + /squad/replace routes).
PUT pipeline: parse -> build_squad_write (reverse-resolve every wire id;
refuse unresolved/duplicate) -> AUTHORIZE every resolved owned item against
the active club (identity resolution is NOT authorization; a valid wire id
owned by another profile is rejected before any mutation) -> Core atomic
replace+extension -> exactly {"id":0}. No Python fallback on failure; no
host-side second extension store; no fingerprint recomputation.
Read path assembles ONE projection input (one read_squad_ext + one batch
all_owned; no per-slot lookup) and runs the single adapter projector. Both
/squad/list and userMassInfo.squad derive from it. Fresh projects; Stale is
never applied; Missing is never fabricated — both are prominent integrity
failures, never served from Python (no split authority). The overlay
replaces only .squad and preserves userInfo/settings/userData/
pileSizeClientData, fixing Content-Length.
Tests: routing, full-replacement + exact ack, unknown/foreign/duplicate item
rejection with Core unchanged, idempotent repeat PUT, coupled read-after-
write (list + userMassInfo agree, real resourceIds + stable wire ids),
overlay field preservation, bounded no-N+1 reads, Stale/Missing integrity.
Exposes GET /squad/ext and PUT /squad/replace so the UTAS host can read
and atomically persist the FIFA17 squad canonical+extension state over
HTTP. Core commit sits atop the frozen contract 615c5fd (branch
rust-migration/squad-ext-routes); no domain change. The preserved dirty
openfut-core worktree (eab522a) is intentionally left untouched, so root
status still shows 'M openfut-core' as before.
mutation-battery.sh injects each of the 14 required wrong behaviours into
the committed source, runs the one invariant test that must catch it, and
requires a non-zero exit (mutant killed), reverting via git after each.
Kills: kit-by-slot, captain-as-resourceId, chemistry-reconciled,
custom-regenerated, index-derived, stale-accepted, missing-fabricated,
faked-asset-id, duplicate-instance-collapse, PUT-as-slot-diff,
wire-id-in-canonical, projector-bypasses-shared-shaper, schema-version-
ignored, player-state-keyed-by-definition. 14/14 killed.
fut::squad_projection is the ONE projector for every squad read shape.
project_squad(canonical squad + Fresh extension + owned items) -> the FIFA
17 squad wire object; user_mass_info_squad and squad_list are envelope-only
wrappers over the same output (no per-endpoint domain model).
Design guarantees exercised by tests:
- purity / no N+1: consumes a host-assembled input (read_squad_with_ext +
one batch owned-cards fetch + in-memory card defs); no per-slot lookup
- shared shaper: every occupied slot is shaped by fut::item::shape_item,
so squad items and /club items cannot drift
- Fresh -> full projection; Stale -> never applied (verdict surfaced);
Missing -> explicit, never fabricated
- captain projects as the resolved WIRE id (never resourceId); index and
formation round-trip verbatim; kit follows the player; two owned copies
of one definition stay distinct
Adds committed sanitized fixtures decoded from the squad session capture
(swap, f433, persisted userMassInfo.squad read, squad/list) and
tests/squad_projection.rs: baseline / swap / formation-change / persisted
read-after-write round-trips asserted by ownership class (canonical,
extension, shadow, derived identity), plus one-projector no-divergence.
Add fut::squad_ext::Fifa17SquadExtensionV1 — the versioned, adapter-owned
payload Core stores opaquely alongside the canonical squad. Carries the
FIFA-only wire state that is not Core-canonical:
- custom[] opaque 33-int string, round-tripped verbatim
- squad_type observed FIFA token
- kit_numbers keyed by owned_card_id (kit follows the PLAYER, proven
by the swap/formation captures), never by slot/definition
- manager opaque item ref (not a squad player; not shaped)
- kicktakers opaque role refs; relationship to captain UNKNOWN, so
preserved verbatim and never normalized to the captain
- client_reported chemistry/rating/starRating shadow, never authoritative
from_payload enforces the payload schema version first (distinct from Core's
DB schema); an unknown version is rejected, never coerced.
build_squad_write turns a parsed PUT + host wire->owned resolver into a
canonical ProposedSquad + extension, refusing on unresolved ids or a
duplicate owned item. Identity resolution is explicitly NOT authorization.
Refactor the 550a59d parser scaffold: ProposedSquad is now pure canonical
(FIFA-only + shadow fields moved to the extension); the canonical formation
is the FIFA wire token verbatim (drop the lossy f442->"4-4-2" map that
could not even represent f433) so formation and index round-trip exactly
with no derivation. Bench split is the fixed 23-slot array convention.
Move the per-item card shaper (CoreOwnedItem, Fifa17Identity,
ItemIdentityResolver, ShapeStats, shape_item) out of club_response into
fut::item so /club and the upcoming squad projection emit byte-identical
items from one source of truth. club_response keeps only the /club
{itemData:[...]} envelope and re-exports the moved types for API
stability. shape_item is now pub; no behavior change (all /club and
oracle-parity tests unchanged and green).
Adds item-shaper tests: full-field identity mapping and the duplicate
owned-copy invariant (two instances of one definition keep distinct wire
ids, share one asset id).
scripts/seed_fifa17_cards.py: deterministic pipeline from committed FIFA17 data
(pool.json + roster.json + leagues/nations/teams tables) -> the card-definition
identity catalog. CardDefinitionId is opaque + deterministic (fifa17_<asset>),
version 0 (base cards only; resource_id == asset_id). --check mode diffs against
committed output (drift-detection mutation-proven). Provenance embedded.
Generated openfut-adapter-fifa17/data/fifa17-card-identities.json: all 17,563
base assets. Semantic definition coverage (to /tmp, not committed here): 17,547
resolvable; 16 skipped for missing roster name (reported, never fabricated).
Adapter loads the committed catalog (test: 17,563 entries, Ronaldo fifa17_20801
-> asset 20801 v0). Phase commit 3/5. NOT owned inventory: this is 'which cards
exist', not 'which the user owns'. Core content seeding + dev-owned set next.
fut::catalog — Fifa17CardCatalog maps a semantic CardDefinitionId to a FIFA 17
render identity (resource_id = (version<<24)|asset_id; version 0 => resource==
asset). Versioned JSON (schema_version=1, game=fifa17); validates schema/game,
rejects asset_id > 24 bits, and rejects two card ids claiming one resource_id.
Unknown definitions resolve to None (callers drop, never fabricate).
Fifa17WireItemIdPolicy carries the owned-item namespace (base 100_000_000,
first id 100_000_001, per the oracle) supplied to the generic store.
Adds serde derive to the adapter. 8 catalog tests; 3/3 mutations killed
(resourceId-drops-version, conflict-detection-off, asset-range-off).
Phase commit 2/5. No card->asset DATA shipped: the synthetic Core catalogue is
unmappable (see seed plan); the loader + format land now, population later.
openfut-identity: durable, reversible (game_id, entity_kind, core_id) <->
external wire id mapping. Game-independent infrastructure (adapters supply the
numeric policy via base_floor; the store guarantees stable/unique/reversible/
game-scoped/persistent/atomic/explicit). JSON-file backed behind an
ExternalIdentityStore trait (SQLite can drop in later); parking_lot-guarded,
atomic temp+rename persist, rejects a torn reverse-duplicate on open.
Core never learns FIFA integers; only the host/adapter that owns a game
boundary uses this. 6 tests, 4/4 mutations killed (same-id-for-two-items,
lost-on-restart, broken-reverse, dropped-game-scope). Phase commit 1/5.
openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club
from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS
route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS);
route classification before execution; a Core error on /club degrades to an
empty page and never falls back to Python. CoreAccess is a host-owned boundary
(the adapter stays transport-agnostic).
openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping,
unknown id = hard error), entities (id<->name from committed tables), and
club_response (FIFA _item shaping; drops items lacking a real FIFA asset id,
never fabricates one).
openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116
multi-game + eab522a replace_squad/SquadRules + the /club semantic query).
11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN.
Retail rendering of Core inventory still blocked on the Core-card->asset-id
identity decision (next phase).
Controlled retail capture, one criterion at a time, cleared between each.
47 transactions. Every filter the My Squad picker sends is now known from
the wire rather than guessed.
Route: GET /ut/game/fifa17/club -- the picker hits UTAS and reuses the
general club-inventory route.
level=any|gold quality lowercase, ALWAYS present
rare=SP "Special" uppercase, OMITTED when off
position=ST position uppercase, omitted when off
nation=52 entity id numeric
league=13 entity id numeric
team=5 entity id numeric, NESTED under league
sort=desc client constant; the UI has no sort control
start=/count=11 pagination
Two encoding families: short string enums, and numeric FIFA ids. The ids
must never reach Core. Filters compose as plain ANDs in one query --
string and id filters alike -- so each maps independently.
THE ROOT CAUSE IS SELF-AMPLIFYING.
club_route honours type, team and league; it never reads start, count,
level, sort or year. Because start is ignored, every page returns the
same full set, so the client concludes the page was full and asks for the
next one. One scroll produced 22 requests and 6.2 MB, stopping at
start=200 only because the client gave up -- against a filtered set of 32
items that should have been three pages.
That also explains why the bug reads as erratic rather than broken:
league=13&position=ST returns every Premier League player instead of
Premier League strikers. Plausible, wrongly sized, hard to notice.
Measured filtered sets, from the real cluttered club -- these are the
acceptance test for the fix:
unfiltered 1962
league=13 350
league=13&team=5 32
Two client behaviours worth carrying forward: the picker fires a query
per highlighted entry, not per selection (two requests for one club
pick), and parameter ORDER is not stable, so parsing must be key-value.
FIXTURE SIZE: bodies over 4 KB are truncated in the committed fixture,
with body_full_len and body_full_sha256 retained, because the same 1.1 MB
club response repeats ~25 times and its hash already proves identity.
13.3 MB -> 247 KB. The raw .ofcap keeps every byte, privately and
gitignored. Truncation is recorded per transaction so a trimmed fixture
is never mistaken for a whole response.
Audited across all three identifier surfaces -- headers, JSON bodies,
query strings -- before and after the size change: no leaks. 6/6
sanitiser mutations still killed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 transactions across 11 connections from a retail session: login,
hub, one pack open, two squad saves, a quick-sell, with before/after
state manifests. Raw .ofcap stays gitignored at 0600; the sanitized
corpus is committed as adapter fixtures.
TWO GAPS FOUND BY AUDITING THE OUTPUT, NOT BY TRUSTING THE SANITISER.
1. `POST /ut/auth` carries `macAddress` and `deviceId`. Session tokens
were being redacted correctly and these were not. A committed fixture
is a published fixture.
2. Then, with those fixed, the audit fired AGAIN on the file about to be
committed: `GET .../phishing/trusteddevice?deviceId=...` puts the id in
the QUERY STRING. Three input surfaces carry identifiers -- headers,
JSON bodies, and query strings -- and the sanitiser knew about two.
Both fixed in the tool rather than by editing the file, with a
regression test and a mutation for the query path.
AND A THIRD ARTEFACT MIX-UP, in the mutation harness itself. It reported
the query-redaction mutation as SURVIVED while a hand-run of the same
mutation killed it. Cause: the harness pointed at a stale scratchpad copy
of the test that pre-dated the query assertion, so it was faithfully
testing the mutated tool against a test that could not detect the
mutation. That is the same class as the build guard checking the wrong
binary and cargo reusing a binary compiled from mutated source -- the
third instance today of measuring the wrong artifact. The harness now
resolves ROOT from its own location and runs the COMMITTED test; the
stale copy is deleted.
Harness committed as scripts/mutate-utas-observe.py so this is repeatable
rather than a thing that happened once in a scratch directory. 6/6 killed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UTAS needs a real request/response corpus before any Rust is written: it
is where protocol shape and FUT state start being coupled, so guessing is
worse here than it was for Blaze. The oracle truncates logged bodies at
~200 chars, and raising that cap would mean editing the behavioural
specification to make it easier to copy -- backwards. A proxy gets the
same evidence and leaves the oracle untouched.
THE DESIGN RULE: TEE, DO NOT REBUILD.
UTAS is plaintext HTTP/1.1 on ThreadingHTTPServer, so keep-alive,
pipelining and chunked transfer are all live. A proxy that parses a
request and re-emits it can corrupt the traffic it exists to observe --
and that corruption would present as a UTAS bug, pointing the
investigation in exactly the wrong direction. So bytes are copied
verbatim in both directions and a second copy goes to disk; transactions
are reconstructed later, offline, from that copy. A parser bug therefore
spoils the record and never the session.
Standalone, NOT in the container, so the same tool can later sit in front
of a Rust UTAS host and replay an identical captured request against both.
Two layers, as with the Blaze captures: raw/*.ofcap is exact bytes at mode
0600 and gitignored; sanitized/transactions.jsonl is the committed
artefact. Bodies are preserved EXACTLY and sanitised second -- only
known-secret headers and JSON keys are replaced, structure is never
reshaped, and every redaction is recorded in the transaction so a reader
knows what was touched.
Captured per transaction: connection id, sequence, relative and wall
time, elapsed ms, method, path, query, HTTP version, headers IN RECEIVED
ORDER as pairs (a dict would drop duplicates and ordering), raw body and
length for both directions, status, and observed keep-alive.
Verified as two independent properties, because they fail differently:
transparency (bytes through the proxy identical to bytes direct, Date
masked, with the mask asserted to have fired) and fidelity (parsed
transactions match what was sent, including a dechunked response and a
300-byte POST body). 5/5 mutations killed, including "record but do not
forward", "drop the last byte of every chunk" and "stop redacting".
scripts/test-utas-observe.py is committed alongside it: a capture tool
nobody can re-verify is not evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Queued cleanup, run only AFTER the roster gate closed in both directions,
so the live A/B changed exactly one thing.
The two `drain_body` implementations were character-for-character
identical, so the extraction is a move. What it guards is not cosmetic:
answering while the client is still sending leaves unread data in the
receive queue and Linux turns the close into an RST rather than a FIN --
invisible in any comparison of the response, and worth two live gate
attempts to find. Behaviour that must be identical across hosts gets one
implementation, the same reasoning that produced openfut-tls.
SCOPE IS DELIBERATELY NARROW. Only the byte-identical part moved. The two
head-reading loops are NOT identical and stay where they are:
redirector roster
head cap 65536 16384
read chunk 4096 1024
on error abort proceed if any bytes arrived
Those differences are probably accidental, but each host is gate-proven
with the values it has. Unifying them would be a behaviour change wearing
a refactor's clothes -- exactly the mistake this project has already paid
for. They converge later as their own change with their own gate, or not
at all.
Purity shown, not asserted: every existing test in both hosts still
passes (426 workspace tests), and 7/7 mutations are killed, including
three in the SHARED crate that must break both hosts at once and one per
host that skips the drain call.
Three test cases neither host had now exist, because the extracted code
finally had somewhere to be tested directly: a malformed Content-Length,
an unterminated head, and a lookalike header. That last one matters --
`X-Original-Content-Length: 99` would drain 99 bytes that were never sent
if the match were `contains` rather than `starts_with`, and a mutation
confirms the test catches it.
Also fixes a race this run exposed in openfut-tls's own tests: keypair()
returned early if the certificate file existed, but wrote the certificate
BEFORE the key, so a parallel test could observe a cert whose key had not
landed. It failed one run and passed the next -- the kind of flake that
gets rerun instead of fixed. Now generated once per process via OnceLock,
key written first, and the suite was repeated five times to confirm.
Nothing deployed and nothing restarted: the running redirector and roster
are still the gate-proven binaries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
redirector.sh and the coming roster.sh needed the same five rules, each
of which cost something to learn:
* resolve /proc/PID/exe; never match a command line. `pkill -f` /
`pgrep -f` match any shell whose ARGUMENTS mention the name, including
the shell running the command. That has killed this session's own
shell twice, and is now banned in migration tooling -- the helper
contains no `-f` matching and the header says why.
* `readlink`, not `readlink -f`. After a rebuild the link reads
"<path> (deleted)" and -f resolves it to nothing, so the orphan check
goes blind to exactly the long-lived processes it exists to find. Two
orphans hid there, one serving the wrong certificate.
* stop PROVES the process is gone and the port free.
* an ambiguous binary is an error for start/verify but NOT for
stop/status: rollback must never be blocked by a question about the
build tree.
* verify the RUNNING process's commit, not the artifact on disk, which
a rebuild can silently advance past.
Copying those into a second script would have been the same mistake as
copying the TLS setup. Instead scripts/host-lifecycle.sh owns them and a
service supplies four facts: name, crate, executable, port variable.
redirector.sh goes from 178 lines to 26 and roster.sh is 24, with no
behaviour change -- the refactored redirector.sh still sees the live
armed process (pid 830736, port 42227) and still refuses correctly
because HEAD has moved past it.
Paths are unchanged (rundir, pidfile, portfile, commit stamp, log), so
the currently running redirector stays manageable across this refactor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second consumer of openfut-tls, and the reason it was extracted first.
This host contains no roster content and no cipher choice: the adapter
owns the 67 bytes and the observed TLS profile, openfut-tls owns the
acceptor, and this crate owns accept/read/drain/write/close.
Lifecycle was MEASURED, not inherited. The obvious mistake here would
have been copying the redirector's 300ms dwell because the other host has
one. A probe against the oracle says otherwise:
dwell after responding 0 ms (redirector: 300 ms)
request body drained POST answered only once it arrives
close clean FIN, never RST
keep-alive none one request per connection
The probe ran against a REPLICA of roster_server.py loaded from its own
source, not against :8081 -- http.server.HTTPServer is single-threaded
and FIFA was mid-session, so holding a connection open to measure the
close would have stalled the game's poll and could have surfaced as the
squad-update error. The replica was then confirmed byte-identical to the
live oracle under masking, the 1-byte delta being the container's Python
version in the Server header.
Differential against the live oracle, every field identical, with the
Server header compared UNMASKED:
GET 230B HEAD 163B POST 163B
drained=True reset=False answered_before_body=False
keepalive: second request accepted by the socket, never answered
Testing follows the redirector's hard-won rule: where a property is
visible both to the client and inside the host, it is asserted inside the
host via ConnOutcome. A client-side check cannot tell "drained" from "not
drained" -- it reads the buffered response either way -- and that exact
mistake let a mutation survive once already.
9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer
before draining", "hold the connection open like the redirector" and
"inherit the redirector's 300ms default".
drain_body is duplicated from the redirector deliberately. Unifying it
means editing the redirector, and the roster A/B must change exactly one
thing. Extraction is scheduled for after the roster gate closes.
Not deployed and not switched: Python still serves :8081.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.
Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.
Split along the line the architecture already draws:
openfut-tls how to build an acceptor. Game-independent.
Knows nothing about which suites any client
offers.
adapter-fifa17::tls what FIFA 17 was OBSERVED to offer: the six
enabled suites, the two refused, the TLS 1.2
window, the EA SNI. Plain strings, so the
adapter keeps its lean dependencies -- reading
a card table should not build OpenSSL.
redirector-host joins the two. Chooses no cipher of its own.
Behaviour is unchanged, and shown to be:
* tests/fifa17_tls_profile.rs carries over every case from the deleted
module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
when gates 1-14 passed. A "pure refactor" that cannot fail is not a
claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
the retail client negotiated at 17:09 today.
Two improvements fall out of having one place to look:
* the startup banner now prints cert_sha256. The mismatch above raised no
error at startup and broke the client much later with nothing logged;
it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
is gate evidence and gets read by people.
Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old check was `grep easw /etc/hosts && echo ok`. It passed on ANY
matching line -- including a line that shadows ours. glibc returns the
first match, and the sed above only deletes lines this script wrote
(`# openfut`), so a foreign entry earlier in the file wins forever and
re-running the script never helps.
Observed today: a leftover `127.0.0.1 easw.easports.com` from the
single-machine era, before the backend moved to its own host. Every arm
reported "/etc/hosts ok" while the name resolved to loopback.
Now it resolves the name -- the same call the game makes -- and compares
address to address, so a server given as a hostname is handled too. On a
mismatch it prints the offending lines with line numbers and says how to
fix them.
It does NOT delete them. This script writes one tagged line and owns only
that line; silently removing entries a user put there by hand is a bigger
hazard than the shadowing it would cure.
Reported as a warning, not an error, because it is survivable: the
responders advertise the server address, so the game stops using this
hostname after the first redirected contact. FIFA reached the FUT hub
today with this exact misconfiguration in place. Claiming it is fatal
would be wrong, and a check that overstates its findings gets ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects, both found by the guards misfiring rather than by reading:
1. BIN preferred target/debug and fell back to release only when debug was
absent. `cargo build --release` therefore produced a correct binary while
the script kept inspecting a stale debug one, and the build guard refused
with a message naming a commit nobody was trying to run. The guard was
right that something was stale — it just pointed at the wrong artifact.
Disagreement between the two is now an explicit refusal naming both, with
OPENFUT_REDIRECTOR_BIN as the deliberate override.
The refusal is recorded at load and raised only by `verify` and `start`.
`stop` and `status` must work in any build-tree state: rollback can never
be blocked by a question about which artifact would have been started.
2. `verify-running` read the stamp file without checking the process still
existed. The stamp outlives the process, so after a stop it reported on a
corpse — either "identity OK" or a REFUSAL naming a commit, both implying
something was running when nothing was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cmd_status` has two paths. The `--name` path filters on an exact tag and
works. The no-name path — the "show me every switch on this box" survey,
which is how an orphan switch under a different name would be found —
built its python with shell quote-juggling and never closed the string
literal, so it died with a SyntaxError every time.
It failed loudly (rc=1, a traceback) rather than reporting "no rules", so
it never lied about the state. But it also meant the survey path had
never once run, which is the more useful lesson: every branch of a safety
tool needs exercising, not just the branch the happy path takes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`list_procs` matched on `readlink -f /proc/PID/exe`. Once the binary is
rebuilt -- which happens constantly here, `cargo test` alone is enough -- the
link reads "<path> (deleted)" and -f resolves it to something that matches
nothing. The scan then finds zero processes, so `start`'s orphan check passes
and a second instance can be launched alongside a stray.
Not theoretical. Two orphans were running undetected tonight:
pid 592731 :42327 a stale-cert redirector left from testing check-tls-parity,
still serving F9:16:1A -- the exact certificate whose
mismatch cost three live gates
pid 542693 :42230 a Blaze sidecar debug build from 03:12
Neither was in a client path, so neither was doing harm, but a stray listener
serving the known-bad certificate is precisely what should never sit around
unnoticed.
Fixed by using plain readlink and stripping the " (deleted)" suffix. Shown both
ways: with the bug `status` reports no processes at all for a live pid; with the
fix it reports 604454. The pidfile path was unaffected, which is why `stop` kept
working and hid this.
It is a TOTAL -- every packet reaching the chain counts there, including ones
already counted by a named-port rule. The old wording invited reading the number
as a remainder, which is how 15 unexplained attempts got misread earlier.
Next component in the migration order (Roster -> LSX -> UTAS). Adapter layer
only: no host, no runtime replacement, nothing armed.
The response is shaped as much by http.server.BaseHTTPRequestHandler as by the
oracle's handler code, so it is captured over the wire rather than reasoned
about:
* HTTP/1.0 status line -- protocol_version is left at its default, so the
reply is 1.0 even though the client asks for 1.1
* send_response injects Server: and Date: BEFORE the handler's own headers
* POST answers with headers only: the handler writes the body `if method ==
"GET"`, so a POST advertises Content-Length: 67 and then sends nothing
That last one is preserved, not corrected. It looks like a bug, but "obviously a
bug" has been the wrong call before in this port, and a test now asserts it so a
future cleanup has to argue with something.
Date and Server are volatile and are MASKED in the fixture rather than dropped,
so their presence and position are still asserted. Server is additionally
recorded verbatim: it carries the container's Python version, so a drift away
from roster::ORACLE_SERVER fails a test instead of silently changing every byte
we emit.
generate_roster.py --check FAILS when it cannot reach the oracle rather than
passing, and mutation-testing the mutation harness itself caught two "surviving"
mutations that were really sed no-ops. With application verified, all four
mutations (header order, Content-Length, XML body, Connection) are killed.
Three times now the same sequence has broken the client path: a build guard
correctly refuses to start the Rust replacement, and the `switch on` that
follows in the same script arms anyway, because it never checked whether
anything was listening. The redirect then lands on a closed socket and the
working Python service is bypassed for no benefit.
`on` now refuses unless the target port is listening. ALLOW_DEAD_TARGET=1
overrides it for arming ahead of a service that is about to start, but that has
to be deliberate. Verified both ways: rc=2 and nothing installed against a dead
port, rc=0 and two rules with the override.
The watchdog now writes a pidfile. Stopping it by command-line match is unsafe
-- any shell whose arguments merely mention the script name matches too, which
has now killed the wrong process twice here (once via `pkill -f`, once via a
/proc/*/cmdline substring loop).
`openfut-switch.sh on` prints "the service MUST stay up" -- true, and useless
when nobody is at the terminal. An armed switch pointing at a dead port means
the client hits a closed socket with no fallback.
This turns the documented rollback into an automatic one, failing toward the
Python oracle. The worst case of a spurious trip is a gate needing re-arming;
it can never leave the client broken.
It only ever REMOVES a switch. It does not install one, restart the Rust
service, or touch Python, and it does not re-arm after tripping -- an
unexplained rollback should be a finding to read, not something hidden by
flapping the switch back on.
The probe goes through the switch and speaks TLS, because a bare TCP connect
would succeed against a process wedged mid-handshake.
Tested both directions, not just the happy path: quiet for 45s against a
healthy service, and against a stopped one it failed 3/3 in 9s, rolled back,
and left Python serving -- verified by re-reading all four tables and by which
implementation's log grew.
`verify` inspects `$BIN --identity`, which is the file on disk. That is not
necessarily what is serving. Caught during gate 14 setup: the live process had
been started from 5bc39e9, then `cargo test` re-ran build.rs (the branch ref
moved when an unrelated script was committed) and restamped the on-disk binary
to fc411bb. `verify` then reported "build identity OK" about an artifact that
was not the running service.
`start` now records the stamped commit to $RUNDIR/redirector.commit, and
`verify-running` compares THAT against HEAD, refusing when they differ. The
existing on-disk check stays -- it is the right gate for "may I start this" --
but only the recorded stamp answers "is the thing currently serving the thing I
think it is", which is the question a live gate's evidence depends on.
Two live gates were lost to a second variable I had been asked to eliminate.
The Rust redirector was pointed at the repo's fifa17-recon/tools/redir_cert.pem
(fingerprint F9:16:1A...), while the running container serves a different cert
baked into its image (E7:F9:46...) which the Python redirector, roster and the
rest of the stack all share. So the A/B compared TLS implementation AND
certificate identity at once.
FIFA 17's ProtoSSL caches the server certificate for a backend. The redirector
is the first TLS connection of a session, so its cert becomes the one the client
expects; the next service presenting a different cert fails its handshake. That
is why the redirect itself always succeeded and the failure surfaced later, on
the roster fetch -- "An error occurred downloading the FUT Squad Update".
It stayed invisible because Python's socketserver swallows it: a handshake
failure at accept() raises ssl.SSLError, which subclasses OSError and is
discarded by _handle_request_noblock. No request log, no stderr. Every server
looked healthy while the client could not talk to any of them.
Confirmed on the wire: tls-observe in front of the roster server captured four
ClientHellos from the client, correct SNI and the same 8 static-RSA suites it
offers the redirector, none of which produced a request.
The check is mutation-tested against the real bug: with a redirector started on
the stale repo cert it exits 1 and names the mismatch.
openfut-observe.sh answers the one question no server log can: when a gate
fails and a service logged nothing, did the client try and fail, or never try?
Both look like silence. Two redirector gates were lost to that ambiguity --
"roster server logged nothing" was equally consistent with a broken roster
service, a wrong roster URL, and a client that never asked.
Built on iptables packet counters because this box has no tcpdump, no
conntrack, and no readable kernel log. That last one is verified rather than
assumed: an initial LOG-based version installed correctly and its rules matched
(counters proved it), but the output went nowhere -- journalctl -k has no
entries and dmesg is empty. Counters are also lower volume and record only SYNs,
so no payload can be captured even in principle.
Validated against the live client, not a loopback stand-in: an initial
self-test using this host's own address counted almost nothing, because
locally-generated packets never traverse PREROUTING. Against the real remote
client it counts 8081 at ~4/min, matching the roster server's own log.
Known gap, recorded rather than hidden: the catch-all TOTAL runs well above the
sum of the named ports, so the client makes steady background attempts to ports
not tracked here. It is present during a working session, so it is not the
failure signature, and it is not chased further here.
verify-build-identity.sh now rejects an argument that is not a commit hash.
Passing the binary path instead of its stamp previously produced a plausible
"REFUSING: binary was built from ./target/release/... but HEAD is <sha>", which
reads as a real stale-build finding rather than a caller mistake -- and a
safeguard that cries wolf is one people learn to route around. Usage error is
now exit 2, distinct from a genuine stale build (1) and success (0).
Two live gate attempts failed with "An error occurred downloading the FUT
Squad Update" while the redirect response was verified byte-identical to the
Python oracle. Rolling back to the Python redirector fixed it, so the response
bytes were never the whole contract.
Log archaeology found the discriminator: the client polls
/fifa17/fut/rosterupdate.xml ~4x/min in every successful FUT session, and the
only gap in 300 recorded fetches is 03:47-03:58 -- exactly the two
Rust-redirector sessions. The Blaze RPC sequence over those sessions is
identical (msgNum 0-53), so the divergence is entirely outside Blaze.
A differential lifecycle probe against both redirectors found the two
behaviours this host never reproduced:
* the oracle drains the request body per Content-Length; this host stopped
at the header terminator, leaving unread data in the receive queue, which
makes Linux close with RST rather than FIN
* the oracle holds the connection open ~300ms before closing
(time.sleep(0.3)); this host closed at 0ms
Both are now reproduced. The dwell is a named constant, ORACLE_CLOSE_DWELL,
overridable only so the causal experiment -- set it to 0, confirm the failure
returns -- can be run without a rebuild.
The suite could not have caught either: it sent Content-Length: 0, so there
was never a body to drain. It now POSTs a body, and asserts a split-write body
is fully consumed.
Testing the drain via client-visible symptoms does NOT work -- verified by
mutation: with the drain removed the client still reads the buffered response
and sees close_notify before any reset. So the host records a per-connection
ConnOutcome and the test asserts on that. Both mutations (no-dwell, no-drain)
are now each caught by exactly one test.
This does not yet prove causation for the FUT Squad Update failure; it removes
the only two measured divergences. Gate 6 is the test.
The binary records only the commit it was built from -- no dirty-tree flag.
Cargo will not re-run a build script because another crate's source changed, so
a compiled-in 'clean' claim can be stale and is not a safeguard; that was
verified on the Blaze host.
scripts/verify-build-identity.sh establishes both facts at LAUNCH, where they
cannot go stale: the stamped commit equals HEAD, and the migration crates are
clean. It REFUSES rather than warns, because for a migration gate a warning on
stderr is something to scroll past.
--identity prints the stamp without valid configuration. The launcher must be
able to establish which commit a binary came from BEFORE deciding whether to
run it; requiring a correct environment first would invert the check.
redirector.sh mirrors sidecar.sh: refuses to start with an orphan present or
the port busy, matches the resolved executable rather than the command line
(pgrep -f matches any shell mentioning the name), and stop PROVES the process
is gone and the port free.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TLS DEPENDENCY, as directed: the openssl crate directly with the `vendored`
feature. NOT native-tls. native-tls abstracts over whatever the platform
provides; here the requirement is the opposite -- precise, evidenced behaviour
for one legacy client -- which needs explicit control of the cipher list,
protocol floor/ceiling and security level. Vendored so a distro libssl update
cannot silently change whether FIFA 17 can connect.
Scoped to this crate alone. Neither OpenFUT Core nor the generic protocol
crates gain an OpenSSL dependency.
CIPHERS driven by the captured retail ClientHello, not by generic legacy
assumptions. The six RSA+AES suites it offers are enabled; RC4 and MD5 are
deliberately NOT, even though the client offers them -- it already negotiates
AES256-GCM-SHA384, so resurrecting RC4 for completeness would weaken the
service for nothing. TLS 1.2 floor and ceiling, matching the observed client;
the floor is not dropped to 1.0 pre-emptively because "the oracle permits it"
is not "the client requires it".
SECURITY LEVEL IS NOT LOWERED. Tried the default policy first, as directed,
and OpenSSL 3.6.3 accepts static-RSA/AES without weakening. No SECLEVEL change
was needed and none is applied; it remains overridable per-listener with
evidence.
CERTIFICATE: the proven Python redirector's material is reused, so the TLS
implementation stays the only variable in an A/B. Verified RSA-2048, CN
winter15.gosredirector.ea.com, cert/key modulus match; the key stays
gitignored.
SHARED CONFIG. New openfut-host-config is now the only crate that reads the
environment, and both hosts resolve endpoints through it. Two hosts each
parsing OPENFUT_ADVERTISE would be exactly the "separate helpers constructing
endpoints from different sources of truth" the address audit forbids.
VERIFICATION BY REAL HANDSHAKE, not by enumeration. The crate exposes no
accessor for a context's configured suites at this version, which turned out
better: the host now rehearses the retail handshake at startup with a client
restricted to exactly FIFA's eight suites and REFUSES TO SERVE if it fails, so
a cipher/version misconfiguration surfaces at boot rather than as an
unexplained failure during a live gate.
Gates 1-5 pass: TLS config unit tests; a FIFA-suite-only client negotiates
TLSv1.2/AES256-GCM-SHA384; each enabled RSA+AES suite negotiable alone; an
RC4-only client is refused; an ECDHE-only client is refused (proving no modern
policy was silently inherited); a full HTTPS round-trip returns bytes
IDENTICAL to the Python oracle's recorded response.
Cargo.lock committed for reproducibility: openssl 0.10.81, openssl-sys 0.9.117,
openssl-src 300.6.1+3.6.3 (OpenSSL 3.6.3). Updating openssl-src is NOT a
routine bump -- it requires re-running the FIFA compatibility gates.
Gates 6-14 need the retail client and are next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Blaze switch was hardwired to 42130 and could not intercept the redirector.
Rather than clone it, the iptables logic now lives in one place:
openfut-switch.sh generic: --server-ip --intercept-port --target-port
--name [--client-ip] [--legacy-tag]
blaze-switch.sh thin wrapper, CLI and output UNCHANGED so the validated
gate runbook and sidecar.sh's cross-check keep working
No deployment IP or port literal in the generic tool; 42130 is supplied by the
wrapper, 42127 by the redirector experiment.
VERIFICATION IS INDEPENDENT OF REMOVAL. Rules are created and deleted by their
comment tag; they are verified by parsing the kernel's own FIELDS (chain,
destination, dport, to-ports) with no reference to the comment. Status detects
duplicates, incomplete pairs, conflicting targets under one name, and foreign
redirects on the same port -- which it reports but never deletes. `off` removes
only rules bearing this switch's exact tag, then re-reads the table to confirm.
THREE BUGS FOUND WHILE BUILDING IT, all in the same family as the original
lying rollback:
1. Renaming the tag ORPHANED live rules. Gate 10 deliberately ended with the
switch on, so rules carrying the old tag were still installed and the
renamed tool could not see them -- `off` would have reported success while
traffic stayed redirected. Hence --legacy-tag: a rename must not strand
rules it owns.
2. Deleting by re-feeding the raw `iptables-save` line through the shell fails
on this iptables, which prints `--comment "tag"` WITH quotes; word-splitting
leaves the quotes inside the value so nothing matches. Bare-comment rules
deleted fine, which is exactly what made it look like it worked. Deletes are
now rebuilt from parsed fields and passed as argv elements.
3. `IFS=$'\t' read` collapsed consecutive tabs because tab is IFS *whitespace*,
so an absent `-s` shifted every later field left and produced
`-s <dport> --dport <to_ports> --to-ports ''`. Harmless here, but a shifted
spec that matched a real rule would delete the wrong one. Now uses \x1f.
Mutation-tested against all seven required cases: wrong intercept port, wrong
target port, missing rule, duplicate rule, changed comment representation
(bare vs quoted), and a rollback that leaves a foreign redirect installed --
which exits non-zero rather than claiming success.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The redirector TLS question cannot be answered from the cipher OpenSSL
selected: its server follows client preference by default, so FIFA preferring
static RSA does not prove ECDHE was unavailable. Choosing a TLS stack on that
inference would be a guess. This reads the actual ClientHello.
PASSIVE BY CONSTRUCTION. Bytes relay verbatim both ways, nothing is injected
or rewritten, and the handshake is still terminated by the untouched Python
redirector. A parse failure logs and relays anyway -- observation must never be
able to break the path it observes.
Reports record/client version, supported_versions, SNI, every offered suite by
name, extensions, and a verdict on whether ANY forward-secret suite is offered,
which is exactly the rustls question. Unknown suites print as hex rather than
being dropped.
Verified end to end against the live Python redirector with openssl s_client:
31 offered suites parsed, 18 classified forward-secret, and Python logged the
relayed request and served its 406B serverinstanceinfo -- proving observation
AND pass-through in one run.
Unit-tested on truncated and non-TLS input; the verdict is asserted in both
directions so a static-RSA-only hello reports RULED OUT rather than defaulting
to the permissive answer.
NOTE: that 18-suite result is from openssl s_client, NOT from FIFA. It proves
the instrument works. The actual question is still open until a retail FIFA
ClientHello is captured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cheap insurance, explicitly not the real check -- the semantic tests in
deployment_config.rs are what prove propagation, using two TEST-NET addresses
and bind != advertise. This grep only stops the lab subnet reappearing months
from now when the reasoning has been forgotten.
Deployment config legitimately contains real addresses and lives in gitignored
files, so it is never scanned. The frozen baseline doc is allowlisted BY PATH:
it records what a past deployment actually was, and rewriting it would falsify
the record.
Also swapped the lab IP for a TEST-NET placeholder in the usage examples and
error messages of compose/entrypoint/client_arm. Those were already correct
architecture -- every one requires the address via ${VAR:?} -- but using the
real lab IP as the example is the same 'happens to match our lab' smell, and
placeholders keep the tripwire allowlist near-empty.
Mutation-tested: adding a lab address to a source file makes it exit 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
REDIRECTOR. The first hop's <serverinstanceinfo> XML, byte-for-byte against
the oracle across three advertised addresses. Owns the response only; TLS and
HTTP transport belong to a host, exactly as the Blaze adapter owns dispatch
while the sidecar owns the socket.
The <secure>0</secure> field is the client being told the second hop is
plaintext -- independent corroboration of the plaintext Blaze finding, now
expressed in code.
NUCLEUS IS NOT PORTED, and that is a finding rather than an omission.
Instrumented across every live session:
listener bound YES 0.0.0.0:42131 since 00:21:32
handler logs on connect YES unconditional, before any parsing
client received the URL YES OSDK_NUCLEUS fetched 10+ times
client connected NO zero requests, including 4 full FUT flows
So the long-standing nucleusConnect=0.0.0.0 anomaly is explained: FIFA never
follows that URL on this path. The invalid address has never mattered because
nothing dials it. Porting the stub would add an untested component for no
parity gain.
TLS CONSTRAINT RECORDED, NOT RESOLVED. All 9 observed handshakes negotiated
AES256-GCM-SHA384 = TLS 1.2 with STATIC RSA key exchange. rustls supports only
forward-secret (EC)DHE suites and cannot serve that. Whether the client also
OFFERS ECDHE is unknown -- OpenSSL follows client preference by default, so
preferring static RSA does not prove it is the only option. This must be
instrumented from a real ClientHello before a TLS stack is chosen; the module
docs say so rather than guessing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Host-side ss cannot see the Python backend's connections: the responders run in
a container, so a client session terminates at 172.20.0.2:42130 inside its
namespace and the host only sees the NAT'd flow. 'ss | grep <client>' on the
host therefore reports nothing while a session is very much alive.
That produced a wrong precondition: 'no .105 Blaze session -- closed' was
reported while FIFA was mid-session on Python, and gate 9 was armed against a
client that had never exited. Python's own log had the answer -- it logs closes
reliably and there was no close for that session.
client-state.sh looks in both namespaces, reports Rust and Python separately,
and exits non-zero while any session is live. An unreachable container counts
as 'cannot confirm', not as 'clear'.
Fourth measurement bug in this tooling, and the most consequential: the other
three mis-COUNTED, this one mis-STATED a precondition and caused an action.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'pack opens recorded: 45' appeared in the gate 8 report. The UTAS log is
cumulative across the entire deployment, so a bare count reads as if 45 packs
were opened during that gate; the real number was 1.
Now reports both, labelled, windowed from the sidecar's start time (it is
restarted per gate, so that is the gate boundary). A bare count in a gate
report will be read as belonging to that gate, so it has to be the one that
does.
Third counting bug in this tooling: the trace frame counter matched OPEN/CLOSE
markers, the capture and trace were read seconds apart during a live session,
and now this. Evidence tooling gets the same scrutiny as the code under test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes, all found while closing out gate 7.
1. Frame count was wrong. It counted lines matching '^conn-', which also
matches the OPEN/CLOSE lifecycle markers, inflating the figure by one or
two. Compared against the capture's record count that looked like a
capture/trace divergence (89 vs 88) when there was none: read at the same
instant, both report 99. Evidence tooling that miscounts is exactly what
this project cannot afford.
2. The raw capture is now copied into the evidence bundle (0600), so a gate's
forensic bytes travel with its report.
3. FUT actions are now observed on the UTAS side. 'Known FUT action succeeded'
is a client-side fact, but FUT actions go over UTAS -- which is never
switched -- so the UTAS log confirms them independently of anyone's
recollection. Gate 7's pack open shows up as:
STORE: opened pack Special Players Pack -> 11 items
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'Rust did not receive it' is weaker than 'Python did'. The redirector always
runs on Python and is never switched, so it advertises the Blaze endpoint on
every run; whether Python then receives the Blaze CONNECT it just advertised
says where the hop actually went.
This is already visible in the existing logs and settles gate 5-6 more firmly
than the sidecar record alone:
02:02:13 Python REDIR SENT -> 10.10.0.120:42130 (to .105)
02:02:13 Rust conn-0005 CONNECT from 10.10.0.105
Python received NO Blaze CONNECT
Same second, both sides: Python advertised the endpoint and did not get the
connection; Rust did. It is also the mechanism gate 8 needs in reverse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Committing updates refs/heads/<branch>, not the HEAD file, so watching HEAD
alone left the stamp one commit behind -- observed live, the banner read
a84a72e immediately after 2337431 was committed. build.rs now also watches the
resolved branch ref.
Belt and braces, since cargo still cannot see every source change: sidecar.sh
compares the binary's stamped commit against the tree's real HEAD at launch and
says so loudly on a mismatch. An evidence artefact that names the WRONG commit
is worse than one that names none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Evidence infrastructure, not protocol functionality. Built before gates 7-10
because those sessions cannot be reproduced -- a later run is a different
session, and the migration-validation runs happen once. Gates 5-6 already went
past without their bytes being recorded.
TWO LAYERS
live FIFA traffic
├── raw capture exact RX/TX bytes, mode 0600, gitignored
└── blaze-sanitize → repository-safe, replayable fixtures
CAPTURE. Off unless OPENFUT_BLAZE_CAPTURE names a file. Deterministic
big-endian container: 20-byte file header, then per-frame records carrying
connection id, a global monotonic sequence, timestamp, direction and the EXACT
frame bytes. RX is recorded as received; TX only AFTER a successful write, so a
record means the bytes were sent rather than intended.
Component/command/msgNum/msgType/payload length are deliberately NOT stored
beside the frame: they are already in its 16-byte header, and a redundant copy
can disagree with the bytes, leaving a reader unable to tell which is true.
Record::header() derives them, so every field the requirements name is
available without duplicating it.
SANITIZER. Redacts only the named tags in SENSITIVE_TAGS (KEY, AUTH, SESS,
MAIL, PML) and reports every substitution with path, kind and length.
Replacement is LENGTH-PRESERVING, so the TDF varint, payload length and Fire2
header are unchanged and the sanitized frame is exactly the size of the
captured one -- asserted per frame, failing rather than emitting a subtly
different conversation. Frames with nothing sensitive keep their exact wire
bytes. Payloads that will not decode are passed through and REPORTED, so a
reader knows they were never inspected rather than assuming they were checked.
TESTS. 39 in this crate. All nine required cases: capture disabled produces no
artefact; RX and TX captured exactly; ordering preserved; fragmented input
(one byte at a time) reconstructs the same frames as a single write; coalesced
input is captured as separate frames, not per-read; capture does not alter wire
output; sanitization removes a real session key from a real captured login;
malformed/truncated/wrong-version captures fail clearly; every listed sensitive
tag is provably reachable.
MUTATION TESTED. Dropping TX capture, truncating captured frames to their
header, and removing KEY from the sensitive list were each verified to turn the
suite red. One mutation was NOT caught: moving the TX capture above the write.
It is indistinguishable while writes succeed and only diverges when one fails.
That invariant is held by code placement and a comment saying so, not by a
test, and the code says as much rather than implying coverage it does not have.
Python oracle unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate 5-6 live run exercised 14 RPCs the recorded fixtures never covered --
Stats, Clubs, OSDKSettings, SponsoredEvents, Messaging::fetchMessages,
Util::getTelemetryServer, Util::userSettingsLoadAll, UserSessions cmd 0x0008,
and the transport PING -- every one taking the empty-reply fallback.
That Python does the same was an inference from reading its dispatch table.
This sends those exact routes to both backends and diffs the replies:
14/14 byte-identical.
Worth keeping: the fixtures were built from what the responder implements, so
they could never have covered what the client asks for and the responder does
not. Only a live session reveals that surface, and this makes it checkable
afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
For the live FIFA gates. Records switch rules, sidecar status, log, trace and
the Python contract result into a timestamped bundle, and reports CONFIGURED
and OBSERVED state as two distinct sections.
The separation is the whole point. 'blaze-switch.sh status = ON' is an
assertion produced by the same tooling that performs the switch, and that
tooling reported a successful rollback once when none had happened. The
observed half comes from an unrelated source: the sidecar's own record of
which peers connected to it. A non-loopback peer in that log proves the
client's Blaze traffic landed on Rust without depending on reading an iptables
rule correctly.
Verified both ways: loopback-only traffic reports 'a FIFA session did NOT land
here'; a non-loopback peer reports that it observably did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compiled-in dirty flag cannot be trusted for this job. Cargo does not
re-run a build script when another crate's source changes, so editing the
adapter and rebuilding the host leaves it reading 'clean' -- verified by
appending a line to the adapter and watching the flag not move.
So the stamp now only names the commit, and the real safeguards run at the
moment they matter and cannot go stale:
* sidecar.sh checks the working tree at LAUNCH and warns.
* check-live-parity.sh REFUSES on a dirty tree, since it produces the
artefact a migration decision is made from. ALLOW_DIRTY=1 overrides for a
throwaway check.
Both scope to the three migration crates, so unrelated submodule dirt does not
trigger them -- a warning that is always on is a warning nobody reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A whole-repo check read DIRTY permanently, because unrelated submodules carry
pre-existing modifications. A warning that is always on is a warning nobody
reads, which defeats the point: the flag exists so a mutated build announces
itself before it can be mistaken for parity evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prerequisites for the live FIFA A/B. Two safeguards here exist because the
corresponding failure actually happened, not because it was imagined.
BUILD IDENTITY. build.rs stamps commit + working-tree cleanliness; the host
prints commit, tree state, profile and a fingerprint of the bundled config
table at startup, into both the log and the trace. A dirty tree prints an
explicit "do NOT treat results from this binary as parity evidence" warning.
The previous step left four sidecars running, two serving mutated builds, and
nothing in their output said so.
SIDECAR LIFECYCLE (sidecar.sh). start/stop/status/check-orphans/with. Start
refuses when any sidecar is already running or the port is busy. Stop kills,
waits, then PROVES it: PID gone AND port free AND no stray processes, failing
if any check does not hold. `with -- CMD` traps EXIT/INT/TERM so cleanup runs
however the command exits.
Bug found and fixed while testing it: orphan detection used `pgrep -f`,
which matched any process whose command line merely mentioned the name --
including the shell running the test script. It now matches the resolved
executable via /proc/PID/exe. `pgrep -x` is unusable because Linux truncates
the process name to "openfut-blaze-h".
BLAZE SWITCH (blaze-switch.sh). Redirects Blaze to the sidecar with a scoped
NAT rule instead of editing the frozen Python oracle, whose redirector
advertises a hardcoded BLAZE_PORT = 42130. Rules match only <LAN_IP>:42130;
127.0.0.1:42130 is deliberately left alone so Python stays reachable on
loopback and the A/B compares real Python against real Rust. Verified both
directions live: LAN->Rust with the switch on, LAN->Python with it off.
Bug found and fixed: `off` reported success while two rules remained active
and rollback had NOT happened. It matched `--comment "tag"` with quotes this
iptables does not emit -- and the verification used the SAME broken matcher,
so it confirmed its own failure. A rollback that lies is worse than one that
fails. Now matched on the bare tag, verified with iptables-save plus a
tag-independent check that nothing still redirects the port.
Second flaw fixed: `sidecar.sh stop` originally warned about a live switch
and then stopped anyway, creating the exact broken state it warned about. It
now REFUSES, with --force as the deliberate override.
The general rule this all converges on, now stated in the README: a
verification must not share the failure mode of the thing it verifies.
116 tests still passing; clippy clean; Python backend untouched and contract
suite 446/446. NAT table left clean, no orphan processes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third migration step, and the one that turns fixture parity into transport
parity. A TCP host that frames a Fire2 stream, keeps one Session per
connection, calls openfut-adapter-fifa17::dispatch(), and writes the returned
frames in order. It owns a socket, a buffer, a session and diagnostics --
that is the complete list. No coins, club, packs, profiles or UTAS logic:
those belong to Core, reached through the adapter later.
NO TLS, and that is evidence-based rather than an omission. The Blaze main
port is plaintext: sending a raw Fire2 Util::ping to the running backend
returns a plaintext PingResponse, blaze_handle uses the raw socket, and only
redir_handle wraps ssl. TLS belongs to the redirector phase.
LIVE A/B AGAINST THE RUNNING PYTHON BACKEND: 101 frames across three
conversations, identical normalized traces. This is the first result in the
migration that is not purely offline. check-live-parity.sh replays the
recorded conversations against both endpoints over real sockets and diffs
volatile-masked traces; session keys and clocks are masked, so anything that
differs is behavioural.
Transport tests cover what fixtures cannot: byte-for-byte replay over a
socket, requests dribbled one byte at a time, several requests in one write,
the four-frame login burst ordered on the wire, session state persisting
across frames and NOT leaking between connections, an absurd payload length
closing the connection instead of allocating, and an undecodable body still
getting a reply. 18 tests here, 116 across the three migration crates.
MUTATION TESTED, including the comparison itself. Dropping a post-login
notification is caught by the probe (frame count) AND the diff; a same-length
content change deep inside a notification body (CTY "US"->"GB", payload 116
both sides) is caught ONLY by the trace digest. So the probe's exit code is
not the test -- the diff is, and the README says so. check-live-parity.sh was
itself verified to exit 1 under mutation.
The listen port is required configuration with no default, so the sidecar
cannot silently collide with the working container. OPENFUT_BIND stays the
advertised-config bind (the adapter derives nucleusConnect from it,
reproducing the oracle) and the listener gets its own setting, so the two are
not conflated.
Gates 1-4 pass and are re-runnable. Gates 5-10 need a FIFA client and are
listed in the README, including the Python -> Rust -> Python -> Rust
back-and-forth that proves the rollback path rather than asserting it.
Python backend untouched and still the live runtime; contract suite 446/446
after this work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second migration step: the layer above the codec, deciding WHAT to say
rather than how to encode it. Sits on openfut-protocol-blaze and supplies
what that crate deliberately refuses to know.
blaze/ids.rs component/command/notification tables
blaze/config.rs injectable identity + endpoints, nothing hardcoded
blaze/session.rs per-connection state
blaze/client_config.rs the fetchClientConfig tables
blaze/responses.rs 16 Blaze::* response bodies
blaze/dispatch.rs (component, command) -> Vec<Frame>
Parity is tested, not asserted. fixtures/generate.py drives the real
blaze_responder_v3b.dispatch() and records 49 request->response(s)
transactions, replayed in order against a shared session per connection so
ordering-dependent behaviour is exercised: preAuth captures the locale later
ALOC fields echo, login sets the auth code getAuthToken returns. Comparison
is byte-for-byte including frame count and order.
98 tests green across both crates; clippy clean.
MUTATION TESTED, and it found a real defect in this commit's own design.
Swapping two post-login notifications and flipping one enum inside
AccountInfo both turned the suite red as intended. Hardcoding an address in
utas_base() did NOT -- the config templating substituted raw hosts directly,
making those helpers dead code that merely looked load-bearing. The table now
templates on URL-level tokens ({utas_base}, {nucleus_base},
{pow_content_url}) so they are the single place a URL shape is defined, and
the mutation is caught.
The client config table (227-243 rows per CFID) is generated from the oracle
rather than transcribed: it is reverse-engineered data, not logic, and 400
hand-copied string literals would add a typo class no reviewer can catch. The
generator substitutes real addresses back in and diffs against the oracle for
every section before writing, so the templating is verified rather than
assumed.
Reproduces one known defect deliberately: nucleusConnect is built from BIND,
not advertise, so the live split deployment tells a client on another machine
to reach Nucleus at http://0.0.0.0:42131. Confirmed against the running
container. Reproduced because it is what the only proven-working config does;
fixing it needs live validation and is a separate change. It also implies the
Nucleus stub is not reached in the current remote flow.
Blaze carries no FUT domain state -- no coins, packs, clubs or squads on this
wire -- so Session stays a session key, locale, service name, auth code and a
flag. That boundary will need defending when UTAS is migrated.
Not wired into anything. The crate answers frames; it opens no socket and
owns no runtime. The Python backend remains the live service and the oracle,
and is unmodified (contract suite still green).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The responder reads OPENFUT_ADVERTISE/OPENFUT_BIND at import time and several
live payloads embed the advertised address (Blaze redirect target, RS4/POW/
roster URLs). Without pinning, regenerating on a machine that exports
OPENFUT_ADVERTISE produces different bytes and --check goes red for a reason
that has nothing to do with the codec.
Pinned to 127.0.0.1 as a placeholder so no real LAN address is baked into a
committed fixture. Not a claim about deployment: remote mode still requires an
explicit advertised address and has no loopback fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First Rust component of the Python -> Rust migration. Chosen first because
it is the lowest genuinely game-independent layer, it has an executable
oracle, and both existing Rust implementations of it are wrong.
Contents:
* fire2 -- the proven 16-byte frame header, frame/stream splitting
* heat2 -- tag packing, varints, all 11 TDF value types
* message -- frame + decoded body, routed by NUMERIC component/command
* diagnostics -- dumps for capture review
No FIFA 17 command tables, response schemas or notification IDs: this layer
knows 0x0009/0x0007 is component 9, command 7, not that it means
Util::preAuth. That mapping belongs to a game adapter, which is what lets a
future FIFA 18/23 adapter reuse this.
Parity is tested, not asserted. fixtures/generate.py drives the proven
Python responders (heat2.py, blaze_responder_v3b.py) and freezes 56 vectors
-- 31 of them real payloads from the responder's own builders, including
the 11.8 KB preAuth reply. tests/oracle_parity.rs replays every one
byte-for-byte. 54 tests green; clippy clean.
Supersedes two wrong framings, neither of which is removed yet:
* fifa-blaze/crates/blaze-proto/frame.rs -- a 12-byte header with a u16
length, nibble-packed type/options, an error field and a JUMBO flag.
A documented guess at FIFA 23 predating the FIFA 17 recon.
* heat2.py::build_fire2_frame -- packs >IHHHHB3s, msgId at [10:12] and
msgType at [12]. Dead code, but its docstring still states that layout.
Confidence is carried in the types: TypeId::is_verified() reports which
layouts are capture-backed (int/string/blob/struct) and which the oracle
marks UNVERIFIED (list/map/union/varlist/objtype/objid/float), with a test
asserting the unverified ones stay flagged.
Cargo.lock is deliberately NOT included: it re-resolves ~240 lines against
the current registry even without this crate, so that churn is pre-existing
and does not belong in a foundation commit.
The Python backend remains the live runtime and is untouched. Nothing
consumes this crate yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fifa17-recon/tools (authoritative) and fifa17-recon/data now feed the Docker
build directly via the curated runtime-tools.list manifest. The duplicated
fifa17-python/tools+data are removed so the repo has a single source of truth;
the rebuilt openfut-fut-backend:dev image is byte-identical to the previous
deployment (verified: manifest diff empty, 446/446 contract checks pass).
Build context moves from docker/fifa17-python/ up to fifa17-recon/ so the
Dockerfile reads the single-source tools/ and data/ trees. Only the 77 runtime
files listed in runtime-tools.list are installed into /app/tools (baseline image
minus the two git-ignored certs, regenerated in-image). memdump and recon
artifacts are excluded via fifa17-recon/.dockerignore.
The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.
Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
- Add 8 files present in docker/fifa17-python/tools but missing from the
top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in
the server's docker tree; byte-identical to the running image).
- Preserve newer responder work already matching the running container:
utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND),
blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py,
test_fut_contract.py, fifa17-hook-m1.sh.
- Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime
registries). Local tree is now a strict superset of B with all shared
files byte-identical.
The frozen baseline image predates two hot-patches made in the running
container after build:
* utas_server.py: FUT_MODES-gated offlineSeason block in GetHubData's club
response (keeps the offline-season summary valid)
* test_hub_offline_season_contract.py added to /app/tools
Sync fifa17-python/tools to the running container (verified byte-identical,
237 files incl. the redir cert pair) and snapshot the live FS as
openfut-fut-backend:python-running-2026-08-10 (docker commit). A fresh build
from the committed sources now reproduces the running backend exactly
(baked SHA256SUMS.txt diffed against the container manifest: identical).
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:
* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
(lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
(env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
restore instructions and rebuild-equivalence procedure
Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00:00
268 changed files with 139574 additions and 1281 deletions
How to connect FIFA 23 to the OpenFUT local simulator, ranked by safety and feasibility.
## Option A — FLE Lua scripting (RECOMMENDED)
**What it does:** Use FIFA Live Editor's in-memory Lua API to read and write the game's
database tables at runtime. FLE is already injected; no additional hooking needed.
**Why it's the right path:**
- Fully offline, no EA servers touched
- FLE is already trusted by the user (it's the launch mechanism)
-`GetDBTableRows` / `EditDBTableField` expose the full Frostbite DB in memory
- Scripts run inside the game process; no IPC complexity
- Same mechanism used by modders for career mode edits today
**Integration design:**
```
openfut-core (SQLite)
│
│ HTTP REST (localhost)
▼
openfut-bridge (port 8080, plain HTTP, no TLS)
│ pulls club/squad/player data as JSON
▼
FLE Lua bridge script
│ calls GetDBTableRows, EditDBTableField
▼
FIFA 23 in-memory DB (Frostbite)
```
The Lua script polls openfut-core's REST API at intervals (or on FUT menu entry)
and writes simulator data (coins, items, squad) into the appropriate DB tables.
**Tables likely involved (to verify with export_squad.lua):**
| Table | Expected FUT content |
|-------|---------------------|
| `players` | Player attributes (OVR, potential, stats) |
| `teams` | Club identity, stadium, colors |
| `fut_clubs` | FUT club record (if in memory when FUT loads) |
| `fut_items` | Card inventory (if in memory) |
| `fut_squads` | Active squad (if in memory) |
**Steps to implement:**
1. Run `tools/squad-exporter/export_squad.lua` from FLE Lua Engine while in FUT to discover which tables are live
2. Map openfut-core's data model to the discovered table fields
3. Write a Lua polling script that fetches `/api/v1/club`, `/api/v1/squad`, etc. from openfut-core and calls `EditDBTableField` to populate them
4. Optionally add a small HTTP client to the Lua script using LuaSocket (FLE ships with Lua 5.4)
**Limitations:**
- Changes are in-memory only; they reset on game restart (acceptable for a simulator)
- Only works while FLE is running (always true in our setup)
- FUT tables may only be populated when the FUT hub is loaded; test with the exporter
---
## Option B — Local save file injection (career mode proxy)
**What it does:** Generate or modify offline career mode save files that contain FUT-like
squad/player data, using Frostbite's FBCHUNKS format.
**Feasibility:** Medium
- FBCHUNKS format is not publicly documented but has been partially reverse-engineered by the Frosty Tool Suite project
- Career saves are 16 MB — large and complex
- Changes take effect only after a game restart
**Best use:** Pre-populating a career club with the same players as the FUT simulator squad, so offline Squad Battles use "your" players.
**Steps:**
1. Use Frosty Tool Suite to open a career save and map the schema
2. Build a Python exporter that writes a valid FBCHUNKS save with simulator squad data
3. Test: replace the career save, launch FIFA, verify squad is correct
---
## Option C — Local companion web UI
**What it does:** The user manages their FUT simulator entirely in a web browser (openfut-core already has this). A button exports the current squad/club state to a format that a Lua script or file injector can consume.
**This is already implemented** — openfut-core serves the FUT simulator REST API. The missing piece is the Lua bridge script (Option A) that reads from it.
---
## Option D — Local proxy for non-secured local calls only
**What it does:** Intercept FIFA 23's calls to `localhost:*` or a known local endpoint (not EA servers) and respond with simulator data.
**Feasibility:** Low value in isolation
- FIFA 23 does not make calls to localhost in normal operation (except EA App on port 10853)
- All FUT API calls go to EA's servers over TLS
- Intercepting those would require the approach we explicitly ruled out
**Not recommended as a primary path.** Could be combined with Option A if the Lua script exposes a local socket that a coordinator process writes to.
**What it does:** Use known memory offsets (FLE's `offset_cache.json`) to read/write FUT state directly in FIFA23.exe's heap.
**Feasibility:** Medium — FLE already does this for career mode
- FLE's `offset_cache.json` contains addresses for many game structures
- FUT in-memory structs are separate from career structs and may not be mapped yet
- This is fragile (offsets change with game updates)
**Not recommended** unless Options A and B both fail — too brittle.
---
## Recommendation
**Start with Option A (FLE Lua scripting).**
1. Run `tools/squad-exporter/export_squad.lua` in-game to discover which DB tables exist in FUT mode
2. Use `tools/file-watch-diff/watch.sh` to snapshot file state entering FUT and identify any new local files
3. Use `tools/network-metadata-logger/netlog.sh` to log which EA hosts FIFA contacts at FUT entry (metadata only, no decryption)
4. Map findings back to openfut-core's data model
5. Implement the Lua bridge script that calls openfut-core's REST API and writes to discovered tables
If FUT tables are not exposed by FLE's DB API (they may not be — FUT data lives server-side in online mode), fall back to **Option B** (career save injection) to provide a squad that mirrors the simulator's club.
---
## Safety boundary
The following are out of scope and must not be implemented:
- Decrypting or inspecting EA's TLS traffic
- Spoofing EA domain names or impersonating EA servers
- Sending modified clients to EA's production services
- Bypassing EA App login or account verification
- Anything that could constitute online cheating or violate EA's ToS for online play
All integration must remain local/offline/single-player.
*Generated 2026-06-30 — read-only stocktake, no code changed.*
---
## Executive Summary
OpenFUT has a mature offline FUT economy backend (Core, 25 phases, fully functional in
isolation) and a sophisticated hook DLL that loads into FIFA 23, redirects EA hostnames
to loopback, and bypasses TLS certificate verification. The Blaze/ProtoSSL layer is
structurally ready: framing code exists, a TLS listener runs, cert-verify is patched.
However the project is currently blocked before any Blaze traffic is ever seen.
The fundamental problem is that FIFA 23 submits `GoOnline` to EbisuSDK and then
**waits for an asynchronous ONLINE_STATUS_EVENT push** from the EA-app LSX server —
a push that current code never sends. Every approach tried so far (flipping poll
return values, forcing the state flags, read-only probes) confirms the gate is
event-driven, not poll-driven. The Blaze captures directory contains six empty files.
No Fire2 frame from FIFA 23 has ever been decoded. Until the ONLINE_STATUS_EVENT push
is synthesized and delivered correctly, Milestones 2–7 are all waiting on the same
single wall.
---
## 1. Proven vs Assumed
| Claim | Status | Evidence |
|---|---|---|
| FIFA 23 uses DirtySDK / ProtoSSL | **Proven** | String scan hit `ProtoSSLSend`, `ProtoSSLRecv`, `gosredirector` in FIFA23.exe memory (Task 1) |
| `version.dll` loads and runs hook code | **Proven** | `hook.log` written at DLL_PROCESS_ATTACH |
| `getaddrinfo` IAT hook redirects EA domains to loopback | **Proven** | Hook log records every EA `getaddrinfo` call; connect_hook log confirms port redirects |
| ProtoSSL cert-verify prologue found and patched (FIFA23.exe) | **Proven** | ssl_patch.rs prologue confirmed at file offset 0xf0c850; hook log "ssl: main exe cert-verify patched" |
| ProtoSSL cert-verify patched in EAWebKit.dll | **Proven** (if loaded) | Lazy patch fires on first EA getaddrinfo call; hook log message confirms |
| Gate is upstream of DirtySDK — no DNS/connect fires on FUT entry | **Proven** | getaddrinfo, connect, WSASend/Recv hooks all show zero external traffic during "connecting to EA Servers" |
| `GoOnline` is called by the game | **Proven** | Read-only detour on `anadius64.dll+0x2BB90` confirmed hit |
| anadius returns GoOnline success | **Proven** | Handler observed returning successfully; game still retries every ~7 s |
| Gate is downstream of GoOnline | **Proven** | GoOnline called + returns success; no Blaze connect follows |
| Gate is event-driven (game waits for async push, not a poll return) | **Proven** | Forced both state flags AND GoOnline return to "1"; game kept retrying; worker-thread stack scan confirms handler runs on anadius IOCP thread, not FIFA's thread |
| GoOnline runs on anadius worker thread, not FIFA's call thread | **Proven** | Stack scan from inside detour found zero FIFA23.exe frames, sp ~2.4 KB from thread stack top |
| `protossl-scan` live toolkit is exhausted for finding GoOnline in FIFA23.exe | **Proven** | No `"GoOnline"` string in image; worker-thread call stack has no FIFA frames; jmpscan yields ~3875 hits (overwhelmingly data false positives) |
| FIFA 23 redirector config references `Authorization:` header (Nucleus token) | **Proven** | Found in FIFA23.exe .rdata pointer table @ `+0x83FC858` |
| openfut-core REST API complete and tested | **Proven** | 25 phases, 15 migrations, passing integration tests |
| Bridge LSX server starts and handles request-response | **Proven** (code) | `openfut-bridge/src/lsx.rs` + `main.rs` — server starts on 127.0.0.1:3216 |
| Bridge LSX server ACTUALLY receives FIFA's LSX connections | **UNCONFIRMED** | anadius may intercept the same calls in-process before the TCP connection reaches the bridge |
| Bridge LSX server `GetInternetConnectedState → connected="1"` unblocks the gate | **UNCONFIRMED (known to fail in-process)** | Flipping the value via anadius in-process failed; bridge path not yet confirmed working |
| ONLINE_STATUS_EVENT push XML format | **UNKNOWN** | No capture; format not derived |
| Fire2 framing is correct for FIFA 23 | **UNCONFIRMED** | Implemented based on post-2012 EA convention; all blaze captures are empty (0 bytes) |
| Blaze component / command IDs for FIFA 23 | **UNKNOWN** | Zero captures; dispatch table entirely empty placeholders |
| ProtoSSL recv-injection convention (non-blocking return values etc.) | **UNCONFIRMED** | Never reached M4; recv_hook module removed from active install path |
| FUT REST endpoint paths in mapper.rs | **SPECULATIVE** | Based on community knowledge of older FIFA titles; the one actual capture in `captures/` is an early GET from before the Blaze strategy |
| FLE Lua API exposes FUT DB tables in memory | **UNKNOWN** | `export_squad.lua` has never been run; FUT data may only exist server-side in online mode |
---
## 2. Milestone Status
| Milestone | Status | Blocker | Depends on unconfirmed assumption? |
|---|---|---|---|
| **M1** — Locate connection-state decision point | ✅ Done | — | No |
| **M2** — Flip gate, force "connected" | ⛔ Blocked | Game waits for async ONLINE_STATUS_EVENT push; no current code sends it | Yes — unknown event XML format |
| **M3** — First ProtoSSL plaintext on Blaze connection | 🔲 Not started | Depends on M2 | Yes — Fire2 framing unconfirmed |
| **M4** — Answer redirector + decode first Fire2 frame | 🔲 Not started | Hard wall: Fire2 framing, recv-injection convention, component/command IDs all unconfirmed | Yes — all three unknown |
| **M5** — Blaze preauth / login / postauth | 🔲 Not started | Depends on M4 | Yes — Blaze auth TDF body layout unknown |
| **M6** — FUT entry + hub load | 🔲 Not started | Depends on M5; also requires FUT REST response shapes confirmed | Yes — endpoint paths speculative |
| **M7** — Squad Battles (AI FUT) | 🔲 Not started | Depends on M6 | Yes |
**Note on roadmap.md wording:** Under M2–M4, roadmap.md uses `**Done (observable):**` bullets. These describe the *success criterion* for each milestone, not an achieved state. The authoritative status is in `connection-gate-findings.md` (M2 attempts failed; M3/M4 never started). The roadmap has not been updated to reflect M2 failure.
### M4 is the first hard wall in detail
Even assuming M2 is solved, M4 requires three unconfirmed things simultaneously:
1.**Fire2 framing** — the 12-byte header layout is assumed; if FIFA 23 uses an older Fire variant or a custom delta, the codec will misparse every packet.
2.**ProtoSSL recv-injection** — delivering responses to the game via recv hook requires knowing what return values and buffer conventions ProtoSSL expects; recv_hook.rs exists but is not installed.
3.**Blaze component/command IDs** — the dispatch table is entirely empty; we cannot answer any request until IDs are known from captures.
All three are resolved by getting one real captured frame. M4 is primarily a capture problem, not a decoding problem — once bytes exist, the framing and IDs are immediately readable.
---
## 3. Blockers, Risks, Unknowns
### Blockers (stop progress now)
1.**ONLINE_STATUS_EVENT push not synthesized***(M2 wall)*
The game calls GoOnline, gets success, then waits indefinitely for a push event on the LSX socket that never arrives. This is the single gate blocking all Blaze work. Options: (a) trace the event format via Ghidra on FIFA23.exe (xref `ONLINE_STATUS_EVENT` string + the game's EbisuSDK listener), (b) RE anadius's LSX event-send path (find what it would push in an "online" scenario), (c) brute-force push candidate event XMLs and observe whether the game advances.
2.**Bridge LSX server delivery unconfirmed***(architectural risk converted to blocker)*
The hook passes port 3216 connections through, assuming the bridge LSX server on the Linux host receives them. If anadius's in-process hooks intercept the winsock calls before they reach the TCP stack, the bridge server is never reached. This must be confirmed by checking `openfut_hook.log` for a getaddrinfo on the LSX host, or by observing the bridge server's accept logs.
### Risks (could derail later)
3.**Fire2 framing wrong***(M4 risk)*
If FIFA 23 uses Fire (pre-2012) or a modified frame layout, the codec misparses. Mitigation: the server has a `Raw` fallback mode for capturing raw bytes when framing fails.
4.**Secondary auth-token gate***(M5 risk)*
`connection-gate-findings.md` noted the redirector request carries an `Authorization:` header. M1's final conclusion said `GetAuthCode` returns a fake token that appears accepted — but this was inferred, not confirmed by seeing the redirector request actually constructed with that token.
5.**EAAC not fully neutralized***(persistent risk)*
`FakeEAACLauncher` bypasses the anticheat launcher. The hook DLL is unsigned. If EAAC is ever active (e.g., after a game update re-enables it), all hooks fail silently. Marked as "not active in offline/cracked builds" — assumed, not confirmed on every launch.
6.**FUT REST response shapes wrong***(M6 risk)*
The 61 endpoint mappings in mapper.rs and the shaper stubs in shaper.rs are based on community guesses about older FIFA FUT APIs, not FIFA 23 captures. Response JSON shapes may differ enough to cause the client to fail silently or crash.
### Unknowns (open questions)
7.**ONLINE_STATUS_EVENT XML format** — exact tag names, field order, sender attribute, and any nonces/tokens required.
8.**GoOnline event sequence** — whether ONLINE_STATUS_EVENT alone is sufficient or a sequence of events (e.g., PROFILE_EVENT, LOGIN_EVENT, COMMERCE_EVENT) is expected.
9.**Whether FLE exposes FUT DB tables** — FUT card inventory and squad data likely live server-side in online mode; FLE may not surface them for in-process editing.
10.**Blaze component/command IDs for FIFA 23** — entirely unknown; no captures.
11.**openfut_hook.log current content** — we have the code but no log output in any document. Whether the current hook (with connect, ssl_patch, tls_bypass, WSAIoctl, origin_spy all installed) fires correctly and what it observes is unverified in this review.
---
## 4. Track Comparison
### Track A — Full EA-backend fake (M1–M7, playable FUT vs AI)
**What it delivers:** The FIFA 23 FUT hub loads from OpenFUT Core; Squad Battles matches play and reward economy items.
**Effort:** Research-grade. Minimum path: synthesize ONLINE_STATUS_EVENT (unknown format, 1–2 weeks of RE), then capture Fire2 frames (days once M2 is solved), then implement Blaze auth handlers (weeks), then implement FUT entry (weeks), then Squad Battles (weeks). Realistic minimum: 3–6 months of expert RE work.
**Assumed:** Fire2 framing correct; component/command IDs discoverable from captures; FUT REST shapes close enough to community guesses; no additional undiscovered gates.
**Evidence for:** Architecture is coherent. The M1 finding (gate precisely named and decoded) was achieved cleanly. The in-process hook approach is validated.
**Evidence against:** M2 was attempted and failed with the in-process approach. The event-driven architecture adds a full EbisuSDK emulation layer before even one Blaze byte is seen. The live toolkit is exhausted (Path A verdict); Ghidra-level work on a 505 MB binary is required. Six capture files with zero bytes.
---
### Track B — Clean-room spec deliverable (M1–M5 documented)
**What it delivers:** A documented map of the connection gate, LSX event sequence, Blaze auth surface (transport, framing, gate conditions, component IDs, TDF schemas). Valuable as an archival/community artifact even if Track A stalls.
**Effort:** Medium. M1 is done. M2–M5 documentation emerges as a by-product of engineering work. The spec itself (writing) is lightweight; the engineering to produce the captures is the cost.
**Proven support:** M1 complete and documented. connection-gate-findings.md is already a high-quality spec artifact.
**Assumed:** Same as Track A for the unconfirmed values, but the spec can mark them `TODO/CONFIRM` rather than needing to implement them.
**Evidence for:** The clean-room constraint means a spec is the only artifact that can be safely published. connection-gate-findings.md shows this approach produces real value. B finishes even if A is never fully playable.
**Evidence against:** Track B alone doesn't produce a playable FUT; it is a foundation, not an end-user product.
---
### Track C — FLE Lua bridge (local-match path, skip the backend gate)
**What it delivers:** FIFA 23 career mode or Kick-Off with an OpenFUT club's players and squad loaded via FLE's in-memory DB API. No online gate, no Blaze, no TLS. Fully offline from day one.
**Effort:** Low-to-medium. FLE is already loaded in the normal launch path. Tools exist (`tools/squad-exporter/`, `tools/profile-exporter/`). Primary unknown is whether FUT-relevant DB tables are accessible.
**Proven support:** FLE Lua API exposes `GetDBTableRows` / `EditDBTableField` for career mode. `fifa23-startup-flow.md` confirms FLE injects at load. `fut-integration-options.md` documents the integration path in detail and rates this as the recommended option.
**Assumed:** FUT card/club/squad data has in-memory DB table representations that FLE can write. If FUT data is purely server-side (loaded from EA servers, not from the Frostbite DB layer), Track C produces no FUT simulation at all — only career mode player stats.
**Evidence for:** Career mode already works with FLE edits (community precedent). Tools are present and designed for this path. No infrastructure work needed.
**Evidence against:** FUT in FIFA 23 uses server-side data. The cards in a player's FUT club, the coins, the squad — these are fetched from `fut.ea.com` REST APIs, not from the Frostbite embedded DB. FLE's `GetDBTableRows` likely exposes base player stats tables but not FUT item tables. The crucial test (run `export_squad.lua` while in FUT mode) has never been done.
---
### Recommendation
**Start Track C immediately as a parallel, low-cost validation.**
Run `export_squad.lua` in FLE while inside the FUT hub (or attempting to enter it). If FUT tables appear in the export, Track C is viable and is the fastest path to something a user can interact with. This test takes one session and costs nothing.
Simultaneously, **continue Track A/B with the next concrete RE step:** synthesize the ONLINE_STATUS_EVENT push. The most actionable option is to run `origin_spy` logs from the current hook to see what LSX events fire during a session, then attempt to push candidate event XMLs via the bridge LSX server and watch whether the game advances. This is bounded, testable work that either unblocks M2 or produces the spec value for Track B.
**Do not abandon Track A/B for Track C** — they are complementary. Core is already built; the bridge is mostly built. The gap is purely the RE wall at M2.
---
## 5. Architecture and Provenance Sanity-Check
### Hook + Brain coherence
The CLAUDE.md bridge architecture diagram (hook intercepts ProtoSSL → plain localhost TCP → blaze_brain → Core) remains coherent. The M1/M2 findings revealed one additional layer (EbisuSDK LSX event) that must precede the Blaze connection. The bridge has been updated to handle LSX directly. The overall design is sound; the M2 blocker is an implementation gap (event synthesis), not an architectural flaw.
**One inconsistency to flag:** The hook's `lsx.rs` contains a complete in-process LSX emulator (AES-128-ECB, CRandom, all response builders), but the recv/send hooks that activate it are explicitly removed (`lib.rs`: "recv/send hooks removed — LSX is now handled by the native openfut-bridge LSX server"). This is dead code. The bridge's LSX server is the current path. The in-process lsx.rs should either be deleted or documented as a fallback; its presence is confusing.
### Clean-room status
No evidence of EA leaked source anywhere in the tree. All RE work is derived from:
- Running the shipping binary and observing behavior (function return values, network traffic patterns)
- Memory scanning of the live process (string search, xref, disasm of observed addresses)
- Reading anadius's own compiled output (its exported symbols, its LSX XML format — which is anadius's own implementation, not EA's)
- Community FUT API knowledge (mapper.rs endpoint paths — plausible but speculative)
The Blaze framing in `fifa-blaze/crates/blaze-proto/src/frame.rs` cites "Fire2 used by ME3, BF3, and most post-2012 titles" — this is sourced from public community documentation of those older titles, not from any leaked EA source. **Clean-room intact.**
The `AES_KEY` in the hook's lsx.rs (`[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]`) is a placeholder key used for the LSX session encryption. The real session key is derived from the challenge seed via CRandom — this algorithm was RE'd from anadius's own binary. No EA source required.
---
## 6. If You Read Only This
- **The project is blocked at M2.** FIFA 23 submits `GoOnline`, gets success, then waits for an async `ONLINE_STATUS_EVENT` push on the LSX socket that no current code ever sends. All six Blaze capture files are empty (0 bytes). No Fire2 frame has ever been decoded.
- **M1 is the only completed milestone.** The gate function (`GetInternetConnectedState @ anadius64.dll+0x27790`) is precisely named and its two-flag branch decoded. Everything after M1 is either blocked or not started.
- **The next concrete action** is synthesizing the ONLINE_STATUS_EVENT push XML and testing whether the bridge's LSX server can deliver it to the game. This is the single thing that unblocks all Blaze work.
- **Track C (FLE Lua) is untested but cheap to validate.** Run `export_squad.lua` while in FUT to find out if FUT DB tables are accessible. If yes, it is the fastest path to user-visible results. If no, it is ruled out with one session.
- **openfut-core is complete and ready** — 25 phases, 15 migrations, full economy REST API, passing tests. It is not blocking anything; it is waiting for the bridge to connect to it.
All listeners verified bound on `0.0.0.0` in the container (LSX/Blaze/nucleus,
roster, UTAS, POW, POW content).
## Dataset manifest
`docker-backups/SHA256SUMS-container-baseline-2026-08-10.txt` — sha256 of all
323 files under `/app/tools` + `/app/data` inside the running container.
`fifa17-python/tools/` and `fifa17-python/data/` are the staged sources that
built this image (verified byte-identical to the container copies at freeze
time). Images rebuilt from git now bake their own `/app/SHA256SUMS.txt`; the
rebuild-equivalence check is `diff` between that and this manifest; the only expected deltas are pycache files (not committed) and the redir cert pair (regenerated per build).
## Restore
```sh
# From the archived image (works offline, exact layers):
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.