openfut-hook builds two mutually exclusive injection paths from one crate. The
default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin_spy transport
hooks; `--features fifa17` installs only the FIFA-17-safe logic (module map,
FIFA 17 cert-verify, SBC dispatch, store tab bind).
Deploying a default-feature build into FIFA 17 hijacks the login transport: the
client reports "Unable to connect to the EA servers at this time" and none of the
FIFA 17 repairs are present in the binary at all.
That happened today: artifact 1c71a17a was built by hand without the feature and
deployed, costing two failed launches. It was diagnosed only by comparing embedded
strings between the deployed DLL and the last known-good one (the deployed DLL had
0 occurrences of CardsDLL_Win64_retail.dll and SBC_DISPATCH, and 6 of cert-verify
plus 1 of "connect: inline-hooked" -- the inverse of a fifa17 build).
`build` already passes --features fifa17, but OPENFUT_FIFA17_HOOK_DLL lets a
hand-built DLL reach stage/deploy, so verify_fifa17_profile asserts the profile on
the bytes: CardsDLL_Win64_retail.dll and SBC_DISPATCH must be present, and the
FIFA-23-only markers must be absent. Wired into verify_inputs (stage/inspect) and
into deploy's staged-artifact checks.
Verified: the gate rejects 1c71a17a, accepts 3641d581 (last known good) and
f0ef528f (the corrected fifa17 build now deployed).
Records the store-entry category-clamp hook (launcher 9aecc65) as the
launcher tip on main. Only the launcher gitlink is bumped; core stays
271c363 and bridge stays 0f581eb (both as already recorded), and the
in-tree formatting/doc churn is left untouched.
Reversed from the pinned CardsDLL (4706a881): eligibilityKey and
eligibilityOperation are localization ordinals (LOC_SBC_ELG_KEY_%d),
not the atom hex ids. The client's only consumer is the requirement-
display string builder at ~0x1800ef900 (formats via indexed locale
keys, no comparison/gate). The ordinal->string map lives only in the
packed locale (absent from all assets we hold), so any emitted value
would render the WRONG requirement text. Submission stays fully
validated server-side by Core; the empty elgReq is display-only.
Correct ENDPOINT_MAP.md's implied atom-id==ordinal assumption and
pin the exact remaining blocker at the emit site.
Picks up openfut-launcher 94feaec, which makes the guarded native SBC dispatch
repair a build-armed promoted feature instead of an OPENFUT_SBC_DISPATCH env gate.
Any launch path (Steam, the launcher Launch button, bare umu-run) now gets the
repair, so the SBC screen no longer depends on a harness script exporting a
variable. Every runtime guard is unchanged; rollback is a version.dll file swap.
A reward pack advertised its own id (70-75) as assetId, which is not a client art
asset, so its My Packs tile rendered blank. Reward tiles now carry their tier
store pack as the cover asset (bronze->1, silver->3, gold->5) while `id` stays the
pack own id (the open packId / SERVER_ID); the sentinel keeps its own id so it
stays an inert placeholder.
LIVE-MAPPED on the retail client 2026-08-18 across all three store tabs and two
reward tiles, which corrected an earlier wrong assumption:
* assetId only gates whether art renders AT ALL (unknown id -> blank tile).
* WHICH art is drawn comes from packType + packContentInfo.rareQuantity, NOT
assetId: BRONZE+1rare -> bronze card, BRONZE+3 -> silver, SILVER+1 -> gold,
SILVER+3 -> silver trio, GOLD+1 -> blue special, GOLD+3 -> red inform.
Remapping assetId 5->3 and 3->2 left both frames unchanged and only rotated
the featured player, which proves art is content-driven.
So a reward tile now shows the same cover as the equivalent purchasable pack (a
gold reward shows the blue-special art the 5000-coin Gold Pack shows - EA art
advertises an aspirational card rather than the tier colour). Regression test
reward_tiles_carry_a_renderable_cover_asset added; pack70 golden regenerated.
Reward/My-Packs tiles were emitted with no currencies row (dropped to avoid an
"undefined" payment label). But FIFA 17 opens My Packs through the store PURCHASE
flow (My Packs is a client-side filter over the store catalogue; the open-vs-buy
fork is client-side and no response selects it), so a tile with no purchase path
is not actionable — clicking it navigates instead of opening, sending no request.
Fix: keep the coins currency at the pack price (0 for reward packs; no extPrice,
so no `or %1s` mtx bug). The client then treats the tile as free and clicking
sends POST /purchased, which the server opens for free (owned-only redeem, no
debit). The empty-My-Packs sentinel keeps no currency row so it stays
non-openable. pack70 golden regenerated.
LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards on the
retail client (host log: POST /purchased/items 200 -> GET /purchased/items).
SBC completion and the other Core reward services grant packs with symbolic
definition_ids ("silver_pack", "gold_pack", ...). The FIFA 17 pack system keys
entirely on numeric catalogue ids, and entitlement_pack_ids / handle_pack_open
resolved definition_ids with definition_id.parse::<u64>(), so every symbolic
reward pack was silently dropped from the openable My Packs list. The unopened
count (recoveredPacks, = entitlement count) still counted them, so the client
showed "you have N packs" but had no tile to open -> "no pack available".
Fix (adapter-layer, Core stays game-neutral): add owned-only reward pack
catalogue entries 71-75 (bronze/silver/gold/rare_gold/icon) and a resolver
owned_pack_id_for_definition() that maps both numeric owned ids and the symbolic
reward names to their numeric owned-only pack. entitlement_pack_ids and the
pack-open entitlement selection now use it, so reward packs render as openable
My Packs tiles and redeem their entitlement for free (consume-once, no debit).
Server-verified on staging: 3 Core reward entitlements now render 3 openable
mypacks tiles matching recoveredPacks=3. Tests: adapter resolver + rendering,
host symbolic-reward open flow; full adapter + host suites green.
The FIFA 17 client gates challenge re-entry on timesCompleted, not on the
repeatable flag: a nonzero count renders the tile COMPLETED and refuses re-entry
even when repeatable=true. So a repeatable challenge now always projects
timesCompleted=0 (challenges_body) and its set as challengesCompletedCount=0
(sets_body); a non-repeatable challenge keeps its true count and stays locked
once completed. Core keeps the authoritative completion record — economy is
unaffected; this is presentation only.
Live-proven on the retail client 2026-08-18: a completed repeatable Bronze
Upgrade now re-opens for a fresh submit instead of blocking. Regression test
repeatable_completed_challenge_stays_enterable added; adapter + full host suite
(incl. differential and the concurrency race) green.
The all-groups landing (shown on first store entry) renders one tile per group
whose background is the client-bundled packs_backgrounds_%d.dds, selected by
displayGroupAssetId. Live-probed on the retail client 2026-08-18: index 0 is
blank; indices 1/2/3 render real pack art. Assign non-zero per category
(bronze=1, silver=2, gold/mypacks=3) so that landing shows native pack art
instead of blank shields. The persistent tabbed store (MY PACKS/BRONZE/SILVER/
GOLD PACKS) draws pack art from the packs themselves and is unaffected.
LIVE-CONFIRMED end-to-end: native tabbed store renders the real 6-pack catalogue
with correct prices (Gold 5000 / Premium Gold 7500), counts (12 items, 10 gold,
1/3 rares), pack art, and no "or %1s" line.
- store_catalog: replace the invented catalogue with the real always-available
FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at
real prices + tier composition; PackDef now carries per-tier quantities.
- pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s"
FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4).
- pack_content: tier-aware generator draws each pack bronze/silver/gold
composition with special_chance bias + empty-tier fallback.
- host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the
FULL card universe via non-minting catalog lookup, owned-inventory fallback.
- economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the
authoritative store; Python oracle stays the untouched rollback baseline).
- fixtures/tests updated to the real catalogue.
Odds are DESIGNED placeholders (FUT17 pack probabilities were never published);
club items remain excluded (cardtype-9 mapping unknown). Full regression green;
real prices + tier-correct draws verified server-side on staging.
Retail Gate C captured PUT /sbs/challenge/101/squad: 23-slot players[] of
{index,itemData:{id,dream}} plus manager/chemistry/rating/formation siblings.
parse_wire_item_ids already handles it (players[].itemData.id, non-zero only);
update the stale "captures unavailable" note and add a verbatim regression test.
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.