235 Commits

Author SHA1 Message Date
funman300 bf6db98f0d Migrate club rename and numeric squad reads 2026-08-18 17:29:24 +00:00
funman300 fc55de19fa test(fifa17-tls): reproducible isolated confirmation of the roster-cert fix
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).
2026-08-18 16:37:19 +00:00
funman300 6ae3364bd0 docs: remove docs/ — migrated into the OpenFUT-Vault (single source of truth)
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.
2026-08-18 16:26:44 +00:00
funman300 5c40b4993f docs: mark the FUT Squad Update cert fix applied (fbc0da2)
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.
2026-08-18 15:58:18 +00:00
funman300 fbc0da2a1b fix(fifa17-tls): carry the advertised IP in the roster/redirector cert SAN
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.
2026-08-18 15:57:19 +00:00
funman300 750d6c2e18 feat(companions): port the launcher's two Python services to Rust
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.
2026-08-18 05:31:00 +00:00
funman300 082246c085 docs: root-cause the FUT Squad Update download failure (client rejects the roster cert)
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.
2026-08-18 05:12:00 +00:00
funman300 16771b0b33 test(scripts): recover the client's error text, and diff hub shapes against production
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.
2026-08-18 04:30:08 +00:00
funman300 022634704a fix(scripts): staging squad now matches production's known-good shape exactly
Adds the manager reference, the last remaining difference from the squad the same
client demonstrably accepts. Staging's squad shape is now identical to production's:
zero missing keys, zero type differences, zero empty-vs-populated mismatches.

The manager looked unfixable. Production points at instance 100000427 while the
staging club holds 11 players and zero staff, so there was apparently nothing to
reference, and inventing an id would have pointed at a non-existent item.

Checking production properly dissolved the problem: 100000427 is absent from
production's OWN club listing too. /club/staff returns 1975 items spanning ids
100000001..100004826 and 100000427 is not among them, and the type=staff/type=manager
filters are ignored (200 players either way). Production's manager reference is
dangling and the client accepts that squad anyway, which proves the client does not
validate the manager id against the club -- only a populated array matters.

So the reference is mirrored verbatim, dangling id included. That replicates the
known-good state exactly and is better than pointing the manager slot at a player,
which would have been a guess dressed up as a fix.

Method note: every step here came from diffing against production rather than reading
the client. The host reported squad-active 200 outcome=ok throughout, and 200 with the
right players was never evidence the client accepted the body.
2026-08-18 03:49:39 +00:00
funman300 96ca7c0484 fix(scripts): the seeded squad was structurally valid but the client still refused it
First seed sent only squadName/formation/captain/players. The host logged squad-active
200 outcome=ok and /squad/0 showed 11 occupied slots, yet the client still threw a FUT
squad update error -- a 200 with the right players is not proof the client accepts the
body.

Diffed against production's known-good squad, which the same client accepts, comparing
key presence and JSON TYPES rather than values. Staging returned null for exactly the
five fields the PUT never carried, because the extension stored nothing for them:
squadType (a string enum), chemistry, rating, starRating (ints) and custom (the opaque
33-int tactics array the client definitely parses). kicktakers was empty where
production carries five.

Now sends all of them: squadType REGULAR_SQUAD, chemistry, rating/starRating derived
from the XI's mean rating, production's custom array verbatim (opaque server-side, only
its shape matters), and five kicktakers. Re-diff leaves exactly one difference --
manager, which production points at owned staff instance 100000427 while the staging
club holds 11 players and zero staff. Left empty rather than inventing an id that
references a non-existent item; recorded in the code as the one known remaining gap.

Also fixes a KeyError from the rewrite dropping the players key.
2026-08-18 03:33:42 +00:00
funman300 202366611e test(scripts): front-load the hub preconditions instead of finding them one restart at a time
The sold A/B stalled twice on preconditions no headless check exercised: the staging
identity had no squad (the hub refuses to open, showing a squad update error), and any
route without a Rust owner falls through to a deliberately dead Python upstream and
answers 502. Each cost a full operator cycle.

Sweeps the routes the client is observed to request and separates three failure classes
that need different fixes: 502/PYTHON_FALLBACK (no Rust owner), missing_integrity (200
but the underlying state is absent -- exactly 'no extension stored' before the squad
was seeded), and 200-but-unusable (a squad with zero occupied slots). A 200 is not
proof the client is satisfied, so squad responses are judged on occupied slots.

Also reads the host's own classification for the requests just made, since the host is
the authority on ownership and integrity rather than the response body.

Every path is verified against what the client actually sends. A first pass flagged
five 'fatal' routes that were my own guesses -- /accountinfo (client uses
/user/accountinfo), bare /squad (uses /squad/active), and /watchlist (camelCase
watchList). Crying wolf about the stack is worse than not checking, so the list now
carries only observed paths and that trap is written down in the comment.

Current result: 14 ok, 0 integrity warnings, 0 fatal.
2026-08-18 03:14:29 +00:00
funman300 ea92057e53 test(scripts): seed the staging seller an XI so the FUT hub will open
The A/B identity had owned items but no squad, because the sold-row work only ever
needed the tradePile wire. Every headless check passed -- none of them asks for a
squad -- but the real client refuses to enter the FUT hub with an empty one and shows
a squad update error. A squad is a hub precondition, not a Transfer-List detail.

Seeds via the real PUT /ut/game/fifa17/squad/0, the same request the client sends, so
parse_squad_put/build_squad_write produce exactly what a genuine save would. Writing
Core rows by hand could yield a shape the live path never emits, which is the kind of
divergence that quietly invalidates an experiment.

Picks one owned player per 4-3-3 slot, best rating first, without reusing an instance;
fills the fixed 23-slot array with 0..=10 as the pitch and empty slots as
itemData.id == 0; refuses to write a partial XI and reports any out-of-position
substitution loudly rather than silently reproducing the broken state. Verified
0 -> 11 occupied with no substitutions and a {"id":0} ack.
2026-08-18 03:07:42 +00:00
funman300 c71593b286 test(scripts): enable the hook on the deployed launcher without a rebuild
Bridges openfut-launcher c542415 onto the already-built launcher on .105 by writing
WINEDLLOVERRIDES=version=n,b into game_profile.env, which that build does apply.
Forward-compatible: the fixed launcher defers to a profile that already pins
version=, so this value simply wins.

Records the prior value -- including its absence, as the literal <absent> -- to a
sidecar before mutating, so revert restores the real previous state instead of
assuming the key was missing. Refuses to edit while the launcher runs, since it holds
its config in memory and would write the stale value back.
2026-08-18 02:59:41 +00:00
funman300 413ad901fb launcher: bump gitlink to c542415 (hook WINEDLLOVERRIDES fix)
Without version=n,b the launcher's own launch path never loaded the version.dll hook
proxy, so the Blaze ports it writes to openfut.cfg were ignored and the client
silently reached production via /etc/hosts instead of the configured server.
2026-08-18 02:57:38 +00:00
funman300 e0e46d8a57 fix(scripts): the port switcher was editing a derived file, so the A/B ran on production
openfut.cfg is not the source of truth for the client's Blaze ports -- the launcher
is. It reconciles openfut.cfg from ~/.config/openfut-launcher/config.json,
fail-closed, immediately before every launch. So `staging` set the ports, verified
them, and the next launch silently reverted them.

Caught only because the capture harness cross-checks instead of trusting the screen.
The operator reported "the Transfers tile does not show Sold" -- which looked like a
clean negative result about the sold counter, and was in fact a reading of their
PRODUCTION club, where sold:0 is correct. Evidence chain:

  * route-log delta contained 5 lines, all of them the harness's own GETs; the
    client issued nothing to staging at all;
  * `ss -tnp` on the client showed FIFA17.exe pid 39482 ESTAB to 10.10.0.120:42130
    (production Blaze) plus TIME-WAIT to :8099 (production UTAS);
  * the hook logged `blaze_redir=42127 blaze_main=42130`;
  * openfut.cfg mtime was 2s before process start, sha back to the production value.

Had the harness reported the tile at face value, the sold counter recovered from
CardsDLL would now be recorded as refuted by a run that never reached the code.

Fixes: own the launcher config (source) before openfut.cfg (derived), with the same
record-before-mutate sidecar discipline on both; refuse to edit while the launcher is
running, since it holds config in memory and would write the stale values back;
report both files and both guards in `show`. launcher_running() matches the
kernel-truncated comm "openfut-launche" -- the full name exceeds 15 chars, which has
bitten this project before.

No production change; staging stack and its variant-A sold row untouched.
2026-08-18 02:40:31 +00:00
funman300 fbe29da05b fix(scripts): sold-client-ports guard self-matched its own shell, wedging it ON
`pgrep -f FIFA17.exe` matched the remote shell executing it -- the SSH command line
contains the literal pattern -- so fifa_running() always returned True and the port
switcher could never edit openfut.cfg. It refused with "REFUSING to edit ... while a
FIFA client is running" moments after FIFA had actually exited.

Fail-closed, so nothing unsafe happened, but the guard was permanently stuck and
blocked the A/B entirely.

Now matches /proc/<pid>/comm exactly, which is the executable name: the invoking
shell reads as zsh and cannot self-match, while a genuine FIFA process still does.
Validated both directions with the same loop -- it found pid 36958 while FIFA was up,
and reports gone once it exited. Still fail-closed on read errors.

The lesson generalises: a pattern-matching process guard checked over a transport
that carries the pattern in its own argv is self-satisfying, and a guard that can
only ever say "yes" is not a guard.
2026-08-18 02:33:53 +00:00
funman300 9ffbd651b1 test(market): one-command live capture for the sold A/B, with in-run validation
Turns the operator's job into "navigate, say go" and removes any chance of a
half-recorded variant. One command captures and labels: the staging wire surfaces,
the client's OWN auction record decoded read-only from /proc/<pid>/mem (STATE,
YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE), and the
staging host route-log DELTA since the last capture -- which is how a client-issued
DELETE .../trade/sold gets OBSERVED rather than assumed.

The part that matters is the wire-vs-memory cross-check. It validates the
observation mechanism against a known-positive in the SAME run: if the wire says
bidState "highest" and the client's memory decodes 2(highest), the probe is
demonstrably reading the right struct this time. It also recomputes the native
IS_GLOW/INBOX formulas from the wire and compares them to what the client stored.

Proven honest on first run: with the client attached to PRODUCTION and not on the
Transfer List, it reported the staging sold row on the wire, 0 client records, and
INSTRUMENTATION NOT VALIDATED -- refusing to draw a conclusion from an empty read.
Two earlier sessions were misled by exactly that (a sampler bug printing
"countdown NO", and auction containers read while the screen was unbound), so an
empty container is explicitly not treated as an empty pile.

Probe base-address discovery was separately confirmed against the live client
(pid 36958, FNV control=MATCH, model resolved, containers read cleanly), and
production's wire independently agreed at total=0.

No production change. Client config untouched (still production Blaze ports).
2026-08-18 02:29:17 +00:00
funman300 aa5fb2cc40 test(market): make the sold A/B one-field attributable, add classified differential
The brief's gate: if the harness varies bidState AND coinsProcessed together, the
client's reaction is attributable to neither. The env knobs were already orthogonal
(--variant and --coins-processed are independent, cp defaults to 0), but
sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly
the contaminated A/B the brief forbids. Fixed: the primary pair now holds
coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState'].

New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic
sale, then re-reads every seller-facing surface under each variant by restarting only
the host (same Core, same DBs, same sale), and diffs with explicit classification --
MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?".
Two orthogonal pairs:

  PRIMARY     bidState highest vs buyNow, coinsProcessed held at 0
  ORTHOGONAL  coinsProcessed 0 vs 1,      bidState held at highest

Result, 36/36: the ONLY finding on /tradePile is
VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs
in exactly the same one path; counts are byte-identical. The orthogonal pair's only
finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the
capture is reproducible rather than merely consistent.

Counts states the live run has to interpret, measured not guessed:
  S1  0 active + 1 sold -> count 0, selling 0, sold 1
  S2  1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode)
That divergence IS the open question for the client; production is unchanged.

scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is
built so restoration cannot depend on memory: it records the production values to a
sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing
if it is absent. It rewrites only known keys (a missing key is an error, never a
silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA
client is running because the hook reads the file at connect time.

Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface,
one file per variant so A can never overwrite B.

Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105
(pid 32188), and live-session mutual exclusion applies. The client config was NOT
touched -- the switcher's guard refused, as designed.

Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0,
counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened.
2026-08-18 02:22:12 +00:00
funman300 468bc0fba9 feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)
Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.

PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.

  OPENFUT_FIFA17_SOLD_EXPERIMENT      = highest | buyNow   (else OFF)
  OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1                  (else 0)
  OPENFUT_FIFA17_SOLD_COUNT_MODE      = active_plus_sold    (else active)

WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.

CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.

FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.

STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
  * scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
    utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
    own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
    responder and asserts every substitution applied, so a silent no-op cannot leave
    it pointing at production. Kills only recorded pids whose cmdline contains the
    staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
  * docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
  * src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
    (CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
    leaves the listing live with nothing moved. Refuses any path containing
    openfut-promotion or the production ports.
  * scripts/sold-wire-check.py -- proves the whole flow headless before any operator
    time is spent.

WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.

Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.

Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.

The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
2026-08-18 02:14:18 +00:00
funman300 571c5f9261 docs(market): recover the FIFA17 sold wire contract from CardsDLL (Ghidra)
Task A, static phase. Ghidra 12.1.2 headless via the repo's own pyghidra harness
over CardsDLL_Win64_retail.dll (13,382 functions). Queries and raw decompiler
output committed under docs/evidence/market-sold-re-2026-08-17/.

RECOVERED FROM THE BINARY

1. No sold token, now EXHAUSTIVELY: both vocabularies dumped to their sentinels
   rather than sampled. tradeState is exactly 4 rows; itemState is exactly 12
   (invalid/free/WAITING_FOR_GAME/inGame/forSale/offered/activeBadge/
   activeHomeKit/activeAwayKit/activeBall/activeStadium/active=255). A sold row
   MUST therefore be a combination of existing atoms.

2. What closed does, complete, from the auctionInfo deserializer 0x18013e410:
     IS_GLOW = (tradeState==closed) ? bidState != none
                                    : bidState in {outbid, buyNow}
     INBOX   = bidState in {highest, buyNow}

3. The full record -> Flash map from the publisher 0x1801bf030, superseding the
   partial list. The prize: record +0xbf is published as COINS_AWARDED, fed by the
   coinsProcessed atom 0x2f4. The corpus had recorded that atom's type and noted
   its consumer was never found; it is now traced. DURATION also renders the
   localised FUT_AUCTION_EXPIRED when expires underflows.

4. highest vs buyNow on a closed row is UNDECIDABLE from CardsDLL, by proof: both
   yield IS_GLOW=1/INBOX=1, bit-identical. But bidState is ALSO published verbatim
   as YOURBID alongside STATE and COINS_AWARDED, so the movie does receive the raw
   values - the discrimination exists and lives entirely in unread ActionScript.
   This retires the question as a static target, and it contradicts the
   third-party lore that a seller's sold row is closed+buyNow (the corpus's own
   lifecycle table says closed+highest and assigns buyNow to the buyer).

5. The clear-sold verb EXISTS. Builder 0x1801647c0 emits "/sold" when the tradeId
   field is zero and "/%lld" otherwise, on route base ut/delete/%s/trade, response
   class RS4 FutISRemoveTradeServerResponse. Confirmed by the client's own
   request-name table entry RemoveAllSoldFromTradePile. A BULK clear-sold verb only
   makes sense if sold rows PERSIST in the seller's pile until cleared, which is
   incompatible with our Fix A invariant - so the sold path will require revisiting
   it under live validation.

6. The seller's SOLD counter is real, proven end to end with no inference: the hub
   tradePile sub-deserializer 0x18013ead0 writes atom sold 0x2c9 to +0x1d8, and the
   tile publisher 0x1800b1dc0 renders +0x1d8 as Flash TEXT3 under the localised
   caption FUT_TF_SOLD. Siblings: selling -> +0x1d2 -> FUT_TF_SELLING,
   count -> +0x1d4 -> FUT_UC_ITEMS, plus FUT_TF_WINNING/FUT_TF_OUTBID on the
   Transfer Targets tile. We and the Python oracle both hardcode sold:0, so that
   bucket can never fill.

7. Reusable method: an atom id is the INDEX into the alphabetical atom-name pointer
   table at base 0x1802d2760. Validated 12/12 against the known auctionInfo atoms
   and cross-checked against fifa17-recon/docs/fut_atoms.tsv. Documented gotcha:
   resolve a name by the pointer slot INSIDE the table, never by the first matching
   string in the binary, or you get confident nonsense.

8. An auction-outcome vocabulary exists (auctionSoldBid 0x39, auctionSoldBuyNow
   0x3a, auctionWon*/auctionLost*) but NO deserializer consumes it - every
   candidate function was checked for the value-SKIP/atom-loop signature and none
   qualifies. Server-side or telemetry only; it does not carry sold state here.

TASK B IS UNDECIDABLE FROM THE CLIENT, and this is a proof of absence: no 0.95 or
0.05 constant of either width, no tax/fee/net/proceeds caption, and no fee
arithmetic anywhere. The client never computes or displays a net, so no experiment
against our own server can measure the rounding - whatever we credit is what it
displays, and there is no oracle. Only an original EA-era seller-balance capture
could settle it. The rule stays an explicit CHOICE (floor the fee, so
fee + proceeds == gross exactly) and is now pinned at the requested boundaries
100/101/119/120/149/150/151/199/200 plus 15,000 and i64::MAX.

Settlement NOT promoted. No production process, port or database was touched.
2026-08-18 01:32:02 +00:00
funman300 cb32fe9b84 docs(market): close Gap 2 and freeze the transfer-list lifecycle as known-good
Return to Club is durable across a full FUT exit/re-entry — the last claim the
forSale promotion could only make server-side.

Same disposable card as Gap 1 (75 ST, res 212188, wire 100000178, tradeId
1000000178), after the 1h auction expired NATURALLY. No timestamp was mutated in
either gap; a read-only sampler watched the whole hour (55 samples), because
`expires` is derived from created_at + duration and watching is the only honest way
to see expiry:

  active   expires 3175 -> counting down -> expired expires 0, itemState forSale
  (absent) total 0                                  <- Return to Club

itemState stayed forSale across active -> expired, the one state change Fix B had
never been watched through live.

The host log then shows the coupled transition and TWO session boundaries:

  route=move-items wire=100000178 pile=club auction_cancelled=1
  route=auth-delete
  route=auth ... sid_opened=true      (fresh session, x2)
  route=hub clubPlayers=1966 auctionCount=0
  route=club total=1986 emitted=1966

auction_cancelled=1 is cancel_active_for_core_item firing, so pile membership and
auction lifecycle cannot disagree. Two independent fresh sessions each rebuilt the
state from durable storage and the operator confirmed the card was still in My Club;
one boundary was the requirement.

18/18 server checks pass IDENTICALLY before and after re-entry: /tradePile total 0,
counts all zero, tradeId -> closed with expires 0 (still resolves, correctly
terminal), /club 1966 with itemState free, 0 duplicate ids, market store cancelled
with 0 active and 0 reserved, coins 29,843,976 unchanged throughout.

No code change was required for EITHER gap. Both tests existed to find out whether
the promoted implementation was already correct on paths it had not been exercised
on, and it was.

Also freezes the full CLUB -> list -> active -> expired -> Return to Club -> CLUB
lifecycle as the reference baseline, with the eight invariants it pins, so a future
change that alters any line is a regression until proven otherwise. Explicitly NOT
established: the SOLD path, /tradePile/counts semantics, the AVM1 gate.
2026-08-18 01:12:46 +00:00
funman300 fbe9804d3e core: quick-sell FK fix (gitlink 637a21e)
Quick-selling a card that was in any squad failed with SQLite 787 FOREIGN KEY
constraint failed, on the live FIFA 17 path (economy_store -> econ.sell_item ->
POST /economy/sell-item). Reproduced, then fixed by evicting the item from every
lineup inside sell_item's existing transaction. routes/cards.rs::delete_owned_card
folded into the same authority, which also gives it the transaction it never had.

Not deployed.
2026-08-18 01:01:20 +00:00
funman300 f6606accb3 feat(market): FIFA 5% transfer fee policy, host settle_sale capability, isolated staging harness
Core gains the generic settlement (gitlink 31ab4a6); the FIFA-specific parts live
here.

FEE (openfut-adapter-fifa17/src/fut/economy_policy.rs), beside pack_price and
match_reward_total because 5% is a game policy constant and Core must stay
game-neutral — Core only validates 0 <= fee <= gross and never computes a rate:

  TRANSFER_MARKET_FEE_PERCENT = 5
  transfer_market_fee(gross)  = floor(gross * 5 / 100), i128 intermediate
  seller_proceeds(gross)      = gross - fee

Integer only. Floating point is never used for coin settlement: 0.05 is not
representable in binary and a f64 round trip can create or destroy a coin at large
prices. Widening to i128 makes overflow unreachable for any i64 price, so no price
ceiling has to be assumed.

ROUNDING IS A CHOICE AND IT IS NOT CONFIRMED. The fee is floored, so the seller
keeps the fractional coin, chosen because it makes fee + proceeds == gross hold
exactly at every input — the property the accounting invariant rests on. The
discriminating case against flooring the seller's 95% instead is a gross of 150:
this rule pays 143, the alternative 142. Nothing in the corpus or the client binary
settles which the real server did (the client is only ever told the gross; no
tax/netPrice/sellerProceeds wire field exists). Pinned at 0/1/19/20/21/39/40/100/
150/200/1_000/15_000/15_000_000/i64::MAX plus a fee+proceeds==gross sweep.

HOST: CoreEconomy gains settle_sale + EconomySale/EconomySaleReceipt, implemented on
HttpCoreClient as POST /economy/settle-sale. Request field names were checked
against Core's actual SettleSaleRequest/SaleReceipt rather than assumed. Absent club
ids are OMITTED from the body (not null), which is what Core's Outside/active-club
defaults depend on, so a unit test pins that body shape. handle_market_buy is
deliberately untouched: the synthetic buy path has no counterparty, so minting there
is correct.

HARNESS: scripts/settlement-staging.py, stdlib only, drives a REAL Core over real
HTTP on an ephemeral port against a throwaway DB (production 8099/8199/18080 in a
hard deny-list checked in three places), seeds the canonical two-party fixture,
prints BEFORE/PURCHASE/AFTER with PASS-FAIL lines, cleans up in a finally. 31/31
pass. It found the rejection-precedence bug fixed in Core, and that Core's content
preflight aborts startup on an owned card whose CardDefinitionId no pack defines.

Gates: Core 194, adapter 217, host 127, harness 31/31, clippy clean, new code
fmt-clean. Nothing deployed; no production process, port or database was touched.
2026-08-18 00:51:37 +00:00
funman300 0a007f4941 docs(market): close Gap 1 — active seller row under forSale is live-confirmed
The one claim the Fix B promotion left open: no active listing existed during
that session, so only the expired path had been exercised.

Closed with a disposable card (75 ST, res 212188, wire id 100000178 — one of
three identical copies, not in the squad) listed through the real FIFA 17 client
at 150/200 for 1h, so expiry arrives naturally. No timestamp touched.

Wire: itemState=forSale, tradeState=active, 12 atoms, prices intact, expires
3562 -> 3556 over a 6s sample (live clock), /trade/status coherent, counts
{count:1, selling:1}, coins unchanged, zero inactive rows (Fix A intact).

The decisive evidence is client-side, not ours: a read-only /proc/<pid>/mem
decode of the live trade-pile auction record returned
  itemState=5(forSale)
on the very field that read -1(<unrecognised>) under listFS. Direct A/B on the
only changed field, taken from the client's own memory.

Operator confirmed the row renders under LISTED ITEMS with correct prices, a
counting-down timer, normal art, and correctly non-actionable while active.

No code change required — the promoted implementation was already correct on the
active path. Claim boundary unchanged: this proves the client DECODES the token
and says nothing about the Flash action-gate term.
2026-08-18 00:15:51 +00:00
funman300 0c4aee6164 market: promote forSale — live-confirmed on the expired path
Operator drove the expired row 1000000155 (res 158023, 93 RW) in FIFA 17 with the
candidate deployed: the row was still actionable, Return to Club was offered, and
it worked — "the card is back in my club".

Server-verified durable afterwards, which is what a fresh session reconstructs:
/tradePile total 0 with zero rows, /tradePile/counts all zero, the card present in
/club (1965 -> 1966 items, itemState "free"), zero duplicate ids, no stale active
listing anywhere in the store (both rows cancelled), and the ended auction
projecting as `closed` (4) on /trade/status. Fix A intact: zero `inactive` rows.

So `listFS` -> `forSale` is protocol-correct AND behaviour-preserving on the path
that matters, and `listFS` is gone from production serialization.

Also worth recording what the result rules out: CARD_OFFERSTATE is NOT a gate term
that requires -1. Every actionable row we had ever seen carried itemState -1, which
looked like a possible client rule; it was a coincidence of our own invalid token.
An expired row decoding CARD_OFFERSTATE = 5 stayed actionable.

Deliberately NOT claimed: anything about the Flash action gate itself. STATE and
the RESERVEDPRICE/MAX_CREDITS pair are untouched and still confounded, so the gate
remains Category C / STRONGLY SUPPORTED / not proven, and AVM1 disassembly of
tradepile.isInActiveAuction is still the separate next investigation.

One gap left open honestly: no ACTIVE seller row existed during the session, so
active-row rendering under `forSale` is unverified. /transfermarket has always
emitted `forSale` on active rows, so it is expected-safe, but it has not been seen.
2026-08-17 23:58:29 +00:00
funman300 a57f4930f0 market: emit FIFA 17's own forSale itemState, not the oracle's listFS
Single-field protocol-correctness fix, deployed as a candidate for a live A/B.

`itemData.itemState: "listFS"` on the seller's own auction rows is not a FIFA 17
token at all: zero occurrences in `CardsDLL_Win64_retail.dll` (md5
4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory, and it
decodes to -1 through `FUN_180166660` — so the client was handed an unrecognised
`CARD_OFFERSTATE`. FIFA 17's value for an item offered for sale is `forSale` (5),
from the 12-row table at 0x180229cc0.

Changed only where the invalid token was emitted: `handle_market_query`
(GET …/tradePile) and `handle_market_status` (GET …/trade/status). The market
search path already emitted `forSale` and is untouched — which is also why the
risk here was lower than it looked: the client has been decoding `forSale` on a
live route all along, and only the seller's own pile carried the bad value.

Wire A/B on the same expired row: EXACTLY one field differs. tradeId, tradeState,
expires, startingBid, buyNowPrice, currentBid, bidState, sellerName,
sellerEstablished, watched, coinsProcessed, the twelve-atom count and the whole
itemData card are byte-identical; coins unchanged at 29,843,976; Fix A's zero
`inactive` rows intact.

The differential asserted PARITY on this field and therefore passed while BOTH
sides were wrong — the exact mechanism by which the defect survived every run.
`market query tradePile` is now DIFFERENT-BY-DESIGN, pinning oracle == "listFS"
and rust == "forSale" so the divergence cannot silently close again. Where the
FIFA 17 binary contradicts the Python oracle, the binary wins.

Gates: 126 host tests, 214 adapter tests, fmt clean, clippy clean.

NOT claimed: that this preserves the list -> expire -> Return-to-Club lifecycle.
That needs an operator FIFA 17 session and has NOT been observed yet. Also not
claimed: anything about the Flash action gate — `CARD_OFFERSTATE` is one of three
still-confounded candidates and this change does not test it. Revert is one line
if the live test fails.
2026-08-17 23:26:06 +00:00
funman300 f9ca901a50 market: stop advertising unlisted pile members as tradeState:"inactive"
RE of the FUT front-end closed the question the Actions-panel investigation left
open, and the answer retracts Q2 rather than completing it.

`tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at
`0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same
edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of
`[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0
inbox=0). The value is then handed to the movie verbatim as the Flash property
`STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the
trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction`
(traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows
reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders
and can never be acted on, which is exactly what the operator saw.

So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER,
which is not the same claim, and I treated it as if it were.

The corpus said this before any of it was built —
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client
path treats it specially; do not emit it." The earlier note explaining that the
warning "was written about the PRESENTATION function" was motivated reasoning.
This also fires the corpus's own pre-registered falsifier E3 (:368-373).

Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`,
`UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`,
`Server::resolve_trade_pile`, and the two helpers that existed only to feed them
(`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`).
Unlisted trade-pile membership is now internal state with no wire expression.

Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an
unlisted pile member stays visible in the club, which is where the client can act
on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive`
rows, `/trade/status` resolving only the real auction, coins unchanged at
29,843,976, and all six former rows present in `/club` (1965 items).

Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that
pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and
`trade_status_answers_only_about_real_auctions`.

NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17
token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in
4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and
the Python oracle emits `listFS` too — which is why the differential never caught
it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and
every actionable row observed carried -1, so that change ships alone with its own
live A/B.
2026-08-17 23:14:35 +00:00
funman300 3ce69f8951 launcher: one-button launch flow with an explicit state machine (gitlink 3174fe4)
Normal users press Launch FIFA 17; LSX, autopatch, client preparation and the
pre-launch checks are orchestrated automatically, reusing whatever is already
healthy, and every manual control moves under Advanced / Diagnostics. Service
ownership is tracked so a service the launcher did not start is never killed.
2026-08-17 22:44:30 +00:00
funman300 acd1def00d launcher: machine-independent preflight tests (gitlink 504ceee)
Bumps openfut-launcher past two test-hygiene fixes found by running the suite on
the game machine (.105) instead of only on the server host: the new hook-config
check added a second warning on any box with a hook deployed, and the
shadowed-hostname test was asserting, via backend_reachable's live sockets, that
the local machine has the OpenFUT ports open. Suite now passes on both hosts.
2026-08-17 22:06:58 +00:00
funman300 5ec9c7f8bf launcher: guided first-run flow + hook-config reconcile (gitlink 357501f)
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).
2026-08-17 21:47:05 +00:00
funman300 11c028e6eb fix(market): /trade/status must resolve the unlisted ids /tradePile advertises
Explains and fixes the Phase C partial failure WITHOUT changing a single wire field.

The operator saw a difference between the one-item probe (Time Remaining "-") and the
generalized rows (Time Remaining "Expired"). Cause: route coverage, not encoding.
/tradePile advertised the unlisted tradeIds while ISVIEWTRADE (GET .../trade/status)
resolved ids from the market store only -- and an unlisted pile member has no listing
row, so the poll returned an empty auctionInfo. Observed live as
`route=market-status requested=1 returned=0` repeating for the row the operator had
selected, while that same id was present in /tradePile. The client polls status for
the row it displays and degrades it when the answer is empty, which is also why no
actions were offered. The probe showed "-" only because the client had not yet polled
that id (logs of the time show only tradeIds=1000000097).

So expires, tradeState, itemData.itemState and pile were all innocent. Nothing was
guessed and no field changed: both routes now share one pile enumeration
(Server::resolve_trade_pile), so an id advertised by /tradePile always resolves on
/trade/status. The corpus predicted exactly this -- tradeId must resolve across
/transfermarket, /tradePile, /watchList AND /trade/status; we had stability but not
coverage. Same defect class as the original empty-trade/status bug.

Status still answers only the ids actually asked about, and a real auction always wins
over an inactive row for the same tradeId. Regression test covers all four cases.

Records the downgraded conclusion: "inactive" is a CONFIRMED section/lifecycle
discriminator; whether the full actionable contract is now complete is the operator's
next test. itemState/pile recovery was queued on the assumption the encoding was
incomplete -- neither was touched, and both remain the next candidates if actions are
still absent.

342 tests pass, 0 failed, clippy clean. Verified live: the six inactive ids went from
returned=0 to returned=6.
2026-08-17 21:01:07 +00:00
funman300 afadb13de4 feat(market): PHASE C — expose every unlisted trade-pile item as tradeState "inactive"
Q2 is LIVE-CONFIRMED (operator saw the inactive row under TRANSFER LIST with Start
Price 0 and no Buy Now / Current Bid / timer, active rows still separate under LISTED
ITEMS, and the state survived a full FUT exit/re-entry). Promoting from the bounded
one-item probe to the real behaviour: the env gate is gone and /tradePile now
enumerates the whole trade pile.

Mechanism: read the pile (async), resolve each member to a shaped card (sync, because
the identity/Core resolvers are not `Send`), then build the response (async). The
core->wire lookup is `wire_for_owned_id`, which uses the identity store's
NON-allocating `external_for` -- enumerating a pile is a READ and must never mint a
wire id for an item the client has not seen. Items with no mapping, no Core record or
no resolvable FIFA identity are skipped, never faked.

Includes a bug the DIFFERENTIAL caught and unit tests did not: a pile row OUTLIVES its
auction, so after a sale the seller's `trade` row is stale, and filtering only on
ACTIVE listings re-advertised a SOLD card as an owned unlisted item. Suppression is now
by listing state via `blocking_core_items()` -- active (real auction shown instead),
reserved (sale in flight) and sold (card gone) -- while `cancelled` is deliberately NOT
suppressed, because a cancelled listing means the card came back to the pile. New test
covers all three plus the store-level rule.

counts semantics deliberately unchanged: `count`/`selling` still track auctions only.

341 tests pass, 0 failed, clippy clean. Deployed: the 6 previously stranded pile items
now render, alongside the 1 active listing, with Ronaldo correctly in /club and out of
the pile. Body preserved as phase-c-full-pile-exposed.json.
2026-08-17 20:50:51 +00:00
funman300 b6398c44e6 docs: record the LIVE-CONFIRMED inactive UI contract and the expired->Club transition
Operator confirmed in the real client that a tradeState "inactive" row lands under the
right-hand TRANSFER LIST section and renders Start Price 0 with Buy Now, Current Bid
and Time Remaining all absent, while an active row in the same body continued to
render separately under LISTED ITEMS. That is the Q2 representation confirmed live,
with the token itself dumped from the client's own string table rather than guessed.

Records the observed wire->UI contract as a table plus a fixture
(inactive-row-live-confirmed.json), and names the regression tests that pin it,
including the two guards that the row is never emitted for an item outside the trade
pile and never duplicates a real auction.

Also records the expired->Club coupled transition verified server-side: the listing
went to `cancelled`, the pile went to `club`, /tradePile dropped the item, /club
regained it, and clubPlayers went 1964 -> 1965. That is durable store state rather
than a client-local view, so it survives a session boundary by construction; tagged
pending the operator's final exit/re-enter confirmation.

Adds the counts observation table. `count` currently tracks AUCTION entries and not
total Transfer List membership; semantics deliberately left unchanged until the full
state set has been observed.

No behaviour change in this commit.
2026-08-17 20:31:20 +00:00
funman300 a2bd048ace feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"
PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:

    'active' = 1   'inactive' = 2   'expired' = 3   'closed' = 4

The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.

PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.

Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.

Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.

340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
2026-08-17 20:25:46 +00:00
funman300 2e97ff1461 docs+tools: dump the CardsDLL route table; narrow Q2 to one candidate by elimination
Re-entry discriminator came back a CONFIRMED BUG: an unlisted transfer-list item does
not survive a fresh FUT session, so our representation cannot reconstruct trade-pile
membership. Evidence acquisition per instruction, corpus and PE first, no guessing.

Adds route_table_dump.py: static read-only dump of CardsDLL's route table from the
on-disk PE, resolving VA->file offset through the real section table instead of
assuming a single .text mapping. Output preserved as evidence. It settles "is
/tradePile the only relevant route?" -- the table holds 45 routes plus 3 empty admin
slots, and row 30 `ut/%s/tradePile` is the ONLY trade-pile route. There is no
trade-pile items route.

That plus three existing PE facts narrows the representation to exactly one candidate
by ELIMINATION rather than choice: the route carries only twelve-atom auction records;
`pile` (0x226) has no arm in the item deserializer so membership is conferred by the
owning list and cannot be added as a field; of the twelve atoms only tradeState
expresses lifecycle; and tradeState's closed vocabulary (active=1 inactive=2
expired=3 closed=4) has exactly one value not already spoken for.

So an unlisted item can only be an auctionInfo record with tradeState "inactive".
Tagged INFERRED-BY-ELIMINATION, not CONFIRMED: the remaining unknown is whether the
Flash Transfer List RENDERS such a record in the unlisted section. Records the
acceptance test (survive a full FUT reload) and the revised invariant that a
transition is complete only when a fresh session reconstructs the same visible state.

No behaviour change in this commit.
2026-08-17 20:10:26 +00:00
funman300 7f37b37be3 docs: capture the unlisted transfer-list state and model the pile/auction boundary
Q2 measured, not guessed. The operator moved a card Club -> Transfer List without
listing it (PUT /item, no POST /auctionhouse) and the state was captured read-only.

Finding: we do not represent the unlisted state on the wire AT ALL. Such an item is
byte-identical to a club item -- itemState `free`, no `pile` field emitted, still
returned in /club and counted in clubPlayers -- while an actively-listed item is
correctly excluded from /club and present in tradePile. Only the host's own pile
store knows the difference. Trade pile held 6 items: 1 listed, 5 unlisted.

The FIFA 17 ENCODING of that state stays UNKNOWN on purpose: returned itemData.pile
is numeric with an unrecovered mapping, and tradeState is a closed table walk where
an unrecognised bidState is silently swallowed as `none`, so a wrong enum produces a
plausible-looking but wrong UI. The corpus warns `inactive` decodes but no client
path treats it specially. One client-only discriminator is recorded instead.

Also models the domain boundary both limbo bugs came from: pile membership and
auction lifecycle are separate facts requiring coordinated transitions. States the
testable invariant -- an item must never be simultaneously excluded from /club and
absent from /tradePile -- with the two ways it was reachable and the commits that
closed each.
2026-08-17 20:01:51 +00:00
funman300 4e31fb98a2 fix(market): returning an item to the club ends its auction; close the panel probe
CLOSES the active-own-auction Actions-panel investigation. Live client plus the RE
corpus plus historical FUT behaviour all agree: an active auction is COMMITTED until
sale or expiry and is not seller-actionable, while an expired unsold item becomes
actionable (relist / return to club). Every observation fits that lifecycle --
active+frozen expires was non-selectable, expired was selectable and relisted fine,
relisting made it active and non-selectable again, and the client never emits a
cancel. Documented with confidence tags, and the dead ends are named so they are not
retried: MAY_BE_REMOVED is a constant 1, and the eight-flag array is the CLUB-CARD
menu with no auction-cancellation flag in it.

Implements the return-to-club transition that closure exposes. A pile move to `club`
now cancels any ACTIVE listing on that item, because the auction that put the card
in the pile has to end with it. Otherwise the pile reads `club` while the row stays
`active`, so the card is filtered out of /club (exclusion keys on active listings)
AND still rendered in the Transfer List: the move appears to do nothing. This is the
same limbo class as the earlier pile-vs-listing bug, found by reasoning about the
transition rather than by another live failure.

Scoped to `active` only: a `reserved` row is mid-sale and a `sold` row is already
gone, so cancelling either would let one card be both sold and returned. Two tests
cover exactly that boundary.

338 tests pass, 0 failed, clippy clean.
2026-08-17 19:56:06 +00:00
funman300 6cc22e5cc5 docs: freeze the known-good auction state and mark tradeOwner DISPROVEN
Records the live-client resolution so the market shape is now a fixture rather than
folklore, and so a future agent cannot burn deployments on tradeOwner again.

Confidence notes updated: tradeOwner remains FIFA17-HISTORICAL (it does exist in
the FIFA 17-era API) but "required by FIFA17.exe Transfer List Actions" is now
DISPROVEN for this client path -- it is not among the twelve atoms the client's
auctionInfo deserializer reads, and it was implemented, deployed, observed inert
and removed.

The real blockers are recorded as CONFIRMED live-client findings: trade/status
polling is load-bearing, `expires` must EVOLVE with wall-clock time (a frozen
value is structurally valid and behaviourally broken), and relist must persist
through the PK conflict that FIFA's re-sent ISStart necessarily causes.

Freezes the known-good bodies under docs/evidence/market-lifecycle-2026-08-17/
with a machine-checkable countdown proof (_index.json._countdown_proof records
expires decrementing, frozen:false) rather than asserting the clock in prose.

States the general rule this cost us: a response can pass differential parity and
render perfectly while still being wrong, because FIFA expects an evolving
server-side state machine, not a static object that resembles one.
2026-08-17 19:42:15 +00:00
funman300 b1d7ed2570 fix(market): relisting an expired auction actually relists it
The client's relist arrives as a fresh ISStart (`POST /auctionhouse`) for an item
that ALREADY has a listing row, so `create_listing` hit a primary-key conflict. The
handler treated `Err(Conflict)` as success: it logged `listed=true`, handed the
client its trade id, and persisted nothing. The stale row kept its old `created_at`,
so the card stayed expired and the relist appeared to do nothing -- observed live,
with the client's price-limits fetch and the ISStart POST both in the log.

The PK conflict IS the relist path. `relist_listing` now resets `created_at` to now
and takes the new prices and duration, so the auction actually returns to the market
with a fresh countdown.

Refuses to revive a `sold` or `reserved` row: re-opening a sold auction would sell
the same card twice. `cancelled` rows ARE relistable (the card is back in the pile).
Missing rows report NotFound rather than silently succeeding. The failure paths still
ack so the screen cannot wedge, but they now say `relisted=false reason=...` in the
log instead of claiming success.

Three store tests: the clock/price reset, the sold+reserved revival guard (plus the
cancelled-is-relistable case), and NotFound.

336 tests pass, 0 failed, clippy clean.
2026-08-17 19:17:35 +00:00
funman300 dcbef721f2 docs+tools: measure the FIFA 17 market gate bytes in the live client
Adds trade_gate_probe.py (read-only: /proc/<pid>/mem O_RDONLY + pread, slide proven
against the on-disk FNV prologue), extending gate_byte_probe.py to vtable slot
+0x270 exactly as the transfer-market analysis asked for.

Measured: IS_TRADING_ENABLED=1 (was 0 in the Python era), TRADE_PILE_SIZE=100
(was 0), watchListSize=50 (was 0), with four controls reading 1. So every
CardsDLL-supplied input that analysis named as a market blocker is now OPEN, which
the Rust host achieves by construction -- it emits userInfo.feature as {} so the
kill switch at 0x180174f19 never arms, and it already sends pileSizeClientData
keys 2 and 4.

This narrows the Actions-panel question to the exe-side UI script term, and rules
out ownership fields, the gate bytes, the cancel route and the state vocabularies
as candidates -- each on measured or PE-derived evidence rather than inference.
2026-08-17 19:09:03 +00:00
funman300 772f8a615a fix(market): pin auctionInfo to FIFA 17's twelve atoms, add the real auction clock
Corrects the record against the CLIENT BINARY rather than library hearsay, using
the project's own reverse-engineering record
(fifa17-recon/docs/plan-2026-08-06-transfer-market.md, read out of the on-disk PE).

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

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

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

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

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

333 tests pass, 0 failed, clippy clean. Verified live: the twelve-atom record, the
four-member envelope, and the listing correctly reading expires=0 / expired after
aging past its hour.
2026-08-17 19:06:33 +00:00
funman300 bf9ae20367 docs: FIFA 17 transfer-market wire findings with confidence tags
Records the auction-record field set, route spellings, pile encoding and the four
open UNKNOWNs so future agents neither reopen settled questions nor re-guess enum
values. Each claim tagged CONFIRMED / FIFA17-HISTORICAL / INFERRED / UNKNOWN.

Captures the key methodological lesson: oracle parity is necessary but NOT
sufficient for a flow the oracle itself never served -- our auction record matched
the oracle key-for-key while both omitted the FIFA 17 ownership fields.
2026-08-17 18:51:13 +00:00
funman300 58d1f9426f fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE
Three defects behind "selecting my own Transfer List listing opens no dialog".
Pressing the card emits NO HTTP at all, so the gate is a field in what we already
return -- the client decides locally from the auction record.

1. OWNERSHIP FIELDS (FIFA17-HISTORICAL). FIFA 17 auctionInfo carries `tradeOwner`
   (bool), `sellerId` and `offers`; we emitted none of them. `tradeOwner` is the
   purpose-built "this auction is mine" flag, and without it the Transfer List has
   nothing to key owner actions (Remove / Re-list) on. `sellerId` now carries the
   configured persona so it agrees with `tradeOwner` and `sellerName` instead of
   telling three different stories. Persona is threaded from config, never baked in.

2. `GET …/trade/status` ANSWERED EMPTY (CONFIRMED from our own live logs). The
   Transfer List polls this continuously to refresh live auction state. The tail has
   no numeric id, so it fell through `t.starts_with("trade")` into the buy/view arm,
   where `trade_id_from_path` fails and the reply is `{"auctionInfo": []}`. The
   client asked for the state of its own listings and was repeatedly told there was
   none. Now a real handler: `tradeIds` filter, or the whole active pile unfiltered;
   unknown ids are absent rather than an error, so a poll never fails closed.

3. PLAIN `DELETE …/trade/<id>` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients
   cancel via `DELETE /ut/game/<sku>/trade/<id>`; only the oracle's
   `/ut/delete/game/…` spelling mapped to MarketCancel, so the plain form landed in
   the buy/view arm and "cancelled" nothing while returning 200. Both spellings now
   map to MarketCancel. Kept the oracle spelling: the differential exercises it.

Why the differential missed all of this: our record's key set was IDENTICAL to the
oracle's, so parity was green. The oracle omits the ownership fields too, because
its own remove flow was never driven by a real client either. The differential now
asserts we COVER every oracle key and that our extra keys are EXACTLY
{offers, sellerId, tradeOwner} -- so an unexplained new divergence still fails,
while the deliberate superset is pinned.

Deliberately NOT changed (no evidence): itemState stays "listFS", expires stays
3600 seconds-remaining, bidState stays "none" for active/unbid, counts stays
count=1, and no FIFA 18+ price fields were added.

332 tests pass, 0 failed, clippy clean. Deployed and verified live: tradeOwner=true
sellerId=33068179 sellerName='CAGE' offers=0 on /tradePile AND /trade/status
(filtered and unfiltered).
2026-08-17 18:50:19 +00:00
funman300 3cd31c4322 fix(market): stamp the player's persona as sellerName, not EA's house name
A card listed on the Transfer Market rendered correctly in the Transfer List but
pressing it opened NO Actions panel, so Remove / Re-list were unreachable. The one
field where our auction record diverged from the oracle was the seller: we stamped
"EASFC" while the oracle stamps the account's persona name. `fut_account.py`
annotates that very property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona /
UTAS sellerName", so EA's house name on the player's OWN listing is simply wrong,
whether or not it proves to be the gate on the Actions panel.

Introduces `non_economy::PERSONA_DISPLAY_NAME` as the single source of truth and
uses it both for the `account/sync` default (previously a bare "CAGE" literal) and
as the market seller. Every listing in this store is the player's own -- there is no
NPC seller in a single-account emulator -- so the fallback is the player.

Also strengthens the differential: it compared only auctionInfo LENGTH and
tradeState, so it was structurally blind to this. It now compares the record key
set and each shared field against the live Python oracle, asserts the seller is the
persona rather than EA, and asserts itemData is the full card rather than a stub.

That strengthened comparison passes against the real oracle subprocess, which
establishes two things: our record's key set is IDENTICAL to the oracle's (we are
missing no field relative to it), and sellerName was the only divergence.

NOTE the limit of that evidence: the oracle's own Transfer List remove flow has
never been confirmed against a real client either (the only live datapoint is a
counts-tile bug), so parity is necessary but may not be sufficient. If the client
still offers no dialog, the missing field is missing on BOTH sides and must come
from client instrumentation, not from the oracle.

14 targets green, clippy clean. Deployed and verified live: sellerName='CAGE',
listing intact, coins unchanged.
2026-08-17 18:29:19 +00:00
funman300 ae5feb05b7 docs: record the external FIFA 17 FUT hub behavioural spec + cross-check
Operator-supplied research document (authored outside this repo) describing the
player-visible FUT hub state machine. Stored verbatim so it cannot drift, with a
provenance header pinning its standing: it is a BEHAVIOUR target, never a protocol
reference. Its own §43 already forbids inventing route/field/sentinel/empty-state
details from it, which matches project policy (guessing wire spellings is the
documented client-freeze class).

Appended a repo-grounded cross-check that tags each relevant claim CONFIRMED /
CONFLICT / GAP / UNVERIFIED, so a future agent cannot mistake the aspirational
parts for observed behaviour. Notably it CONFLICTS with the recovered client
tables twice (Manager League is deliberately excluded from the consumable
overlay; there is no apply-consumable endpoint upstream at all), and it usefully
confirms that "sent to the Transfer List but not currently listed" is a real FUT
state -- which is exactly the limbo f2c4927 worked around.
2026-08-17 18:25:23 +00:00
funman300 f2c4927ea6 fix(club): hide only ACTIVELY-LISTED cards, not the whole trade pile
Keying the club exclusion on the `trade` pile put cards in limbo: the pile can
hold cards with no active listing (a bare "Place on Transfer Market" move, or a
listing later cancelled/sold), and `/tradePile` renders ONLY active listings — so
those cards were invisible in BOTH views. Live prod had 5 trade-pile rows but 1
active listing, so 4 owned cards had no reachable screen (clubPlayers 1966->1961).

Key on the ACTIVE LISTING instead (market store `core_item_id` of `state=active`).
This is self-healing: the moment a listing stops being active the card is back in
the club, with no extra transition to maintain and no need to invent an
"unlisted transfer-list" wire shape (`tradeState` has no verified spelling for
that state, and guessing enum spellings is the documented client-freeze class).

A bare pile move therefore no longer hides a card. That is deliberate: our
`/tradePile` shows only active listings, so hiding on the move alone would
reintroduce the limbo it is meant to prevent.

Verified live: clubPlayers 1961 -> 1965 (exactly the one listed card hidden, the
4 stranded cards recovered); listed wire still absent from /club; counts and
tradePile unchanged. 14 targets green + clippy clean.
2026-08-17 18:10:32 +00:00
funman300 aa2abc2772 fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:

1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
   client greyed out "Place/List on Transfer Market" for the whole club. Owned
   and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
   for owned copies too (item_def keeps `true`; instances do not).

2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
   FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
   the handler fail-closed and returned 200 while persisting NOTHING. It now
   resolves server-side: wire id -> Core owned instance -> its card_id (minted on
   a synthetic buy) + FIFA resourceId (the auction record). This also enforces
   that a listing can only name a card the club actually owns.

3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
   row the client could not draw -> "1 item listed" but no visible sale. A
   listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
   additive migration) built by the same `shape_item` shaper `/club` and the
   squad projection use, so the auction card renders identically to the club
   card. The seller's own pile stamps `itemState: listFS`; market search keeps
   `forSale` (the oracle distinguishes these).

4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
   deserializers: `/counts` is FutGetAuctionCount, five scalar ints
   (count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
   everything else. Served the `auctionInfo` body it left every count at 0, so
   the Transfer List screen showed no active sale while the hub tile showed one.
   New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
   also accepts the /counts path).

Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.

Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.

Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.

Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
2026-08-17 18:03:06 +00:00
funman300 1aa84afa9a feat(host): migrate item-defs + marketdata UTAS reads to Rust
Two more real client-hit reads move off the Python proxy:

- GET /item/resource, /defid (Route::ItemDefs): build {itemData:[item_def…]}
  for every >=3-digit id in the query, replicating the oracle's item_def
  (assetId = resourceId & 0xffffff; hardcoded Ronaldo asset 20801 + a generic
  "Player" 75 CM placeholder). The client renders the real card from its local
  DB, so the placeholder is exact parity.
- GET /marketdata (+ /marketdata/pricelimits) (Route::MarketData): suggested
  pricing, constant band 150..15000. /pricelimits returns a BARE ARRAY (one
  {defId,minPrice,maxPrice} per queried defId); plain /marketdata returns an
  OBJECT {minPrice,maxPrice}. The container type is load-bearing — object-where-
  array froze a live client at the listing screen, so the handler picks it from
  the path.

Adds extract_long_ints / extract_defid_param query parsers, shape+parser unit
tests (incl. the freeze-critical container-type assertions), and classify-table
coverage. Deployed to prod-host 2026-08-17; verified owner=RUST 200 for all four
(Ronaldo/placeholder resolve, pricelimits=array, marketdata=object).

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated. Remaining
Python tail is now only mutation (user/club), no-Core-model (squad/<n>), and
unimplemented modes (draft/leaderboards/sbs).
2026-08-17 16:21:17 +00:00
funman300 33e9118329 feat(host): migrate flag-off UTAS reads (season/tournament/champion/clubUser/user-list) to Rust
FUT modes (Seasons/Tournaments/FUT Champions) and the club-identity service are
disabled in this emulator, so these GET reads return {} verbatim from the Python
oracle. Serve them directly from Rust via a new Route::FeatureOffEmpty +
non_economy::feature_off_body() -> {} (byte-identical to the flag-off oracle),
reducing the proxied Python surface.

- The mutating club rename (user/club) stays on Python (needs a Core write).
- Enabling a mode later requires a real Rust handler here, never a Python
  fallback (no split authority).
- classify tests: 5 routes owned + user/club/wrong-method lookalikes stay
  Passthrough; updated the stale clubUser assertion.
- Deployed to prod-host 2026-08-17; verified owner=RUST 200 {} for all five.

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated.
2026-08-17 16:10:53 +00:00
funman300 e06fd57211 feat(host): migrate non-economy UTAS routes to Rust + launcher redesign
Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
  persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
  (nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.

Launcher: bump gitlink to 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.
2026-08-17 16:04:20 +00:00
funman300 42fd3c7e90 core: deploy correctness fixes (SBC exploit + economy TOCTOU) + docs
Bump openfut-core gitlink to 68d1065 (correctness fixes: SBC duplicate-card
exploit, non-atomic economy CAS guards, season/checkin panics, sbc_submissions
club_id migration 0019). Deployed to prod-core (DB migration ver 18 -> 19).

Add docs/CORE_CORRECTNESS_ISSUES.md (audit + Resolution) and
docs/OVERNIGHT_HANDOFF_2026-08-17.md.
2026-08-17 16:01:27 +00:00
funman300 0bc71dbd74 docs(evidence): capture full-length UTAS responses + userMassInfo envelope
Fix extractor truncation (bound each HTTP message by Content-Length): userMassInfo
(8 KB) and purchasegroup responses are now complete in the committed corpus, not
cut at 4 KB. Document the userMassInfo envelope contract (target shape for a future
full-Rust migration; needs the clubAbbr/established account triad Core lacks).
2026-08-17 04:13:55 +00:00
funman300 2ecd830d75 docs(evidence): commit fresh sanitised UTAS wire captures (2026-08-15 live A/B)
Rebuilds the primary-capture corpus lost to .gitignore (Known Issues #200): 10
real-client requests + 12 responses captured during the post-P1 staging A/B on a
real FIFA 17 client, sanitised (SID/authCode/deviceId/MAC/tokens redacted; raw pcap
withheld). Documents the account/sync, empty-My-Packs 65534 sentinel, and userMassInfo
contracts, incl. the finding that account/sync is coupled to Python active-profile
selection (so it can't migrate standalone from the userMassInfo hybrid).
2026-08-17 04:09:56 +00:00
funman300 3a51b0ebd4 docs: correct wrong Fire2 header traps in heat2.py + fifa-blaze frame.rs
Both files documented a wrong Fire2 header layout as authoritative, the reader
trap called out in Known Issues:
- heat2.py's module docstring labelled its >IHHHHB3s header 'VALIDATED'. The
  round-trip only validates the payload length + TDF body; decode->encode with the
  same mislabelled header trivially reproduces the capture, so it never tested the
  [10:16] field boundaries. Marked superseded; cite the proven layout; warn at
  build_fire2_frame. Code unchanged (dead tooling).
- fifa-blaze frame.rs: see submodule commit f4f3396.

Bumps fifa-blaze submodule eccd46f -> f4f3396 (FIFA23 stub; not in the prod
container; no prod impact).
2026-08-16 21:29:52 +00:00
funman300 ad406f21bd fix(tls): share bare-probe classification across all FIFA-facing TLS hosts
A reachability probe (TcpStream::connect then drop; the launcher preflight makes
them) reaches a TLS acceptor as 'unexpected EOF' — byte-identical to the
certificate mismatch that cost three live gates. The redirector classified the
opening before the acceptor to keep a benign probe from forging a TLS fault, but
the roster host (the second FIFA-facing TLS host) did not, so the documented
hazard 'remains in any other TLS host that has not adopted it' was live there.

Lift the pure policy (PeerOpening + classify_opening) plus a peer_opening(&TcpStream)
peek helper into the shared openfut-tls crate (game-independent; +unit tests).
The redirector now re-exports them (public API + its probe_classification test
unchanged; behaviour identical). The roster host adopts them: a ProbeCount, a
probes() handle, and a pre-acceptor peek that logs PROBE and returns instead of
failing the handshake. New roster probe_classification integration test (3 cases:
bare probe classified, real client after a probe still served 200, speaks-then-
fails still reported as a fault). Full workspace tests green; clippy -D clean.
2026-08-16 20:30:50 +00:00
funman300 12fb9fc38b chore: update workspace Cargo.lock after excluding openfut-hook
openfut-hook (now its own workspace root) and its windows-sys deps are no longer
part of this workspace's lockfile.
2026-08-15 19:32:18 +00:00
funman300 7b580a0070 chore: bump openfut-hook clippy -D warnings cleanup (0d3f33c -> d1a71bd) 2026-08-15 19:31:25 +00:00
funman300 22443a3810 build(hook): exclude openfut-hook from workspace so its release profile applies
openfut-hook (Windows version.dll injected into the FIFA client) declared a
[profile.release] with panic=abort/strip/opt-level=s that Cargo silently ignored
because it was a non-root workspace member (per-package `panic` overrides are
forbidden). Move it out of `members` into `exclude`; the submodule now carries a
matching empty [workspace] table so it builds as its own root. Fixes cross-FFI
panic-unwind UB in the injected DLL, shrinks it 1200126 -> 861696 B, and lands the
artifact in openfut-hook/target/ (matching launcher config.rs hook_dll_path).

Bumps openfut-launcher submodule ca7ce26 -> 0d3f33c.
2026-08-15 19:25:19 +00:00
funman300 0fce1e521c docs: mark club/stats/{year,consumables} Rust-owned in authority matrix
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).
2026-08-15 18:04:23 +00:00
funman300 71fcf5e251 feat(fifa17): own club/stats/{year,consumables} in Rust (Core-accurate)
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).
2026-08-15 17:56:37 +00:00
funman300 979e71fbea docs: record precise blockers for remaining Python non-economy routes 2026-08-15 17:31:15 +00:00
funman300 45e0b0bd95 docs: mark hub + club/stats/staff migrated in authority matrix 2026-08-15 17:18:55 +00:00
funman300 70eb3fc13f feat(fifa17): own FUT hub tile counts in Rust
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.
2026-08-15 17:18:06 +00:00
funman300 b30aa352f6 docs: record post-P1 candidate migration status + clientdata Core blocker 2026-08-15 17:13:47 +00:00
funman300 67cc33cfee feat(fifa17): own club/stats/staff (empty stat set) in Rust
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.
2026-08-15 17:13:16 +00:00
funman300 6eec3b9ec7 test(fifa17): add UTAS route-reachability reporter (Python-hit gate)
Parses the host owner= dispatch log into per-owner + per-domain counts and gates
on the post-P1 invariants: economy Python hits == 0 (P1 regression), migrated
non-economy routes (accountinfo/settings/leaderboards/match-reset/phishing) ==
0, and residual Python domains == documented set. Read-only; staging-preflight
and Phase 40 live-ownership use.
2026-08-15 17:01:11 +00:00
funman300 57773b98ec docs: Python retirement readiness classification (post-P1) 2026-08-14 05:36:32 +00:00
funman300 fe9b899a0e docs(fifa17): record .105 Legends unrecoverable verdict (dcplayernames empty) 2026-08-14 05:26:02 +00:00
funman300 abe9e663c1 feat(fifa17): import consumable + staff content as first-class Core content
Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are
unrecoverable from PC data). Verdict A (no Core change): consumables/staff become
ordinary Core CardDefinitions (neutral player fields + honest family/role names)
and owned instances via the SAME generic import path; a catalog kind lets the
adapter exclude them from the player-only /club projection.

- adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label
  (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate.
- adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype
  (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups.
- adapter item/club_response: shape_club_response excludes non-player kinds
  (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player.
- host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so
  /club excludes consumables/staff in production.
- import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions
  (resourceId-grouped, subtype-consistency gated); emit_content writes non-player
  defs + catalog kind + manifest; apply mints owned instances via owned_item_id.

Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player
defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
2026-08-14 05:26:02 +00:00
funman300 f5a33eb58c test(fifa17): prove non-economy routes are Rust-owned with no Python fallback 2026-08-14 05:08:37 +00:00
funman300 a85090c3c6 feat(fifa17): own non-economy static + security-question routes in Rust
Migrate 5 non-economy UTAS route families from the Python oracle proxy to
Rust host ownership: user/accountinfo, settings, leaderboards/options,
match/reset, and phishing/{trusteddevice,question,validate}.

- adapter fut::non_economy: pure IO-free shapers matching the observed prod
  oracle bodies + a verbatim port of security_question_route (stateless ack;
  answer never stored/compared; trusted-device is an invariant constant).
- host: Route variants + classify() arms + owner=RUST dispatch; the
  security-question X-UT-SID gate reuses SessionStore::session_known.
- tests: 9 adapter unit tests (contract) + host non_economy_route_ownership
  (classify + classify_economy negatives).
2026-08-14 04:57:28 +00:00
funman300 97d48d8371 docs: record post-P1 production authority matrix + FIFA17 content completeness 2026-08-14 04:57:28 +00:00
OpenFUT Agent 5020137050 docs(fifa17): record retail economy route grammar (purchased/items, tradePile) 2026-08-14 00:50:03 +00:00
OpenFUT Agent cf05ab2a9e test(fifa17): replay retail purchased/items BUY + route matrix via dispatch
- pure_economy_routes (NEVER-BOTH + no-fallback) gains POST/GET purchased/items,
  lowercase tradepile, tradePile/counts -> all asserted Rust-owned, proxy 0.
- retail_purchased_items_buy_debits_core_through_dispatch: the exact round-2
  live failure -> POST /purchased/items now debits Core, reveal shows minted
  items, repeat reveal idempotent, Python proxy count 0.
2026-08-14 00:50:03 +00:00
OpenFUT Agent a6416a3f1d fix(fifa17): classify full retail economy route shapes
Round-2 live staging (candidate 47ced22) showed the CONFIRMED retail Store BUY
uses POST /ut/game/fifa17/purchased/items (reveal GET .../purchased/items),
which the exact-tail 'purchased' match missed -> Python (Core coins unchanged).
Comprehensive audited fix in classify_economy:
- is_purchased_tail: 'purchased' AND 'purchased/items' (POST->PackOpen,
  GET->PackReveal); bounded (rejects purchasedfoo, purchased/items/extra).
- is_tradepile_tail: 'tradePile' family CASE-INSENSITIVE incl 'tradePile/counts'
  (hub tile polls lowercase; oracle routes via re.I); allocation-free.
- (kept) v1/v2 prefix normalization + store/transaction[/<digits>].
Adds retail_route_matrix unit test = the machine-auditable route contract gate
(all economy shapes + negative near-misses). Host lib 75.
2026-08-14 00:50:03 +00:00
OpenFUT Agent 47ced228de docs(route-authority): record v1/v2 economy URL prefix contract
Accepted prefixes /ut/game/<sku>/ and /ut/v2/game/<sku>/ for every economy
route; StoreBuy accepts store/transaction and store/transaction/<txn-id>.
2026-08-13 23:53:44 +00:00
OpenFUT Agent b8beeba98d test(fifa17): cover retail v2 Store route shapes
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.
2026-08-13 23:53:44 +00:00
OpenFUT Agent df6994c957 fix(fifa17): classify retail v2 economy routes
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).
2026-08-13 23:53:44 +00:00
OpenFUT Agent d74e86c065 docs(fifa17): record Rust economy authority proof (E1 cutover ready)
Final per-route authority table: every economy route owner=Rust, Python
proxy=NO (userMassInfo the one hybrid: Python envelope + Rust economy/squad
overlay). Barrier 93a46d4; from_config 43917a0. Proofs: differential 15 PARITY
+ 1 DIFFERENT-BY-DESIGN, concurrency 8x50, failure 10 (complete-sale SAFE, no
E3), importer 5-step, from_config E2E, NEVER-BOTH / no-fallback / stale-reader.
Python source byte-unchanged; oracle suite 32/32.
2026-08-13 22:44:12 +00:00
OpenFUT Agent 76512f6048 test(fifa17): prove post-barrier economy authority (never-both / no-fallback / stale-reader)
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.
2026-08-13 22:39:45 +00:00
OpenFUT Agent 93a46d4de7 feat(fifa17): cut over FUT economy authority to Rust
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.
2026-08-13 22:39:36 +00:00
OpenFUT Agent 3ba24a0faf test(import): verify durable FIFA17 economy migration
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.
2026-08-13 22:31:01 +00:00
OpenFUT Agent 6926bb9528 test(fifa17): add Python-oracle economy differential coverage
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).
2026-08-13 22:30:47 +00:00
OpenFUT Agent b1643309f6 test(fifa17): prove host economy concurrency and failure rollback
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.
2026-08-13 22:30:35 +00:00
OpenFUT Agent 43917a0051 feat(fifa17): attach economy services in Server::from_config
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.
2026-08-13 21:59:29 +00:00
OpenFUT Agent 1fac71e3ef docs(route-authority): market resourceId mapping + reveal contract landed
Records fe72f0d (resourceId->Core card_id reverse mapping; listings carry both
identities; full Core+store restart E2E) and 747cc23 (GET /purchased reveal =
durable purchased pile, idempotent). Both pre-barrier correctness gaps closed.
Remaining: Python differential, host concurrency matrix, failure injection,
importer, from_config attachment, then the classifier barrier + reachability.
2026-08-13 21:51:52 +00:00
OpenFUT Agent 747cc234c1 fix(fifa17): preserve owned-pack reveal state for GET /purchased
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.
2026-08-13 21:51:12 +00:00
OpenFUT Agent fe72f0def2 fix(fifa17): map market resource ids to authoritative Core card ids
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.
2026-08-13 21:43:48 +00:00
OpenFUT Agent 884ecbba64 docs(route-authority): async bridge + dispatch wiring + real E2E landed (580d80a)
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).
2026-08-13 21:30:56 +00:00
OpenFUT Agent 580d80a86e feat(fifa17): wire async economy handlers into the host via a runtime bridge (unrouted)
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.
2026-08-13 21:30:10 +00:00
OpenFUT Agent 0e2ca5a7c3 docs(route-authority): record landed Store/Market/pack handlers (unrouted)
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.
2026-08-13 20:48:39 +00:00
OpenFUT Agent 4d2b8b9be3 economy(fifa17): land Store + Market writer handlers + pack generator (unrouted)
Implements the FIFA17 economy WRITER cluster on top of the landed Core
economy authority + host CoreEconomy client + identity/item-shaper infra.
Handlers are pub, unit-tested, and NOT yet routed: classify() and
ROUTE_AUTHORITY are untouched — the classifier barrier is a later single
coherent flip. No stubs; real Core-backed behavior; fail-closed on CoreError.

Pack generator (adapter fut/pack_content.rs):
  generate_pack_contents(&PackDef, &mut impl Rng, &[GeneratedCandidate])
  -> Vec<GeneratedCard>. Pure, seeded (deterministic), gold-tier split +
  special_chance gate as documented OPENFUT PLACEHOLDER policy (Python
  open_pack/_pack_body parity note inline). Fail-closed empty on empty pool.

Store/item writers (host economy_store.rs), matching oracle wire shapes:
  - handle_store_buy   PUT /store/transaction -> purchase_items (debit+mint N)
    -> createPackResponse; cancel/unknown/owned_only -> 200 {}; insufficient
    -> 461 {reason,credits}; CoreError -> 503.
  - handle_pack_open   POST /purchased -> owned_only consumes the unopened
    entitlement (redeem_entitlement, consume-once); normal packs debit+mint.
  - handle_quick_sell{_path,_body}  DELETE .../item/<id> + POST /ut/delete/.../item
    -> reverse-resolve wire->Core id (SquadWireResolver) -> sell_item ->
    {items:[{id}],totalCredits}; not-owned skipped.
  Production OwnedItemLookup = CoreItemLookup over CoreAccess.

Market (host market_store.rs / pile_store.rs / market.rs), synthetic-seller:
  - MarketStore over sqlx SQLite (WAL-once + busy_timeout=5s + BEGIN IMMEDIATE
    for writes, mirroring openfut-core::db). listings(active/reserved/sold/
    cancelled), owner-checked cancel, CAS reserve/complete_sale/rollback.
    Typed errors NotFound/Sold/Cancelled/WrongOwner/Conflict.
  - PileStore: durable pile/location metadata keyed by Core item id.
  - handle_market_{list,query,cancel,buy} + handle_move_items. Buy-now =
    reserve (CAS) -> balance precheck (461) -> Core purchase_item (mint+debit)
    -> complete_sale; any Core failure rolls the reservation back active.
    Two concurrent buyers -> exactly one sale + one debit.

Deps (additive): rand 0.8 (adapter+host), sqlx 0.7 sqlite/runtime-tokio (host).
Tests: adapter +7 (pack_content), host +43 (economy_store 20, market/store 23
incl two_reservers_exactly_one_wins, two_buyers_exactly_one_sale_one_debit,
state_survives_reopen, move_persists_across_reopen). All green; clippy
-D warnings clean; rustfmt clean.
2026-08-13 20:47:57 +00:00
OpenFUT Agent 0b31abe1d1 test(fifa17): run economy harness on a real multi-connection Core pool
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.
2026-08-13 20:20:58 +00:00
OpenFUT Agent 6b652cb0a2 chore(core): BEGIN IMMEDIATE write transactions (75b1830 -> fbb54ea) 2026-08-13 20:20:21 +00:00
OpenFUT Agent 3507c5714d feat(fifa17): add purchase_items to host CoreEconomy client
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.
2026-08-13 20:06:54 +00:00
OpenFUT Agent 4f78b9a875 test(fifa17): keep economy harness serialized; document multi-conn blocker
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.
2026-08-13 20:05:34 +00:00
OpenFUT Agent f3aebafcc7 chore(core): serialize WAL establishment (75b1830) 2026-08-13 20:03:40 +00:00
OpenFUT Agent 1df0bc4f00 test(fifa17): make economy integration harness deterministic
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.)
2026-08-13 19:57:53 +00:00
OpenFUT Agent 7d5d0cff06 chore(core): sqlite busy_timeout + per-connection pragmas (bcc4f51 -> 0360135) 2026-08-13 19:55:13 +00:00
OpenFUT Agent 8d752cb0e4 test(fifa17): real host<->Core economy integration harness
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.
2026-08-13 19:50:42 +00:00
OpenFUT Agent 96e80ab293 feat(import-fifa17): seed unopenedPackIds as Core entitlements
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.
2026-08-13 19:38:04 +00:00
OpenFUT Agent 49c5185ae3 docs(utas-host): update economy cutover progress (readers + match writer landed)
Core API + purchase_items + entitlement import + host reader handlers
(credits/purchasegroup/userMassInfo) + match reward writer landed and tested;
classifier still unflipped (barrier pending BUY/pack-open/quick-sell item shaping,
market listing state, differential/concurrency/restart).
2026-08-13 19:32:45 +00:00
OpenFUT Agent 181bd94341 feat(fifa17): Rust purchasegroup, userMassInfo economy, match reward handlers
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).
2026-08-13 19:31:23 +00:00
OpenFUT Agent 09675a9f7f feat(fifa17): economy policy mappers (match reward, pack price)
Pure FIFA17 policy: match_result_coins/match_reward_total (oracle MATCH_COINS
won 400/draw 200/loss 100 + participation 0), result_from_end_reason (endReason
enum -> outcome, draw default), pack_price (catalogue buy-now, None for
unknown/owned-only). 3 unit tests.
2026-08-13 19:31:23 +00:00
OpenFUT Agent 56c364e4c9 chore(core): purchase_items + entitlement import (d32dc6e -> bcc4f51) 2026-08-13 19:27:06 +00:00
OpenFUT Agent 3021a4e761 docs(utas-host): record economy cutover progress in route-authority gate
Core economy HTTP API + host CoreEconomy client (fail-closed) + credits reader
landed; classifier not yet flipped (coherent barrier pending full cluster + Core
seed).
2026-08-13 19:14:39 +00:00
OpenFUT Agent d240a61157 feat(fifa17): host Core economy client + credits reader vertical
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).
2026-08-13 19:14:13 +00:00
OpenFUT Agent d7c5307045 chore(core): expose economy HTTP API (c8269d0 -> d32dc6e)
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.
2026-08-13 19:11:04 +00:00
OpenFUT Agent 838d76f95e docs(utas-host): add economy route-authority cutover gate
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.
2026-08-13 18:59:46 +00:00
OpenFUT Agent 9791eee67b chore(core): add generic purchase_item economy primitive (ee2caa0 -> c8269d0)
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.
2026-08-13 18:58:49 +00:00
OpenFUT Agent 5d119f5555 chore(core): reconcile Core to validated trunk + generic economy
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.
2026-08-13 18:45:21 +00:00
funman300 46e5f612c8 feat(fifa17): add authoritative economy engine + fut_profile importer
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.
2026-08-13 18:26:09 +00:00
funman300 41494bd18f feat(fifa17): port Store pack catalog + purchasegroup wire model (oracle parity)
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.
2026-08-13 18:16:47 +00:00
funman300 40ebf7c1e7 feat(fifa17): own UTAS auth/capability/purchasegroup session vertical in Rust
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.
2026-08-13 17:38:59 +00:00
funman300 c7609252d2 feat(fifa17): add Rust FUT session/capability state machine (empty My Packs)
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.
2026-08-13 16:52:13 +00:00
funman300 4cf388dd3b chore: reconcile openfut-launcher submodule
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.
2026-08-13 15:11:18 +00:00
funman300 00d85aa6e4 docs(fifa17): finalize capability deployment candidate
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.
2026-08-13 05:18:14 +00:00
funman300 d9e80a774a test(fifa17): add explicit per-SID topology-freeze regression
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.
2026-08-13 05:18:13 +00:00
funman300 a82407c686 docs(fifa17): harden patched-client session binding
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.
2026-08-13 04:39:53 +00:00
funman300 805d754dc8 fix(fifa17): isolate patched-client capability per session
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).
2026-08-13 04:39:53 +00:00
funman300 d4c3811665 docs(fifa17): document patched-client store negotiation
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.
2026-08-13 04:03:37 +00:00
funman300 b25761ea31 feat(fifa17): negotiate clean empty My Packs mode
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.
2026-08-13 04:03:37 +00:00
funman300 1c396dd562 feat(fifa17): report verified client patch capability
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.
2026-08-13 04:03:37 +00:00
funman300 fc29c2eb9b docs(fifa17): record no-sentinel client resolver proof
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.
2026-08-13 03:13:40 +00:00
funman300 b0d5e04bb9 fix(fifa17): guard missing store category resolution
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.
2026-08-13 03:13:39 +00:00
funman300 6746c75302 docs(fifa17): RE-backed native client-fix design for empty My Packs
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.
2026-08-13 01:48:58 +00:00
funman300 b2697b13dc docs(fifa17): establish verified card taxonomy
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.
2026-08-13 01:31:22 +00:00
funman300 e8ee6c34e7 docs(fifa17): record empty My Packs client contract
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).
2026-08-13 01:08:47 +00:00
funman300 f42279f869 fix(fifa17): keep empty My Packs group client-safe
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.
2026-08-13 01:08:37 +00:00
funman300 54ad9e8f79 docs(state): Slice 8 — real-data staged retail A/B PASS
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.
2026-08-12 21:37:42 +00:00
funman300 6f16a231fc fix(fifa17): implement rare=SP 'Special' club filter via rareflag
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.
2026-08-12 21:25:18 +00:00
funman300 626c972232 fix(fifa17): carry observed rareflag so special cards render as specials
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.
2026-08-12 21:13:51 +00:00
funman300 44fcf24d92 fix(import-fifa17): nation/league/team are instance metadata, not definition identity
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.
2026-08-12 20:58:33 +00:00
funman300 e187cd49a2 fix(fifa17): preserve versioned resourceId on the wire (no special->base collapse)
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.
2026-08-12 20:46:48 +00:00
funman300 1631d3b1a2 feat(import-fifa17): --apply — recoverable two-store real-profile import
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.
2026-08-12 20:36:34 +00:00
funman300 c71c2a8d33 feat(import): --emit-content (production pack + host catalog + private manifest)
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.
2026-08-12 19:41:17 +00:00
funman300 a51947562c feat(import): identity import API + FIFA17 real-profile dry-run importer
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.
2026-08-12 19:23:19 +00:00
funman300 63f02c4fb1 docs(state): Slice 7 — FUT squad read+write retail-validated on FIFA 17
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.
2026-08-12 18:41:24 +00:00
funman300 4fd5ee2608 redirector: a port probe must not forge the signature of a TLS fault
"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>
2026-08-12 17:25:21 +00:00
funman300 c7d4b9f753 style(adapter): rustfmt catalog test assertions
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.
2026-08-12 17:15:14 +00:00
funman300 37c2e5d7ee feat(utas-host): serve GET /squad/active from Core
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.
2026-08-12 17:10:04 +00:00
funman300 7dbd878398 test: update mutation-battery anchors after rustfmt reflow
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).
2026-08-12 04:01:37 +00:00
funman300 c2e2e0d8f2 style: rustfmt squad host + adapter files
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.
2026-08-12 03:59:15 +00:00
funman300 afc909fd3b fix(fixtures): sanitize lab subnet from committed UTAS captures
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.
2026-08-12 03:59:15 +00:00
funman300 b607ff28cb chore: bump openfut-core gitlink to rustfmt'd squad-ext routes (3084a46) 2026-08-12 03:58:51 +00:00
funman300 cf86d4e425 test(utas-host): squad host mutation battery (20 mutants, all killed)
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.
2026-08-12 03:11:25 +00:00
funman300 85761390a8 feat(utas-host): FIFA17 squad authority — PUT, /squad/list, userMassInfo overlay
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.
2026-08-12 03:04:24 +00:00
funman300 4d30d8b3e8 chore: bump openfut-core gitlink to squad-ext HTTP routes (9b2c6b8)
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.
2026-08-12 02:47:36 +00:00
funman300 0e30980632 style(adapter): elide redundant lifetime in squad projection test helper 2026-08-12 02:31:13 +00:00
funman300 46a81e7a07 test(adapter): squad mutation battery (14 mutants, all killed)
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.
2026-08-12 02:30:38 +00:00
funman300 e09344490f feat(adapter): single FIFA17 squad projector + fixture round-trip tests
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.
2026-08-12 02:28:05 +00:00
funman300 80a8bc4520 feat(adapter): FIFA17 squad extension v1 + full-replacement PUT builder
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.
2026-08-12 02:19:22 +00:00
funman300 b50e0359f7 feat(adapter): extract shared FIFA17 FUT item-shaping primitive
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).
2026-08-12 02:14:41 +00:00
funman300 58a300c7f4 core: game-scoped opaque squad extension + atomic fingerprint write (submodule 615c5fd) 2026-08-12 01:40:13 +00:00
funman300 eb8311a5ee evidence: FIFA17 squad controlled retail capture (swap/formation/relaunch) sanitized fixtures 2026-08-12 01:16:46 +00:00
funman300 550a59d12c feat(adapter): FIFA17 squad full-replacement wire parser + reverse-map scaffolding (unrouted) 2026-08-12 00:53:46 +00:00
funman300 8c1d1ed958 docs(mirror): /club RUNTIME VALIDATED on retail FIFA 17 2026-08-12 00:36:26 +00:00
funman300 fc00b0c6f9 docs(mirror): /club composition proven live (slice 5) 2026-08-11 23:05:29 +00:00
funman300 5276dd2066 feat(utas-host): send X-OpenFUT-Game to Core + end-to-end /club composition test 2026-08-11 23:00:08 +00:00
funman300 88da16a11e feat(fifa17): curated dev content pack generator + Core dev seed (submodule 36abd4b) 2026-08-11 22:57:02 +00:00
funman300 3ef3bc32ec feat(utas-host): real Fifa17IdentityResolver (catalog + store + policy), drop placeholders 2026-08-11 22:33:40 +00:00
funman300 36fe1caa3f feat(fifa17): deterministic base-card seed generator + full identity catalog
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.
2026-08-11 22:19:57 +00:00
funman300 f55c401b6c feat(fifa17): card-definition identity catalog + owned-item wire-id policy
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.
2026-08-11 21:59:50 +00:00
funman300 b8037b9b22 feat(identity): generic game-scoped external-identity store
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.
2026-08-11 21:57:15 +00:00
funman300 c0a3f68ded feat(utas): FIFA17 UTAS migration host + /club adapter mappings
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).
2026-08-11 21:40:15 +00:00
funman300 04c5043aba utas: My Squad filter corpus — every filter identified, root cause measured
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>
2026-08-11 19:32:57 +00:00
funman300 0b66662525 utas: first real corpus, and two sanitiser gaps the audit caught
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>
2026-08-11 18:33:59 +00:00
funman300 cdea85e214 utas: standalone recording proxy that tees rather than rebuilds
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>
2026-08-11 18:13:06 +00:00
funman300 05f6147433 http: extract the shared body drain; fix a flaky test race it exposed
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>
2026-08-11 18:04:08 +00:00
funman300 8f3b659c33 lifecycle: one host-lifecycle helper; roster.sh; ban pkill -f
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>
2026-08-11 17:45:15 +00:00
funman300 c9ae914910 roster-host: transport host for the FUT roster update, lifecycle-matched
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>
2026-08-11 17:41:11 +00:00
funman300 84e81f2037 tls: extract a shared listener; move FIFA 17's profile into its adapter
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>
2026-08-11 17:30:01 +00:00
funman300 696386a9c1 client_arm: verify the hosts entry by resolution, not by presence
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>
2026-08-11 17:19:52 +00:00
funman300 aa2679162d redirector.sh: never leave it ambiguous which binary is under test
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>
2026-08-11 16:20:16 +00:00
funman300 096d1c882f switch: fix the unquoted python string that broke status with no --name
`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>
2026-08-11 16:16:10 +00:00
funman300 ca63095786 lifecycle: stop the orphan check going blind when the binary is rebuilt
`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.
2026-08-11 05:25:33 +00:00
funman300 c65e9c54ce observe: correct a stale comment calling the catch-all 'other'
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.
2026-08-11 05:20:24 +00:00
funman300 b1bc7a764e adapter: port the FUT roster-update response, held to the live oracle's bytes
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.
2026-08-11 05:19:29 +00:00
funman300 d7c0a5521d switch: refuse to arm at a dead target; watchdog: use a pidfile
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).
2026-08-11 05:13:40 +00:00
funman300 2ae90b1ea9 tooling: watchdog that rolls an armed switch back when the service stops answering
`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.
2026-08-11 05:11:41 +00:00
funman300 cfb0435d96 redirector.sh: verify the RUNNING process's commit, not just the binary on disk
`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.
2026-08-11 05:03:47 +00:00
funman300 fc411bb6f1 scripts: require one certificate across the whole FIFA-facing TLS stack
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.
2026-08-11 04:38:23 +00:00
funman300 5bc39e902d tooling: observe client connection ATTEMPTS; make the build guard reject bad args
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).
2026-08-11 04:22:40 +00:00
funman300 e2c4ca6d56 redirector-host: reproduce the oracle's connection lifecycle, not just its bytes
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.
2026-08-11 04:12:32 +00:00
funman300 288d990821 empty commit to advance HEAD for the stale-binary mutation test 2026-08-11 03:46:08 +00:00
funman300 c03702707b redirector: commit stamp + shared build-identity verifier that REFUSES
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>
2026-08-11 03:46:07 +00:00
funman300 89f77470f3 redirector: Rust host on vendored OpenSSL; shared typed config extracted
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>
2026-08-11 03:28:04 +00:00
funman300 0d576a14b7 switch: one generic NAT implementation; blaze-switch becomes a wrapper
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>
2026-08-11 03:12:09 +00:00
funman300 c5807c07a9 blaze-host: passive ClientHello observer for the redirector TLS decision
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>
2026-08-11 03:04:13 +00:00
funman300 8f5f54833f ci: tripwire against lab addresses creeping back into tracked source
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>
2026-08-11 02:59:41 +00:00
funman300 f451406058 audit: eliminate deployment-address hardcoding; single typed endpoint config
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>
2026-08-11 02:55:50 +00:00
funman300 8aab2c0d41 adapter: FIFA 17 redirector response; Nucleus deliberately not ported
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>
2026-08-11 02:48:12 +00:00
funman300 ed0ccb8c2b blaze-host: check client sessions in BOTH network namespaces
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>
2026-08-11 02:34:46 +00:00
funman300 b40adac3fc gate-evidence: window FUT-action counts to the gate, not the whole log
'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>
2026-08-11 02:31:55 +00:00
funman300 bfb7876ed4 gate-evidence: count trace frames correctly, archive the raw capture, observe FUT actions
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>
2026-08-11 02:28:27 +00:00
funman300 55b4e54d5f gate-evidence: add the Python-side positive/negative observation
'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>
2026-08-11 02:21:21 +00:00
funman300 fafa2f1858 blaze-host: document capture, sanitization, and what the tests do not cover 2026-08-11 02:16:11 +00:00
funman300 c84fd14cac blaze-host: make the build stamp trustworthy for evidence attribution
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>
2026-08-11 02:15:11 +00:00
funman300 23374312bc blaze-host: opt-in raw frame capture + auditable sanitizer
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>
2026-08-11 02:14:25 +00:00
funman300 a84a72e0c0 blaze-host: A/B the RPC routes a real FIFA session actually used
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>
2026-08-11 02:05:18 +00:00
funman300 6c102f00c0 gitignore: gate-evidence bundles are run artefacts, not source
They contain live traces and logs from a specific run; they belong with the
run, not in the tree.
2026-08-11 02:01:18 +00:00
funman300 48aa955212 blaze-host: per-gate evidence capture, separating asserted from observed
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>
2026-08-11 02:00:31 +00:00
funman300 cf3ddde3a6 blaze-host: move the dirty-tree safeguard to launch and evidence time
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>
2026-08-11 01:56:43 +00:00
funman300 468b006008 blaze-host: scope the dirty-tree check to the crates the binary is built from
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>
2026-08-11 01:55:20 +00:00
funman300 e091921b18 blaze-host: safe sidecar lifecycle, Blaze switch, build identity
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>
2026-08-11 01:54:40 +00:00
funman300 a9eb54ae9c openfut-blaze-host: thin Blaze sidecar, live-parity with Python
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>
2026-08-11 01:42:23 +00:00
funman300 cf961603fe openfut-adapter-fifa17: FIFA 17 Blaze adapter, oracle-tested
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>
2026-08-11 01:18:29 +00:00
funman300 cc3ecddc06 openfut-protocol-blaze: pin advertise/bind so fixtures do not depend on the shell
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>
2026-08-11 00:59:36 +00:00
funman300 a9a816e0ed openfut-protocol-blaze: generic Blaze protocol layer, oracle-tested
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>
2026-08-11 00:53:59 +00:00
funman300 3153a93edf fifa17-recon: drop superseded docker-side tools/data copies
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).
2026-08-10 17:21:58 -07:00
funman300 f64106ed8b fifa17-recon: fix compose dockerfile path for relocated build context 2026-08-10 17:20:27 -07:00
funman300 9faaf12dd7 fifa17-recon: Docker build consumes authoritative tools via curated manifest
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.
2026-08-10 17:19:07 -07:00
funman300 83539e33ec fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
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).
2026-08-10 17:12:27 -07:00
funman300 695421cfd4 Merge remote-tracking branch 'origin/main' into fifa17-fut-squad-and-userinfo 2026-08-10 17:08:08 -07:00
funman300 8cba70dc90 fifa17-recon: reconcile authoritative tools with running backend (B)
- 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.
2026-08-10 17:08:06 -07:00
root 28773e7cf1 fifa17-python: sync tools to running container state
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).
2026-08-10 23:56:58 +00:00
root 3ae5587a38 docs: baseline manifest equivalence note (pycache + cert deltas expected) 2026-08-10 23:54:59 +00:00
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
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
funman300 622a774f6a chore: update openfut-launcher submodule to feat/sbc-hook-tracing branch
Tracks SBC hook tracing PR #1 for FIFA 17 reverse-engineering
2026-08-08 17:50:53 -07:00
funman300 cc694774a3 wip: checkpoint FIFA 17 SBC research for Windows migration 2026-08-07 12:03:22 -07:00
funman300 3d3239bab9 feat: document and stage FIFA 17 SBC hook workflow 2026-08-07 11:44:05 -07:00
funman300 a7e3e43ae9 fifa17-recon: the refusing modes have no server fix, and the hub-atom lead is cosmetic too
Completed the refusing-modes workflow (ground truth + 4 per-mode investigations +
adversarial verify each + synthesis). All four mode families -- Seasons, Draft,
SBC/Objectives, Tournaments -- are NOT_SERVER_REACHABLE, HIGH confidence, all four
adversarial refutations failed.

Live re-confirmed on pid 24653 (slide proven via FNV control): every named
mode-gating byte reads ENABLED=1 (IS_FRIENDLY_SEASON_ENABLED +0x1fd3a,
IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b, IS_DRAFT_MODE_ENABLED +0x1fd3d, plus the
unnamed offline-draft-enable +0x1fd3e) yet the tiles stay greyed.

The new lead this pass added -- do the six /hub mode sub-objects gate availability?
-- is refuted: friendlySeason/offlineSeason/onlineSeason/draftSummary/tournament/
tournamentProgress carry only stats and display strings, no enabled/available/
unlocked atom. They are cosmetic, exactly like hub.tradePile. The one
server-writable input that exists (friendlySeasonsEnabled -> +0x1fd3a via applier
FUN_18011dc50) has its sole reader in the packed FIFA17.exe front-end via a vtable
getter with no CardsDLL caller, and it is already 1. The refusal is decided in the
Denuvo-packed Frostbite front-end, which has no server surface.

docs/plan-2026-08-06-refusing-modes.md: full evidence chains, gate-byte table, the
six sub-deser field maps, per-mode verdicts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
2026-08-06 18:59:23 -07:00
funman300 31fc590b99 fifa17-recon: the FUT-hub Transfer List tile counts, and the hub parser is NOT reflection
The Transfer List hub tile read "0 items / Selling 0" while a card was actively
listed. Enumerating the /hub parser FUN_180139610 straight from the on-disk
CardsDLL (objdump) refutes the old ENDPOINT_MAP claim that it uses C++ reflection
with "no atom ladder, nothing to enumerate": it has an ordinary running-sum atom
ladder reading 18 atoms. The tile is fed by hub.tradePile (0x333), a nested object
(sub-deser 0x18013ead0) reading count/selling/sold as scalar ints -- the same
scheme as GetAuctionCount, so serving it in the hub body is freeze-safe. The tile
never re-polls the standalone /tradePile/counts, which is why fixing that endpoint
alone did not move the tile.

Also: the hub tile polls LOWERCASE tradepile/counts while the Transfer List screen
uses camelCase tradePile; our case-sensitive routes matched only the screen, so the
tile's counts call fell through to /trade and got a shape the counts deser skips.
Made the tradePile routes case-insensitive.

And bake the proven transfer-market flags (FUT_TRADING/PILESIZES/TRADEABLE/
DISCARD_TABLE/DISCARD_SEND) into openfut-fut.sh so a plain `start` brings up the
working state instead of regressing trading to greyed-out.

- tools/utas_server.py: hub_data() serves tradePile:{count,selling,sold};
  tradePile routes now re.I
- tools/openfut-fut.sh: utas launched with the working flag set
- docs/ENDPOINT_MAP.md: full 18-atom hub map + tile map, correction of the
  reflection claim
- tools/ghidra_queries/objdump_atom_ladder.py: the objdump-based atom-ladder
  decoder used to derive the above

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
2026-08-06 18:28:52 -07:00
funman300 245c22161b fifa17-recon: correct tradePile/counts shape, and narrow the marketdata array fix
Two follow-ups on the working transfer market.

1. GET /tradePile/counts now returns the FutGetAuctionCount shape
   ({count, maxAuctionsAllowed, offered, selling, sold}, all scalar ints, atoms
   0xbc/0x1bf/0x1e5/0x2b8/0x2c9) via a dedicated route ordered before /tradePile.
   Previously it fell through to tradepile_route and got the auction-LIST body, which
   the counts deser skips, leaving every tally at its constructor default. Survivable
   but wrong; the doc flags the loaded byte at +0x28 as gating a completion-handler
   branch. selling reflects real STORE.listings().

2. Narrowed the marketdata bare-array fix to /pricelimits only. The client sends TWO
   marketdata requests: /marketdata/pricelimits (GetSuggestedPricing, a bare array,
   the thing that froze) and plain /marketdata?defId=N (price comparison, an OBJECT).
   The prior commit returned the array for both, which the contract suite caught
   (test_market_bodies: 'list' has no attribute get) -- plain /marketdata wants
   {minPrice,maxPrice} and was never the freeze. Returning the array for it would be
   the same desync in reverse. Now: pricelimits -> array, plain marketdata -> object.

The contract suite catching my over-broadened fix before it reached the game is the
suite doing its job. 439 contract checks pass, market unit suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:39:38 -07:00
funman300 43557989f5 fifa17-recon: the transfer market works -- listed a card end to end, no freeze
The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.

1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
   not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
   q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
   the massinfo END_OBJECT the client runs
     cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
   feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
   and unconditionally, which is why the gate read 0 all day regardless of /settings or
   the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
   0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).

2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
   0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
   Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
   (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
   code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
   key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.

3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
   OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
   (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
   0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
   Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
   0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
   requested defId. Live: price screen opened and Submit succeeded.

Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.

Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.

Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).

Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:33:10 -07:00
funman300 a3fd51692f fifa17-recon: the trading gate is still shut, and two of yesterday's conclusions were wrong
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.

REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.

REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.

AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
    settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
      -> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.

The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.

Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
  vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e        vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
  vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
  vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d      vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
  vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40  vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.

WHAT IS ACTUALLY SHIPPED HERE, all default off:
  * FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
    INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
    in _item(), because the save holds 246 items minted before the flag existed and the
    club route serves them straight from the save. That gap was caught by reading the
    served JSON, not by unit-testing the factory.
  * FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
    only as a record of the refutation, with the reasoning inline so nobody retries it.
  * fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
    not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
    block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
    preferred could never have answered this for three of five families.
  * Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
    (which was never bisected; value is the established suspect and that response also
    carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
    never copied into the record.

Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:47:02 -07:00
funman300 e578443d73 fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in
docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I
committed earlier today.

THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on
Transfer Market" have been disabled in the reveal screen and nobody knew why.
TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at
vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is
the tradingEnabled gate byte. Read live and reproduced independently:

  slot +0x2b0 friendlySeasons  disp 0x1fd3a  VALUE=1
  slot +0x2c8 draftMode        disp 0x1fd3d  VALUE=1
  slot +0x2e0 packOpeningAnim  disp 0x1fd45  VALUE=1
  slot +0x270 tradingEnabled   disp 0x1fd2e  VALUE=0

tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates
the settings work from this morning: that plan died because every gate it targeted
already read 1, and the conclusion drawn was that the settings array does not matter.
It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in
_SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off.

So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping
only the boolean would look like the finding failed.

THE DISCARD "MISS" NEVER EXISTED, which corrects e3092ca. fcc_discardcoins is resident
and complete, the client lookup runs and is correct, and it lands at item+0x3c. The
tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the
client was not failing a lookup; it was faithfully displaying the 0 we sent. Same
observable, completely different mechanism, and the version in e3092ca is wrong.
FUT_DISCARD_SEND remains exactly the right fix, now for the right reason.

WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the
table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own
+0x3c reads 36. That closes the gap I flagged in e3092ca as not-guessed.

CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9,
stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination.
All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in
the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have
answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent
a launch and returned nothing for three of five families.

THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it
lands on and whether the client keeps it. Built by diffing what we serve against the
parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer
rather than by assuming the +0x38 offset). Corrections that change what we serve:
+0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is
itemState, definitionId is not an atom at all.

A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at
+0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the
control was the wrong FORM. That is a new variant of the absence trap, which has now
cost six wrong verdicts, and it is recorded in the doc.

FIX TO MY OWN PATCH from e3092ca: purchased() and last_pack() lacked the _with_discard
wrapper that items() had, so the pending pile, which is the one place a quick-sell
value is actually read, served unstamped cards. Found by verification, not testing.
All three read paths now stamp.

Correcting an overstatement in e3092ca: "turning the flag off is a true revert" holds
for the read paths, which copy, but NOT for cards minted while armed, because _item()
stamps at creation and those persist (9 items currently). Kept deliberately: the pack
reveal serves itemList straight from open_pack(), not through purchased(), so removing
creation-stamping would leave the screen that matters unstamped. Persisted values are
correct and self-heal, since every read recomputes and overwrites.

Nothing here has been on screen. Six patches are proposed in the doc as pasteable text,
env-flagged, defaulting off, none applied.

Live: 439 contract checks, 414 card-family checks, market suite, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:07:01 -07:00
funman300 e3092ca0f9 fifa17-recon: the client now shows the quick-sell value it is actually paid
Follow-on to 21a81ad, both halves confirmed live.

FUT_DISCARD_TABLE IS PROVEN. Quick selling a 75-rated rare gold paid 600 and the
balance moved 9,844,900 -> 9,845,500, exact. The invented tier would have paid 150.
75 * 800 / 100 = 600, straight off the recovered fcc_discardcoins row.

BUT THE SCREEN SAID 0, which is why this commit exists. The value was right and
invisible: "Quick Sell 0" on the card and "Quick Sell all remaining Items 0" too, so
the wallet contradicted the display on every card. Cause is the guard the table work
had already reversed. FUN_18013fe00 stores our discardValue (atom 0xd7) at item +0x38
and 0x180141025 skips the client's own fcc_discardcoins lookup only when that value is
NON-ZERO. We seeded 0, so the client ran its own lookup, that lookup returns no row for
our cards, and it rendered 0.

FUT_DISCARD_SEND puts the value on the wire and the client uses ours verbatim. Live
result, one launch:
    a 77-rated rare gold shows "Quick Sell 616"   (77 * 800 / 100 = 616, exact)
    "Quick Sell all remaining Items" shows 5,640
and 5,640 is exactly the sum of the ten rated PLAYER cards in the pending pile. The
eleventh, a consumable, is excluded by the client from the bulk figure; the twelfth is
a staff card we deliberately send no value for. Both halves of the display now agree
with what the server credits.

WHY THE CLIENT'S OWN LOOKUP MISSES for our cards is still UNKNOWN. This routes around
that question rather than answering it, and it is worth answering.

TWO CORRECTNESS FIXES THE REPORT DID NOT COVER, both found by running the whole save
through the formula rather than trusting the 22/22 sample:
  * The recovered formula scales by rating, so a rating-less STAFF card collapses to 0.
    The old tier paid 50, so shipping it as-is was a regression. discard_value() now
    returns None when the formula does not apply and callers fall back. What FUT really
    pays for staff and consumables is UNKNOWN; the likely answer is the unscaled table
    price, but that is a guess and is not shipped as one.
  * A missing table row also returns None rather than 0, for the same reason.
  Verified across all 246 club items plus the pending pile: not one pays 0 coins.

discardValue is stamped inside the single item factory so every path gets it (pack
contents, starter grant, club, market), and additionally on the club READ path, because
_item() only covers cards minted from now on while the save already holds 246 built
before the flag existed. Stamped on the way out and NOT persisted, so the save stays
clean and turning the flag off is a true revert. Confirmed: 0 items on disk carry the
key.

FUT_DISCARD_SEND requires FUT_DISCARD_TABLE and silently stays off without it, so the
invented tier can never reach the screen and become authoritative-looking.

Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
scalar getter 0x1801c79d0; every freeze on this project has come from an object or
array where a scalar was expected, never the reverse.

Both flags still default OFF. Live: 439 contract checks pass, market suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:55:05 -07:00
funman300 21a81ad63c fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.

THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:

    value = round_half_up(rating * price / 100)
    level    = 3 if rating >= 75, 2 if 65..74, else 1   (0x180141e8a..0x180141ea3,
               derived from rating, NOT a wire field)
    cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
               across every subtype 0..599 with zero disagreements

A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.

This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.

Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.

ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.

THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.

The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.

extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.

A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.

Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.

Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.

Live: 439 contract checks pass, market suite passes, both flags off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:43:51 -07:00
funman300 3f3d5704a7 fifa17-recon: quick sell has never been reachable, and finalFunds is the rendered price
Live run, 2026-08-05 evening. Three results, one of them a route that has been dead for
the whole life of the project.

QUICK SELL WAS NEVER SERVED. Captured on the wire:
    DELETE /ut/game/fifa17/item/100000240      (single card, id in the URL, no body)
ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, ROUTES was built from the
doc, and the regex therefore never matched a real quick sell. Every quick sell fell
through to the catch-all, so quick_sell_route() and STORE.quick_sell() behind it had
never once been called.

The empty response was not a harmless no-op. This body is BALANCE-BEARING: the client
takes its coin total from it, so with totalCredits absent it rendered an uninitialised
value. A real session showed 1,133,686,384 coins against a true balance of 9,889,600.
Display artifact only, corrected by the next GET /user/credits, and the save was never
touched, but it is the reason a stub is not acceptable here.

quick_sell_url_route() serves the real form and returns the corrected shape,
{"items":[{"id":N}],"totalCredits":N}. DEFAULT ON, which the house rule now permits:
two quick sells fired through it in one session, each credited 150 and removed the
card, and the coin arithmetic reconciled exactly against the 15,000 pack purchases
either side of them. An unknown id returns {"items": []} rather than claiming a sale we
cannot account for.

Still UNKNOWN and deliberately not chased: whether the client ASSIGNS totalCredits as
the new balance or ADDS it as a delta. We send the new balance. The discriminating
window is about five seconds wide, because the client refetches GET /user/credits
straight afterwards, so either reading self-corrects and the practical impact is a brief
wrong number. It mattered only while we answered {}, because that garbage persisted.
The credit amount is STORE.quick_sell()'s invented rating tier, not FUT's real discard
table, which remains unknown.

finalFunds IS THE RENDERED COIN PRICE. Served funds=15000 / finalFunds=4321 on one pack
and the tile read 4,321. funds is not displayed. ENDPOINT_MAP updated to CONFIRMED LIVE
with the method recorded. FUT_PRICE_PROBE, the flag that produced it, stays default off
and is disarmed: it puts a price on a tile that the buy path does not charge.

TWO UNPLANNED FINDINGS, both recorded for the next round rather than fixed here:
  * The store grouping is broken and it is NOT cosmetic. All three packs collapse into
    one display group, and the Bronze, Gold and Premium group tiles all drill into the
    same single Premium Gold pack, so TWO OF THREE PACKS CANNOT BE BOUGHT. We send
    displayGroup {"value": name} but never displayGroupAssetId (0xda), so everything
    lands in group 0. The docs had this parked as a cosmetic "tiles read unknown"
    issue; it is an availability bug.
  * The FIFA Points price renders as the literal "or %1s", an unsubstituted printf
    placeholder. extPrice.finalPrice is served as {"amount":N,"currency":"mtx"} and
    "mtx" is evidently not a currency token the client resolves. Cosmetic.

Corrected in passing: packContentInfo DOES reach the tile (11 ITEMS / 11 GOLD /
11 RARES against exactly what we serve). An earlier screen showing zeros was the
display-GROUP level, which carries no content info. D3 was right.

Live: 439 contract checks pass, both probe flags off, store prices back to honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:03:41 -07:00
funman300 89da7b7609 fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.

THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.

The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.

Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.

HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.

Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.

TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.

ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
  * duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
    list at :1095. Bare ints where the element parser expects objects is a tokenizer
    desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id.

No behaviour change. utas_server.py is comment-only. 439 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:35:27 -07:00
funman300 1605e6effd fifa17-recon: the envelope rule, and the one key of the auth body that is silently eaten
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.

TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):

  9  START_OBJECT      case 0x64, pushes state 2
  10 END_OBJECT        case 0x65, pops state 2
  11 FIELD_NAME        confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
                       atom-hashes the string at +0xf8
  12 START_ARRAY       case 0x66, pushes state 3
  13 END_ARRAY         case 0x67, pops state 3
  1  error             the caseD_78 sink

So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.

The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.

THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.

The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.

No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.

Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:25:01 -07:00
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:37 -07:00
funman300 d0dbfa99c0 fifa17-recon: the /settings gate bytes were never zero, and the plan built on that is dead
Yesterday's settings-gate plan asserted that IS_FRIENDLY_SEASON_ENABLED and
IS_DRAFT_MODE_ENABLED "have never been set to true by anything, on any run", and
proposed spending a launch on that premise. Measured against the running client,
both read 1, and so does packOpeningAnimationEnabled, while /settings has only ever
been answered {"configs": []}.

  disp 0x1fd3a (friendlySeasonsEnabled)      value = 1
  disp 0x1fd3d (enableDraftMode)             value = 1
  disp 0x1fd45 (packOpeningAnimationEnabled) value = 1

Reproduced on two separate launches and two different pids.

Where the reasoning went wrong: the finding that FUN_18011dc50 is the only writer of
those bytes, and that the FutDataManagerImpl constructor never touches them, was
correct. The inference was not. The applier runs whether or not the configs array has
content, and the struct it is handed defaults these fields to 1, so the bytes were
being written all along. "Nothing populates the array" was treated as "nothing writes
the byte". Only the first of those was ever established.

Seasons therefore does not refuse because its gate byte is false. Its gate byte is
true. That diagnosis restarts, and the live test in section 3 should not be run as
written. The doc keeps the wrong turn on the record rather than quietly deleting it.

tools/gate_byte_probe.py makes this repeatable instead of a one-off. It is read-only
(O_RDONLY + pread), resolves the pid by comm, re-derives the CardsDLL slide from
/proc/<pid>/maps rather than caching it across launches, proves the slide against the
FNV prologue at 0x180180d00 read from the on-disk PE before trusting any address, and
decodes each gate displacement out of its accessor stub (0f b6 81 <disp32>) rather
than reading it from a table. Needs the client at the FUT hub, since CardsDLL loads
only then.

Also carries the two /settings changes that were pending from before: the mode
defaults to `off` (the live-proven baseline, since nothing here has faced the game)
and the transfer-pile probe is 77 rather than 100, because 100 is a stock-looking
number that would prove nothing if it showed up in game.

Live: 439 contract checks pass. check_settings_flags.py passes in all four modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:02 -07:00
funman300 897259c8fb fifa17-recon: the /settings 42-flag gate, and why Seasons never asks
ENDPOINT_MAP said this class reads one key, `configs`, and that was true and
useless. What it missed is what happens after each element closes: the client
feeds the STRING VALUE of `type` back through the atom hasher and switches on
the result, 42 arms wide. A flag is a row, not a key, and the client hashes our
string itself.

Followed it to the end. FUN_18011dc50 is the only writer of the IS_* UI gate
bytes inside FutDataManagerImpl, every line is `byte = (field == 1)`, and the
constructor never touches those bytes. So a flag nobody sends is a gate nobody
opens. friendlySeasonsEnabled and enableDraftMode have never been sent by
anything, which is a mechanism for Seasons refusing while making zero requests
to any of the four servers.

The store is the control that makes this readable: IS_STORE_ENABLED is the same
kind of byte and its screen works, because storeEnabled and friends already
ship through the Blaze config store. That list has no seasons or draft flag.

Ship the gates behind FUT_SETTINGS (off/keep/gates, default gates), and
re-assert the working store flags in the same array on purpose: once a
populated array makes the applier run, it writes EVERY gate byte, so omitting
them could switch off a screen that works today.

maximumTradePileSize=100 rides along as a positive control, because a boolean
that changes nothing cannot distinguish "the flag did not help" from "the array
never reached the consumer".

check_settings_flags.py asserts each shipped name against the atom table AND
the recovered switch, since a misnamed flag is silently inert and looks exactly
like a failed fix. enableSquadBuildingSetsFeature is the reason both checks are
needed: a real atom with no arm here.

Live: 439 contract checks pass, market unit suite passes.
Not yet tested in game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:11:20 -07:00
616 changed files with 177720 additions and 1258 deletions
+9
View File
@@ -27,3 +27,12 @@ __pycache__/
# OS
.DS_Store
Thumbs.db
# Frozen baseline archives / inspects / manifests
/docker-backups/
gate-evidence/
# Raw Fire2 frame captures — forensic evidence, may contain session material.
# Sanitize with `blaze-sanitize` before anything leaves this machine.
*.ofcap
captures/
Generated
+6639
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
[workspace]
resolver = "2"
members = [
"openfut-core",
"openfut-protocol-blaze",
"openfut-adapter-fifa17",
"openfut-blaze-host",
"openfut-host-config",
"openfut-http",
"openfut-tls",
"openfut-redirector-host",
"openfut-roster-host",
"openfut-utas-host",
"openfut-identity",
"openfut-import-fifa17",
"openfut-bridge",
"openfut-launcher",
# The two companion services the launcher used to shell out to Python for.
"openfut-lsx",
"openfut-autopatch",
"fifa-blaze/crates/blaze-proto",
"fifa-blaze/crates/server",
]
# openfut-hook is a Windows-only version.dll proxy injected into the FIFA client.
# It MUST build with its own [profile.release] (panic="abort" — unwinding across
# the DllMain/FFI boundary into the game process is UB — plus strip + opt-level="s").
# Cargo ignores a non-root member's profile and forbids per-package `panic` overrides,
# so the hook is deliberately EXCLUDED from this workspace to build as its own root
# (this also lands its artifact in openfut-hook/target/, matching the launcher's
# config.rs default hook_dll_path). Build: cargo build --release --target x86_64-pc-windows-gnu.
exclude = ["openfut-launcher/openfut-hook"]
+340
View File
@@ -0,0 +1,340 @@
{
"metadata": {
"reportDate": "2026-07-28",
"codebaseName": "OpenFUT",
"version": "0.1.0",
"submodulesCovered": [
"openfut-core",
"openfut-bridge",
"openfut-launcher"
],
"language": "Rust",
"framework": "Axum + SQLite"
},
"vulnerabilities": [
{
"severity": "critical",
"category": "authentication",
"file": "openfut-core/src/services/profile.rs",
"line": 8,
"cwe": "CWE-287",
"title": "Missing Authentication on All Endpoints",
"description": "No authentication or authorization checks on any API endpoint. The system uses single-profile design with get_active_profile() returning the first row (LIMIT 1) without any token validation, session management, or per-user isolation. In a networked context, any HTTP client can access all endpoints without credentials.",
"impact": "Complete compromise of data confidentiality and integrity. Any attacker can view, modify, or delete all user data without authentication.",
"exploitPath": "curl http://127.0.0.1:8080/clubs - accesses club data without any auth headers or tokens",
"recommendation": "Implement stateless JWT tokens or session-based authentication. Add middleware to validate tokens on all endpoints. Implement per-user authorization checks in services."
},
{
"severity": "critical",
"category": "injection",
"file": "openfut-core/src/routes/auth.rs",
"line": 87,
"cwe": "CWE-89",
"title": "SQL Injection via String Interpolation",
"description": "SQL table names are interpolated using string formatting: sqlx::query(&format!(\"DELETE FROM {table}\")). Although currently hardcoded in a loop, this violates parameterized query principles and creates a risk if the table list ever becomes user-controlled or the pattern is copied elsewhere.",
"impact": "Potential remote code execution via database manipulation. If extended to user input, attackers could modify arbitrary tables or drop the database.",
"exploitPath": "Currently mitigated by hardcoded table names, but the pattern is dangerous and violates secure coding practices.",
"recommendation": "Use SQLx's dynamic query builders or identifier types that properly escape table/column names. Replace format! string interpolation with sqlx::query_builder for dynamic identifiers."
},
{
"severity": "high",
"category": "configuration",
"file": "openfut-bridge/src/proxy.rs",
"line": 44,
"cwe": "CWE-295",
"title": "TLS Certificate Validation Disabled",
"description": "HTTP client explicitly disables TLS certificate validation: .danger_accept_invalid_certs(true). This bypasses all certificate pinning, expiration, and hostname verification, making the bridge vulnerable to man-in-the-middle attacks.",
"impact": "Attacker positioned between bridge and upstream can intercept, modify, or read all traffic. Compromises confidentiality and integrity of requests to Core and external services.",
"exploitPath": "MITM attack between openfut-bridge and openfut-core or upstream services. ARP spoofing on localhost subnet would redirect traffic.",
"recommendation": "Remove .danger_accept_invalid_certs(true) in production. If testing requires it, gate behind a development-only environment variable with strong warning. Use proper certificate management (CA bundles, cert pinning)."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-core/src/services/season.rs",
"line": 23,
"cwe": "CWE-248",
"title": "Unguarded expect() Causes Denial of Service",
"description": "Multiple unchecked expect() calls that will panic and crash the server if database queries fail or return unexpected results: Ok(fetch(pool, profile_id).await?.expect(\"just inserted\"))",
"impact": "Denial of service. A single database inconsistency or race condition crashes the entire server, making the application unavailable.",
"exploitPath": "Trigger race conditions during concurrent requests (e.g., rapid profile deletion + season fetch). Database corruption or migration failure crashes the service immediately.",
"recommendation": "Replace expect() with proper error handling (Result types, error logging, graceful degradation). Handle database query failures without panicking. Add integration tests for race conditions."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-core/src/services/season.rs",
"line": 69,
"cwe": "CWE-248",
"title": "Unguarded expect() in season fetch",
"description": "let season = fetch(pool, profile_id).await?.expect(\"season must exist\"); Panics if season is not found.",
"impact": "Server crash on missing or deleted season records.",
"exploitPath": "Delete a season via concurrent requests, then call /seasons endpoint. Server panics.",
"recommendation": "Return proper error (AppError::NotFound) instead of panicking."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-core/src/services/season.rs",
"line": 144,
"cwe": "CWE-248",
"title": "Unguarded expect() in season update",
"description": "let updated = fetch(pool, profile_id).await?.expect(\"season must exist\");",
"impact": "Server crash on concurrent season modifications.",
"exploitPath": "Rapid concurrent season updates that fail race conditions.",
"recommendation": "Handle missing records gracefully."
},
{
"severity": "high",
"category": "cors",
"file": "openfut-core/src/app.rs",
"line": 257,
"cwe": "CWE-346",
"title": "Permissive CORS Configuration Allows All Origins",
"description": ".layer(CorsLayer::permissive()) enables CORS for all origins (*), methods, and headers. Any website can make cross-origin requests to the API and access/modify data.",
"impact": "Cross-site request forgery (CSRF) attacks. Malicious websites can issue API requests on behalf of users. Data exfiltration via JavaScript from any origin.",
"exploitPath": "Attacker website:\n <img src=\"http://127.0.0.1:8080/clubs\" />\n Fetch API calls to delete profiles, modify squads, etc.",
"recommendation": "Restrict CORS to specific origins (e.g., localhost:3000 for web UI, or the game process if exposed). Use CorsLayer::very_restrictive() as default and explicitly allowlist origins."
},
{
"severity": "high",
"category": "dos",
"file": "openfut-bridge/src/proxy.rs",
"line": 47,
"cwe": "CWE-248",
"title": "HTTP Client Construction Panic",
"description": ".expect(\"failed to build HTTP client\") will panic if the HTTP client fails to initialize, crashing the entire proxy service on startup.",
"impact": "Service unavailability. Bridge cannot start if HTTP client configuration is invalid.",
"exploitPath": "Invalid system configuration or missing TLS libraries causes HTTP client build to fail, crashing bridge during startup.",
"recommendation": "Return Result<ProxyState, Error> from new() and handle construction errors. Use anyhow::Context for better error messages."
},
{
"severity": "medium",
"category": "information-disclosure",
"file": "openfut-core/src/error.rs",
"line": 54,
"cwe": "CWE-209",
"title": "Error Messages Leak Implementation Details",
"description": "JSON parsing errors are returned directly to clients: format!(\"json parse error: {e}\"). Exposes serde_json parser internals and syntax details useful for crafting attacks.",
"impact": "Information disclosure. Attackers learn the JSON parser implementation and can tailor payloads to bypass validation or find parser-specific quirks.",
"exploitPath": "Send malformed JSON to any endpoint. Response includes parser error details (e.g., 'expected `,` at line 2 col 5') that aid in crafting exploits.",
"recommendation": "Return generic error message to clients: 'invalid request format'. Log detailed errors internally with tracing for debugging."
},
{
"severity": "medium",
"category": "information-disclosure",
"file": "openfut-core/src/error.rs",
"line": 40,
"cwe": "CWE-215",
"title": "Database Errors Logged with Full Details",
"description": "Database errors are logged with full SQL/query details: tracing::error!(\"Database error: {e}\"). If logs are exposed or compromised, schema, query patterns, and data structure are revealed.",
"impact": "Information disclosure in logs. Compromised log files expose database schema and query logic useful for SQL injection or data exfiltration planning.",
"exploitPath": "Access server logs (via log aggregation service, file access, etc.) and extract database schema and query patterns.",
"recommendation": "Log only error type and ID to clients. Sanitize logs before exporting. Use structured logging with field masking for queries."
},
{
"severity": "medium",
"category": "input-validation",
"file": "openfut-core/src/routes/auth.rs",
"line": 17,
"cwe": "CWE-1025",
"title": "Hardcoded Default Credentials",
"description": "Default username 'Player 1' is hardcoded with no unique identifier enforcement. Multiple profiles can be created with identical usernames, and weak defaults are used.",
"impact": "Weak account creation, potential for account confusion or conflicts. No strong identity guarantees.",
"exploitPath": "Multiple users create profiles with default 'Player 1' username. No way to distinguish profiles programmatically.",
"recommendation": "Require explicit username on profile creation. Use UUIDs as primary identifiers. Validate username uniqueness and minimum length."
},
{
"severity": "medium",
"category": "input-validation",
"file": "openfut-core/src/services/",
"line": 0,
"cwe": "CWE-400",
"title": "Missing Input Length Validation",
"description": "No maximum length checks on string fields (usernames, club names, squad names, etc.). Large inputs can cause database bloat, memory exhaustion, or DoS.",
"impact": "Denial of service via large payloads. Database bloat. Memory exhaustion. While DefaultBodyLimit::max(256KB) provides some protection, field-level validation is missing.",
"exploitPath": "POST /auth/local with username = 256KB string. Database receives bloated data. Repeated calls exhaust storage.",
"recommendation": "Add input validation for all user-submitted strings. Set maximum lengths (e.g., username: 50 chars, club name: 100 chars). Validate at route handler level."
},
{
"severity": "medium",
"category": "configuration",
"file": "openfut-core/src/db.rs",
"line": 13,
"cwe": "CWE-315",
"title": "Unencrypted SQLite Database on Disk",
"description": "SQLite database file (openfut.db) is stored unencrypted on disk. All user data, profiles, squads, cards, etc., are readable by anyone with filesystem access.",
"impact": "Data breach if server filesystem is compromised. No protection against:local file access, stolen backups, forensic recovery.",
"exploitPath": "Attacker gains filesystem access (compromised server, stolen disk). Reads openfut.db directly. All game data is readable without authentication.",
"recommendation": "Use SQLite encryption (e.g., sqlcipher crate) or migrate to PostgreSQL with TLS. Implement file-level encryption. Use restrictive filesystem permissions (0600)."
},
{
"severity": "medium",
"category": "rate-limiting",
"file": "openfut-core/src/app.rs",
"line": 0,
"cwe": "CWE-770",
"title": "No Rate Limiting on Endpoints",
"description": "No per-IP or per-user rate limiting. Endpoints like POST /auth/reset can be called repeatedly without restriction, allowing attackers to repeatedly wipe all data.",
"impact": "Denial of service and data destruction. Attacker can spam /auth/reset to destroy user data or exhaust server resources.",
"exploitPath": "for i in 1..1000: POST /auth/reset with confirm='reset'. All data wiped repeatedly.",
"recommendation": "Implement rate limiting middleware using tower_governor or similar. Add per-IP limits (e.g., 10 requests/min) and per-endpoint limits. Use exponential backoff."
},
{
"severity": "low",
"category": "audit-logging",
"file": "openfut-core/src/services/",
"line": 0,
"cwe": "CWE-778",
"title": "Missing Audit Logging",
"description": "No audit trail of user actions (profile creation, data deletion, squad modifications). Cannot detect unauthorized access, data tampering, or compliance violations.",
"impact": "Incident response and forensics are impossible. Cannot determine who did what and when. Compliance risks (GDPR, etc.).",
"exploitPath": "Attacker deletes all profiles, modifies squads. No audit log shows what happened or who did it.",
"recommendation": "Add audit logging for all data mutations. Log: timestamp, user (profile) ID, action, resource affected, before/after state. Store in separate immutable table."
},
{
"severity": "low",
"category": "dependencies",
"file": "openfut-bridge/Cargo.toml",
"line": 0,
"cwe": "CWE-1035",
"title": "Older Dependency Versions (reqwest, rustls)",
"description": "openfut-bridge uses reqwest 0.11 (latest is 0.12) and rustls 0.21 (latest is 0.23). Intentional for version matching, but creates a larger surface area for known CVEs.",
"impact": "Potential vulnerabilities in older dependencies. Delayed access to security patches.",
"exploitPath": "Known CVE in reqwest 0.11 or rustls 0.21 could be exploited. Combined with danger_accept_invalid_certs, TLS bypass becomes easier.",
"recommendation": "Upgrade dependencies to latest versions when possible. Monitor CVE databases (CVE, RustSec) for the versions in use. Pin versions and set up automated dependency updates."
},
{
"severity": "low",
"category": "error-handling",
"file": "openfut-core/src/app.rs",
"line": 256,
"cwe": "CWE-248",
"title": "Body Size Limit Without Per-Field Validation",
"description": "DefaultBodyLimit::max(256KB) limits the entire request body, but individual fields are not validated. A single large field can consume most of the limit.",
"impact": "Mild DoS. Large field values cause database bloat. Not a critical issue due to body limit, but field-level validation would be better.",
"exploitPath": "POST /auth/local with 250KB club_name field. Database receives bloated data.",
"recommendation": "Add per-field validation in addition to body limits. Validate and sanitize fields before database insertion."
}
],
"riskScore": 82,
"riskCategory": "CRITICAL",
"riskSummary": "OpenFUT has critical security issues that would make it unsafe for production or networked deployment. The most severe are the complete absence of authentication/authorization and the SQL injection pattern in the auth.rs module. The system is designed as single-player (single-profile) with no multi-tenant isolation, which is dangerous if exposed to the network.",
"recommendations": [
{
"priority": "CRITICAL",
"area": "Authentication & Authorization",
"recommendation": "Implement JWT-based or session-based authentication on all endpoints. Add middleware to validate auth tokens on every request. Implement per-profile authorization checks. Currently any HTTP client can access all endpoints.",
"effort": "High",
"impact": "Blocks all data breaches from unauthenticated access"
},
{
"priority": "CRITICAL",
"area": "SQL Injection Prevention",
"recommendation": "Replace sqlx::query(&format!(...)) in auth.rs:87 with proper parameterized identifiers. Use sqlx::query_builder for dynamic table/column names instead of string interpolation.",
"effort": "Low",
"impact": "Prevents SQL injection even if pattern is copied to user input"
},
{
"priority": "HIGH",
"area": "TLS & Transport Security",
"recommendation": "Remove .danger_accept_invalid_certs(true) from proxy.rs:44. If development requires it, gate behind an environment variable (e.g., DEV_SKIP_TLS_VERIFICATION) with strong warnings in logs.",
"effort": "Low",
"impact": "Prevents MITM attacks on bridge-to-core communication"
},
{
"priority": "HIGH",
"area": "Error Handling",
"recommendation": "Replace all expect() calls with proper Result handling. Use anyhow::Context or custom error types. Add logging for debugging but return generic errors to clients.",
"effort": "Medium",
"impact": "Prevents DoS via server panics"
},
{
"priority": "HIGH",
"area": "CORS",
"recommendation": "Replace CorsLayer::permissive() with CorsLayer::very_restrictive() or explicit allowlist. For single-player use, restrict to localhost and the game process only.",
"effort": "Low",
"impact": "Prevents CSRF and cross-origin attacks"
},
{
"priority": "HIGH",
"area": "Rate Limiting",
"recommendation": "Add per-IP rate limiting using tower_governor or similar. Implement limits on destructive endpoints (e.g., POST /auth/reset: 1 request per hour per IP).",
"effort": "Medium",
"impact": "Prevents DoS and repeated data destruction"
},
{
"priority": "MEDIUM",
"area": "Input Validation",
"recommendation": "Add maximum length validation for all string fields (username, club_name, squad_name, etc.). Enforce at route handler level. Example: username max 50 chars, club_name max 100 chars.",
"effort": "Medium",
"impact": "Prevents database bloat and data validation failures"
},
{
"priority": "MEDIUM",
"area": "Data Encryption",
"recommendation": "Use SQLite encryption (sqlcipher) or migrate to PostgreSQL with TLS. Set restrictive filesystem permissions (0600) on openfut.db.",
"effort": "High",
"impact": "Protects data at rest from filesystem access"
},
{
"priority": "MEDIUM",
"area": "Error Message Handling",
"recommendation": "Return generic error messages to clients. Log detailed errors internally. Example: client sees 'invalid request', server logs 'JSON parse error: expected `,` at line 2'.",
"effort": "Low",
"impact": "Reduces information disclosure"
},
{
"priority": "MEDIUM",
"area": "Audit Logging",
"recommendation": "Add audit trail for all data mutations (create, update, delete). Log timestamp, profile ID, action, resource, and before/after state. Store in immutable audit_log table.",
"effort": "Medium",
"impact": "Enables incident response and forensics"
},
{
"priority": "LOW",
"area": "Dependency Management",
"recommendation": "Upgrade reqwest to 0.12 and rustls to 0.23 when possible. Set up Dependabot or RustSec monitoring for CVEs. Regularly audit dependencies.",
"effort": "Low",
"impact": "Reduces attack surface from known CVEs"
},
{
"priority": "LOW",
"area": "Default Values",
"recommendation": "Remove hardcoded default username 'Player 1'. Require explicit username on profile creation. Use UUIDs for profile identification.",
"effort": "Low",
"impact": "Improves account identity and prevents confusion"
}
],
"securityDesignNotes": {
"intendedUse": "OpenFUT is designed for single-player offline use. Single-profile design is intentional for local FIFA 23 emulation.",
"deploymentContext": "Localhost only (127.0.0.1:8080). Not intended for networked or multi-user deployment.",
"implicationForSecurity": "Many security issues (no auth, permissive CORS) are acceptable for localhost-only use. However, the code structure lacks security boundaries, so if ever exposed to the network, it would be completely unsecured. Recommend adding security gates now rather than retrofitting later.",
"suggestedDefensiveApproach": "Even for single-player use, add security layers (basic auth, CORS restrictions, rate limiting) to prevent accidental misuse if deployed in an unsafe context."
},
"positiveFindingsAndStrengths": [
"✓ SQLx is used throughout with parameterized queries (except auth.rs:87)",
"✓ Foreign key constraints are enforced in SQLite",
"✓ UUIDs are used for entity IDs instead of sequential IDs (reduces enumeration attacks)",
"✓ Request body size is limited to 256KB (prevents large payload DoS)",
"✓ Concurrency is limited to 256 concurrent requests",
"✓ Sensitive tokens (X-UT-SID, X-UT-PHISHING-TOKEN) are stripped from captures",
"✓ Logging is structured using tracing crate (good for audit trails)",
"✓ Services layer properly encapsulates database access"
],
"testingRecommendations": [
"Add integration tests for authentication bypass (attempt to access endpoints without tokens)",
"Test SQL injection payloads in auth.rs:87 pattern (if table names become dynamic)",
"Test CORS with cross-origin requests from external origins",
"Test rate limiting with rapid concurrent requests to /auth/reset",
"Test input validation with oversized strings (100MB+ usernames)",
"Test panic handling with corrupted database state",
"Test TLS MITM scenarios (certificate pinning validation)",
"Add fuzz testing for JSON parsing to find edge cases"
],
"complianceNotes": {
"gdpr": "No explicit data handling policy. If user data is processed, GDPR requires consent, data retention limits, and audit trails. Not currently implemented.",
"dataProtection": "Unencrypted database at rest violates most data protection frameworks.",
"logging": "Audit logging is missing, violating compliance requirements."
}
}
-250
View File
@@ -1,250 +0,0 @@
# OpenFUT — Direction Document
*The pivot: FUT lives in the app; FIFA 23 is the match renderer.*
*Supersedes the Blaze-backend approach as the primary plan. Last updated 2026-06-30.*
---
## 1. Goal (revised)
Deliver an **intuitive way to play a FUT-style experience with FIFA 23**, where:
- The entire **FUT experience** — cards, squads, packs, SBCs, coins, chemistry,
progression — lives in a **custom app** (web UI or desktop) built on the
already-complete OpenFUT Core economy backend.
- **FIFA 23 is demoted to a match renderer.** Its only job is to play a
single-player match using the squad the app built. No FUT mode, no online, no
Blaze, no EA servers.
This deliberately drops in-game FUT cards/UI (they live in the app) in exchange
for a project that **converges** instead of being gated behind months of
backend reverse-engineering.
### Why this replaces the backend plan
The status review confirmed the backend route (faking EA's online stack) is
blocked at an upstream in-process EbisuSDK gate, with Blaze/Fire2 unconfirmed
beyond it — realistically 3–6 months of expert RE that may not converge. The
app-centric route sidesteps **every** wall in that review by never making FIFA's
own FUT mode run.
---
## 2. Base mode: Career, not Kick-Off
**Career mode is the base.** Reasons:
- FLE's live-editing API (`EditDBTableField`, Freeze Lineup) is **confirmed to
work in career mode** and explicitly does NOT work in FUT/online modes.
- Career already provides the FUT-shaped scaffolding we'd otherwise fake:
persistent club, a fixture schedule, recorded results, progression across a
season.
- **Match results are written into the career DB**, making result capture a DB
read rather than a fragile live-memory grab.
**Kick-Off is the prototype sandbox.** Use it first to prove squad injection
works with nothing to corrupt (no save to break), then move the real loop onto
career. Run the foundational injection test in BOTH.
---
## 3. Core architecture: the bidirectional FLE bridge
The backbone is a **bidirectional channel between the app and a resident FLE Lua
script running inside the game.** Everything else is messages over this channel.
```
Custom App (FUT experience)
│ squad push ──────────────► ┌─────────────────────────────┐
│ │ Resident FLE Lua script │
│ ◄────────── game state │ (inside FIFA 23, career) │
│ ◄────────── match result │ - reads game state │
└────────────────────────────► │ - applies squad live │
(file-watch or local socket) │ - reads results from DB │
└─────────────────────────────┘
│
FIFA 23 plays the match
```
Three message types over the bridge:
1. **App → Game: squad push.** The app's chosen XI + stats applied LIVE via
`EditDBTableField`, replicating whatever DB write FLE's "Freeze Lineup"
feature performs (see `docs/foundational-xi-injection-test.md` — the exact
field(s) are found by diffing, not assumed). No restart, no
file-copy-reload. (File-load remains a fallback.)
2. **Game → App: game state.** The resident script polls the game's current
screen/menu state and reports "safe to apply" vs "not safe", driving a smart
Apply button in the app (see §5).
3. **Game → App: match result.** After full-time, the script reads the result
from the career DB and pushes score/scorers to the app, which awards
coins/progression. (Manual entry is the baseline fallback.)
The bridge transport can be a watched file the in-game Lua polls, or a local
socket — decided in build (see §7). Either way the *game keeps running*; a file,
if used, is just the message channel, not a reload.
---
## 4. Tiered mod scope
Build in tiers matched to risk. The core tier is all the SAME kind of DB write,
so it lands together once squad injection works.
### Tier 1 — Core writes (ride the same live DB-edit mechanism)
- **Squad / custom XI** — the load-bearing primitive (Freeze Lineup's
underlying write, replicated via script — see §6).
- **Player stats as "cards"** — card tiers, in-form versions, SBC upgrades all
expressed as written attribute values.
- **Chemistry as stat adjustment** — app computes FUT chemistry, applies it as
small stat bumps when writing players in (no in-game chem UI; that's in the app).
- **Appearance / identity** — kits, names, team assignment, so the club looks
like your club on the pitch.
- **Formation / tactics** — squad structure carries the app's build onto the pitch.
### Tier 2 — Confirm-then-add
- **Match difficulty per game** — to drive a Squad-Battles-style "this opponent is
World Class". Settable in-game trivially; programmatic drive needs confirming.
- **Match rules / modifiers** (half length, etc.) — for app-defined challenges.
### Tier 3 — Result capture (manual baseline + automated stretch)
- **Manual:** user enters the score in the app after the match. Zero RE, ships
first.
- **Automated:** resident script reads the career-DB result (or, for Kick-Off,
reads the in-match score from memory at full-time — precedent exists: the
CM cheat table's `export_season_stats.lua` already reads goals/cards from
memory via known offsets). Push to app → auto-award progression.
### Out of scope (stays in the app, by design)
- In-game FUT cards, FUT menus, pack-opening animation, chemistry board, FUT
presentation. The app is where it looks/feels like FUT.
---
## 5. The smart Apply button (state-aware)
Live DB edits only "stick" in safe menu states (the in-game "Edit Player" screen,
for example, overwrites edits). So the bridge reads game state and gates applying:
- Resident Lua script polls the game's current-screen value (a few Hz),
classifies **safe / not safe**, reports to the app.
- App's **Apply button is enabled only when the script confirms a safe state**
(squad hub, main menu); greyed otherwise.
- **Safe-by-default-OFF:** unknown state → button greyed → never a risky write.
Expand the known-safe list incrementally as states are confirmed.
- **v2 (more seamless):** instead of greying, the app always lets you click and
the script **queues** the apply, executing the moment a safe state is entered,
then confirms back. Greying is v1; queue-and-apply is v2.
`IsInCM()` is a confirmed state-read; the specific screen-state address + the
value→screen mapping is one-time reconnaissance (same technique as result reading).
---
## 6. What's confirmed vs what needs validating
**Confirmed (from FLE's own Lua API docs/wiki, checked 2026-06-30):**
- FLE live-edits the running career DB without restart, via `EditDBTableField`
(real signature: `EditDBTableField(cell)` where `cell = row["fieldname"]`
with `.value` mutated first — not the table/index/field/value form an
earlier draft of this doc assumed).
- FLE reads game state via `IsInCM()`.
- A `MEMORY` Lua class exists (`ReadInt`/`WriteInt`/`ReadMultilevelPointer`/
etc.) for arbitrary process memory — confirms the result-reading fallback
in §4 Tier 3 is a real, documented capability, not just cheat-table analogy.
- `GetPlayersStats()` is a documented function returning per-player
goals/assists/cards/etc. — a better confirmed path for match-result capture
than raw memory offsets.
- **Freeze Lineup** (Formation Editor → arrange XI → tick "Freeze Lineup" →
`Data → Save`) is FLE's actual documented mechanism for forcing a starting
XI in career mode. This **replaces** "selection bias" below.
- OpenFUT Core (economy) is complete and tested.
**Walked back — not actually confirmed:**
- "Selection bias forces specific players into the starting XI" — no such
field appears anywhere in FLE's documented Lua API or its own example
scripts. This was an unverified assumption carried over from general FIFA
modding precedent (other titles), not anything checked against FLE/FIFA 23.
See `docs/foundational-xi-injection-test.md` for the corrected plan, which
uses Freeze Lineup instead.
**Needs validating (the foundational tests — see §7):**
- Whether Freeze Lineup actually holds into a played match (FLE's wiki
documents the feature but not a live-match test of it).
- What DB table/field Freeze Lineup's `Data → Save` actually writes — it's
GUI-only and undocumented at that level; finding it is part of the
foundational test.
- Whether that write can be replicated by a script (`EditDBTableField`) well
enough to drive it from an EXTERNAL trigger, not just the Formation Editor
UI — required for the app↔game bridge.
- The app↔game bridge transport (file-watch vs socket) works cleanly under the
run setup.
- The screen-state address + safe/not-safe classification (FLE's `Events`
API page exists in the wiki index but its content is currently empty/
undocumented — this is more open than previously assumed).
- Result read-back from the career DB after a match.
**Standing caveat:** the whole stack rides on **EAAC staying neutralized**
(FLE's fake-launcher bypass). If a game update re-enables it, hooks fail. Keep
game updates off; confirm neutralized state each session.
---
## 7. Build order / next steps
Each is a bounded, verifiable step. Do them in order; later ones depend on
earlier answers.
1. **FOUNDATIONAL TEST — live custom XI in career.** Confirm Freeze Lineup
holds into a played match, reverse-engineer the DB write it makes, then
replicate that write from a script so it can be triggered externally
instead of through the Formation Editor UI. See
`docs/foundational-xi-injection-test.md` for the full procedure. *Done =
a script-driven write produces a match that fields the squad you
specified.* Everything rests on this.
2. **Pick the bridge transport.** Decide file-watch vs local socket for app↔game
messaging; implement the minimal app→game squad push. *Done = app sends a
squad, the resident script receives and applies it.*
3. **Game-state reader + smart Apply.** Find the screen-state address, classify
safe/not-safe, expose to the app, gate the Apply button. *Done = button greys
when you enter a match/edit screen, enables in the squad hub.*
4. **Result read-back.** Read the career-DB match result post-game, push to app,
award progression. Manual entry ships alongside as the fallback. *Done = app
updates coins from a played match.*
5. **Tier 1 breadth.** Extend the squad push to carry stats, appearance,
formation (same write mechanism). *Done = the club looks and plays like the
app's build.*
6. **Tier 2 + economy loop polish.** Difficulty drive, challenges, and the full
pack → SBC → squad → match → reward loop closed end-to-end.
### Decision still open
- **App form factor:** web UI vs desktop app. This affects the bridge transport
(a desktop app can hold a local socket more naturally; a web UI leans toward a
small local helper/file-watch). Decide before step 2.
---
## 8. Provenance
Clean-room throughout. This route relies on FLE's documented public API and the
game's own supported career mode — no EA backend, no Blaze, and nothing derived
from leaked EA source. The earlier backend RE remains clean-room and is preserved
as a spec artifact; it is simply no longer the primary path.
---
## 9. One-paragraph summary
OpenFUT becomes a **FUT companion app that uses FIFA 23 as a match engine.** The
app owns the entire FUT experience; a resident FLE Lua script in career mode
applies the app's squad live (no restart), reports game state to drive a safe
Apply button, and reads match results back to feed progression. This sidesteps
every backend wall, runs on confirmed FLE capabilities, builds on the finished
economy core, and delivers the intuitive, offline, FUT-flavored loop that is the
actual goal.
-108
View File
@@ -1,108 +0,0 @@
# FIFA 23 PC Startup Flow (Offline / Proton)
Observed via FLE log, hook log, and file inspection on 2026-06-26.
## Launch chain
```
umu-run / Steam → FIFA23.exe (via Proton/Wine)
│
├─ DLL load order (before entry point)
│ ntdll.dll, kernel32.dll, ws2_32.dll …
│ version.dll ← our hook DLL slot (loads here)
│ FIFALiveEditor.DLL ← injected by FLE launcher after ~100 ms
│
├─ anadius / LSX emulator (anadius64.dll)
│ Fakes EA App / Origin session
│ Reads HKLM\SOFTWARE\Wow6432Node\Origin\ClientPath
│ Writes AppData\Local\anadius\LSX emu\achievement-*.xml
│ Provides fake PersonaId=1144668899 / UserId=1000200030000
│
├─ EA Anti-Cheat (EAAntiCheat.GameServiceLauncher.exe)
│ Spawns as child; checks EAAntiCheat.cfg
│ Not active in offline/cracked builds (FakeEAACLauncher present)
│
└─ FIFA23.exe entry point
Frostbite engine init (BuildDate 2023-07-05, changelist 5417699)
Reads Data\initfs_Win32 ← Frostbite package manifest
Reads Data\layout.toc ← file-system layout
Reads Patch\initfs_Win32 ← patches on top of base
Reads Documents\FIFA 23\fifasetup.ini ← display settings
Reads Data\locale.ini ← language table
Reads Data\db_meta.xml (via FLE) ← DB schema for all tables
```
## Phase timing (observed, single machine)
| Phase | Time after launch | Trigger |
|------------------------------|-------------------|----------------------------------|
| DLL load + FLE injection | 0 – 0.3 s | OS loader |
| Engine + DirectX init | 0.3 – 5 s | FIFA23 entry point |
| "Press any key" splash | ~5 s | First rendered frame |
| Main menu | ~25 s | After key press |
| FUT mode entry (attempted) | user-driven | User selects FUT tile |
| Network calls to EA services | at FUT entry | DirtySDK / EAWebKit |
## Files read at startup (observed)
| File | Format | Purpose |
|------|--------|---------|
| `Data/initfs_Win32` | Frostbite pkg | Base asset manifest |
| `Data/layout.toc` | Frostbite TOC | File layout index |
| `Patch/initfs_Win32` | Frostbite pkg | Patch layer |
| `Data/locale.ini` | INI | String localisation |
| `Data/db_meta.xml` | XML | DB schema (loaded by FLE) |
| `Data/id_map.json` | JSON | Player/team ID→name map |
| `Data/char_conv.json` | JSON | Character conversion table |
| `Documents/FIFA 23/fifasetup.ini` | INI | Display/audio settings |
| `AppData/Local/Temp/FIFA 23/_replay0.bin` | binary | Replay buffer |
| `anadius.cfg` | VDF | Fake EA persona config |
| `AppData/Local/anadius/LSX emu/achievement-*.xml` | XML | Achievement state |
## Files written during a session (observed)
| File | When written | Content |
|------|-------------|---------|
| `Documents/FIFA 23/settings/Settings*` | Main menu reached | FBCHUNKS — controller/display prefs |
| `Documents/FIFA 23/settings/ProfileOptions` | Profile load | FBCHUNKS — 1.5 MB profile blob |
| `Documents/FIFA 23/filesystemcache/survey.state` | Startup | Empty state file |
| `Documents/FIFA 23/filesystemcache/atlPlayTimeJson/playtime_*.json` | Ongoing | Playtime tracking |
| `FIFA 23 Live Editor/config.json` | FLE ready | FLE settings (rewritten each session) |
| `Logs/log_DD-MM-YYYY.txt` | Throughout | FLE debug log |
## Save file formats
### FBCHUNKS (Frostbite chunk container)
- Magic: `46 42 43 48 55 4E 4B 53` (`FBCHUNKS`)
- Byte 8: version (01 seen)
- Offset 0x12: null-terminated label string (e.g. "Personal Settings 1", "Career - Player Progress 1")
- Remainder: compressed/binary chunk data — no public spec; requires Frostbite tooling to fully parse
- Tools: [Frosty Tool Suite](https://github.com/CadeEvs/FrostyToolSuite) can read/write these
### fifasetup.ini
- Plain `KEY = VALUE` ini, fully human-readable
- Safe to edit (display resolution, locale, vsync)
## Network calls at FUT entry (observed with iptables redirect)
Traffic pattern captured before changing strategy:
- Multiple TLS connections to port 443 (destination: EA servers, resolved as various EA IPs)
- TLS 1.3, AES-256-GCM (DirtySDK's copy of ProtoSSL, inline in FIFA23.exe)
- No SNI sent (DirtySDK does not set `server_name` extension)
- Connections originate from Wine/Proton network stack via Linux kernel TCP
Specific EA hostnames used (from openfut-bridge captures, not decoded from TLS):
- `fut.ea.com` (FUT API)
- `accounts.ea.com` (auth)
- `gateway.ea.com` (entitlements)
- `pin-river.data.ea.com` (telemetry)
## Key FLE Lua API hooks
FLE injects `FIFALiveEditor.DLL` and exposes a Lua engine that can:
- Read any in-memory DB table via `GetDBTableRows(tableName)`
- Write any cell via `EditDBTableField`
- Query career mode state via `IsInCM()`
- Get player/team names via `GetPlayerName`, `GetTeamName`
This is the primary safe integration path (see `fut-integration-options.md`).
-191
View File
@@ -1,191 +0,0 @@
# Foundational test — live custom XI via Freeze Lineup
**Status: PENDING — test has not yet been run.**
This is build-order step 1 from `docs/direction.md`: the test everything else
in the direction pivot depends on.
## What changed since the first draft of this doc
The first version of this test guessed at a "selection bias" DB field and a
candidate squad/lineup table name, based on general FIFA-modding precedent
that turned out not to hold for FLE's documented API — no such field appears
anywhere in FLE's actual Lua API docs or its own example scripts. While
researching an unrelated hotkey issue, a **confirmed, FLE-documented**
mechanism for forcing a starting XI turned up instead: the **Formation
Editor's "Freeze Lineup" feature** (FLE wiki, `Formation-Editor.md`):
> This feature can be used in player career mode if you want to manage the
> starting lineup of your team. Can be also used in manager career mode to
> manually manage your next opponent's starting lineup.
Steps (GUI, no scripting): open Formation Editor for a team → arrange players
on the pitch → tick **Freeze Lineup** → `Data → Save`.
This is real and documented, but it's GUI-only — there is no Lua function for
it, and what DB write it actually performs under the hood is undocumented.
This test is now two phases: confirm the GUI feature works at all, then
reverse the DB write it makes so it can be replicated programmatically
(required for the app→game bridge in build-order step 2, which needs this
driven from outside the game, not from a person clicking checkboxes).
Also fixed in this pass: `EditDBTableField`'s real signature, confirmed from
FLE's own docs and `lua/scripts/99ovr_99pot.lua`, is
`EditDBTableField(cell)` where `cell` is `row["fieldname"]` with `.value`
mutated in place — **not** `EditDBTableField(table, row_index, field, value)`
as originally (incorrectly) written into the first draft of the injector
script.
## What this test settles
Whether a *specific, externally-chosen* 11 players can be forced into a
career (or Kick-Off) match's starting lineup, live, with no restart — and
whether the mechanism that does it (Freeze Lineup's underlying DB write) can
be driven by a script instead of a person clicking through the Formation
Editor UI.
If Freeze Lineup itself doesn't actually hold under match start (the wiki
doesn't show it being tested against a live match, only "you should be able
to see... when you play against them"), the whole bridge architecture in
`docs/direction.md` §3 needs rethinking — there is no other documented
mechanism for forcing a lineup.
## Prerequisites
- FIFA 23 launched normally (FLE injected, EAAC neutralized — same baseline
as `track-c-fut-table-test.md`)
- A career save loaded (Freeze Lineup is documented for career mode
specifically — confirm separately whether it does anything in Kick-Off,
don't assume it does)
- Note 11 player IDs from your club (`tools/squad-exporter/export_squad.lua`
output, `playerid` field) that are NOT currently your starting XI
## Phase 1 — confirm Freeze Lineup actually holds into a match
This has zero scripting and should be done first since everything else is
wasted effort if it fails.
1. Open the Live Editor overlay (F9, or `Windows → Settings` from the
overlay's own menu bar if the hotkey isn't registering — see the umu/Wine
hotkey note below).
2. `Features → Teams` → find your team → `Edit`.
3. `Team → Formation` to open the Formation Editor.
4. Swap players around on the pitch so the XI differs from your current
actual starting XI in some checkable way (e.g. swap two outfield players'
positions, or bench/start a specific player).
5. Tick **Freeze Lineup**.
6. `Data → Save`.
7. Hide Live Editor (F9), save your career **on a new slot** (don't overwrite
your main save in case this corrupts something), exit to main menu, reload
that save, and check the team's lineup screen / play a match and watch who
starts.
**Record in the Results table below whether the frozen lineup actually took
the pitch.** If not, stop here — Phase 2 is moot.
## Phase 2 — find the underlying DB write
Only proceed if Phase 1 confirmed Freeze Lineup works.
1. In FLE's Lua Engine, run `tools/squad-injector/snapshot_lineup_tables.lua`.
This dumps every DB table whose name contains `squad`, `lineup`,
`formation`, `tactic`, `teamsheet`, `selection`, `players`, or `teams` to
`C:\FIFA 23 Live Editor\openfut_snapshot_<timestamp>.json`. Note this
filename — this is your **before** snapshot.
2. Without restarting or reloading, repeat the Formation Editor steps from
Phase 1 (steps 2–6 only — open Formation Editor, change the lineup, tick
Freeze Lineup, `Data → Save`). Don't save/reload the career between
snapshot and this step — keep it to a single live session so the diff
isn't polluted by other state changes.
3. Run `snapshot_lineup_tables.lua` again. This is your **after** snapshot.
4. Copy both JSON files out of the Wine prefix (same path pattern as
`track-c-fut-table-test.md`: `~/Games/umu/.../drive_c/FIFA 23 Live
Editor/`) and run:
```bash
python3 tools/squad-injector/diff_snapshots.py before.json after.json
```
5. The output shows exactly which table(s) and field(s) changed. This is the
real, confirmed write Freeze Lineup performs — record it in the Results
table below.
## Phase 3 — replicate the write via script
1. Open `tools/squad-injector/apply_lineup_write.lua` and fill in
`TARGET_TABLE` and `TARGET_FIELDS` using Phase 2's diff output.
2. Edit `C:\FIFA 23 Live Editor\openfut_test_xi.json`:
```json
{
"team_id": 12345,
"xi": [
{ "player_id": 111111, "position": 0 },
{ "player_id": 222222, "position": 5 }
]
}
```
Use 11 entries. Position codes are **confirmed numeric 0–27**
(`GK=0, SW=1, RWB=2, RB=3, RCB=4, CB=5, LCB=6, LB=7, LWB=8, RDM=9, CDM=10,
LDM=11, RM=12, RCM=13, CM=14, LCM=15, LM=16, RAM=17, CAM=18, LAM=19,
RF=20, CF=21, LF=22, RW=23, RS=24, ST=25, LS=26, LW=27`) — from
`lua/scripts/export_season_stats.lua`'s `get_pos_name` table in FLE's own
repo, not a guess.
3. Run `apply_lineup_write.lua` from FLE's Lua Engine.
4. Repeat the save-to-new-slot / reload / check-lineup verification from
Phase 1, but this time without ever opening the Formation Editor — the
write was made entirely from the script.
## Classification criteria
### "Confirmed — full mechanism works"
Phase 1 holds, Phase 2 finds a clean diff, Phase 3's scripted write produces
the same in-match result as the manual GUI path.
**Verdict:** Build-order step 1 done. Proceed to step 2 (bridge transport) in
`docs/direction.md`.
### "GUI works, script doesn't"
Phase 1 holds but Phase 3's replicated write doesn't stick, even though the
diffed fields matched what changed in Phase 2.
**Verdict:** Freeze Lineup likely does more than a single DB field write
(e.g. an internal engine call beyond `EditDBTableField`'s reach, or a second
write the diff missed because it happened in a table outside the `KEYWORDS`
filter in `snapshot_lineup_tables.lua` — widen the filter and redo Phase 2).
### "Freeze Lineup doesn't hold at all"
Phase 1 fails — the lineup reverts to the game's own AI-picked XI regardless.
**Verdict:** No confirmed mechanism exists for forcing a lineup. This kills
the bridge architecture as designed in `direction.md` §3 and needs a return
to first principles — there is no fallback documented anywhere in FLE's wiki
for this specific case.
## A note on the umu/Wine F9/F11 hotkey issue
If FLE's F9 (hide/show) hotkey isn't registering under umu, this is plausibly
a Wine keyboard-hook limitation (FLE's global hotkey detection likely uses a
low-level hook that doesn't translate cleanly through Wine's input layer) —
not something documented anywhere in FLE's own troubleshooting docs, which
don't mention Linux/Wine at all. F11 specifically has **no documented FLE
function** — F9 is the only documented toggle. Workaround: click directly
into the FLE overlay window (it should still be visible/clickable even if the
hotkey doesn't fire) and use its own menu bar instead of relying on the
hotkey.
## Results
*(To be filled in after the test is run.)*
| Field | Value |
|---|---|
| Date run | — |
| Phase 1: Freeze Lineup holds into a match? | — |
| Phase 2: table(s)/field(s) changed | — |
| Phase 3: scripted write reproduces Phase 1 result? | — |
| **Classification** | **PENDING** |
-136
View File
@@ -1,136 +0,0 @@
# FUT Integration Options
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.
---
## Option E — Memory bridge (Cheat Engine / FLE offsets)
**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.
-208
View File
@@ -1,208 +0,0 @@
# OpenFUT Status Review
*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 |
| Connection-state function: `GetInternetConnectedState @ anadius64.dll+0x27790` | **Proven** | Located via anadius LSX command-registration table; two-flag branch decoded (`+0xCAB1A`, `+0xCAB1B`) |
| 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.
**Proven support:** Hook loads and redirects correctly. TLS bypass patched. Core economy backend complete. Blaze framing code and TLS listener exist.
**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.
-93
View File
@@ -1,93 +0,0 @@
# Track C — FUT DB table viability test
**Status: PENDING — test has not yet been run.**
## What this test settles
Track C ("FLE Lua bridge") would inject OpenFUT club data directly into FIFA 23's
in-memory Frostbite DB tables at runtime, bypassing the entire backend/Blaze stack.
It is only viable for FUT (not just career mode) if FUT-specific tables — card
inventory, squad composition with FUT fields, coins — are accessible in memory when
the game is in the FUT area.
FUT data in online mode is fetched server-side from `fut.ea.com`. It is not known
whether FIFA 23 mirrors any of this into the Frostbite in-memory DB that FLE
can read/write. This test settles that question directly.
## Test procedure
**Prerequisites:**
- FIFA 23 launched normally via umu-run/Steam
- FLE (FIFA Live Editor) injected and active (normal launch path)
- EAAC in offline/neutralized state
- Game navigated as deep into FUT as possible (FUT hub if reachable; otherwise the
furthest FUT screen before the gate blocks it)
**Run the exporter:**
1. In FLE's Lua Engine, open and run `tools/squad-exporter/export_squad.lua`
(full path on the Windows side: `C:\<game>\openfut_squad_export.json`)
2. Wait for the MessageBox "Done! N players, M teams." or "ERROR writing..."
3. Retrieve the output file from the Wine prefix:
`~/Games/umu/fifa23-tools/drive_c/FIFA 23 Live Editor/openfut_squad_export.json`
(or wherever `C:\FIFA 23 Live Editor\` maps in the active prefix)
**What to inspect in the output:**
- `all_db_tables` array — the complete list of table names visible to FLE right now
- `fut_tables` object — any table whose name contains `fut`, `club`, `pack`, `item`, or
`market` (the script auto-extracts these)
- `is_career_mode` — confirms whether FUT or career mode was active
## Classification criteria
### "FUT tables present"
`fut_tables` is non-empty AND contains FUT-specific fields beyond base player stats:
- e.g., `fut_items` with card-type / rating / chemistry fields
- e.g., a squad table with FUT formation / chemistry / loan-flag fields
- e.g., a coins or points balance field
**Verdict:** Track C is viable for FUT. Fastest path to user-visible results.
### "only base player tables"
`fut_tables` is empty (no `fut_*` / `club_*` / `item_*` / `market_*` table names found
in `all_db_tables`), OR those tables exist but contain only base player attributes
(OVR, potential, position, pace, …) — the same fields visible in career mode.
**Verdict:** Track C cannot produce FUT. It could at most provide a custom Kick-Off or
career-mode match with players sourced from OpenFUT Core. FUT items and coins exist
only on EA's servers (not in the in-memory DB in offline mode).
### "FUT area unreachable to test"
The connection gate blocked entering FUT deeply enough for FUT tables to be populated.
Record which tables were visible and at what screen the test was run.
**Verdict:** Retest after M2 is unblocked, OR test with `TLS_ENABLED=false` bridge
handling the entry check stub.
## Results
*(To be filled in after the test is run.)*
| Field | Value |
|---|---|
| Date run | — |
| FIFA screen at test time | — |
| `is_career_mode` | — |
| Total tables in `all_db_tables` | — |
| FUT-specific table names found | — |
| Key FUT fields present | — |
| **Classification** | **PENDING** |
## Honest prior
`fut-integration-options.md` rates this as the recommended path and lists `fut_clubs`,
`fut_items`, `fut_squads` as "expected" tables. However those expectations are based on
analogy with career mode (which does store club/squad in the DB). FUT's data model is
architecturally different — it is account-bound server-side. The expectation may be
wrong. This test is the oracle.
The `export_squad.lua` script checks `GetDBTablesNames()` exhaustively (not just
assumed names), so it will surface any FUT tables that actually exist, regardless of
what name they use.
+16
View File
@@ -0,0 +1,16 @@
# Keep the authoritative-tree build context lean: only tools/ and data/ runtime
# files (plus the Dockerfile's own entrypoint/manifest) are needed in-image.
.git
.gitignore
artifacts
captures
futmem
staging
docs
FUT-RUNBOOK.md
README.md
data/memdump
**/__pycache__
*.pyc
*.pem
*.key
+1
View File
@@ -9,4 +9,5 @@
*.log
__pycache__/
captures/
staging/
tools/fifa17_profile.json
+1
View File
@@ -0,0 +1 @@
state/
@@ -0,0 +1,11 @@
# Copy to .env in this directory. Required for remote deployment.
#
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# OPENFUT_BIND — address the listeners bind inside the container.
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
# uses the loopback default baked into the responders when unset.
OPENFUT_BIND=0.0.0.0
@@ -0,0 +1,70 @@
# OpenFUT FIFA-17 FUT backend — Python migration deployment.
#
# Runs the 5 network responders (LSX / Blaze / roster / UTAS / POW) that FIFA 17
# dials to reach the FUT hub. Pure-Python; the only third-party dep is
# pycryptodome (LSX AES handshake). autopatch.py is intentionally NOT run here —
# it patches the game process memory and belongs on the client (105).
#
# Build context is fifa17-recon/ (the repo tree). tools/ is the AUTHORITATIVE
# recon tree (fifa17-recon/tools/). Only the runtime file set listed in
# docker/fifa17-python/runtime-tools.list is installed into /app/tools, so the
# deployed manifest stays byte-identical to the frozen baseline image
# openfut-fut-backend:python-baseline-2026-08-10 (see docs/BASELINE-*.md) while
# recon scripts, ghidra_queries and docs stay out of the image. data/ comes
# from the authoritative fifa17-recon/data. A SHA256SUMS.txt is baked into the
# image so any running backend can be matched to the exact dataset it was built
# from.
FROM python:3.12-slim
RUN pip install --no-cache-dir pycryptodome==3.20.0
WORKDIR /app
# Stage the authoritative tools tree in full...
COPY tools/ /app/tools-full/
# ...then install ONLY the runtime manifest (baseline image minus the two
# git-ignored certs, which are regenerated below).
COPY docker/fifa17-python/runtime-tools.list /app/runtime-tools.list
RUN set -eu; \
mkdir -p /app/tools; \
while IFS= read -r f; do \
[ -n "$f" ] || continue; \
mkdir -p "/app/tools/$(dirname "$f")"; \
cp "/app/tools-full/$f" "/app/tools/$f"; \
done < /app/runtime-tools.list; \
rm -rf /app/tools-full
COPY data/ /app/data/
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine — but the
# client dials the roster and redirector BY IP, and that path still checks the
# SAN against the dialed address (it is NOT covered by the two patched gates), so
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
# reissues with IP:$OPENFUT_ADVERTISE at start.
#
# openssl therefore has to remain in the image for the entrypoint, not be dropped
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
# so a fresh checkout builds without extra steps.
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
rm -rf /var/lib/apt/lists/*
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1"; \
fi
# Bake a dataset manifest so every image is self-identifying.
RUN find /app/tools /app/data -type f | LC_ALL=C sort | xargs sha256sum > /app/SHA256SUMS.txt
COPY docker/fifa17-python/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# LSX 4216 | Blaze redir/main/nucleus 42127/42130/42131 | roster 8081 | UTAS 8099 | POW 8094/8080
EXPOSE 4216 42127 42130 42131 8081 8099 8094 8080
ENTRYPOINT ["/app/entrypoint.sh"]
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT FIFA-17 — CLIENT-side arming (runs on the GAME machine, e.g. 105).
#
# Companion to the dev container on the SERVER (120). The server runs the heavy
# responders (Blaze / UTAS / roster / POW). Two pieces are inherently local to
# the game and therefore stay here:
#
# * autopatch.py — patches FIFA17.exe process memory (ProtoSSL cert-verify).
# Must run where the game runs; cannot be containerised.
# * lsx_responder — the Origin/EADesktop emulator the game dials on the
# hardcoded loopback 127.0.0.1:4216. Loopback IPC can't be
# cleanly redirected to a remote host, so it lives here.
#
# Everything the game reaches by a routable address is redirected to the server:
# * winter15.gosredirector.ea.com (hardcoded EA IP 159.153.51.20) -> SERVER:42127
# * easw.easports.com (dead hardcoded UTAS host) -> SERVER (:8099)
#
# The server's responders were started with OPENFUT_ADVERTISE=<SERVER_IP>, so
# after these first redirected contacts the game is handed <SERVER_IP> for every
# later hop (Blaze main, roster, UTAS, telemetry) and dials the server directly.
#
# Usage: sudo OPENFUT_SERVER=203.0.113.10 ./client_arm.sh
# (re-run after every reboot; the sysctl/iptables state is volatile)
# ============================================================================
set -euo pipefail
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 203.0.113.10}"
GOS_EA_IP="159.153.51.20" # winter15.gosredirector.ea.com (hardcoded in FIFA17)
UTAS_HOST="easw.easports.com" # dead UTAS host baked into CardsDLL
UTAS_RE="${UTAS_HOST//./\\.}" # same, safe to embed in a regex
if [ "$(id -u)" -ne 0 ]; then
echo "!! must run as root (sudo). Re-run: sudo OPENFUT_SERVER=$SERVER $0" >&2
exit 1
fi
echo "[client_arm] backend server = $SERVER"
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
sysctl -q kernel.yama.ptrace_scope=0
# 2) Redirect the hardcoded Blaze redirector IP to the server's redirector.
# (Replace any stale rule first so re-runs and IP changes are clean.)
while iptables -t nat -D OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT \
--to-destination "$SERVER:42127" 2>/dev/null; do :; done
iptables -t nat -A OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT --to-destination "$SERVER:42127"
# 2b) DNAT from OUTPUT to a REMOTE host needs a matching source-NAT on the way
# out, or the server's replies (from its own IP) won't match the game's
# conntrack entry. MASQUERADE the redirected flow so it is SNAT'd to this
# host's outbound IP. (Harmless duplicate-guarded like the DNAT above.)
while iptables -t nat -D POSTROUTING -p tcp -d "$SERVER" --dport 42127 \
-j MASQUERADE 2>/dev/null; do :; done
iptables -t nat -A POSTROUTING -p tcp -d "$SERVER" --dport 42127 -j MASQUERADE
# 3) Point the dead hardcoded UTAS host at the server. The port (8099) is carried
# in the game's own URL, so only the name needs redirecting. Remove any prior
# OpenFUT-managed line (loopback or other server) and write the current one.
sed -i "/[[:space:]]${UTAS_RE}\b.*# openfut\$/d" /etc/hosts
printf '%s\t%s\t# openfut\n' "$SERVER" "$UTAS_HOST" >> /etc/hosts
echo "[client_arm] --- armed ---"
sysctl kernel.yama.ptrace_scope
iptables -t nat -L OUTPUT -n | grep -i "$GOS_EA_IP" || echo " (DNAT missing!)"
# Verify the hosts entry by EFFECT, not by presence.
#
# glibc returns the FIRST match in /etc/hosts, so our line can be written
# correctly and still lose to an earlier one -- and the sed above only removes
# lines this script wrote (`# openfut`), so re-running never clears a foreign
# one. The old check here was `grep easw /etc/hosts && echo ok`, which passed on
# the shadowing line itself and reported success while resolution was wrong.
#
# Observed on 2026-08-11: a leftover `127.0.0.1 easw.easports.com` from the
# single-machine era shadowed the OpenFUT line, and every re-run said "ok".
resolved="$(getent ahosts "$UTAS_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
# SERVER may be a hostname, so compare address-to-address rather than comparing
# the literal string against resolved IPs (which would warn spuriously).
server_ips="$(getent ahosts "$SERVER" 2>/dev/null | awk '{print $1}' | sort -u)"
[ -n "$server_ips" ] || server_ips="$SERVER"
match=0
for ip in $server_ips; do
printf '%s' "$resolved" | grep -qw -- "$ip" && match=1
done
if [ "$match" -eq 1 ]; then
echo " /etc/hosts ok ($UTAS_HOST -> $resolved)"
else
echo
echo " !! WARNING: $UTAS_HOST resolves to [$resolved], not $SERVER."
echo " An earlier /etc/hosts line is shadowing the OpenFUT one:"
grep -nE "^[[:space:]]*[^#].*[[:space:]]${UTAS_RE}([[:space:]]|\$)" /etc/hosts \
| grep -v '# openfut$' | sed 's/^/ /' || true
echo
echo " Not fatal: the responders advertise $SERVER, so the game stops using"
echo " this name after the first hop. Worth removing the line above anyway."
echo " Lines are listed rather than deleted -- this script will not remove"
echo " /etc/hosts entries it did not write."
fi
echo
echo "[client_arm] Next: start the LOCAL pieces (LSX + autopatch) with client_local.sh,"
echo " ensure the container is up on $SERVER, then launch FIFA 17."
@@ -0,0 +1,49 @@
# OpenFUT FIFA-17 FUT backend — declarative deployment (server side, runs on 120).
#
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
# POW) and is required — there is no silent loopback fallback in remote mode.
#
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
name: openfut-fut-backend
services:
fut-backend:
build:
context: ../..
dockerfile: docker/fifa17-python/Dockerfile
image: openfut-fut-backend:dev
container_name: openfut-fut-backend
restart: unless-stopped
environment:
# Bind all interfaces inside the container.
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
POW_CONTENT_ADDR: "0.0.0.0:8080"
POW_CONTENT_HOST: "${OPENFUT_ADVERTISE}:8085"
# Launcher-selected EA/Origin identity shared by LSX, Blaze, POW and UTAS.
# FUT saves are isolated by persona beneath /state/accounts.
FUT_ACCOUNT_PATH: "/state/active_account.json"
FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off"
FUT_MODES: "1"
volumes:
- "../state:/state"
ports:
- "4216:4216" # LSX (Origin bootstrap)
- "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub
- "8081:8081" # FUT roster XML (HTTPS)
- "8099:8099" # UTAS / RS4 FUT REST API
- "8094:8094" # POW / EASFC API
- "8085:8080" # POW content (host 8085 -> container 8080; avoids core:8080)
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT FIFA-17 FUT backend — in-CONTAINER orchestrator.
#
# Runs the 5 network responders that the game dials. Unlike the host-based
# openfut-fut.sh, this does NO host arming (no pkexec / iptables / /etc/hosts /
# ptrace) — those are client-side concerns handled on the game machine (105).
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
# run on the box the game runs on.
#
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 203.0.113.10)
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")/tools"
BIND="${OPENFUT_BIND:-0.0.0.0}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
# POW keys advertised by blaze must also point at the server, not loopback.
export POW_HOST="${POW_HOST:-$ADV:8094}"
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
echo "[openfut] bind=$BIND advertise=$ADV"
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
# The client dials the roster (:8081) and redirector by that IP, and that path
# validates the cert's SAN against the dialed address — it is NOT covered by the
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
# only when the current cert does not already carry it, so a restart reuses the
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
CERT=redir_cert.pem KEY=redir_key.pem
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
-subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
>/dev/null 2>&1 \
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
fi
# name script extra-env
declare -a SERVERS=(
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
"blaze|blaze_responder_v3b.py|-"
"roster|roster_server.py|-"
"utas|utas_server.py|FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 FUT_DISCARD_SEND=1"
"pow|pow_server.py|-"
)
pids=()
names=()
for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry"
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086
$envprefix python3 -u "$script" &
pids+=($!)
names+=("$name")
done
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() {
echo "[openfut] shutting down…"
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
wait
exit 0
}
trap term TERM INT
# If ANY responder dies, take the whole container down so the failure is visible
# (they all bind ports the game needs — a partial stack is a broken stack).
while true; do
for i in "${!pids[@]}"; do
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
echo "[openfut] responder ${names[$i]} (pid ${pids[$i]}) exited - bringing container down"
term
fi
done
sleep 2
done
@@ -0,0 +1,77 @@
origin_login_probe.py
card_proof.py
force_login_flag.py
card_record_poke.py
test_tournament_contract.py
dmp_stack.py
fut_clubitems.py
test_autopatch_logging.py
capture_lsx.py
roster_server.py
autopatch.py
dbschema_probe.py
test_account_profiles.py
coach_window.py
watch_club_model.py
db_dump.py
coach_probe.py
uidiff.py
probe_club_stats.py
blaze_responder_v3.py
dbdata_extract.py
decode_fire2.py
check_club_stat_vocab.py
fut_accounts.py
strip_dead_cards.py
test_hub_offline_season_contract.py
repair_club.py
forge_node.py
verify_preauth.py
fut_coaches.py
heat2.py
test_security_question.py
test_utas_log_redaction.py
sbc_populate_poke.py
atomdump.py
lsx_responder.py
fut_staff.py
fut_cards.py
blaze_responder.py
blaze_responder_v2.py
fut_store.py
blaze_responder_v3b.py
test_fut_contract.py
utas_server.py
lsx_force_online.py
grab_crash_code.py
gate_byte_probe.py
fut_admin.py
test_match_rewards.py
lsx_responder_v2.py
card_identity_probe.py
extract_player_ids.py
watch_online_mode.py
store_enable_poke.py
pow_server.py
fut_account.py
blaze_responder_v3_patched.py
check_settings_flags.py
test_match_lifecycle.py
sbc_hook_poke.py
futlog.py
fut_seed.py
hub_counter_probe.py
fut_consumables.py
db_catalog_walk.py
memtool.py
build_player_facts.py
sweep_collect.py
test_card_families.py
fut_club_stats.py
dmp.py
build_consumables.py
test_market_buy.py
dump_login_code.py
auth_watch.py
vgamepad.py
ghidra_env.py
@@ -0,0 +1,121 @@
# Python backend baseline — 2026-08-10
Frozen rollback target for the working offline FUT backend (Python migration) as
it ran on 10.10.0.120. Everything here was recorded from the live system before
any cleanup/restructure; the image and state are archived in
`/home/alex/OpenFUT/docker-backups/`.
## Frozen image
| field | value |
|------------|-------|
| tag | `openfut-fut-backend:python-baseline-2026-08-10` |
| image id | `e1f93ad647ab` |
| digest | `sha256:e1f93ad647abbec32e2751f3e88fed75d3e574d4500395b21c31d0f0b96abac6` |
| created | 2026-08-10T02:14:56Z (built as `openfut-fut-backend:dev`) |
| size | 278 MB |
| archive | `docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz` (53 MB, `docker save \| gzip -1`) |
## Frozen container
| field | value |
|------------|-------|
| id | `f16d3204cbf48151be232ff8f4194b429e311042f8f6f95644760d4b8eba2938` |
| created | 2026-08-10T02:14:56.194470252Z |
| image | `openfut-fut-backend:dev` (= baseline image id) |
| restart | `unless-stopped` |
| network | `docker_default`, IP `172.19.0.2`, aliases `openfut-fut-backend`, `fut-backend` |
| log | json-file |
| inspect | `docker-backups/openfut-fut-backend-container-inspect-2026-08-10.json` |
### Environment (Config.Env)
```
FUT_SETTINGS=off
FUT_MODES=1
OPENFUT_BIND=0.0.0.0
OPENFUT_ADVERTISE=10.10.0.120
POW_CONTENT_ADDR=0.0.0.0:8080
POW_CONTENT_HOST=10.10.0.120:8085
FUT_ACCOUNT_PATH=/state/active_account.json
FUT_PROFILE_ROOT=/state/accounts
PYTHON_VERSION=3.12.13 (python:3.12-slim base)
```
### Volumes / mounts
Bind mount `docker/state` (host) -> `/state` (container, rw). Runtime state:
`active_account.json` (active persona) + `accounts/` (FUT saves by persona).
Snapshot: `docker-backups/state-2026-08-10/`.
### Ports (host -> container)
| host | container | service |
|------|-----------|---------|
| 4216 | 4216 | LSX (Origin bootstrap) |
| 42127 | 42127 | Blaze redirector (TLS) |
| 42130 | 42130 | Blaze main |
| 42131 | 42131 | Nucleus OAuth stub |
| 8081 | 8081 | FUT roster XML (HTTPS) |
| 8099 | 8099 | UTAS / RS4 FUT REST API |
| 8094 | 8094 | POW / EASFC API |
| 8085 | 8080 | POW content (remapped to avoid openfut-core:8080) |
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):
docker load -i /home/alex/OpenFUT/docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz
docker tag openfut-fut-backend:python-baseline-2026-08-10 openfut-fut-backend:dev
# Or rebuild from git:
cd /home/alex/OpenFUT/fifa17-recon/docker/fifa17-python
cp .env.example .env # set OPENFUT_ADVERTISE
docker compose up -d --build
```
## Status at freeze time
- The 2026-08-10 `openfut-fut-backend` container was **left running untouched**
(the .105 launcher audit uses it). No rebuild/replacement happens until that
audit finishes; the frozen image is the rollback target if cleanup breaks it.
- `docker/state` was **not** moved during restructure (bind path must not change
while the container is live); the new compose mounts `../state` from the same
location.
- TURN/relay re-addressing (multiplayer) and long-tail endpoints (weather,
matchday, kit assets) are deferred feature gaps — tracked separately.
## Running state vs image — what the frozen image does NOT contain
The baseline image (`python-baseline-2026-08-10` / `dev`) was built at 02:14Z,
but the container's `/app` was hot-patched afterwards:
* `tools/utas_server.py` — gained the `FUT_MODES`-gated `offlineSeason` block in
GetHubData's club response (keeps the hub's offline-season summary valid).
* `tools/test_hub_offline_season_contract.py` — added to `/app/tools`.
`docker save` captures the image, not the container's writable layer, so the
baseline tar.gz lacks those two changes. Two paths cover the exact runtime:
* `openfut-fut-backend:python-running-2026-08-10` — `docker commit` of the
running container (sha256:093a98fa0496...), the exact runtime FS.
* The committed `fifa17-python/tools` + `data` — synced to match the running
container byte-for-byte (237 files verified, incl. the redir cert pair), so a
fresh build reproduces the actual running backend. Proven by rebuilding from
the committed sources and diffing the baked `/app/SHA256SUMS.txt` against the
container manifest: identical.
Archive: `docker-backups/openfut-fut-backend-python-running-2026-08-10.tar.gz`.
+125 -16
View File
@@ -215,7 +215,11 @@ Path template `%s = "game/fifa17"`. Methods inferred from struct verb + endpoint
### Freeze-risk summary (type fidelity is mandatory)
- `auctionInfo` → **array** (never object/scalar).
- `itemData` inside each record → **object** (the card; reuse `item_def`).
- `duplicateItemIdList` → **array**.
- `duplicateItemIdList` → **array of objects** (element deser `0x180138e10`: `itemId` 0x16d,
`duplicateItemId` 0xeb, `itemLoans` 0x16f, `duplicateItemLoans` 0xed). Not an int list.
`[]` is safe; a list of bare ints is a freeze. Control that this is not a misread:
`dreamSquads` 0xe9 in FutMoveCard genuinely IS a bare int array, parsed by a
`while (tok != 0xd)` loop calling the int getter with no inner object loop.
- `bidState`, `tradeState`, `sellerName` → **strings**.
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
- `watched` → **bool**.
@@ -966,7 +970,11 @@ Notes:
{ "itemData": [ /* the single updated card item */ ] }
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 }
// CORRECTED 2026-08-05: `items` is an array of OBJECTS and there is no top-level `id`.
// The previous shape, { "items": [ 123456789 ], ..., "id": 123456789 }, was wrong twice
// over, and feeding a bare int where the element parser expects an object is a tokenizer
// desync, i.e. a hard freeze at 0x1801c7f1a, not a soft failure.
{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 }
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
{ "totalCredits": 15000 }
@@ -1042,7 +1050,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk) |
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk). **`finalFunds` is the number the tile RENDERS. CONFIRMED LIVE 2026-08-05** by serving `funds=15000, finalFunds=4321` on one pack and reading `4,321` off the store tile. `funds` is not displayed. |
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
| `sortPriority` | 0x2cb | INT | |
@@ -1092,7 +1100,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
| `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk |
| `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | freeze-risk |
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
- **Minimal known-good**:
@@ -1243,21 +1251,122 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
- **Deser:** `0x18013c6d0`
- **HTTP:** `GET ut/%s/settings`
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED
- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end)
- **HTTP:** `GET ut/%s/settings`, and the `settings` (0x2bf) member of `userMassInfo`
(both callers of the deser: `0x18014e590` and `0x180174630`)
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
`{ type (0x354), value (0x377) }`.
- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`.
`{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else.
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
**The mechanism the key ladder hides.** A flag is not a JSON key. When an element
closes, the client feeds the STRING VALUE of `type` back through the atom hasher
(`FUN_180180d00`) and switches on the result, 42 arms wide:
```json
{"configs": [{"type": "friendlySeasonsEnabled", "value": 1}]}
```
So the flag vocabulary is the same atom table everything else uses, and the client
hashes our string itself — a flag cannot be misnamed silently, it simply falls
through to the default arm and is ignored.
- **`value` is type-forgiving.** Its getter `0x1801c79d0` accepts int (token 2),
float (3), bool (4) and string (5, via `sscanf "%I64d"`), coercing all four to
int64. `1`, `"1"` and `true` are equivalent. This is one of the few scalar
getters in the API with NO desync risk on scalars. An object or array is still
a freeze.
- **The applier demands exactly 1.** `FUN_18011dc50` is the only writer of the
gate bytes and every line is `gate_byte = (field == 1)`. Not truthiness. `2`,
`-1` and `"yes"` all read as OFF.
**Flags that publish a UI gate key.** `FUN_18006cc60` publishes IS_* state keys by
reading single bytes inside `FutDataManagerImpl` (service id `0xed84b11`, ctor
`0x18010cdc0`). Those bytes are written ONLY by the applier, and the ctor never
touches them (whole 16620-char ctor scanned):
| flag `type` | field | gate byte | UI key |
|---|---|---|---|
| `tradingEnabled` | `[10]` | `0x1fd2e` | `IS_TRADING_ENABLED` |
| `storeEnabled` / `_JP` | `[0xb]` / `[0xc]` | `0x1fd2f` / `0x1fd30` | `IS_STORE_ENABLED` (accessor `0x18011c600` picks `_JP` when region == 4) |
| `friendlySeasonsEnabled` | `[0x16]` | `0x1fd3a` | `IS_FRIENDLY_SEASON_ENABLED` |
| `tournamentQuitEnabled` | `[0x20]` | `0x1fd3b` | `IS_TOURNAMENT_QUIT_ENABLED` |
| `processingStateEnabled` | `[0x21]` | `0x1fd3c` | `IS_PROCESSING_STATE_ENABLED` |
| `enableDraftMode` | `[0x17]` | `0x1fd3d` | `IS_DRAFT_MODE_ENABLED` |
| `enableOfflineDraftMode` = `enableSinglePlayerDraftMode` | `[0x18]` | `0x1fd3e` | (shared arm, one field) |
| `storyModeRewardEnabled` | `[0x1f]` | `0x1fd3f` | `IS_STORY_MODE_REWARD_ENABLED` |
| `returningUserRewardsScreenEnabled` | `[0x19]` | `0x1fd40` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` |
**Why this is the standing suspect for Seasons and Draft.** Both refuse while
making zero requests to any of the four servers, which no response shape can
explain. A UI key evaluated from a byte that nothing ever wrote does explain it.
The store is the control: `IS_STORE_ENABLED` reads the same kind of byte and its
screen works, because `storeEnabled` and friends are already shipped through the
**Blaze** client-config store (`FUT_RS4_CONFIG` in `blaze_responder_v3b.py`) —
and that list contains no seasons, draft or tournament flag. Same mechanism, one
population, one blank.
This is a hypothesis with a mechanism, not a confirmed cause. It predicts that
sending the flags opens the screens; if they still refuse, the gate is upstream
of the UI key and the whole settings line is dead.
**Two arms that are not simple assignments:**
- `enableObjectives` (0xfd) and `enableObjectivesAsManagerTasks` (0xfe) share an
arm that can only ever CLEAR `[0x1c]`: `if (value == 0) field = 0`. Sending 1
is a no-op. Objectives cannot be turned ON here, only off.
- `clientKeepAliveResetTimeoutSec` (0x86, vtable +0x68) and `getOperationTimeoutSec`
(0x13d, +0x58) do not store a field; they call a timer object with `value * 1000`.
Sending a small number shortens client timeouts. Leave them alone.
**`maximumTradePileSize` (0x1c0) is the positive control.** It lands in `[0]` and
is passed to `FUN_18011f380`, and transfer-list capacity is visible in game. It
distinguishes "the flag did not help" from "the configs array never reached the
consumer at all", which no boolean flag can do on its own.
**Not in the switch:** `enableSquadBuildingSetsFeature` (0x100) is a real atom but
has NO arm here, so SBC is gated somewhere else. Scanned the full decompile;
this absence is asserted over the whole function, not a slice.
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
`off` restores the historical `{"configs": []}`.
### FutGetHubDataServerResponse — CONFIDENCE: HIGH (schema fully enumerated) — ✅ HANDLED (tiles populated)
- **Deser:** `FUN_180139610` (root object parser). Wrapper `0x1801736ad`.
- **HTTP:** `GET ut/%s/hub`
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`,
`call [rdx+0x1f8]`), NOT an inline atom ladder — no static field ladder to
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with
its own deser. Empty `{}` is tolerated (fields default).
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP.
- **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
It reads **18 atoms**, all enumerated below straight from the on-disk CardsDLL
via objdump (`fifa17-recon` scratchpad `hub_ladder.py`). The vtable calls are the
per-sub-object dispatch one indirection deeper, not the field read itself.
- **The 18 root atoms** (name ← `fut_atoms.tsv`):
`allObjectivesForCurrentGameSpaceId`(0x15), `auctionCount`(0x33),
`championEvent`(0x7a), `clubPlayers`(0x90), `draftSummary`(0xe4),
`friendlySeason`(0x131), `leaderboard`(0x186), `liveMessagesAvailable`(0x190),
`objectivesForCurrentUser`(0x1e3), `offlineSeason`(0x1ec), `ONLINE`(0x1f1),
`onlineSeason`(0x1f6), `SINGLE_PLAYER`(0x29d), `squad`(0x2cd),
`tournament`(0x328), `tournamentProgress`(0x32c), `tradePile`(0x333),
`watchlist`(0x381).
- **TILE MAP (which atom drives which hub tile):**
- `clubPlayers`(0x90) int → MY CLUB tile "N players" (TILE_ID 0x210)
- `auctionCount`(0x33) int → TRANSFER MARKET tile "N LIVE TRANSFERS" (TILE_ID 0x1b0)
- `tradePile`(0x333) **nested object**, sub-deser `0x18013ead0` → TRANSFER LIST
tile "N ITEMS / Selling / Sold". Sub-atoms: `count`(0xbc), `notification`(0x1da),
`selling`(0x2b8), `sold`(0x2c9) — all scalar int via `0x1801c79d0` (5 int reads,
one SKIP, object field loop; no array/nested object → no type-desync surface).
Same atom scheme as `FutGetAuctionCount`. **All active listings are `selling`;
`count == selling == len(listings)`, `sold == 0`.**
- `watchlist`(0x381) nested object, sub-deser `0x18013f3b0` → WATCH LIST tile (not
yet populated; empty watch list defaults to 0, which is correct today).
- **LIVE SYMPTOM this fixed (2026-08-06):** a card was actively listed
(`auctionCount` 1, Listed Items screen showed it) yet the TRANSFER LIST tile read
"0 items / Selling 0". The tile reads `hub.tradePile`, which we were omitting; it
does **not** re-poll `/tradePile/counts` (the standalone GetAuctionCount endpoint)
once at the hub. Serving `hub.tradePile:{count,selling,sold}` corrected the tile.
- **Handled:** `utas_server.hub_data()` serves `clubPlayers`, `auctionCount`, and
`tradePile:{count,selling,sold}` (`FUT_HUBDATA=1`, default on). Remaining atoms
(seasons/draft/tournament/objectives/leaderboard summaries) default to 0/absent,
which is correct while those modes are unpopulated.
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
# The /settings feature gate - live-test script
> **CORRECTION, 2026-08-05 evening. Section 1 of this document is FALSE and the
> test in section 3 should not be run as written.**
>
> Section 1 claims `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have
> never been set to true by anything, on any run". They are measured as **1**, on
> two separate launches, while `/settings` was answering `{"configs": []}`:
>
> ```
> disp 0x1fd3a (friendlySeasonsEnabled) value = 1
> disp 0x1fd3d (enableDraftMode) value = 1
> disp 0x1fd45 (packOpeningAnimationEnabled) value = 1
> ```
>
> Reproduce with `tools/gate_byte_probe.py` (needs the client at the FUT hub, since
> CardsDLL loads only then): it resolves the pid by comm,
> re-derives the CardsDLL slide from `/proc/<pid>/maps`, proves it against the FNV
> prologue at `0x180180d00` read from disk, walks the model singleton at
> `DAT_1802e6398`, and decodes each displacement out of its accessor stub
> (`0f b6 81 <disp32>`) rather than assuming it.
>
> **Where the reasoning went wrong.** The finding that `FUN_18011dc50` is the only
> writer and that the `FutDataManagerImpl` constructor never touches those bytes was
> correct. The inference drawn from it was not. The applier runs whether or not the
> configs array has content, and the settings struct it is handed defaults these
> fields to 1, so the bytes were being written all along. "Nothing populates the
> array" was treated as "nothing writes the byte". Those are different claims and
> only the first one was established.
>
> Seasons therefore does not refuse because its gate byte is false. Its gate byte is
> true. The mechanism is still unknown and needs a fresh diagnosis. Everything below
> the correction is kept as the record of a wrong turn, not as a plan.
Written 2026-08-05, after reversing `FutGetSettingsServerResponse` end to end.
Nothing here has been in front of the game yet. The code default is `off`, which
serves the exact historical `{"configs": []}`, so the tree is currently at the
proven baseline and this test is opt-in.
Full schema, atom ids, gate bytes and accessor addresses are in `ENDPOINT_MAP.md`
under `FutGetSettingsServerResponse`. This file is only the experiment.
---
## 1. The claim being tested
`GET /settings` is requested 11 times a session and has always been answered with
an empty array. The array is not decoration:
- Each element is `{"type": "<name>", "value": <scalar>}`. The client hashes the
**string value** of `type` through the atom hasher and switches on it, 42 arms
wide, so a flag is a row rather than a key.
- `FUN_18011dc50` is the **only** writer of the `IS_*` UI gate bytes inside
`FutDataManagerImpl`, and every line of it is `byte = (field == 1)`.
- The `FutDataManagerImpl` constructor never touches those bytes. The whole
16620-char decompile was scanned for the block; it is absent.
So `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` have never been set
to true by anything, on any run, in the whole history of this project.
That is a mechanism for the standing bug in which **Seasons refuses while making
zero requests to any of the four servers.** No response shape could ever explain
that. A UI key evaluated from a byte nobody wrote does.
**The store is the control that makes this readable.** `IS_STORE_ENABLED` is the
same kind of byte read the same way, and the store screen works. It works because
`storeEnabled` and its siblings already reach the client through the **Blaze**
client-config store (`FUT_RS4_CONFIG`). That list contains no seasons flag, no
draft flag, no tournament flag. Same mechanism, one populated, one blank.
This is a hypothesis with a mechanism, not a demonstrated cause.
---
## 2. Pre-flight, from the terminal, costs nothing
```bash
cd fifa17-recon/tools
FUT_SETTINGS=gates python3 check_settings_flags.py # expect: 14 rows, PASS
python3 check_settings_flags.py # expect: mode=off, PASS
```
The checker asserts every shipped flag name against **both** the atom table and
the recovered switch arms. Both are needed: `enableSquadBuildingSetsFeature` is a
genuine atom with no arm in this switch, so the atom table alone would wave
through a flag that does nothing. A misnamed flag is silently inert and looks
exactly like a failed fix, which is the failure mode this guards.
---
## 3. The run
Budget: **one launch.**
```bash
cd fifa17-recon/tools
FUT_SETTINGS=gates ./openfut-fut.sh start
~/Desktop/launch-fifa17.sh
```
Then, in order, and write down what each one does:
1. **Store.** Open it. This is the control and it goes first, because if
populating the array broke the store then the applier demonstrably ran and
everything after this reads differently.
2. **Transfer list capacity.** Transfers → Transfer List. Read the capacity
number. We send `maximumTradePileSize = 77`, a number FUT would never choose
on its own.
3. **Seasons.** Single-player Seasons, the exact path that has been refusing.
4. **FUT Draft.** Both the offline and online entries.
5. **Tournaments**, for `tournamentQuitEnabled`.
---
## 4. Reading the result
The control in step 2 is what makes a negative result informative, so read it
before concluding anything about steps 3 to 5.
| Store (1) | Capacity (2) | Seasons (3) | Reading |
|---|---|---|---|
| works | **77** | opens | Confirmed. The gate was the empty array. Make `gates` the default and move to the `/match` shape, which has been blocked behind this. |
| works | **77** | still refuses | The array reached the consumer and the flag was applied, so the gate is **upstream of the UI key**. The settings line is then dead for Seasons and the next move is a live probe of the refusal path, not more response work. This is a real result, not a null one. |
| works | not 77 | still refuses | The array never reached the consumer at all. Everything above is untested rather than refuted. Suspect the massinfo `settings` member (the deser's other caller) is what the client actually reads, and check which of the two paths fires in `/tmp/utas.log`. |
| **breaks** | any | any | The applier ran and re-asserting the store flags did not hold them. Fall back to `FUT_SETTINGS=keep`, which sends only the already-working flags plus the control. If `keep` also breaks the store, populating the array is harmful in itself and the whole approach is wrong. |
`keep` exists precisely so that "populating the array at all" and "the new gates"
can be separated without guessing, and it costs one restart to use.
---
## 5. What would make this whole plan wrong
**The gate might not be a UI key at all.** Seasons could be refusing on an
entitlement, a persona attribute, or a Blaze session property evaluated inside
the Denuvo-packed executable, in which case no `/settings` body reaches it. The
step-2 control is what tells these apart: it distinguishes "the flag did not
help" from "the array was never consumed", and no boolean flag can do that alone.
**The store control could be weaker than it looks.** The argument assumes
`IS_STORE_ENABLED` currently comes from the Blaze store rather than from a
default. If it turns out the store screen does not read that key at all, then it
is not a control for anything and the reasoning in §1 loses its anchor.
**Draft has a second known suspect.** `GET ut/%s/squad/mode/draft/state` is still
answered by the generic `/squad` handler with a full active-squad object, which
is a textbook type-desync candidate. If Draft still fails while Seasons opens,
that route is the next thing to look at, not the flag.
**A negative result here is worth having.** The settings array has been the
standing suspect for the greyed-out entry points for two rounds without anyone
sending a single flag. Ruling it out costs one launch and removes it from the
backlog permanently.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
# The refusing modes: Seasons, Draft, SBC/Objectives, Tournaments — where the greying is decided
Written 2026-08-06. One reconnaissance pass over the live gate-byte block and the
six `/hub` mode sub-deserializers, then four parallel per-mode investigations
(Seasons, Draft, SBC+Objectives, Tournaments), each followed by an independent
adversarial verification round. FIFA 17 was running throughout as **pid 24653**,
sitting at the FUT hub, and was read strictly read-only. No server was restarted,
no server code was changed, no memory was poked, and FIFA was never launched or
killed.
Slide for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, i.e.
slide `0x6ffe7c140000`, re-derived from `/proc/24653/maps` and proved by
`tools/gate_byte_probe.py` reporting **CONTROL FNV MATCH** against the FNV hasher
prologue at `0x180180d00`. CardsDLL is mapped from `/mnt/games/FIFA 17/
CardsDLL_Win64_retail.dll`; the on-disk copy read with `objdump` is
`/tmp/fut/cardsdll.dll`, image base `0x180000000`. Every address below is
live-verified.
This document answers one question the brief posed: is the refusal of these four
mode families decided by a **server-reachable input we are failing to send** (a hub
mode sub-object, a massinfo member, a settings/config field, or a dedicated
endpoint), **or** is it decided in the **Denuvo-packed FIFA17.exe / Frostbite
front-end** with no server surface at all?
---
## 1. Headline — final verdicts (after adversarial verify)
Every mode was independently re-derived by a second agent that attempted to refute
the first. **All four refutations failed. All four verdicts stand.**
| Mode | Atoms | Final verdict | Confidence | Verify |
|---|---|---|---|---|
| **FUT Seasons** (offline + online + friendly) | `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
| **FUT Draft** (offline + online) | `draftSummary 0xe4` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), strengthened |
| **SBC + Objectives** | `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId 0x15` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), prior chain corrected |
| **FUT Tournaments** | `tournament 0x328`, `tournamentProgress 0x32c` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
**There is no server fix for any of the four.** Every server-reachable input that
touches these modes is either cosmetic (a hub stat list feeding a caption/count),
an *output* value the client emits and never branches on, or a settings byte that
is **already live=1** while the tile stays greyed. The decision lives in the packed
front-end. This is the same shape as the transfer-market finding of the same day —
except there the switch (`userInfo.feature.trade`) was ours to flip; here **no such
switch exists on the wire.**
---
## 2. Ground truth
### The named gate-byte block (`FutDataManagerImpl`, live pid 24653)
Names were resolved by finding the config serializer at `0x18006ccd0`, which pairs
each `IS_*_ENABLED` string key (`.rdata 0x1801fc118..`) with a getter vtable slot,
then decoding each slot's accessor stub (`0f b6 81 <disp32> c3`) to its model
displacement. All values read live, slide-proven.
| Name | Displacement / slot | Live value |
|---|---|---|
| (unnamed) | `+0x1fd24` | 0 |
| (unnamed) | `+0x1fd2c` | 1 |
| (unnamed) | `+0x1fd2d` | 1 |
| **IS_TRADING_ENABLED** | `+0x1fd2e` (slot+0x270) | 1 |
| (unnamed) | `+0x1fd30` | 1 |
| (unnamed) | `+0x1fd37` | 1 |
| **IS_FRIENDLY_SEASON_ENABLED** | `+0x1fd3a` (slot+0x2b0) | 1 |
| **IS_TOURNAMENT_QUIT_ENABLED** | `+0x1fd3b` (slot+0x2b8) | 1 |
| **IS_PROCESSING_STATE_ENABLED** | `+0x1fd3c` (slot+0x2c0) | 1 |
| **IS_DRAFT_MODE_ENABLED** | `+0x1fd3d` (slot+0x2c8) | 1 |
| (unnamed) | `+0x1fd3e` (offline-draft-enable) | 1 |
| **IS_STORY_MODE_REWARD_ENABLED** | `+0x1fd3f` (slot+0x2d8) | 1 |
| **IS_RETURNING_USER_REWARDS_SCREEN_ENABLED** | `+0x1fd40` (slot+0x2f0) | 0 |
| (unnamed) | `+0x1fd41` | 0 |
| (unnamed) | `+0x1fd42` (allowGracePeriod, SBC) | 0 |
| (unnamed) | `+0x1fd43` | 0 |
| **objectives-enable** (corrected — see §5.3) | `+0x1fd44` | 1 |
| **packOpeningAnimation** | `+0x1fd45` | 1 |
| (unnamed) | `+0x1fd46` | 1 |
| (unnamed) | `+0x1fd47` | 0 |
| (unnamed) | `+0x1fd48` | 1 |
| **IS_STORE_ENABLED** | computed getter slot+0x280 @`0x18011c600` (not a byte field) | (computed) |
Every named gate byte that governs a **refusing** mode reads **ENABLED=1** live.
The only `0`-valued `*_ENABLED` byte, `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED`,
does not gate any of the four mode families. This re-confirms the brief's prior
ground truth: the gate-byte layer does **not** explain the refusals.
### The six `/hub` mode sub-deserializers (`/hub` parser = `FUN_180139610`)
The hub parser reads 18 atoms via a running-sum sub/dec ladder; six dispatch to the
refusing modes. Each nested sub-deser was read in full. **None carries an
enable/available/unlocked boolean.**
| Atom | Name | Sub-deser VA | Fields (all cosmetic/data) |
|---|---|---|---|
| `0x131` | friendlySeason | `0x1801392a0` | creationTime, dataVersion, opponentPersonaId, opponentUserPoints, round, seasonId, userPoints, defId (8 ints) |
| `0x1ec` | offlineSeason | `0x18013c3a0` | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
| `0x1f6` | onlineSeason | `0x18013c3a0` (shared) | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
| `0xe4` | draftSummary | `0x180138d60` | draftState (str-enum), gamesWon (int) |
| `0x328` | tournament | `0x18013dc00` | id, assetName, imageFormat, silhouetteName, timeUntilEnd, tournamentType, AMATEUR, live_offline, offerState (display) |
| `0x32c` | tournamentProgress | `0x18013df20` | data, tutorialClientData (free-form std::map) |
The recurring trap: several of these desers write a per-field byte
(`offline/onlineSeason` `[r14+0xa]=1`; `tournament` `[rdi+0x162]=1`) that an early
naive pass could mistake for a JSON enable flag. Every such write is a
**parser-local "field present" marker**, written identically for every field —
**not** a JSON-sourced availability input. This is the same class of mistake that
made `hub.tradePile` look like a gate before it was shown to be a mere count.
---
## 3. FUT Seasons — NOT_SERVER_REACHABLE (HIGH)
**Atoms:** `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6`.
**Gate byte:** `IS_FRIENDLY_SEASON_ENABLED +0x1fd3a`, live=1.
**Evidence chain.** The decisive site is the gate byte `+0x1fd3a`. A whole-`.text`
grep finds **exactly two** references:
- **Writer** `0x18011dd2d`: `mov byte[rdi+0x1fd3a],al` inside settings applier
`FUN_18011dc50`, preceded by `cmp dword[rbx+0x58],1 / sete al` — the byte is
`(settings.field+0x58 == 1)`, sourced from config key `friendlySeasonsEnabled`.
This is the **only** writer.
- **Reader** `0x18011c500`: `movzx eax,byte[rcx+0x1fd3a]; ret` — a standalone
vtable getter stub (slot+0x2b0). Its absolute address appears in the file exactly
once, at the vtable, and grep finds **no** call/jmp to `0x18011c500` anywhere in
CardsDLL `.text`. Its only consumer is the packed FIFA17.exe front-end via vtable
dispatch.
The one server-writable input (`friendlySeasonsEnabled → +0x1fd3a`) is **already 1
live**, and the tile is still greyed — so the front-end does not gate on this byte
alone; it reads additional non-server state.
- **Hub sub-objects** carry no enable flag. `offline/onlineSeason` share deser
`0x18013c3a0`, which FNV-hashes string keys and for each stores a division/games/
points/version stat; `friendlySeason 0x1801392a0` is 8 numeric stats. The
`[r14+0xa]=1` write is the "field present" marker. These feed a caption/count.
- **Settings/massinfo:** `friendlySeasonsEnabled` is the sole season key the applier
consumes → `+0x1fd3a`, already covered. No massinfo member carries a season enable.
There is **no** `onlineSeasonEnabled`/`offlineSeasonEnabled` config key or gate
byte anywhere in the DLL — verify enumerated all 24 gate-region getter stubs and
the only season getter is `+0x1fd3a`.
- **Dedicated endpoint:** `/season` and `/season/user` routes exist in
`utas_server.py` (guarded by `FUT_MODES`) but the client has **never** requested
them — 0 season hits across `captures/`, 486 real ProtoHttp requests over ~30
boots, none for `/season`. And the tile greys at hub load, *before* any `/season`
request could fire.
- **Front-end:** the only season-enable identifiers in the whole DLL are the config
*input* `friendlySeasonsEnabled` and the *output* getter name
`IS_FRIENDLY_SEASON_ENABLED`. The viewmodel names
(`futonlineseasonsviewmodel`, `futofflineseasonsviewmodel`,
`futfriendlyseasons*viewmodel`) live in the Denuvo-packed FIFA17.exe.
**Authority boundary.** `friendlySeasonsEnabled` is a **server-writable input**,
but it is already at ENABLED with its only reader **off-DLL (client)**. Offline/
online seasons have **no server surface at all** — no config key, no gate byte, no
getter. The grey/refuse decision is **client-side**.
---
## 4. FUT Draft — NOT_SERVER_REACHABLE (HIGH, strengthened by verify)
**Atoms:** `draftSummary 0xe4`. **Gate byte:** `IS_DRAFT_MODE_ENABLED +0x1fd3d`,
live=1.
**Evidence chain.** Cross-ref of displacement `0x1fd3d` returns exactly two real
sites (a `lea` to `0x1801fd3d8` and an instruction at address `0x18011fd3d` are
coincidental, not xrefs):
- **Accessor stub** `0x18011c4b0`: `movzx eax,[rcx+0x1fd3d]; ret` (getter vtable
`.rdata 0x18021c568`).
- **Writer** `0x18011dd5a`: `mov [rdi+0x1fd3d],al` in applier `FUN_18011dc50`,
`al = (settings[rbx+0x5c]==1)` = parsed `enableDraftMode`.
There is **no cmp/test/branch** on this byte anywhere. Its only CardsDLL consumer
is the config serializer `0x18006ccd0`, which walks the `IS_*_ENABLED` key table and
`call [rax+0x2c8]` to **emit** the value outward. So `IS_DRAFT_MODE_ENABLED` is an
**output the client serializes, not an input any logic branches on.**
**The verifier strengthened this** by finding a consumer the first pass missed: a
flux "DESTINATION" navigation emitter around `0x1800b2700`. At `0x1800b2711` it
loads getter slot `+0x2c8` (draft-enable, `+0x1fd3d`) into `sil` and slot `+0x2d0`
(offline-draft-enable, `+0x1fd3e`) into `[rsp+0x21]`. All six `GOTO_DRAFT_DISABLED`
emit sites (`0x1800b2cb2`, `0x1800b2dc9`, `0x1800b333b/347`, `0x1800b349f/4a7`) are
guarded by `test sil,sil` / `cmp [rsp+0x21],0` and route to `GOTO_DRAFT_DISABLED`
**only when those bytes are 0**, else to `GOTO_DRAFT_OFFLINE/ONLINE`. Both bytes are
**live=1**, so this emitter — the closest thing to a nav decision inside CardsDLL —
already produces the ENABLED destinations, yet the tile is still greyed.
- **Hub sub-object** `draftSummary 0xe4`, member deser `0x180138d60`: exactly two
atoms — `draftState 0xe3` (STRING → enum decoder `0x180138cc0`, a resume-state
enum: INVALID + 2..8) and `gamesWon 0x13a` (INT). Wrapper `0x18013980c` loops
`ONLINE 0x1f1` / `SINGLE_PLAYER 0x29d`, each → `0x180138d60`. No enable atom;
`draftState` is the continue-state read after entry, not a tile gate.
- **Settings/massinfo:** atoms `enableDraftMode 0xf9` / `enableOfflineDraftMode
0xfa` / `enableSinglePlayerDraftMode 0xff` land on sibling emit-only bytes
`+0x1fd3d`/`+0x1fd3e`/`+0x1fd3c` via the same applier — none branched on.
- **Dedicated endpoints:** `GET /squad/mode/draft/state` (deser `0x180147070`) and
`POST /purchase/mode/N/draft` (deser `0x18014c260`) are already routed in utas —
but these are the **post-click** entry/session flow (render the draft screen, buy
entry *after* the tile is pressed), not a tile-availability query.
- **Front-end:** token strings (`USER_HAVE_DRAFT_TOKENS 0x1802055f8`,
`GOTO_DRAFT_DISABLED 0x180209aa8`, etc.) are bare key-name `lea` emitters with no
greying branch. Decision is in the packed FIFA17.exe.
**Authority boundary.** The two server-writable inputs (`enableDraftMode`,
`enableOfflineDraftMode`) are **already at their enabled value**, and **every**
CardsDLL consumer of them (config serializer *and* the navigation emitter) already
treats draft as enabled. The persistent greying is decided **client-side** on
non-server state.
---
## 5. SBC + Objectives — NOT_SERVER_REACHABLE (HIGH, prior chain corrected)
**Atoms:** `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId
0x15`. **No `IS_OBJECTIVES`/`IS_SBC` gate-byte name exists** — the task premise that
these are governed by no named `FutDataManagerImpl` gate byte is confirmed.
### 5.1 Hub sub-object = cosmetic list
In `FUN_180139610` both objectives atoms share one arm: `objectivesForCurrentUser
0x1e3` (`0x180139794`) and `allObjectivesForCurrentGameSpaceId 0x15`
(`0x1801397ad`) both jump to `0x1801398fe`, guarded by the parser-local marker
`cmp BYTE [rsp+0x21],0x1`, calling sub-deser `0x18013a7f0`. That deser parses a
nested `objectives 0x1e2` **array** of records (element parser `0x18006c9b0`) with
**no** enabled/available/unlocked atom — it feeds the "MANAGER TASKS N/M" tile
count/caption, the same cosmetic class as `hub.tradePile`.
### 5.2 No dedicated endpoint at the hub
The live log across 26+ hub sessions shows the client requests only `/hub` and
`/settings`; it **never** calls `/sbs/*` (grep count 0) or any `/objectives`
endpoint. `utas_server.py` has no `/sbs` route. No `FutGetObjectivesServerResponse`
class exists — objectives are **ManagerQuests**, client-driven. The `sbs/*` structs
that exist serve challenge **content after entry**, never polled at the hub.
### 5.3 The correction (verify fixed the first pass's chain)
The first pass mis-traced objectives to settings field `[0x1c]` → model `+0x1fd28`
(default 60). **The verifier re-derived the settings jump table (dispatch
`0x18013ca1e`, byte-idx `0x18013ced4`, jtbl `0x18013ce90`) and found the truth:**
- `enableObjectives 0xfd` **and** `enableObjectivesAsManagerTasks 0xfe` route to
handler `0x18013cabd` = clear-only-on-zero into settings field `[0x70]`; applier
`0x18011ddc7` (`cmp [rbx+0x70],1; sete al; mov [rdi+0x1fd44],al`) maps it to model
gate byte **`+0x1fd44`** — which is **inside** the named gate block (not outside,
as the first pass claimed), reads **1 (ENABLED) live**, and has exactly one reader
DLL-wide: a getter stub `0x18011c570` returning the byte to the front-end with no
internal gating use.
- The first pass's `+0x1fd28` (default 60) is actually
`squadBuildingSetsGracePeriodMinutes 0x2d0`, a numeric grace-period param —
behavioral, not availability.
- **SBC side:** `enableSquadBuildingSetsFeature 0x100` falls in the dispatch **gap**
(`0x100-0x18=0xe8 > 0xe7 → DEFAULT/no handler`), as do `squadBuildingSetsClientData
0x2cf` and `squadChallenge 0x2d1`. Only numeric SBC params have handlers
(`allowGracePeriod 0x18 → +0x1fd42`, `allowUntradeable 0x19 → +0x206f8`,
`gracePeriodMinutes 0x2d0 → [0x1c]/+0x1fd28`). **No SBC availability model byte
exists.**
So the single server-controllable objectives-enable input (`+0x1fd44`) is already at
1 yet the tile refuses, and SBC has **no** server enable surface whatsoever.
**Authority boundary.** Objectives-enable is a **server-writable byte already ON**,
read only by the **client**. SBC availability has **no server surface** — its enable
key is in the settings dispatch gap and lands on no byte. Decision is **client-side**
(`futmanagerquestsviewmodel`; providers `FUT_MQ_QUESTS_DATA_DP` /
`FUT_SQUAD_QUESTS_DP`) in the packed FIFA17.exe.
---
## 6. FUT Tournaments — NOT_SERVER_REACHABLE (HIGH)
**Atoms:** `tournament 0x328`, `tournamentProgress 0x32c`. **Gate byte:**
`IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b`, live=1 — but this governs **quitting** a
tournament, not tile availability, and no `tournamentEnabled` atom exists in
`docs/fut_atoms.tsv`.
**Evidence chain.**
- **Hub sub-objects, both cosmetic.** `tournament 0x328` deser `0x18013dc00` writes
only display fields: id `[rdi+0x150]`, round `[rdi+0x160]`, timeUntilEnd
`[rdi+0x158]`, silhouette-int `[rdi+0x15c]`, string blobs `[rdi]`/`[rdi+0xa8]`
(assetName/silhouette/type), an `imageFormat=="dds"` render bool `[rdi+0x163]`
(strcmp vs `.rdata 0x180219400`), and a `tournamentType` enum `[rdi+0x154]`
decoded to `live_offline 0x195`/`live_online 0x196`/`offline 0x1e8`/`online 0x1f0`
— a categorization, not availability. The `[rdi+0x162]=1` write is a
record-completeness marker (all core fields present), not a JSON enable.
`tournamentProgress 0x32c` deser `0x18013df20` builds a std::map (ctor
`0x1801e5210`) of string keys `data 0xc9` / `tutorialClientData ~0x353` — free-form
clientData, no enable atom. (The earlier `0x28a = returningUserRewardsScreenEnabled`
label was a running-sum mis-decode; the true sum is `0xc9+0x28a=0x353
tutorialClientData`.)
- **Massinfo/settings.** `tournamentCoins 809 → +0x30` and `teamOfTournamentWinner
776 (bool) → +0x34` appear only in the **FutDestroyMatch** reward deser
`0x180121b60` — a match payout reached only *after* you are inside a tournament
match; a reward count/trophy flag, not a tile gate. The settings applier switch
`0x18013c6d0` has 42 arms; the only tournament arm is `tournamentQuitEnabled 0x32D
→ +0x1fd3b` (quit, live=1).
- **Gate byte** `+0x1fd3b`: getter stub `0x18011c660` is the vtable **emit**
accessor the config serializer `0x18006ccd0` pairs with the JSON key to write it
out — the client emits it, does not read it as a server input. Writer
`0x18011dd3d`, `al = sete(cmp settings[rbx+off],1)`, defaults to 1. Already 1,
wrong feature.
- **Dedicated endpoint, never called.** `tournament_list` (deser `0x180169ef0`) and
`tournament_user` (deser `0x180147cb0`) exist in `utas_server.py` but grep over
`captures/` and the live `/tmp/utas_server.log` (3224 lines) finds **zero**
ProtoHttp requests for any `/tournament` path across all boots — same as `/season`.
The responses are never consumed.
- **Front-end.** No CardsDLL response deserializer writes any "tournament
available/unlocked" field. `eligibilities 0xf1` / `unlocks 0x35c` / `available 0x3e`
are SBC/store vocab per `docs/ENDPOINT_MAP.md`, not wired to tournaments. Decision
is in the packed FIFA17.exe.
**Authority boundary.** The only server-touchable tournament byte
(`IS_TOURNAMENT_QUIT_ENABLED`) is an **emitted output** governing a different
feature, already 1. Everything else is cosmetic hub data or post-entry reward data.
Tile availability is decided **client-side**.
---
## 7. What changed vs the prior conclusion
The prior workflow examined **only the `FutDataManagerImpl` gate bytes** and
concluded "no server fix" for these modes. This workflow re-opened the question by
chasing the **hub-atom lead** — the six mode sub-deserializers we do not currently
populate — plus massinfo members, settings arms, and dedicated endpoints.
**The hub-atom lead does not change the conclusion for any mode.** Per mode:
- **Seasons:** the hub `friendlySeason`/`offline`/`onlineSeason` sub-objects are
numeric stat blobs (division/games/points), cosmetic like `hub.tradePile`. The
`[r14+0xa]=1` byte is a "field present" marker, not a JSON enable. No change —
still NOT_SERVER_REACHABLE.
- **Draft:** `draftSummary` carries only `draftState`+`gamesWon`; verify additionally
found the in-DLL navigation emitter already routes to the *enabled* destination on
current live state. No change — verdict **strengthened**.
- **SBC/Objectives:** the objectives hub arm is a cosmetic list feeding "MANAGER
TASKS N/M". Verify *corrected the prior chain* — the real objectives-enable byte is
`+0x1fd44` (inside the gate block, live=1), and SBC's enable key falls in a
dispatch gap with no byte at all. No change to the verdict; the correction only
hardens it.
- **Tournaments:** both hub sub-objects are display/clientData only. No change.
**Net:** examining the hub atoms was the right next step, and it closed the lead
rather than opening a fix. Every server-reachable surface for these four modes is
now accounted for and none is an availability input. The prior "no server fix"
conclusion holds, now on much broader evidence.
---
## 8. Client-vs-server authority boundaries (explicit)
| Surface | Who writes it | Who reads it | Is it a mode-availability gate? |
|---|---|---|---|
| Gate bytes `+0x1fd3a/3b/3d/44` etc. | **server** (settings applier `FUN_18011dc50`) | **client** (getter stubs, off-DLL vtable dispatch) + config serializer `0x18006ccd0` (emit) | No — all live=1, never branched on inside CardsDLL |
| Hub mode sub-objects (`0x131/1ec/1f6/e4/328/32c`) | **server** (`/hub` body) | CardsDLL parsers → cosmetic captions/counts | No — no enable atom in any of the six desers |
| `[r14+0xa]=1`, `[rdi+0x162]=1`, `[rsp+0x21]==1` markers | CardsDLL parser (local) | same parser | No — "field present" bookkeeping, never JSON-sourced |
| Settings config keys (`friendlySeasonsEnabled`, `enableDraftMode`, `enableObjectives`, `tournamentQuitEnabled`) | **server** (`/settings`) | applier → gate bytes → **client** | No — inputs already at enabled; readers are off-DLL |
| SBC enable (`enableSquadBuildingSetsFeature 0x100`) | — | — | **No surface** — falls in the settings dispatch gap, lands on no byte |
| Offline/online season enable | — | — | **No surface** — no config key, no gate byte, no getter |
| `/season`, `/tournament`, `/sbs/*` endpoints | server (utas, routed) | never requested at hub | No — client never polls them; tile greys before any request |
| DestroyMatch reward fields (`tournamentCoins`, `teamOfTournamentWinner`) | server (post-match) | reward payout | No — reached only inside a match |
| The greying/refusal decision itself | — | **client** (Denuvo-packed FIFA17.exe / Frostbite viewmodels) | **This is the gate — and it has no server surface** |
The single load-bearing fact across all four modes: **every server-writable enable
input that exists is already at ENABLED live, its only reader is the client, and the
tile refuses anyway.** No response body we can send flips a state the front-end has
already decided.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,256 @@
# FIFA 17 SBC client-hook implementation plan
## Outcome
Implement an opt-in, fail-closed hook that repairs the native response-to-deserializer
dispatch for `GET /ut/game/fifa17/sbs/sets`. The hook must reuse the genuine response
object and SAX reader from the real HTTP 200 transaction, run synchronously on the native
transaction thread, and preserve the game's allocator, object ownership, callbacks, and
index rebuilds.
This plan supersedes the intervention direction in `plan-2026-08-07-sbc-hook.md` and
`sbc-hook-dll-spec.md` wherever those documents claim the client never issues `/sbs/sets`
or recommend constructing a synthetic reader. The fresh 10:20:20 exchange proves the
request is issued and receives populated JSON. The reconciliation report is authoritative.
## Proven anchors
All addresses are static VAs in `CardsDLL_Win64_retail.dll`, image base `0x180000000`.
Runtime addresses are `CardsDLL base + (static VA - 0x180000000)`.
| Purpose | Address / identity |
|---|---|
| Category request constructor | `0x18017a7c0`, request vtable `0x18022e5c0`, tag `0x753c` |
| `/sets` URI builder | `0x18017a980` |
| Typed response factory | `0x18017aa10`, response vtable `0x18022e5b0` |
| Typed category deserializer | `0x18017b2b0`, `rcx=response`, `rdx=genuine reader` |
| Generic completion | `0x18016cca0`, exact-200 check at `0x18016cdd0` |
| FUT root | `A = *0x1802e6398`, expected vtable `0x18021c2a0` |
| SBC gate cache | `B=A+0x1f9d8`; ready byte `B+0x28` |
| Category store | `M=*(A+0x20a68)`; count `WORD[M+0x50]` |
| Renderer count read | `0x1800b5eda` |
Entering `0x18017b2b0` necessarily invokes the `A+0x20a68` lazy getter before JSON-key
parsing. The fresh transaction left that pointer null, proving that the typed category
deserializer was not entered.
## Architecture decision
Use the existing `openfut-hook` Rust `cdylib` and FIFA 17 feature boundary. Retain its
deferred CardsDLL discovery, RVA calculation, guarded reads, default-off environment
gates, and logging. Replace the stale Tier-1 idea of constructing a reader with this flow:
```text
real /sbs/sets HTTP 200
-> native generic completion and typed-response factory
-> observe the real response object and real reader/body cursor
-> at the proven skipped dispatch boundary, call the original typed method once
-> native parser populates M and rebuilds its indices
-> resume the native callback/completion chain
-> validate M; use native gate state if available
-> only if necessary, arm B+0x28 while B+0x08 remains zero
```
Do not intercept at the socket layer, fabricate a SAX reader, retain response/reader
pointers beyond their synchronous lifetime, hand-build EASTL category/set records, or
write `B+0x08`/`B+0x20`.
## State and feature gates
Use independent flags; no stronger stage should be implied by a weaker one:
- `OPENFUT_SBC_HOOK=1`: resolve and fingerprint only.
- `OPENFUT_SBC_TRACE=1`: install passive probes and structured logging.
- `OPENFUT_SBC_DISPATCH=1`: enable the one-shot native dispatch repair.
- `OPENFUT_SBC_COMMIT=1`: permit gate/refresh action after validated parse success.
- Keep `OPENFUT_SBC_ARM_ONLY=1` solely as a separate negative-control experiment.
Represent runtime progress with an atomic state machine:
```text
Disabled -> Resolved -> Intercepted -> Parsed -> Validated -> Committed
\-> Failed
```
Add a recursion-depth guard and a transaction one-shot keyed by request/response identity.
Any fingerprint, pointer, status, class, thread, reader, or postcondition mismatch moves to
`Failed` and resumes native execution without a write.
## Milestones
### M0 — reconcile and freeze the baseline
1. Mark the reconciliation report as the address/path authority.
2. Record SHA-256, PE timestamp, `SizeOfImage`, and selected section hashes for the shipped
CardsDLL, FIFA executable, built hook, and deployed proxy DLL.
3. Preserve a known-good launcher and proxy DLL. Do not overwrite a game-directory DLL
without an exact backup and hashes.
4. Capture a baseline: FUT hub succeeds, `/sbs/sets` returns 200, SBC shows the modal,
`M==0`, and the category deserializer is not observed.
Exit: the baseline is repeatable and its artifacts identify one binary build exactly.
### M1 — stabilize DLL loading
The existing `version.dll` injection has one historical successful log, but the current
FIFA 17 launcher disables it after later crashes. Resolve this before SBC detours:
1. Port or implement the complete VERSION proxy export surface and forward every export.
2. Build only `--features fifa17` for `x86_64-pc-windows-gnu` into a staging directory.
3. Inspect PE architecture, exports, and imports with the MinGW binutils.
4. Add a FIFA-17-specific launch path using the existing prefix/UMU configuration and
explicit `WINEDLLOVERRIDES=version=n,b`.
5. Run three cold launches with every SBC mutation/trace flag disabled.
Exit: all three launches reach the FUT hub, VERSION calls forward correctly, and disabling
the override restores the pre-hook baseline.
### M2 — strengthen runtime resolution
Before any detour or byte write, validate:
- exact CardsDLL identity (`SizeOfImage`, PE metadata, and multiple section/function hashes);
- FNV control bytes at `0x180180d00`;
- expected bytes at every proposed patch site;
- `A` and its expected vtable;
- `B` and its expected vtable;
- readable `M` slot and sane cache fields; and
- that runtime VAs lie inside the expected CardsDLL sections.
Use the external read-only `futmem`/probe tooling as an independent oracle. Never cache an
ASLR slide across launches.
Exit: resolve-only mode passes on two launches with different slides and aborts cleanly on
a deliberately mismatched fingerprint fixture.
### M3 — passive transaction tracing
Instrument, without changing return values or state:
1. generic completion `0x18016cca0`;
2. typed response factory `0x18017aa10`;
3. typed category deserializer `0x18017b2b0`; and
4. once found, the common body/SAX virtual-dispatch callsite.
Log a monotonic timestamp, session/build ID, thread ID, recursion depth, status, request
pointer/vtable, response pointer/vtable, reader/body pointer and vtable, and `M`/`B`
before and after. Correlate a request ordinal with `/tmp/utas.log`; do not log SID/auth
values or full response bodies.
Do not use the existing generic four-register probe wrapper for `0x18016cca0`. That routine
has a fifth stack argument. Use a relocated trampoline or a narrowly verified assembly
stub that preserves the full Win64 ABI: nonvolatile GPRs, XMM6-XMM15 if touched, 32-byte
shadow space, 16-byte call alignment, and all stack arguments. The diagnostic
unhook/call/rehook mechanism is also racy and is not acceptable for the final repair.
Exit: one fresh exchange unambiguously identifies whether the factory is skipped, the typed
object exists without a body/reader, or virtual deserialization dispatch is skipped.
### M4 — reverse the exact dispatch contract
Use M3 captures and static analysis to answer all of these before enabling intervention:
- the exact common body-to-response-deserializer callsite;
- the relationship between response vtable `0x18022e5b0` slot `+0x08` and the older
message-object vtable `0x18022e598` slot `+0x20`;
- which completion argument or object field owns the genuine reader;
- the reader's valid synchronous lifetime;
- whether `0x1800b8c30` executes after a successful forced parse;
- the native transaction/game thread identity; and
- whether the parser can be reached more than once for one response.
Exit: a written call contract identifies the exact hook site, preserved instructions,
original target, arguments, ownership, thread, and resume address.
### M5 — behavior-preserving detour
Install the production-form detour at the chosen boundary but initially tail-call the
original path unchanged. Prefer a small audited trampoline abstraction over copying the
repository's unhook/rehook diagnostic pattern.
Exit: exactly one balanced entry/exit is recorded per SBC exchange; HTTP traffic, modal,
M/B state, timing, and unrelated FUT screens remain unchanged.
### M6 — guarded dispatch repair
On the native transaction thread and only while the genuine objects are live:
1. require request vtable `0x18022e5c0`, response vtable `0x18022e5b0`, and status 200;
2. require a readable reader pointer/vtable and recursion depth zero;
3. require that this transaction has not already been parsed;
4. call the original typed method `0x18017b2b0(response, reader)` exactly once;
5. capture its return and the resulting M state; and
6. resume the native completion/callback path.
Never run this from the deferred worker or while the SBC controller is iterating. Do not
attempt in-place memory repair after an exception or partial parse; preserve logs and
relaunch FIFA.
Exit: the deserializer is observed once, returns successfully, and native execution
continues without gate or refresh writes.
### M7 — validate and commit UI state
Before exposing populated data, require:
- `M != 0` and a bounded category count;
- category vector `begin <= end <= capacity`;
- `(end-begin) % 0xf0 == 0` and vector length equals `WORD[M+0x50]`;
- sane, unique category/set identifiers and bounded nested counts;
- all native index-rebuild/finalization calls observed; and
- no duplicate parse or partial state.
First allow the native callback to arm the cache. If it does not, the only fallback is
`BYTE[B+0x28]=1` while `B+0x08==0`; never write `B+0x08` or `B+0x20`. Initially require
the user to close/reopen SBC for refresh. Do not synthesize Scaleform events until the
signature and ownership contract of `0x1801a4a70` are independently proven.
Exit: no modal; displayed categories and set counts match the served response.
### M8 — regression, soak, and rollback proof
1. Open/close SBC ten times; enter every set/challenge and return.
2. Verify a second `/sets` response is idempotent and does not duplicate data.
3. Smoke-test hub, club, store, squads, and normal service traffic.
4. Repeat from two fresh launches with different ASLR slides.
5. Soak 30–60 minutes with navigation and, if supported, repeated FUT enter/exit.
6. Disable all SBC flags and confirm the baseline behavior returns without detours/writes.
7. Disable `WINEDLLOVERRIDES`, restore the exact backed-up proxy if needed, and prove hard
rollback with FIFA closed.
Exit: zero crashes/freezes, stable counts and memory behavior, no unrelated FUT regression,
and both soft and hard rollback are demonstrated.
## Testing and build checks
Run at minimum:
```text
cargo fmt --check
cargo test --features fifa17
cargo check --release --features fifa17 --target x86_64-pc-windows-gnu
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
```
Extract pure, host-testable helpers for RVA calculation, fingerprint comparison, state
transitions, bounded vector validation, and structured event formatting. Windows calls,
raw pointer reads, and patching should remain behind small interfaces so guard logic can be
tested without launching FIFA.
## Stop conditions
Stop and roll back on any unknown binary fingerprint, patch-byte mismatch, wrong vtable,
wrong thread, unexpected factory/deserializer count, recursion, invalid vector geometry,
missing finalizer, partial parse, crash/freeze, unrelated FUT regression, or save/profile
change. Preserve hook log, UTAS log, binary hashes, and crash evidence before relaunching.
## Definition of done
- The hook is default-off and endpoint/class-specific.
- Exact binary and patch-site fingerprints are verified before intervention.
- The real category deserializer runs exactly once for each intended HTTP 200 response,
using the genuine response and reader on their native thread.
- `M` passes structural validation and the populated SBC menu supports drill-down.
- No communication modal appears and non-SBC FUT behavior is unchanged.
- Two fresh ASLR-distinct launches and the soak test pass.
- Unsetting flags restores inert behavior; removing the proxy restores the original launch.
@@ -0,0 +1,237 @@
# SBC Menu Render Intervention — Plan (2026-08-07)
**STATUS (one line): YES, WITH CAVEATS — a populated SBC menu is achievable via a
client-side hook, but ONLY by making the game's own parser fill its store; a
/proc/mem byte poke alone can open the menu (negative control) but renders EMPTY, and
the one remaining un-reversed item (the SAX input-source `vtable[+0x8]` byte-yield
contract) blocks the fully-offline populate until a served /sbs/sets response or a
completed reader is wired.**
All addresses are on-disk RVAs against CardsDLL image base `0x180000000`
(`/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`, working copy `/tmp/fut/cardsdll.dll`).
Live slide this session = `0x6ffe7c140000` (mapped base `0x6ffffc140000`), proven via
FNV prologue at `0x180180d00`. Live values below are from read-only `/proc/12201/mem`.
---
## 1. Definitive SBC data-flow
### Object graph
- **A** = FUT root singleton = `*[0x1802e6398]`. Getter `0x18011a830`. A.vtable static
`0x18021c2a0`. Live A = `0xb83e2b60` (vtable matches static — CONFIRMED).
- **B** = SBC request/TTL gate cache = `A + 0x1f9d8`. B-getter = A.vtable[+0x4e8] =
thunk `0x18011c1f0` (`lea rax,[rcx+0x1f9d8]; ret`). B.vtable static `0x1801fae70`
(3 slots: dtor `0x180063040`, isValid `0x180065d40`, clear `0x180065d20`). Live B =
`0xb8402538` (vtable matches). **B is the GATE, not the render source.**
- **M** = SBC categories/sets store = `*(A + 0x20a68)`. Reached via A.vtable[+0x9b0] =
lazy getter `0x18011b7d0` (if `A[+0x20a68]==0` it factory-creates an EMPTY M, type-id
`0x13f0`, and caches it). Live M = `0x0` (never built this session — SBC menu not
opened). **M IS the render source.**
- The "SBC manager" is **A itself**: service-id `0xed84b12` resolver A.vtable[+0x18] =
`0x180113f50` returns `this`, so `manager.vtable[+0x9b0] == A.vtable[+0x9b0] ==
0x18011b7d0`. The old lead `0x1801e9010` is DEBUNKED — it is an `.rdata` function
pointer slot (`->0x18018577a`), not a manager global.
### Render source (CLIENT authority)
The SBC hub/squads controller (ctor `0x1800b5267`) caches M into `controller+0x140`
by calling A.vtable[+0x9b0] once (`0x1800b554d`→`0x1800b5571`→store `[rsi+0x140]`),
then registers Scaleform events `0x756c`–`0x7574`. The tile-build method (`0x1800b5e00`
region) reads `[ctrl+0x140]=M` and at **`0x1800b5eda`** does
`movzx ebx,WORD[M+0x50]; add bx,0x2; call [scaleform.vtable+0x58](count)` → emits
**(category_count + 2) tiles**. This region reads `[ctrl+0x140]` seven times and reads
B/`A+0x1fa00` **zero** times. M layout: cat count `WORD[M+0x50]`; cat vector
`[M+0x58]..[M+0x60]` stride `0xf0`; per-cat set count `WORD[cat+0xb8]`, set vector
`[cat+0xc0]` stride `0x3570`; secondary/featured vec `[M+0xa10]..[M+0xa18]`;
indices at `+0x9e0/+0xa10/+0xa40`. **Correction on record:** earlier passes that
called `B[+0x08]` the render source conflated the gate with the data source — the empty
render was because M was null/empty, NOT because `B[+0x08]` was null.
### Populate path (CLIENT authority)
The sbs/sets deserializer **`0x18017b2b0`** (rcx=this IGNORED; rdx=SAX cursor is the
only live input) does the whole populate: fetch manager → get store M via
`[manager.vtable+0x9b0]` (at `0x18017b327`) → clear `0x18015f3a0` → loop atom `0x6f`
"categories": per item ctor `0x180159da0` (0xf0, vtable `0x18021b520`), cat-deser
`0x18017ab80`, cat-finalize `0x180160e50`, APPEND `0x18015a770` (copy-ctor
`0x18015a2b0`), dtor `0x1801105d0` → after loop rebuild indices `0x180160e00` +
`0x180160f30` + `0x180161020` → commit `manager.vtable[+0x8]`. Always returns true.
Set-row deser `0x18017ad60`. **Populate-target == render-source (both are M).**
### Prefetch gate (SERVER/front-end authority — THE WALL)
There is **no native flag** to flip. The only native online check `0x1801642c0`
(inside isValid) is stubbed `mov al,1; ret` — NOT the wall. The block is upstream in
the Flash/ActionScript FUT front-end (FNV-name-hash bound; `RequestChallengeData` =
`0x1801f9b30`, `futsbchubviewmodel` = `0x1801ee0a0` — no native xref), which refuses to
issue `GET ut/game/fifa17/sbs/sets` offline, so deser `0x18017b2b0` never runs.
**Newly proven:** the URL template `"ut/%s/sbs"` (`0x18021d908`) has ZERO references
in the image (siblings `ut/%s/tournament`, `ut/%s/season` ARE referenced) — so
**CardsDLL has no native code that self-builds/issues the sbs GET.** This kills any
"force the req-mgr at A+0x2a0 to fetch on its own" idea. This is why the fix must be
client-side and must FORCE the populate.
### Ready-arm (CLIENT authority)
isValid `0x180065d40(B)` verified: `if !0x1801642c0() ret0` (stub→always passes);
`cmp [rbx+0x28],0; je fail`; **`cmp QWORD[rbx+0x8],0; je 0x180065d75` → returns 1
immediately (short-circuit)**; else QueryPerformanceCounter (`0x1801e50c0`) and compare
`[rbx+0x20]` deadline. Normally B is armed by the completion callback `0x1800b8c30`
(subscribed in svc ctor `0x1800b5765` via `manager.vtable[+0xa90]`) through the generic
cache copy-assign `0x1800c21a0` (sets B+0x08=collection, B+0x20=deadline, B+0x28=1).
Offline that callback never fires (no response). Live: `B[+0x08]=0`, `B[+0x28]=0`.
---
## 2. Chosen minimal intervention and WHY
**Reuse the client's own parser; do NOT hand-build structs; arm ONLY `B[+0x28]`.**
Two tiers, safest-first:
- **Tier-0 (negative control — proves the gate):** write ONLY `BYTE[B+0x28]=1`.
isValid short-circuits (B+0x08==0 branch) → menu OPENS instead of the error modal
(`0x18016c330`), but renders EMPTY (M is null/empty). Do NOT write `B+0x08` or
`B+0x20` — pointing B+0x08 at a collection forces isValid into the QPC-deadline
branch, and with the live-stale deadline (`0xf10fb8cb9`) the gate SHUTS → modal, i.e.
it DEFEATS the fix. This is the load-bearing correction from adversarial verification.
- **Tier-1 (real fix — populates M):**
- **Preferred (Option 1, cleanest, zero forged state):** inject a canned
`/sbs/sets` JSON response at the message-receive layer so the game builds the
response-msg (ctor `0x18017b1c0`, vtable `0x18022e598`, deser slot +0x20 =
`0x18017b2b0`), seats a genuine SAX cursor, its OWN chain populates M, and the
native completion callback `0x1800b8c30` arms B for you. The bridge/core serves the
JSON. Nothing forged.
- **Fallback (Option 2):** from the hook, stand up a real SAX cursor over canned JSON
(ctx `0x1801c63e0` + lexer `0x1801c8060` + an input-source whose `vtable[+0x8]`
yields bytes), call deser `0x18017b2b0(rcx=ignored, rdx=cursor)`, then arm ONLY
`BYTE[B+0x28]=1`. **Blocker:** the input-source `vtable[+0x8]` byte-yield contract
is the ONE un-reversed item — a cold call with a null-source cursor CLEARS M
(`0x18015f3a0`) then byte-scans a garbage pointer (`mov rdi,[rdi]` ~`0x18017b353`)
→ wipes state + segfault. So Option 2 is NOT safe to run until the reader is
reversed.
**Why not hand-build:** feeding `0x18015a770` a hand-built 0xf0 category (with nested
0x3570 set records / EASTL sub-vectors) is the highest crash risk — the copy-ctor
`0x18015a2b0` deep-copies inner sub-vectors; any bad begin/end/cap → heap corruption.
The parser writes the correct geometry AND runs the index-rebuild finalizers that
hand-built appends get wrong. Ruled out.
**Refresh:** after M is populated, fire refresh events `0x756c`–`0x7574` (or re-open the
menu) so `0x1800b5eda` re-reads `WORD[M+0x50]`.
---
## 3. STAGED MORNING TEST PLAN (safest-first)
Precondition: FIFA at the FUT hub with CardsDLL loaded. Rollback for EVERY step =
**relaunch FIFA** (all effects are volatile — single-byte poke or in-session hook state,
cleared on restart). NEVER run `--apply` while the SBC menu is open/mid-iterate.
### Step 1 — Dry-run read confirm (ZERO writes)
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py
```
Expect: CONTROL FNV MATCH; A vtable match; B offset decoded live = `0x1f9d8`; B/A vtables
match statics; `B+0x28=0`; `M=*(A+0x20a68)=0` (until SBC menu opened once).
PASS = addresses match the model. Rollback: none needed (read-only).
### Step 2 — Review the DLL populate spec (ZERO writes)
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --spec
```
Expect: printed injected-DLL spec (Option 1 preferred, Option 2 fallback). Read-only.
### Step 3 — Negative control (Tier-0, ONE byte write) — proves the GATE
With the SBC menu **CLOSED**:
```
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --apply
```
Writes exactly `BYTE[B+0x28]=1` (re-proves slide+vtables at write time; aborts on any
mismatch; hard-refuses to write B+0x08/B+0x20). Then re-open the SBC menu.
Expect: menu OPENS, no error modal, ~2 empty/placeholder tiles. This proves the gate +
isValid short-circuit LIVE — it does NOT prove data. If it CRASHES: stop — B
resolution/slide is wrong. Rollback: relaunch FIFA (byte clears on restart).
### Step 4 — Real fix (Tier-1) — proves the DATA (NOT for a blind run)
Do this only after the DLL populate is implemented. Preferred: bring up the bridge/core
`/sbs/sets` responder and let Option 1 (message-layer injection) drive the native chain;
the completion callback arms B and M fills. Then the same gate opens a POPULATED menu
(N+2 tiles). The hook module scaffold is `openfut-hook/src/sbc_hook.rs` — Tier-1
`populate_m()` is present but deliberately refuses to call the deser until the SAX
input-source reader is reversed (else it clears M and crashes). Build (when ready):
```
cd /home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook && \
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
```
Deploy as `version.dll` per launcher setup. Env gates (all default OFF):
`OPENFUT_SBC_HOOK=1` (read-only resolve+log), `OPENFUT_SBC_ARM_ONLY=1` (Tier-0),
`OPENFUT_SBC_POPULATE=1` (Tier-1, currently logs the blocker and returns).
Rollback: unset env vars and relaunch FIFA.
### Step 5 — Cleanup
Unset all `OPENFUT_SBC_*` env vars; relaunch FIFA to a clean state.
---
## 4. Crash-risk assessment
1. **Cold-calling `0x18017b2b0` without a real seated cursor** — CLEARS M
(`0x18015f3a0`) first, then `mov rdi,[rdi]` byte-scan on a garbage ptr → wipes
state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed.
2. **Writing `B+0x08`/`B+0x20`** — forces isValid into the QPC-deadline branch; stale
deadline → gate SHUTS (modal), or garbage-ptr iterate crash. Self-defeating.
Tool/code write ONLY `B+0x28`.
3. **Populate off the game thread / mid-iterate** — lazy getter allocates on game heap,
appender mutates EASTL vectors; a foreign thread races the allocator/menu iterate →
heap corruption. Tier-1 must run on the game/message-pump thread with the menu closed.
4. **Skipping the index-rebuild finalizers** (`0x180160e00/0x180160f30/0x180161020`)
after append → stale `+0x9e0/+0xa10/+0xa40` indices → by-index getter `0x180160a80`
reads OOB → crash/garbage tiles.
5. **`WORD[M+0x50]` > actual 0xf0-stride entries** → tile loop walks past vector end
(OOB read).
6. **Hand-built 0xf0/0x3570 structs fed to `0x18015a770`** — copy-ctor `0x18015a2b0`
deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid.
7. **No refresh after populate** (non-crash) — controller keeps the cached empty M at
`ctrl+0x140`; `0x1800b5eda` won't re-run → still 2 placeholder tiles. Fire
`0x756c`–`0x7574` or re-open.
8. **Manager/store null** — deser does `mov rax,[rbx]` on the manager; registry lookup
(hashes `0xed84b11`/`0xed84b12`) returning null → null-deref. Live registry
`*[0x1802c2988]` non-null, so low risk; hook must still null-check M/store.
Tier-0 (single `B+0x28=1` write, B+0x08 left 0) is the verified-SAFE case: isValid
short-circuits to 1, renders empty, no crash; bg-thread-tolerant like the /proc poke.
---
## 5. Poke tool + DLL-spec locations
- Poke tool (read-only default; `--spec`; `--apply` = ONLY `BYTE[B+0x28]=1`):
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py`
- Negative-control byte poke (older, triple-guarded):
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_populate_poke.py`
- Slide/read template + FNV control proof:
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/gate_byte_probe.py`
- DLL integration spec (RVA math, object graph, gate disasm, function-signature table,
3 intervention tiers, 8-item crash register, staged test plan):
`/home/alex/Documents/OpenFUT/fifa17-recon/docs/sbc-hook-dll-spec.md`
- Injected-DLL module (fifa17-only; Tier-0 live, Tier-1 scaffolded/refusing):
`/home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook/src/sbc_hook.rs`
(wired via `lib.rs` `#[cfg(feature="fifa17")] mod sbc_hook;` + `fifa17.rs`
`crate::sbc_hook::install();`)
- Atoms table: `/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv`
---
## Client-vs-server authority boundaries (flagged)
- **RENDER (M, tiles at `0x1800b5eda`)** — CLIENT. The client draws tiles solely from
M; the server never touches this. Fix is client-side.
- **POPULATE (deser `0x18017b2b0` → M)** — CLIENT parser, SERVER-fed data. The parser
is native and reusable; the DATA it needs (`/sbs/sets` JSON) is a server response.
Preferred fix has the bridge/core supply that JSON so the client parses it natively.
- **PREFETCH GATE (issue `GET sbs/sets`)** — SERVER/front-end. THE WALL. No native
flag; the SWF/ActionScript front-end refuses to request offline, and CardsDLL has no
native code that issues the GET (`ut/%s/sbs` unreferenced). This cannot be fixed
server-side by responding — the request is never sent. The hook must force the
populate (inject the response at the message layer or drive the parser).
- **READY-ARM (`B[+0x28]`, callback `0x1800b8c30`/commit `0x1800c21a0`)** — CLIENT.
Normally armed by the completion callback (server-response-driven); offline the hook
arms it (Tier-0 byte, or Option 1 lets the native callback arm it).
@@ -0,0 +1,138 @@
# SBC "problem communicating with the FIFA Ultimate Team servers" — definitive analysis
**Date:** 2026-08-07
**Binary under study:** `/tmp/fut/cardsdll.dll` (on-disk PE, image base `0x180000000`; copy of `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`)
**Method:** clean-room, read-only. On-disk `objdump` re-verified in this pass; live values quoted from prior read-only `/proc/<pid>/mem` reads (pid 12201, slide `0x6ffe7c140000`, FNV control MATCH). No memory was written; FIFA was not touched.
---
## VERDICT (one line)
**The SBC modal is a CLIENT-SIDE, per-feature completion-path defect — the FUT client never re-arms a fetch/re-render for `sbs/sets` the way it does for the hub — so NO server response can cure it; the only offline lever is a client-memory patch, and the clean single-byte patch (`model+0x1fa00 = 1`) only SUPPRESSES the modal by forcing the completion predicate true, rendering from an empty, never-populated cache. It is NOT the go-online wall.**
---
## 1. What the SBC completion predicate actually checks (CONFIRMED on-disk)
The SBC menu entry runs a completion continuation whose gate is the shared predicate **`0x180065d40`**, called as `[cache_vtable+0x08]`. Re-disassembled this pass, byte-for-byte:
```
180065d40 call 0x1801642c0 ; online/liveness sub-check
180065d4e test al,al
180065d50 je fail
180065d52 cmp byte [rbx+0x28],0 ; <-- THE GATE: "value ready" flag
180065d56 je fail
180065d58 cmp qword [rbx+0x8],0 ; pending-op ptr
180065d5d je pass (mov al,1) ; empty-collection shortcut -> success
180065d5f lea rcx,[rsp+0x38]
180065d64 call QueryPerformanceCounter ; [rip]->0x1801e50c0
180065d6a mov rax,[rbx+0x20] ; QPC deadline
180065d6e sub rax,[rsp+0x38]
180065d73 js fail ; deadline passed -> fail
180065d75 mov al,1 ; pass
...
180065d7d xor al,al ; fail
```
Reduces to: `subcheck() && byte[cache+0x28]!=0 && (qword[cache+0x08]==0 || deadline[cache+0x20] not yet past)`.
- **The online/liveness sub-check `0x1801642c0` is stubbed OUT.** On-disk bytes are `b0 01 c3` = `mov al,1; ret` — always true, in the shipped file (not a live loader patch). **This is the reason SBC is NOT the go-online wall** (see §5).
- `cache` (`rbx`) is an **embedded sub-object of the FUT root singleton** `A = *[0x1802e6398]`, selected by a vtable thunk (see §2). Its `+0x28` byte is a "value-ready" flag (init 0 by ctor `0x180062460`); `+0x08` is a pending-op pointer; `+0x20` is a QPC deadline. This is a copyable future/async-result value type. **The predicate never reads the parsed SBC categories, HTTP status, session, or any live-connection boolean.**
> **AUTHORITY BOUNDARY:** everything the predicate reads lives inside client process memory (`A+…`). Nothing in the `sbs/sets` HTTP response is an input to it. This is a **client-authority** decision end to end.
---
## 2. Why hub passes but `sbs/sets` fails (CORRECTED after adversarial verification)
Both features run the **same predicate function** `0x180065d40`, but on **different embedded caches**, reached through **different per-response-class continuations**. That structural divergence is real and confirmed. **The originally-stated reason ("hub passes because its cache `+0x28` is set") is WRONG** and is corrected here — corroborated by a live measurement (HUB cache `+0x28 = 0` while the hub is displayed with no modal) and by the on-disk FALSE-branch disassembly gathered this pass.
### The two continuations, side by side (on-disk, this pass)
| | SBC (`FutLoadSetTypesServerResponse`) | HUB (`FutGetHubDataServerResponse`) |
|---|---|---|
| continuation | `0x180154860` | `0x180173770` |
| get singleton A | `call 0x18011a830` (`mov rax,[0x1802e6398]`) | same |
| select cache | `call [rdx+0x4e8]` → thunk `0x18011c1f0` = `lea rax,[rcx+0x1f9d8]` → **SBC cache A+0x1f9d8** | `call [rdx+0x1f8]` → thunk `0x18011a810` = `lea rax,[rcx+0x1fd70]` → **HUB cache A+0x1fd70** |
| predicate | `call [rdx+0x08]` = `0x180065d40` | **same** `0x180065d40` |
| on TRUE (jne) | render `0x18015491a → 0x180154600` | render `0x18017383d → 0x1801735e0` |
| **on FALSE** | `lea rdx,[rbp-0x9]` (descriptor `0x18020a8b8`); **`call 0x18016c330`**; `jmp` return | **`call 0x1801213b0` (state reset)**; `lea 0x1801736f0` (continuation fn); **`call 0x18011f8e0` (register completion closure)**; `lea 0x18022cd30` (descriptor); **`call 0x18016c330`**; **`call 0x18011f900` (cleanup)** |
### What this proves
1. **`0x18016c330` is NOT an SBC-only "modal" function.** The HUB continuation calls the very same `0x18016c330` (at `0x18017382c`) on its own not-ready branch. It is a shared, descriptor-parameterized async dispatcher; SBC passes descriptor `0x18020a8b8`, hub passes `0x18022cd30`.
2. **At idle both predicates return FALSE.** Live: HUB cache `A+0x1fd70+0x28 = 0` **and** SBC cache `A+0x1f9d8+0x28 = 0`, both `+0x08 = 0`. The hub is on screen with no modal *while its own predicate would return FALSE*. So "hub `+0x28` is set" is false; a set flag is not what makes the hub pass.
3. **The real asymmetry is the FALSE-branch work.** On not-ready the HUB continuation **resets its request-state region** (`0x1801213b0`), **registers a completion closure** (`0x18011f8e0`, continuation `0x1801736f0`) so the arriving response re-runs the continuation and re-renders, then cleans up (`0x18011f900`). It is a proper get-or-fetch: cache-miss → (re)issue request → render on completion. **The SBC continuation does NONE of that** — it fires the dispatcher once with delegate `0x180154590`/descriptor `0x18020a8b8` and returns. It never re-arms a fetch and never wires the `sbs/sets` response back into a re-render.
**Conclusion:** hub and SBC diverge at the cache-selection call site (`[rdx+0x1f8]` vs `[rdx+0x4e8]`, one instruction apart), and — decisively — in the not-ready handling. The modal is produced **downstream in the SBC dispatched path** (dispatcher `0x18016c330` + delegate `0x180154590`), because the SBC feature is wired as a one-shot with no re-fetch/re-render, whereas the hub is wired as a self-rearming get-or-fetch. It is **not** decided by cache selection alone, **not** by the shared predicate, and **not** by the `+0x28` byte value at idle.
---
## 3. VERDICT by route — is SBC beatable, and how?
| Route | Outcome | Why |
|---|---|---|
| **A. Server response field / header / status** | **RULED OUT — no offline fix here** | No field in the `sbs/sets` body reaches the predicate (client-authority §1). Deeper: the SBC continuation never registers a completion closure to consume the response and re-render, so *even a perfect response is dropped on the floor*. The deserializer `0x18017b2b0` returning TRUE is genuinely irrelevant. |
| **B. Client memory byte patch** `model+0x1fa00 = 1` | **Suppresses the modal, but empty menu — cosmetic** | Forces predicate TRUE → routes to the SBC render branch `0x18015491a → 0x180154600`, which reads the embedded SBC cache. That cache was never populated (`+0x08 == 0`, empty collection), so the likely result is an empty / non-functional SBC screen, not populated SBCs. **Untested under the read-only rule.** |
| **C. Config `FUT/SBC_USE_STUBS`** (rdata `0x1802270f8`) | **Not the gate** | Read at the deser top only; the normal (off) path already runs. Flipping it does not touch `+0x28` or the continuation wiring. |
| **D. "Needs the go-online wall solved"** | **REFUTED** | The only connection-like sub-check on this path (`0x1801642c0`) is stubbed to always-true on-disk. SBC is blocked by local per-feature completion wiring, not by the reconnect gate. See §5. |
| **E. Client CODE patch of the SBC FALSE-branch** | **The only route to a *functional* SBC menu** | Make `0x180154860`'s not-ready branch replicate the hub's sequence: state reset `0x1801213b0` + register completion closure `0x18011f8e0`/`0x1801736f0` + dispatch + cleanup `0x18011f900`, so the `sbs/sets` response is fetched and rendered. This is a code patch, not a byte flip and not a server change. Out of scope for a server-side preservation fix; a client-side authority modification. |
**Bottom line:** there is **no server-side fix**. SBC is "beatable" only in the client-authority sense — either cosmetically (byte B, hides the modal over an empty menu) or functionally (route E, a code patch replicating the hub's re-arm). Neither is a change our offline server can make.
---
## 4. Memory patch details (if used) — flagged CLIENT-SIDE AUTHORITY
> **CLIENT-SIDE AUTHORITY — this is a modification of the FIFA client's own process memory, not an OpenFUT server response. It changes what the client decides, and it violates the current read-only rule; it is documented for completeness, not endorsed as the fix.**
- **Cosmetic modal-suppression (route B):**
- **Absolute displacement into FUT root singleton:** `A + 0x1f9d8 + 0x28` = **`model + 0x1fa00`**, where `A = *[0x1802e6398]`.
- **Live absolute (pid 12201 snapshot):** `0xb8402538 + 0x28 = 0xb8402560`.
- **Value:** write `0x01` (one byte).
- **Effect:** predicate `0x180065d40` short-circuits at `cmp byte[rbx+0x28],0` → with `+0x08==0` the empty-collection shortcut returns TRUE → continuation `jne 0x18015491a` renders. **Modal gone; SBC cache empty → expect an empty/possibly-broken menu.** Not verified (read-only).
- **Persistence:** the object is embedded in the singleton (singleton lifetime). The SBC path calls only `[vt+0x08]`; nothing on this path calls the invalidator `[vt+0x10]=0x180065d20`, so a write should persist across menu re-entry (inferred from structure, not demonstrated).
- **Functional fix (route E)** requires a `.text` patch to the SBC continuation, not a data byte — see §3 row E. Do not confuse the two.
---
## 5. Relationship to the online-modes / go-online-wall finding
SBC is **not** the same wall as online Draft's "PRESS Q TO RECONNECT":
- The single connection-like sub-check reachable from the SBC predicate, `0x1801642c0`, is compiled out (`mov al,1; ret`) in the shipped binary. The SBC gate therefore encodes **no** unmet network condition — it is a purely local completion-wiring problem.
- The online modes differ structurally: their gate keeps a real pending network op at `+0x08` and/or a non-stubbed sub-check, so their predicate encodes a network state a local byte-flip cannot satisfy. That is why the online wall is not beatable by a byte and SBC's modal is (cosmetically).
- This is consistent with the prior **"refusing modes = no server fix"** finding: no field, count, header, or status in any HTTP response flips the client-side completion state for these features. SBC extends that finding with the precise mechanism — the client never re-arms the `sbs/sets` fetch/re-render at all.
---
## Appendix — confirmed addresses (image base `0x180000000`)
| Symbol | Address | Note |
|---|---|---|
| FUT root singleton getter | `0x18011a830` | `mov rax,[0x1802e6398]; ret` |
| FUT root singleton ptr | `[0x1802e6398]` | live `A = 0xb83e2b60` |
| FUT root vtable (static) | `0x18021c2a0` | |
| SBC cache selector thunk | `0x18011c1f0` | `lea rax,[rcx+0x1f9d8]` (slot `A.vt+0x4e8`) |
| HUB cache selector thunk | `0x18011a810` | `lea rax,[rcx+0x1fd70]` (slot `A.vt+0x1f8`) |
| SBC cache | `A+0x1f9d8` | vtable `0x1801fae70`; live `0xb8402538` |
| HUB cache | `A+0x1fd70` | vtable `0x18021c1e0` |
| shared predicate `isValid` | `0x180065d40` | `cache.vt+0x08` for both |
| stubbed online sub-check | `0x1801642c0` | `b0 01 c3` = `mov al,1; ret` |
| cache ctor / copy-ctor | `0x180062460` / `0x1800c21f3` | init `byte[+0x28]=0` |
| invalidator | `0x180065d20` | `cache.vt+0x10`; not called on SBC path |
| SBC continuation | `0x180154860` | class `RS4:FutLoadSetTypesServerResponse` (str `0x1802270b8`, vt row `0x180227090`) |
| HUB continuation | `0x180173770` | class `RS4:FutGetHubDataServerResponse` (str `0x18022ce40`, vt row `0x18022ce18`) |
| shared async dispatcher | `0x18016c330` | called by BOTH FALSE-branches (SBC `0x180154913`, HUB `0x18017382c`) |
| SBC delegate / descriptor | invoke `0x180154590` / desc `0x18020a8b8` | |
| HUB re-arm: state reset | `0x1801213b0` | HUB-only, `0x1801737bd` |
| HUB re-arm: register closure | `0x18011f8e0` (cont. `0x1801736f0`) | HUB-only, `0x180173815` |
| HUB re-arm: cleanup | `0x18011f900` | HUB-only, `0x180173836` |
| SBC render branch (on TRUE) | `0x18015491a → 0x180154600` | reads empty SBC cache |
| `sbs/sets` deserializer | `0x18017b2b0` | returns TRUE unconditionally (`mov al,1 @0x18017b751`); irrelevant to predicate |
| QueryPerformanceCounter import | `0x1801e50c0` | |
**Which prior conclusion won:** the structural divergence (same predicate, different cache, different continuation; online sub-check stubbed; not server-fixable) is upheld. The specific pass/fail *reason* is corrected: it is the **FALSE-branch re-arm asymmetry**, not a set `+0x28` byte and not an SBC-exclusive `0x18016c330`.
@@ -0,0 +1,211 @@
# FIFA 17 SBC response reconciliation
**Verdict:** the live client receives HTTP 200 for `GET /ut/game/fifa17/sbs/sets`, but the
typed `FutSBCLoadCategoryDetailsServerResponse` deserializer is not invoked. The evidence
does **not** identify a server-controlled header, envelope field, or correlation value that
can fix this. The previous `0x180154860` “SBC continuation” diagnosis was based on the wrong
request class and is retracted.
## Scope and authority
This pass used only:
- the shipped `CardsDLL_Win64_retail.dll` copied to `/tmp/fut/cardsdll.dll`;
- read-only `/proc/<pid>/mem` access to the running game;
- the local OpenFUT request log; and
- existing clean-room notes and scripts in this repository.
No game memory was written, no breakpoint was inserted, and no service or game process was
restarted during the measurement.
## Fresh live observation
The control run used fresh FIFA process **PID 59054**. The CardsDLL mapping resolved to
`0x6ffffc140000`, giving slide `0x6ffe7c140000`. Bytes at static control function
`0x180180d00` matched the on-disk DLL, proving the mapping/slide before data reads.
At the FUT hub, before opening SBC:
- `A = *[0x1802e6398] = 0xb78f7c50`;
- `M = *(A+0x20a68) = 0`;
- hub cache byte `*(A+0x1fd70+0x28) = 1` (fresh hub response ready); and
- SBC cache byte `*(A+0x1f9d8+0x28) = 0`.
The user then opened the SBC tile. The real client exchange was:
```text
[10:20:20] GET /ut/game/fifa17/sbs/sets
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: OPENFUT-SID-0000000000000001
Accept-Encoding: gzip
-> 200 {"categories":[...]}
```
The game displayed “There was a problem communicating with the FIFA Ultimate Team servers.”
With that modal still open, the same slide was re-proved and `M` was still exactly zero.
### What `M == 0` proves
The typed `/sets` deserializer is `0x18017b2b0`. At `0x18017b309`–`0x18017b327` it obtains
the FUT root and calls vtable slot `+0x9b0`, the lazy getter `0x18011b7d0`. That getter
allocates and stores `A+0x20a68` before the deserializer examines the root object or the
`categories` key.
Consequently:
- valid JSON would leave `M` non-null;
- malformed or empty JSON reaching this function would also leave `M` non-null; and
- `M == 0` after the completed HTTP transaction means `0x18017b2b0` was not invoked.
The normal reset of `M` is `0x180114ee0`; its observed use belongs to broad FUT-root
initialization/reset work, not the `/sets` completion path. There is no evidence that the
deserializer ran and then immediately cleared `M` during this transaction.
## Correct class map
Three classes were conflated in earlier notes:
| Function/class | Proven URI | Role |
|---|---|---|
| `FutSBCLoadCategoryDetailsServerResponse`, request URI builder `0x18017a980`, factory `0x18017aa10`, response deser `0x18017b2b0` | `/sets` under the `ut/%s/sbs` base | Initial category/set list; this is the live failing request |
| `FutSBCSetDataServerResponse`, factory `0x18016fca0`, deser `0x18016fe90` | `/squadBuildingSets` (`0x18022bd88`) | Parses `reset`; not the observed `/sbs/sets` request |
| `FutLoadSetTypesServerResponse`, deser `0x180154990` | `/challenge/%d/squad` (`0x1802270e0`) | Parses `challengeId`, `playerRequirements`, and `squad`; later challenge flow |
This corrects two prior claims:
1. `FutSBCSetDataServerResponse` does **not** share the literal `/sets` URI in this binary;
its URI string is `/squadBuildingSets`.
2. `0x180154860` is not a dedicated completion continuation for the initial category-list
request. `0x180154830` is a generic callback thunk used by multiple request classes, while
the nearby `0x180154990` parser and `/challenge/%d/squad` URI belong to
`FutLoadSetTypesServerResponse`.
Therefore the earlier hub-versus-`0x180154860` comparison contrasted the hub with a later
challenge-squad operation, not with `GET /sbs/sets`. Its proposed “copy the hub re-arm path”
fix is unsupported for the category-list failure.
## What the generic completion code actually checks
The shared request completion routine `0x18016cca0`:
1. calls request vtable slot `+0x80` at `0x18016cd32` to create the class-selected typed
response object;
2. stores the received status at request offset `+0x48` (`0x18016cd3d`); and
3. compares it with decimal 200 at `0x18016cdd0`.
Exactly 200 takes the success branch to `0x18016d0b9`. Non-200 status invokes the error
translation path through request slot `+0x60` first. Response construction is selected by
the request vtable; it is not selected by an HTTP response header or a JSON envelope field.
No pre-deserialization branch found in this path reads `Content-Type`, a request/correlation
ID, the `X-UT-SID` response header, or a top-level JSON key. The live server already supplies
the one proven transport-level success input: status 200.
## Hub comparison
The fresh hub response was consumed successfully and set the hub cache byte to one. After
the subsequent navigation its resting value returned to zero. The SBC cache byte remained
zero. This confirms that cache `+0x28` is transient async-result/TTL state; a later resting
zero does not establish which completion branch ran.
The previous report's live snapshot—where both values were zero long after the requests—was
therefore insufficient to infer the hub/SBC divergence. The fresh before/after measurement
supersedes it.
## Server-fixability verdict
**Not demonstrated.** In particular:
- changing the category JSON cannot make the typed parser start, because the lazy store is
allocated before any JSON key is inspected;
- the server already returns the proven success status, 200;
- request-class/response-class selection is client-owned; and
- no header, envelope, or correlation field was found feeding a pre-parser decision.
This does not mathematically prove that no transport variation could ever affect the client.
It does prove that the specific server-fix candidates proposed by the killed workflow were
speculative and had no reading instruction behind them.
## Exact remaining unknown and next measurement
The unresolved boundary is between:
```text
ProtoHttp completion with status 200
-> class-selected response object creation
-> delivery of response bytes/SAX cursor
-> response vtable +0x08 (`0x18017b2b0`)
```
The next useful experiment is transient tracing of calls—not another resting-state scan.
Instrument, in a disposable/local diagnostic build or a non-mutating tracing facility:
- request factory `0x18017aa10`;
- typed deserializer `0x18017b2b0`;
- generic completion entry `0x18016cca0` and its status at `0x18016cdd0`; and
- the generic response-body/SAX dispatch site that calls response vtable slot `+0x08`.
Record whether the factory is called, whether it returns an object with vtable
`0x18022e5b0`, and whether a body/SAX object is delivered. That separates three remaining
client-side possibilities: wrong request instance despite the URI, typed object created but
body not attached, or body attached but virtual deserialization dispatch skipped.
Until that transient trace exists, the defensible implementation direction remains the
client-side hook described in `docs/sbc-hook-dll-spec.md`, but its rationale must be stated
as “native category deserializer is not reached,” not the retracted `0x180154860`
hub-rearm theory.
## 2026-08-07 passive-trace result: deserialization is proven
The first gated passive client trace supersedes the final inference above. During exactly
one SBC navigation, with every mutation feature disabled, the hook recorded:
```text
SBC_TRACE: factory entry=1 exit=1 tid=652 this=0xb80cd910 result=0x7a99178;
deser entry=1 exit=1 tid=652 this=0x7a99178 reader=0x7fcff7f8 result=true
```
The matching UTAS request occurred at `11:09:01`: `GET /ut/game/fifa17/sbs/sets` returned
HTTP 200 with one category and two sets. No degraded hook state was reported, and FIFA
remained alive until the operator closed it after the single permitted attempt.
This proves all of the following for the observed request:
- the category response factory is called exactly once and returns a non-null object;
- the native category deserializer is called exactly once on that same object;
- the body reader is non-null;
- deserialization returns success (`true`); and
- both calls return normally on the same native thread.
Therefore the earlier `M == 0` resting snapshot did not prove that `0x18017b2b0` was
skipped. The failure boundary is now strictly **after successful native deserialization**.
The next measurement must trace the response object's post-deserializer completion,
ownership handoff, and publication into the SBC UI/cache collection. Repeating the factory
or deserializer trace will not add useful information.
## 2026-08-07 post-deserializer handoff trace
A second one-shot run combined the factory/deserializer probes with atomic replacements of
the category request vtable slots `+0x90` (completion callback dispatch) and `+0x88`
(response ownership transfer). All four calls completed on native thread 656:
```text
request = 0xb80cdfe0
factory response = 0x7c94808
deserializer this = 0x7c94808, result=true
+0x90 callback argument = 0x7c94808
+0x88 owner-slot address = 0xbc51f7e8
```
The matching `GET /ut/game/fifa17/sbs/sets` at `11:24:00` returned HTTP 200, and the same
communication modal appeared. Both callback probes reported `entry=1 exit=1`; no degraded
hook state or process failure occurred.
This proves that the parsed response reaches the category request's completion dispatcher
and that its ownership-transfer routine also returns normally. The remaining failure
boundary begins at the receiving owner object's vtable `+0x18` consumer invoked from
`0x1801631e0`, or later collection/cache/UI validation. Network transport, response
construction, native parsing, callback dispatch, and request-side ownership handoff are no
longer candidate root causes.
+337
View File
@@ -0,0 +1,337 @@
# SBC render intervention — injected-DLL integration spec
**Goal:** make the FIFA 17 FUT **SBC menu render real SBC data** from inside the
process (client-side), proven not server-fixable. The DLL is the existing
`openfut-hook` (`version.dll`, cross-compiled `x86_64-pc-windows-gnu`, feature
`fifa17`). In-process calls to client functions are safe here (unlike `/proc/mem`
writes), because we run on the game's own threads with the real allocator.
**Binary of record (clean-room):** `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`
(on-disk copy `/tmp/fut/cardsdll.dll`), PE image base `0x180000000`. Every address
below was re-verified byte-exact against this PE in this pass (vtable slots read from
`.rdata`, prologues from `.text`). Do **not** build/deploy from this spec without the
staged morning test (§9).
---
## 1. Module base + RVA math
CardsDLL is **not** present at `DllMain`/worker time — the boot module dump
(`C:\openfut_hook.log`) has no `CardsDLL*` entry. It is loaded lazily **only when the
user first enters Ultimate Team**. Therefore the hook must **defer** and poll for it,
exactly like `probe::install_probes_deferred` polls for `anadius64.dll`.
- Loaded module name (Wine keeps the on-disk filename): **`CardsDLL_Win64_retail.dll`**.
`GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0")`. Fallback: ToolHelp module walk
matching a name containing `CardsDLL` (see `fifa17::dump_modules` for the pattern).
- Image base in the PE is `0x180000000`. For any static VA in this doc:
```
rva = VA_static - 0x180000000
VA_runtime = cards_base + rva
```
`cards_base` is the runtime `HMODULE` of `CardsDLL_Win64_retail.dll` (its in-memory
load address). All the "0x180…" addresses below are **static VAs**; subtract
`0x180000000` to get the RVA, add `cards_base` to get the live pointer/callable.
- Slide-proof control (optional sanity, mirrors `tools/gate_byte_probe.py`): the FNV
prologue at VA `0x180180d00` must match the on-disk PE bytes
`48 83 ec 28 48 85 c9 74 50 45 33 c0 ba c5 9d 1c 81 …`. If it does not, **abort** —
the module map moved and the offsets are untrustworthy.
---
## 2. Verified object graph
```
A = FUT root singleton = *(0x1802e6398) getter thunk 0x18011a830 = { mov rax,[rip→0x1802e6398]; ret }
A.vtable (live [A]) = static 0x18021c2a0
A.vtable[+0x4e8] = 0x18011c1f0 = { lea rax,[rcx+0x1f9d8]; ret } -> B getter
A.vtable[+0x9b0] = 0x18011b7d0 = M lazy getter (see §3) -> M getter
A.vtable[+0x18] = 0x180113f50 = service-id 0xed84b12 -> returns `this` (proves manager == A)
B = SBC request/ready TTL cache = A + 0x1f9d8 vtable static 0x1801fae70
B+0x08 collection ptr (live 0 offline)
B+0x20 QPC deadline
B+0x28 ready byte (== A+0x1fa00 alias) <- the isValid gate byte
B.vtable[+0x00] dtor = 0x180063040
B.vtable[+0x08] isValid = 0x180065d40 (see §4)
B.vtable[+0x10] clear = 0x180065d20
M = SBC categories/sets store = *(A + 0x20a68) <- THE RENDER SOURCE (see §3, §5)
M+0x50 WORD category count
M+0x58 cat-vector begin (element stride 0xf0)
M+0x60 cat-vector end
M+0xa10 secondary/featured vec begin (8-byte elems) (emptiness-checked at render)
M+0xa18 secondary vec end
per category (+0xf0 stride):
cat+0xb8 WORD set count
cat+0xc0 set-vector begin (element stride 0x3570)
set+0x1c9 byte per-set flag
```
HUB cache (works online) is the **same class** at `A + 0x1fd70` (vtable `0x18021c1e0`)
— reference only.
**Manager fetch used by BOTH the deser and the render controller** (so
populate-target == render-source):
```
reg = 0x1800d7170() ; -> &registry (static 0x1802c2988)
mgr = 0x180009c80(&out, reg) ; out = manager (hashes 0xed84b11 / 0xed84b12)
M = mgr.vtable[+0x9b0](mgr) ; 0x18011b7d0, lazily creates/returns *(A+0x20a68)
```
Because svc-id `0xed84b12` resolves to `A` (A.vtable[+0x18] returns `this`),
`mgr == A` and `mgr.vtable[+0x9b0] == A.vtable[+0x9b0] == 0x18011b7d0`. The hook may
therefore fetch M the short way — `A = *(0x1802e6398); M = (*(void***)A)[0x9b0/8](A)` —
**or** the long way (registry) — they return the identical object.
---
## 3. M lazy getter — 0x18011b7d0 (verified disassembly)
```
18011b7d0 push rbx; push rdi; sub rsp,0x38
18011b7e0 mov rdi,rcx ; rcx = A (this)
18011b7e3 cmp QWORD [rcx+0x20a68],0 ; M already built?
18011b7eb jne 18011b873 ; yes -> return it
18011b7f1 call 0x18019e3c0 ; factory: allocate an EMPTY M (type-id 0x13f0)
… … ; init fields, cache at A+0x20a68, return
```
Cold-calling this alone **creates an EMPTY M** (`WORD[M+0x50]==0`) → the menu draws
**2 placeholder tiles** (count+2). It does **not** populate. Populating is §5.
---
## 4. The gate — isValid 0x180065d40 (verified disassembly)
```
180065d40 push rbx; sub rsp,0x20; mov rbx,rcx ; rcx = B
180065d49 call 0x1801642c0 ; online sub-check — STUBBED `mov al,1;ret`
180065d4e test al,al ; je fail ; never the wall
180065d52 cmp BYTE [rbx+0x28],0 ; je fail ; <-- READY BYTE gate
180065d58 cmp QWORD [rbx+0x8],0 ; je 0x180065d75 ; <-- if collection==0 -> RETURN 1 (short-circuit)
180065d5f lea rcx,[rsp+0x38]; call [rip→0x1801e50c0]; QueryPerformanceCounter
180065d6a mov rax,[rbx+0x20]; sub rax,[rsp+0x38] ; deadline - now
180065d73 js fail ; past deadline -> fail
180065d75 mov al,1 ; …; ret ; success
```
**Load-bearing correction (adversarially confirmed, verified in this pass):** arm
**only** `BYTE[B+0x28]=1` and **leave `QWORD[B+0x08]=0`**. With `B+0x08==0` the function
takes the `je 0x180065d75` short-circuit and returns 1 immediately. If you instead
write `B+0x08` (pointing it at the collection), isValid falls into the QPC-deadline
branch; with the live-stale deadline (`B+0x20 = 0xf10fb8cb9`, already in the past) it
returns **0 → modal → gate SHUTS**. So **never** manually write `B+0x08` or `B+0x20`.
Rendering reads **M** (§5), not `B+0x08`, so nothing needs `B+0x08` set.
---
## 5. Render source — M, not B (verified disassembly)
Controller ctor caches M into `controller+0x140`:
```
1800b554d call 0x1800d7170 ; reg
1800b555d call 0x180009c80 ; mgr = out
1800b556b mov rax,[rbx] ; mgr.vtable
1800b5571 call [rax+0x9b0] ; M = 0x18011b7d0(mgr)
1800b5577 mov [rsi+0x140], rax ; controller+0x140 = M
…then registers Scaleform events 0x756c-0x7574 via 0x1801a4a70
```
Tile-count emit (each menu build):
```
1800b5eda mov rax,[r13+0x140] ; rax = M
1800b5ee1 movzx ebx,WORD [rax+0x50] ; ebx = category count
1800b5ee5 add bx,0x2 ; +2 placeholder tiles
1800b5ee9 mov rax,[r15] ; Scaleform model vtable
call [rax+0x58](count) ; push (category_count + 2) list tiles
```
Helper thunks (verified): `0x18015fff0 = lea rax,[rcx+0x58]` (&M cat-vector),
`0x1801607e0 = lea rax,[rcx+0xa10]` (&M secondary vector). **Zero** reads of
`B`/`A+0x1f9d8`/`A+0x1fa00` exist in the tile-build region — B is purely the entry
gate. Populate M ⇒ tiles appear.
---
## 6. Populate path — reuse the real parser (deser 0x18017b2b0)
The category rows are appended **only** by the sbs/sets deserializer. Its geometry and
finalizers are the correct way to fill M (hand-building `0xf0`/`0x3570` structs is
brittle and rejected — §8).
```
18017b2b0 (rcx = this, IGNORED) (rdx = a PRIMED SAX reader over the token stream)
18017b2ef mov rdi,rdx ; keeps the incoming reader in rdi (the byte source)
18017b2fb call 0x1801c63e0(&localctx, 0, 0) ; builds a SECONDARY ctx with a NULL source
18017b309 call 0x1800d7170 ; reg
18017b316 call 0x180009c80 ; mgr
18017b327 call [mgr.vtable+0x9b0] ; M (0x18011b7d0)
… clear 0x18015f3a0(M) ; ALWAYS clears M first (see crash risk C1)
… loop atom 0x6f "categories":
0x180159da0(&tmp) ; cat ctor (0xf0, vtable 0x18021b520)
0x18017ab80(&tmp, reader) ; cat deser (needs the reader)
0x180160e50(&tmp) ; cat finalize (set index)
0x18015a770(M, &tmp) ; APPEND (copy-in; copy-ctor 0x18015a2b0)
0x1801105d0(&tmp) ; cat dtor
… 0x180160e00(M); 0x180160f30(M); 0x180161020(M) ; rebuild M indices (+0x9e0/+0xa10/+0xa40)
… commit mgr.vtable[+0x8](mgr)
18017b751 ret (always true)
```
**The reader (`rdx`) is the crux.** The deser does **not** ingest `rdx` through the
`0x1801c63e0` ctx it builds (that one is created with a NULL source, `rdx=0/r8=0`);
instead it keeps the **incoming** `rdx` in `rdi` and scans its bytes directly (e.g. the
NUL-terminated backslash-unescape at `~0x18017b353` does `mov rdi,[rdi]`). So `rdx`
must be a **fully-constructed, already-primed SAX reader/cursor object** seated over
your canned `sbs/sets` JSON — the same object type the message framework produces on a
real response. **Building that reader from scratch is the one remaining un-reversed
contract** (its vtable, and specifically the `[+0x8]` byte-yield slot, are not yet
pinned). Until it is, the fully-offline parser-reuse call is **not turnkey** — see the
three tiers in §7.
SAX primitives already known (for when the reader is reconstructed): ctx init
`0x1801c63e0(rcx=ctx,rdx=source,r8=flags)`, lexer `0x1801c8060`, next-token
`0x1801c7f10`, begin-object `0x1801c8270`, INT `0x1801c79d0`, STR `0x1801c7aa0`,
BOOL `0x1801c7620`, SKIP `0x180135ff0`.
Response-msg object (for the message-layer tier): ctor `0x18017b1c0` installs vtable
`0x18022e598`; slot `[+0x20] == 0x18017b2b0` (deser) — **verified**. Constructing this
object alone still does **not** seat the reader (the framework does that from received
bytes), so it doesn't remove the reader gap.
---
## 7. Three intervention tiers (implement in this order)
**Tier 0 — arm-only negative control (SAFE, non-crash, renders EMPTY).**
Resolve A→B, write `BYTE[B+0x28]=1`, leave `B+0x08=0`. isValid short-circuits true, the
menu opens and draws **2 placeholder tiles** (M empty/null). Proves the gate model live
without any populate. This is the first morning step and the baseline. Implemented and
env-gated in `sbc_hook.rs` (`OPENFUT_SBC_ARM_ONLY=1`).
**Tier 1 — parser-reuse populate (the intended fix, BLOCKED on the reader).**
On the game thread: build a primed SAX reader over canned `sbs/sets` JSON served by the
bridge/core, `call 0x18017b2b0(rcx=0, rdx=reader)` (self-locates mgr, clears, appends,
finalizes, commits → fills M), then Tier-0 arm (`BYTE[B+0x28]=1` only), then trigger a
menu refresh (§ below). **Cannot be enabled** until the reader contract (§6) is
reversed. `sbc_hook.rs` contains the guarded scaffold that logs the blocker and returns
— it does **not** call the deser with a fabricated reader (that would clear M and/or
crash — C1/C6).
**Tier 2 — message-layer injection (cleanest long-term, feasibility unproven).**
Push a canned `sbs/sets` response through the real receive path so the framework builds
the response-msg (`0x18017b1c0`), seats the reader itself, runs `0x18017b2b0`, fires the
completion callback (`0x1800b8c30`, subscribed in svc ctor `0x1800b5765` via
`mgr.vtable[+0xa90]`), and arms B natively (generic copy-assign `0x1800c21a0`) — **zero
forged state**. Requires reconstructing the message-receive entry + response-msg wiring;
treat as the target, not the default.
**Refresh trigger** (Tier 1/2): the controller re-reads `WORD[M+0x50]` at `0x1800b5eda`
on every build, so **re-opening the SBC menu** suffices. Programmatic alternative: fire
Scaleform refresh events `0x756c-0x7574` via `0x1801a4a70`. If M is populated but no
refresh fires and the controller already cached an empty M at `ctrl+0x140`, you still see
2 placeholder tiles (no crash, just no data) — see C7.
---
## 8. Function signatures (Win64 `extern "system"`; rcx, rdx, r8, r9 → rax)
| Purpose | Static VA | Signature (Rust `unsafe extern "system"`) |
|---|---|---|
| A getter thunk | 0x18011a830 | `fn() -> *mut u8` (returns `*(0x1802e6398)`) |
| B getter (via A vtable +0x4e8) | 0x18011c1f0 | `fn(a: *mut u8) -> *mut u8` (`a+0x1f9d8`) |
| M lazy getter (A vtable +0x9b0) | 0x18011b7d0 | `fn(mgr: *mut u8) -> *mut u8` (`*(mgr+0x20a68)`, lazily built) |
| isValid (B vtable +0x08) | 0x180065d40 | `fn(b: *mut u8) -> bool` |
| registry getter | 0x1800d7170 | `fn() -> *mut u8` |
| manager getter | 0x180009c80 | `fn(out: *mut *mut u8, reg: *mut u8) -> *mut u8` |
| sbs/sets deser (whole) | 0x18017b2b0 | `fn(this_ignored: *mut u8, reader: *mut u8) -> bool` |
| SAX ctx init | 0x1801c63e0 | `fn(ctx: *mut u8, source: *mut u8, flags: u64) -> *mut u8` |
| clear M | 0x18015f3a0 | `fn(m: *mut u8)` |
| cat ctor (0xf0) | 0x180159da0 | `fn(tmp: *mut u8) -> *mut u8` |
| cat deser | 0x18017ab80 | `fn(tmp: *mut u8, reader: *mut u8) -> bool` |
| cat finalize | 0x180160e50 | `fn(tmp: *mut u8)` |
| append into M | 0x18015a770 | `fn(m: *mut u8, tmp: *mut u8)` |
| cat dtor | 0x1801105d0 | `fn(tmp: *mut u8)` |
| M index rebuild ×3 | 0x180160e00 / 0x180160f30 / 0x180161020 | `fn(m: *mut u8)` each |
| QueryPerformanceCounter thunk | 0x1801e50c0 | (indirect; not needed if B+0x08 left 0) |
| Scaleform refresh dispatch | 0x1801a4a70 | `fn(ctrl: *mut u8, event_id: u32, …)` (event ids 0x756c-0x7574) |
M is **per-session heap** — never hardcode its address; always go A → `A.vtable[+0x9b0]`.
---
## 9. Staged morning test plan (human, live)
Preconditions: FIFA 17 at the FUT hub (so CardsDLL is loaded). One env var flips each
tier; all default **off/inert**. Watch `C:\openfut_hook.log`.
1. **Injection + resolution (read-only).** Launch with `OPENFUT_SBC_HOOK=1` only. The
deferred thread should log: CardsDLL base + slide-control OK, then `A=…`, `B=…`,
`B+0x28=0`, `M=*(A+0x20a68)=…` (0 until the SBC menu is opened once). No writes.
*Pass:* addresses match the model; control FNV OK.
2. **Tier-0 arm-only (negative control).** Add `OPENFUT_SBC_ARM_ONLY=1`. Open the SBC
menu. Expected: **menu opens, draws ~2 empty placeholder tiles, no modal, no crash.**
Confirms the gate byte and short-circuit live. If it crashes → stop (means B
resolution is wrong; recheck slide).
3. **Tier-1 populate — BLOCKED.** Do **not** enable until the SAX reader contract (§6)
is reversed. `OPENFUT_SBC_POPULATE=1` currently only logs the blocker and returns.
Next RE session: pin the reader vtable (`[+0x8]` byte-yield) and the reader ctor,
then wire the §6 sequence and re-test on the game thread with the menu **closed**,
then re-open to refresh.
4. Revert env vars to unset when done.
---
## 10. Crash-risk register (verified against the PE + prior adversarial passes)
- **C1 — cold-calling deser without a real reader.** `0x18017b2b0` **clears M first**
(`0x18015f3a0` before any append). A null/garbage reader → parses nothing but **wipes
M** (renders empty, destroys prior state), and the byte-scan at `~0x18017b353`
(`mov rdi,[rdi]`) segfaults on a bad pointer. This is exactly why Tier 1 is gated off.
- **C2 — clear/finalize race.** Deser clears then rebuilds M's vectors+indices; if the
render thread reads `WORD[M+0x50]` (`0x1800b5eda`) or by-index `0x180160a80` mid-build
→ OOB/crash. Populate on the game thread with the menu **closed**, then refresh.
- **C3 — skipping finalizers.** Any manual append via `0x18015a770` **must** be followed
by `0x180160e00`/`0x180160f30`/`0x180161020` or the `+0x9e0/+0xa10/+0xa40` indices go
stale and by-index lookups read OOB.
- **C4 — hand-built `0xf0`/`0x3570` structs.** Append's copy-ctor `0x18015a2b0`
deep-copies EASTL sub-vectors; a bad begin/end/cap → heap corruption. **Rejected**
(§8): drive the real parser instead.
- **C5 — writing `B+0x08`/`B+0x20`.** Forces isValid into the deadline branch; the
live-stale deadline shuts the gate → modal. **Set only `B+0x28`, leave `B+0x08=0`.**
- **C6 — null manager/M.** Deser does `mov rax,[mgr]`; if the registry lookup returned
null it's a null-deref. Live registry `*(0x1802c2988)` is non-null offline, but the
hook must null-check A, mgr, M before any use.
- **C7 — no refresh (non-crash).** Populate without firing refresh / re-open → controller
keeps its cached empty M → still 2 placeholder tiles. Fails the goal, not a crash.
- **C8 — foreign-thread allocation.** The lazy getter and appenders allocate on / mutate
the game heap; running them off the main/render thread races the allocator. Execute the
populate on a game thread (message-pump / a game-thread detour), not a bg thread. The
Tier-0 single-byte arm is tolerant of a bg write (it's what the `/proc` poke does), but
populate is not.
---
## 11. Live-probe baseline (this pass, read-only `O_RDONLY`, zero writes)
FIFA17.exe **was running** at spec time (pid 12201), CardsDLL mapped. Fresh live reads
this pass match the static model 1:1:
```
slide 0x6ffe7c140000 CONTROL FNV OK
A 0xb83e2b60 (= *(0x1802e6398))
B 0xb8402538 vt=0x1801fae70 (matches static) B+0x08(coll)=0 B+0x20=0xf10fb8cb9 B+0x28(ready)=0
HUB 0xb84028d0 vt=0x18021c1e0 coll=0 ready=0 (reference only)
M *(A+0x20a68)=0 (SBC menu not opened this session -> M not yet built)
```
So live: gate SHUT (`B+0x28=0`), collection null, **M null** — Tier-0 arm alone would
render empty (matches the model). All §2–§6 addresses + all vtable slots were
re-verified byte-exact against the on-disk PE in this pass.
+1
View File
@@ -0,0 +1 @@
/target
+16
View File
@@ -0,0 +1,16 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "futmem"
version = "0.1.0"
dependencies = [
"memchr",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "futmem"
version = "0.1.0"
edition = "2021"
description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)"
publish = false
# An EMPTY [workspace] table makes this crate its own workspace root.
# Without it, cargo walks up the directory tree, finds
# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its
# `members` list, and refuses to build. That parent manifest is untracked and
# must not be edited, so we opt out from this side instead.
[workspace]
[dependencies]
# memchr is the ONLY dependency, and it earns its place.
# A `find` sweep covers roughly 3 GB of resident memory. The naive
# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's
# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster,
# which turns a multi-minute sweep into a few seconds.
# Everything else (argument parsing for four subcommands, /proc/<pid>/maps
# parsing, hex dumping) is a few dozen lines of std and does not justify
# pulling in clap or a proc-maps crate.
memchr = "2"
[profile.release]
opt-level = 3
+249
View File
@@ -0,0 +1,249 @@
# futmem
A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT
preservation project.
`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only
inside the running process. Anything the packed executable owns can be reached
only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the
FUT logic, is unpacked but is loaded at a different address on every launch.
`futmem` answers both problems: it finds the process, tells you where everything
is loaded, and lets you search and dump it without touching a byte.
```
cargo build --release
./target/release/futmem maps
```
## Read only by construction
A live game session may be running while this tool is used, and corrupting it
costs the user their session. The read-only property is therefore structural
rather than a matter of discipline:
* `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The identifier
`OpenOptions` does not appear anywhere in this crate.
* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no
raw file descriptor, so no caller outside `mem.rs` can upgrade the handle.
* Nothing here calls `ptrace`, sends a signal, or stops the target.
There is no code path in this crate that can write to another process. Even if
one were added by mistake, the kernel would reject the write on an `O_RDONLY`
descriptor. Keep it that way.
## Subcommands
```
futmem maps [--pid N]
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
[--grep SUBSTR] [--max N]
futmem read <va> <len> [--pid N]
```
With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly
`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry
"fifa17" in their command line, including a convincing
`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good
enough. Only `comm` is authoritative.
Addresses may be written `0x140000000` or `140000000`; bare values are read as
hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`,
`2m`.
## What `maps` gives you that `cat /proc/pid/maps` does not
### The relocation slide, computed for you
Every address in the project's Ghidra database is based at `0x180000000`. The
live module is somewhere else. `maps` prints the conversion directly:
```
CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000
CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000
```
It derives this by reading `ImageBase` from the *on-disk* PE (where the module
wanted to load) and subtracting it from the live load address. The live header
cannot be used for this, because Wine rewrites its `ImageBase` field to the
actual load address.
**Module bases move on every launch.** Never cache the slide across a restart.
### The Wine mapping gotcha, made visible
Wine keeps only a PE's 4 KiB header file-backed and copies every section into
anonymous memory. So this returns exactly one line:
```
$ grep CardsDLL /proc/4048/maps
6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
```
It is easy to misread that as "the module is barely mapped". A module table built
naively from path grouping reports CardsDLL as a 4 KiB module; it is really
`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead
and flags the discrepancy:
```
6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous]
```
This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in
an anonymous region as far as the maps are concerned, so `find` checks module
image spans *before* the region list and reports
`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`.
Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered
addresses, and letting its min..max span count as an "image" mis-attributed
gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their
exact regions.
### Honest degradation
If the game has not loaded FUT yet, the difference is visible at a glance rather
than showing as an empty table:
```
KEY MODULES
FIFA17.exe PRESENT base 0x140000000 ...
CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet)
```
An explicit `--pid` that does not point at the game is called out too, so a
wrong-target mistake cannot pass unnoticed:
```
pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process
```
## Design notes
### pread, not seek + read
`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable
cursor on the file. A `&ProcMem` can therefore be shared across threads later
without a mutex and without one thread's seek corrupting another's read, and a
whole class of "forgot to seek" bugs disappears.
### Partial sweeps are normal, and are reported
Many regions marked readable in `/proc/<pid>/maps` are not actually readable:
guard pages, Wine's special mappings, and pages Denuvo has not faulted in all
return `EIO`. A failed read is skipped and counted, never fatal, and every sweep
prints its counts:
```
1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over
```
That line is there so a zero-hit result is never mistaken for proof of absence.
When `find` returns nothing it says so explicitly.
### Chunked reads and the `pattern_len - 1` overlap
The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The
classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary
is never found: the tail of chunk N holds its first bytes and the head of chunk
N+1 holds the rest, and neither buffer contains the whole thing.
Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That
number is neither too small nor too large. Let a chunk cover `[0, n)` and the
pattern have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`,
so the last match wholly inside the chunk starts at `s = n - P`. Advancing by
`n - (P - 1)` starts the next chunk at `n - P + 1`, so:
* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside
the next chunk;
* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past
`n - P`, the last index that can host a complete match in this chunk. The
windows of reportable match *starts* are disjoint even though the byte windows
overlap.
Overlapping by `P` would report every boundary-straddling match twice;
overlapping by `P - 2` would miss one alignment.
This is verified against the live process rather than merely asserted. Region
`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at
`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary,
and is found exactly once:
```
$ futmem read 0x1452d2ff8 16
0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t|
$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17
0x0001452d2ff8 FIFA17.exe+0x52d2ff8
1 hits
```
One hit, not zero and not two.
String extraction uses a different mechanism for the same reason: it sweeps with
zero overlap and carries an unfinished run across contiguous chunks, so a string
spanning a boundary is still emitted whole. UTF-16 additionally carries a
dangling low byte when a chunk ends mid-pair.
### Dependencies
`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order
of magnitude faster than `windows(n).position(...)` over multiple gigabytes,
which is the difference between a several-minute sweep and a few seconds.
Everything else (argument parsing for four subcommands, maps parsing, PE header
parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling
in `clap`.
### Standalone workspace
`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the
directory tree, finds the untracked workspace manifest at the repo root, sees that
`futmem` is not in its `members` list, and refuses to build. Opting out from this
side avoids editing that manifest.
## Performance
Measured against pid 4048 with the game sitting at the main menu, release build,
best and worst of three runs each. These are wall clock, and they are dominated
by the `pread` syscalls rather than by the search itself.
| Sweep | Scope | Wall clock |
|---|---|---|
| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s |
| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s |
| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s |
| `maps` | n/a | 0.05 s |
Scoping with `--module` is over a hundred times cheaper and should be the default
habit when the target is known to live in CardsDLL. A global sweep costs about
six seconds, which is cheap enough to use freely but not in a tight loop.
## Worked example
```
$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll
scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB)
from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
pattern 16 bytes, 7 candidate regions (3.11 MiB)
0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618
6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave|
6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..|
6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......|
6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......|
1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over
```
The `+0x22c618` offset converts straight back to the Ghidra address
`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not
`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather
than assuming them.
## Scope
This tool is client-side instrumentation. It establishes nothing about the UTAS
wire protocol and nothing a server emulator must reimplement. Its value is as the
addressing base that lets other work read server-authoritative logic out of
CardsDLL. Do not let addresses produced by this tool leak into a protocol
document as if they were protocol.
+125
View File
@@ -0,0 +1,125 @@
//! A deliberately tiny argument parser.
//!
//! Four subcommands do not justify a `clap` dependency and its build time. The
//! only subtlety is that some long options take a value (`--pid 165925`) and
//! some are bare booleans (`--utf16`). A parser cannot tell those apart from
//! the token stream alone, so each subcommand declares which of its options
//! take a value and we look the name up in that list.
use std::collections::HashMap;
pub struct Args {
opts: HashMap<String, Option<String>>,
pub positional: Vec<String>,
}
#[derive(Debug)]
pub struct ArgError(pub String);
impl std::fmt::Display for ArgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Args {
/// `value_flags` lists the long option names that consume the following
/// token as their value. Everything else beginning with `--` is a boolean.
/// `--name=value` is always accepted regardless of the list.
pub fn parse<I: Iterator<Item = String>>(
argv: I,
value_flags: &[&str],
) -> Result<Args, ArgError> {
let mut opts: HashMap<String, Option<String>> = HashMap::new();
let mut positional = Vec::new();
let mut it = argv.peekable();
while let Some(tok) = it.next() {
if let Some(rest) = tok.strip_prefix("--") {
if rest.is_empty() {
// A bare `--` ends option parsing; the rest is positional.
positional.extend(it.by_ref());
break;
}
if let Some((name, value)) = rest.split_once('=') {
opts.insert(name.to_string(), Some(value.to_string()));
} else if value_flags.contains(&rest) {
let value = it
.next()
.ok_or_else(|| ArgError(format!("--{rest} needs a value")))?;
opts.insert(rest.to_string(), Some(value));
} else {
opts.insert(rest.to_string(), None);
}
} else {
positional.push(tok);
}
}
Ok(Args { opts, positional })
}
pub fn has(&self, name: &str) -> bool {
self.opts.contains_key(name)
}
pub fn value(&self, name: &str) -> Option<&str> {
self.opts.get(name).and_then(|v| v.as_deref())
}
pub fn parse_value<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, ArgError> {
match self.value(name) {
None => Ok(None),
Some(raw) => raw
.parse::<T>()
.map(Some)
.map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))),
}
}
/// Reject typos instead of silently ignoring them. `futmem find --acii foo`
/// should not quietly scan for nothing.
pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> {
for name in self.opts.keys() {
if !known.contains(&name.as_str()) {
return Err(ArgError(format!("unknown option --{name}")));
}
}
Ok(())
}
}
/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present,
/// decimal otherwise) into a virtual address.
pub fn parse_addr(raw: &str) -> Result<u64, ArgError> {
let cleaned = raw.replace('_', "");
let parsed = match cleaned
.strip_prefix("0x")
.or_else(|| cleaned.strip_prefix("0X"))
{
Some(hex) => u64::from_str_radix(hex, 16),
// Bare addresses in this project are always written in hex
// (`6ffffc140000`), so try hex first and fall back to decimal only for
// values that are unambiguous.
None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::<u64>()),
};
parsed.map_err(|_| ArgError(format!("bad address {raw:?}")))
}
/// Parse a length: `4096`, `0x1000`, `16k`, `2m`.
pub fn parse_len(raw: &str) -> Result<u64, ArgError> {
let lower = raw.to_ascii_lowercase();
let (body, mult) = match lower.strip_suffix('k') {
Some(b) => (b, 1024u64),
None => match lower.strip_suffix('m') {
Some(b) => (b, 1024 * 1024),
None => (lower.as_str(), 1),
},
};
let n = match body.strip_prefix("0x") {
Some(hex) => u64::from_str_radix(hex, 16),
None => body.parse::<u64>(),
}
.map_err(|_| ArgError(format!("bad length {raw:?}")))?;
Ok(n * mult)
}
+33
View File
@@ -0,0 +1,33 @@
//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks.
use std::fmt::Write as _;
use std::io::{self, Write};
fn ascii_gutter(row: &[u8]) -> String {
row.iter()
.map(|&b| {
if (0x20..=0x7e).contains(&b) {
b as char
} else {
'.'
}
})
.collect()
}
/// Classic 16-bytes-per-line dump with absolute addresses in the left column.
pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> {
for (i, row) in data.chunks(16).enumerate() {
let addr = base + (i * 16) as u64;
let mut hex = String::with_capacity(50);
for (j, b) in row.iter().enumerate() {
if j == 8 {
hex.push(' ');
}
// Writing into a String is infallible.
let _ = write!(hex, "{b:02x} ");
}
writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?;
}
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
//! Turning `/proc/<pid>/maps` lines into a usable module table, and turning an
//! address back into `module+offset`.
//!
//! # The Wine mapping gotcha this module exists to work around
//!
//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every
//! section into ANONYMOUS memory. So `grep CardsDLL /proc/<pid>/maps` returns
//! exactly one line, 4 KiB long, and a module table built naively from path
//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes.
//! An agent who trusts the maps extent concludes the module is "barely mapped"
//! and gives up, or computes a wrong module size and mis-attributes every hit.
//!
//! The fix: read `SizeOfImage` out of the live PE header at the module base.
//! That field is authoritative for the module's real extent, and the header is
//! the one part of the image that is reliably readable.
//!
//! # Deriving the slide automatically
//!
//! Wine rewrites the `ImageBase` field of the *live* header to the actual load
//! address, so the live header cannot tell us where the module wanted to load.
//! The on-disk file still can, and the maps line gives us its path. Reading the
//! on-disk `ImageBase` and subtracting gives the relocation slide:
//!
//! ```text
//! slide = live_base - disk_image_base
//! live_va = static_va + slide
//! ```
//!
//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the
//! number every Ghidra-derived address in this project has to be adjusted by.
//! Printing it removes the most error-prone manual step in the workflow.
use crate::maps::Region;
use crate::mem::ProcMem;
use std::fs;
#[derive(Debug, Clone)]
pub struct Module {
/// Bare file name, e.g. `CardsDLL_Win64_retail.dll`.
pub name: String,
pub path: String,
/// Lowest mapped address carrying this path. For a PE this is the header.
pub base: u64,
/// Highest address still carrying this path in the maps. Badly understates
/// the truth under Wine; see the module docs.
pub maps_end: u64,
/// Number of separate maps lines mentioning this path.
pub region_count: usize,
/// `SizeOfImage` from the live PE header, the real extent.
pub size_of_image: Option<u64>,
/// `ImageBase` from the on-disk file: where the module was linked to load.
pub disk_image_base: Option<u64>,
}
impl Module {
/// Best available end address: PE-derived when we have it, maps otherwise.
pub fn end(&self) -> u64 {
match self.size_of_image {
Some(size) => self.base + size,
None => self.maps_end,
}
}
/// The relocation slide: add this to a static (Ghidra) VA to get a live VA.
pub fn slide(&self) -> Option<i128> {
self.disk_image_base
.map(|disk| self.base as i128 - disk as i128)
}
/// Is this actually a PE image, as opposed to a device node, font or `.nls`
/// data file that merely happens to be mapped?
pub fn is_pe(&self) -> bool {
self.size_of_image.is_some()
}
/// Only PE images claim an address range.
///
/// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is
/// mapped at many scattered addresses, so its min..max span covers gigabytes
/// of unrelated anonymous memory, and every hit in there would be reported
/// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions
/// listed for it in the maps, which `describe` handles as a fallback.
pub fn contains(&self, va: u64) -> bool {
self.is_pe() && va >= self.base && va < self.end()
}
}
/// Little-endian scalar helpers. Returning `Option` keeps a truncated or
/// malformed header from panicking the whole run.
fn u16_at(buf: &[u8], off: usize) -> Option<u16> {
buf.get(off..off + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn u32_at(buf: &[u8], off: usize) -> Option<u32> {
buf.get(off..off + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn u64_at(buf: &[u8], off: usize) -> Option<u64> {
buf.get(off..off + 8)
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
}
/// `SizeOfImage` and `ImageBase` from a PE header blob.
///
/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte
/// COFF header follows; the optional header starts at signature+24. Within the
/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the
/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18
/// for PE32+ and 4 bytes at 0x1c for PE32.
fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> {
if buf.get(0..2)? != b"MZ" {
return None;
}
let nt = u32_at(buf, 0x3c)? as usize;
if buf.get(nt..nt + 4)? != b"PE\0\0" {
return None;
}
let opt = nt + 24;
let magic = u16_at(buf, opt)?;
let size_of_image = u32_at(buf, opt + 0x38)? as u64;
let image_base = match magic {
0x20b => u64_at(buf, opt + 0x18)?, // PE32+
0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32
_ => return None,
};
Some((size_of_image, image_base))
}
fn pe_from_disk(path: &str) -> Option<(u64, u64)> {
// 4 KiB is more than enough for MZ + PE + optional header on any real image.
let data = fs::read(path).ok()?;
parse_pe(&data[..data.len().min(4096)])
}
/// Build the module table. Modules are returned sorted by base address.
pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec<Module> {
use std::collections::HashMap;
let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new();
for r in regions {
let Some(path) = r.path.as_deref() else {
continue;
};
if r.pseudo() {
continue;
}
let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0));
entry.0 = entry.0.min(r.start);
entry.1 = entry.1.max(r.end);
entry.2 += 1;
}
let mut out: Vec<Module> = by_path
.into_iter()
.map(|(path, (base, maps_end, region_count))| {
// The live header gives the true extent; the on-disk header gives
// the link-time base, which is what the slide is measured against.
let live = mem.read_partial(base, 4096);
let live_pe = parse_pe(&live);
let disk_pe = pe_from_disk(path);
Module {
name: path.rsplit('/').next().unwrap_or(path).to_string(),
path: path.to_string(),
base,
maps_end,
region_count,
size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)),
disk_image_base: disk_pe.map(|(_, b)| b),
}
})
.collect();
out.sort_by_key(|m| m.base);
out
}
/// Case-insensitive lookup by name substring, e.g. `cardsdll`.
pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> {
let needle = needle.to_ascii_lowercase();
mods.iter()
.find(|m| m.name.to_ascii_lowercase().contains(&needle))
}
/// Describe an address as `module+0xoff`, falling back to the region kind.
///
/// Checking module image spans BEFORE the region list is essential here: a hit
/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps
/// are concerned, and would otherwise be reported as `anon`, throwing away the
/// single most useful piece of context.
pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String {
if let Some(m) = mods.iter().find(|m| m.contains(va)) {
return format!("{}+{:#x}", m.name, va - m.base);
}
match regions.iter().find(|r| va >= r.start && va < r.end) {
Some(r) => match r.path.as_deref() {
Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start),
None => format!("anon:{:#x}({})", r.start, r.perms),
},
None => "unmapped".to_string(),
}
}
+576
View File
@@ -0,0 +1,576 @@
//! # futmem: a read-only live-memory inspector for FIFA 17
//!
//! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's
//! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext
//! only inside the running process. Anything the packed executable owns can be
//! reached only through live memory. This tool is how you reach it.
//!
//! ## READ ONLY BY CONSTRUCTION
//!
//! A live game session may be running while this tool is used, and corrupting it
//! costs the user their session. The read-only property is therefore structural
//! rather than a matter of discipline:
//!
//! * `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The string
//! `OpenOptions` does not appear anywhere in this crate.
//! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and
//! no raw descriptor, so no caller can upgrade the handle to a writable one.
//! * Nothing here calls `ptrace`, sends a signal, or stops the target.
//!
//! There is no code path in this crate that can write to another process. Even
//! if one were added by mistake, the kernel would reject the write on an
//! `O_RDONLY` descriptor.
//!
//! ## Design notes
//!
//! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an
//! argument instead of mutating a shared file cursor, so a `&ProcMem` can be
//! shared across threads later without a mutex, and a whole class of "forgot
//! to seek" bugs disappears. See `mem.rs`.
//! * **Partial sweeps are normal.** Many regions marked readable in
//! `/proc/<pid>/maps` are not actually readable: guard pages, Wine's special
//! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed
//! read is skipped and counted, never fatal, and the counts are printed so a
//! zero-hit result is never mistaken for proof of absence. See `scan.rs`.
//! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly
//! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks
//! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a
//! boundary is still found, and not double-reported. `scan.rs` carries the
//! proof that this specific overlap is the correct one; it is the classic
//! off-by-one in scanners of this kind.
//! * **Minimal dependencies.** `memchr` is the only one, and it earns its place
//! on a multi-gigabyte sweep. Four subcommands do not justify `clap`.
//!
//! ## The Wine mapping gotcha
//!
//! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into
//! anonymous memory. `grep CardsDLL /proc/<pid>/maps` therefore returns exactly
//! one 4 KiB line. A module table built naively from the maps reports CardsDLL as
//! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads
//! `SizeOfImage` from the live PE header instead, and derives the relocation
//! slide by comparing the live load address against the on-disk `ImageBase`, so
//! the number needed to convert Ghidra addresses to live ones is printed rather
//! than recomputed by hand.
mod cli;
mod dump;
mod image;
mod maps;
mod mem;
mod scan;
use cli::{parse_addr, parse_len, ArgError, Args};
use maps::{human, Region};
use mem::ProcMem;
use std::io::{self, BufWriter, Write};
use std::process::ExitCode;
const COMM: &str = "FIFA17.exe";
/// Modules this project always wants to know the status of.
const KEY_MODULES: [&str; 3] = [
"FIFA17.exe",
"CardsDLL_Win64_retail.dll",
"powdll_Win64_retail.dll",
];
const USAGE: &str = "\
futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling)
USAGE
futmem maps [--pid N]
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
[--grep SUBSTR] [--max N]
futmem read <va> <len> [--pid N]
COMMON
--pid N Target pid. Omitted, futmem resolves the process whose
/proc/<pid>/comm is exactly \"FIFA17.exe\". Decoy processes in the
Proton tree match a pgrep -f on \"fifa17\", so comm is the authority.
find
--ascii Pattern is ASCII text. This is the default.
--utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI
strings.
--hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored.
--module NAME Restrict the scan to a module's image span, matched case
insensitively on a substring of the file name, e.g. --module cardsdll.
--max N Stop after N hits.
strings
--min N Minimum run length. Default 6.
--range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000.
--module NAME Scan a module's image span.
--utf16 Extract UTF-16LE strings instead of ASCII.
--grep S Only print strings containing S, matched case insensitively.
--max N Stop after N strings.
With none of --range or --module, the default scope is every anonymous private
region, which is where a packed executable's decrypted data lives.
Addresses may be written 0x140000000 or 140000000; bare values are read as hex.
Lengths accept 0x100, 256, 16k, 2m.
All operations are strictly read-only. See the crate docs for the guarantee.
";
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
let Some(sub) = argv.first().cloned() else {
print!("{USAGE}");
return ExitCode::FAILURE;
};
let rest = argv.into_iter().skip(1);
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
let result = match sub.as_str() {
"maps" => cmd_maps(&mut out, rest),
"find" => cmd_find(&mut out, rest),
"strings" => cmd_strings(&mut out, rest),
"read" => cmd_read(&mut out, rest),
"-h" | "--help" | "help" => {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
other => {
eprintln!("futmem: unknown subcommand {other:?}\n");
eprint!("{USAGE}");
return ExitCode::FAILURE;
}
};
// Flushing separately so a broken pipe (futmem strings | head) is not
// reported as a failure.
let flushed = out.flush();
match (result, flushed) {
(Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
(_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
(Err(e), _) => {
eprintln!("futmem: {e}");
ExitCode::FAILURE
}
(Ok(()), Err(e)) => {
eprintln!("futmem: {e}");
ExitCode::FAILURE
}
(Ok(()), Ok(())) => ExitCode::SUCCESS,
}
}
fn arg_err(e: ArgError) -> io::Error {
new_invalid(e)
}
/// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`.
fn resolve_pid(args: &Args) -> io::Result<i32> {
match args.parse_value::<i32>("pid").map_err(arg_err)? {
Some(pid) => Ok(pid),
None => maps::find_pid(COMM),
}
}
// ---------------------------------------------------------------- maps
fn cmd_maps<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
args.reject_unknown(&["pid"]).map_err(arg_err)?;
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
// Report the comm we actually found, not the one we hoped for: an explicit
// --pid may point anywhere, and silently labelling it "FIFA17.exe" would
// make a wrong-target mistake invisible.
let comm = maps::read_comm(pid);
let warn = if comm == COMM {
String::new()
} else {
format!(" <-- NOT {COMM}; this is not the game process")
};
writeln!(
out,
"pid {pid} (comm {comm:?}), {} mapped regions{warn}",
regions.len()
)?;
writeln!(out)?;
// -- key modules first, so "is FUT loaded yet?" is answerable at a glance.
writeln!(out, "KEY MODULES")?;
for want in KEY_MODULES {
match image::find_module(&mods, want) {
Some(m) => {
let slide = match m.slide() {
Some(s) if s >= 0 => format!("slide +{:#x}", s),
Some(s) => format!("slide -{:#x}", -s),
None => "slide unknown".to_string(),
};
let static_base = m
.disk_image_base
.map(|b| format!("static {b:#x}"))
.unwrap_or_else(|| "static ?".to_string());
writeln!(
out,
" {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}",
m.name,
m.base,
m.size_of_image.unwrap_or(m.maps_end - m.base),
)?;
}
None => writeln!(
out,
" {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)"
)?,
}
}
if let Some(m) = image::find_module(&mods, "CardsDLL") {
if let Some(slide) = m.slide() {
writeln!(out)?;
writeln!(
out,
" CardsDLL address conversion: live_va = static_va + {slide:#x}"
)?;
writeln!(
out,
" (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \
module bases move on every launch.)",
m.disk_image_base.unwrap_or(0),
m.base
)?;
}
}
writeln!(out)?;
// -- full module table
writeln!(out, "MODULES (file-backed, grouped by path)")?;
writeln!(
out,
" {:<14} {:<14} {:<12} {:>5} name",
"base", "end (PE)", "size", "regs"
)?;
for m in &mods {
let note = if !m.is_pe() {
// A device node, .nls table or font, not a loadable image. Its
// min..max span is meaningless, so say so rather than imply an extent.
" [non-PE mapping; span is min..max of scattered regions]".to_string()
} else if m.maps_end - m.base < m.end() - m.base {
// The Wine gotcha, made visible instead of silently misleading.
format!(
" [maps shows only {}; sections are anonymous]",
human(m.maps_end - m.base)
)
} else {
String::new()
};
writeln!(
out,
" {:<14x} {:<14x} {:<12} {:>5} {}{}",
m.base,
m.end(),
human(m.end() - m.base),
m.region_count,
m.name,
note
)?;
}
writeln!(out)?;
// -- writable + executable regions: where packers put decrypted code.
let wx: Vec<&Region> = regions
.iter()
.filter(|r| r.writable() && r.executable())
.collect();
let wx_total: u64 = wx.iter().map(|r| r.size()).sum();
writeln!(
out,
"WRITABLE + EXECUTABLE REGIONS ({} regions, {})",
wx.len(),
human(wx_total)
)?;
// Wine emits hundreds of 4 KiB per-thread stubs that are pure noise.
let mut small_wx = 0usize;
for r in &wx {
if r.size() <= 64 * 1024 {
small_wx += 1;
continue;
}
writeln!(
out,
" {:012x}-{:012x} {} {:>10} {}",
r.start,
r.end,
r.perms,
human(r.size()),
describe_region(r, &mods)
)?;
}
if small_wx > 0 {
writeln!(
out,
" (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)"
)?;
}
writeln!(out)?;
// -- large anonymous private regions
let mut anon: Vec<&Region> = regions
.iter()
.filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024)
.collect();
anon.sort_by_key(|r| std::cmp::Reverse(r.size()));
let anon_total: u64 = anon.iter().map(|r| r.size()).sum();
writeln!(
out,
"ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})",
anon.len(),
human(anon_total)
)?;
for r in &anon {
writeln!(
out,
" {:012x}-{:012x} {} {:>10} {}",
r.start,
r.end,
r.perms,
human(r.size()),
describe_region(r, &mods)
)?;
}
Ok(())
}
/// Label a region with the module whose image span contains it, if any.
fn describe_region(r: &Region, mods: &[image::Module]) -> String {
if let Some(p) = r.path.as_deref() {
// The file offset matters for a packed executable: it says which part of
// the on-disk image this mapping still corresponds to.
let name = p.rsplit('/').next().unwrap_or(p);
return format!("{name} @fileoff {:#x}", r.offset);
}
match mods.iter().find(|m| m.contains(r.start)) {
Some(m) => format!("anon, inside {} image", m.name),
None => "anon".to_string(),
}
}
// ---------------------------------------------------------------- find
fn cmd_find<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let known = ["pid", "ascii", "utf16", "hex", "module", "max"];
let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?;
args.reject_unknown(&known).map_err(arg_err)?;
let Some(raw) = args.positional.first() else {
return Err(new_invalid(ArgError("find needs a pattern".into())));
};
let pattern: Vec<u8> = if args.has("hex") {
parse_hex(raw).map_err(arg_err)?
} else if args.has("utf16") {
// Widen ASCII to UTF-16LE: each byte followed by a zero high byte.
raw.bytes().flat_map(|b| [b, 0]).collect()
} else {
raw.as_bytes().to_vec()
};
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
let module = match args.value("module") {
Some(name) => match image::find_module(&mods, name) {
Some(m) => Some(m.clone()),
None => {
return Err(new_invalid(ArgError(format!(
"no module matching {name:?} in pid {pid}; run `futmem maps` to list them"
))))
}
},
None => None,
};
if let Some(m) = &module {
writeln!(
out,
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
m.name,
m.base,
m.end(),
human(m.end() - m.base),
m.path
)?;
}
let targets = scan::scan_targets(&regions, module.as_ref(), false);
let target_bytes: u64 = targets.iter().map(|r| r.size()).sum();
writeln!(
out,
"pattern {} bytes, {} candidate regions ({})",
pattern.len(),
targets.len(),
human(target_bytes)
)?;
writeln!(out)?;
let mut hits: Vec<u64> = Vec::new();
let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va));
for va in &hits {
let loc = image::describe(*va, &mods, &regions);
writeln!(out, "{va:#014x} {loc}")?;
let ctx = mem.read_partial(*va, 64);
if !ctx.is_empty() {
dump::hexdump(out, *va, &ctx, " ")?;
}
}
writeln!(out)?;
writeln!(out, "{} hits; {}", hits.len(), stats.summary())?;
if hits.is_empty() {
writeln!(
out,
"note: {} regions were unreadable, so an empty result is NOT proof of absence.",
stats.regions_skipped
)?;
}
Ok(())
}
fn parse_hex(raw: &str) -> Result<Vec<u8>, ArgError> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_whitespace() && *c != ':' && *c != ',')
.collect();
let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
if !cleaned.len().is_multiple_of(2) {
return Err(ArgError(format!(
"hex pattern has an odd number of digits ({})",
cleaned.len()
)));
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&cleaned[i..i + 2], 16)
.map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2])))
})
.collect()
}
// ---------------------------------------------------------------- strings
fn cmd_strings<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let known = ["pid", "min", "range", "module", "utf16", "grep", "max"];
let args =
Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?;
args.reject_unknown(&known).map_err(arg_err)?;
let min = args
.parse_value::<usize>("min")
.map_err(arg_err)?
.unwrap_or(6);
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
let grep = args.value("grep");
let utf16 = args.has("utf16");
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
let targets: Vec<Region> = if let Some(range) = args.value("range") {
let (a, b) = range
.split_once('-')
.ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?;
let start = parse_addr(a).map_err(arg_err)?;
let end = parse_addr(b).map_err(arg_err)?;
if end <= start {
return Err(new_invalid(ArgError(format!(
"--range end {end:#x} is not above start {start:#x}"
))));
}
writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?;
vec![Region {
start,
end,
perms: "r--p".to_string(),
offset: 0,
path: None,
}]
} else if let Some(name) = args.value("module") {
let m = image::find_module(&mods, name).ok_or_else(|| {
new_invalid(ArgError(format!(
"no module matching {name:?} in pid {pid}"
)))
})?;
writeln!(
out,
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
m.name,
m.base,
m.end(),
human(m.end() - m.base),
m.path
)?;
scan::scan_targets(&regions, Some(m), false)
} else {
// Default scope: anonymous private memory, where a packed executable's
// decrypted data lives.
let t = scan::scan_targets(&regions, None, true);
let bytes: u64 = t.iter().map(|r| r.size()).sum();
writeln!(
out,
"scanning {} anonymous private regions ({})",
t.len(),
human(bytes)
)?;
t
};
let mut count = 0usize;
let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| {
count += 1;
// Ignoring the write error here keeps the closure simple; a broken pipe
// is caught when the buffer is flushed in main.
let _ = writeln!(out, "{va:#014x} {}", s);
});
writeln!(out)?;
writeln!(out, "{count} strings; {}", stats.summary())?;
Ok(())
}
// ---------------------------------------------------------------- read
fn cmd_read<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
args.reject_unknown(&["pid"]).map_err(arg_err)?;
if args.positional.len() < 2 {
return Err(new_invalid(ArgError("read needs <va> and <len>".into())));
}
let va = parse_addr(&args.positional[0]).map_err(arg_err)?;
let len = parse_len(&args.positional[1]).map_err(arg_err)?;
if len == 0 || len > 64 * 1024 * 1024 {
return Err(new_invalid(ArgError(format!(
"length {len} out of range (1 .. 64 MiB)"
))));
}
let pid = resolve_pid(&args)?;
let regions = maps::read_maps(pid)?;
let mem = ProcMem::open(pid)?;
let mods = image::modules(&regions, &mem);
writeln!(out, "{va:#x} {}", image::describe(va, &mods, &regions))?;
let data = mem.read_exact(va, len as usize)?;
dump::hexdump(out, va, &data, "")?;
Ok(())
}
fn new_invalid(e: ArgError) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, e.0)
}
+168
View File
@@ -0,0 +1,168 @@
//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.
use std::fs;
use std::io;
#[derive(Debug, Clone)]
pub struct Region {
pub start: u64,
pub end: u64,
/// The raw four permission characters, e.g. `rwxp` or `r--s`.
pub perms: String,
/// File offset this mapping starts at, meaningless for anonymous regions.
pub offset: u64,
/// `None` for anonymous mappings.
pub path: Option<String>,
}
impl Region {
pub fn size(&self) -> u64 {
self.end - self.start
}
pub fn readable(&self) -> bool {
self.perms.as_bytes().first() == Some(&b'r')
}
pub fn writable(&self) -> bool {
self.perms.as_bytes().get(1) == Some(&b'w')
}
pub fn executable(&self) -> bool {
self.perms.as_bytes().get(2) == Some(&b'x')
}
pub fn private(&self) -> bool {
self.perms.as_bytes().get(3) == Some(&b'p')
}
pub fn anonymous(&self) -> bool {
self.path.is_none()
}
/// Pseudo-files the kernel exposes. Reading `[vvar]` through
/// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
pub fn pseudo(&self) -> bool {
matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
}
}
pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
format!("no process with pid {pid}")
} else {
format!("pid {pid} exists but its maps are unreadable (different user?)")
};
io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
})?;
Ok(text.lines().filter_map(parse_line).collect())
}
/// The target's `comm`, so output can name what was actually inspected rather
/// than assuming an explicit `--pid` pointed at the game.
pub fn read_comm(pid: i32) -> String {
fs::read_to_string(format!("/proc/{pid}/comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".to_string())
}
/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
let bytes = line.as_bytes();
while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
*cursor += 1;
}
let start = *cursor;
while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
*cursor += 1;
}
if start == *cursor {
None
} else {
Some(&line[start..*cursor])
}
}
fn parse_line(line: &str) -> Option<Region> {
// Format: `start-end perms offset dev inode path`
//
// The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
// carry a ` (deleted)` suffix, so we consume exactly five leading fields by
// position and take the untouched remainder as the path.
//
// Doing this with `line.find(inode)` to locate the split point is a trap:
// the inode of an anonymous mapping is "0", and `find("0")` happily matches
// a zero digit inside the address range at the very start of the line. That
// silently turns half the address into a path. Hence the explicit cursor.
let mut cursor = 0usize;
let range = next_field(line, &mut cursor)?;
let perms = next_field(line, &mut cursor)?;
let offset = next_field(line, &mut cursor)?;
let _dev = next_field(line, &mut cursor)?;
let _inode = next_field(line, &mut cursor)?;
let (start, end) = range.split_once('-')?;
let start = u64::from_str_radix(start, 16).ok()?;
let end = u64::from_str_radix(end, 16).ok()?;
let tail = line[cursor..].trim();
let path = if tail.is_empty() {
None
} else {
Some(tail.to_string())
};
Some(Region {
start,
end,
perms: perms.to_string(),
offset: u64::from_str_radix(offset, 16).ok()?,
path,
})
}
/// Find the FIFA 17 process.
///
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
/// whose command lines mention fifa17, and at least one of them
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
/// wine64-preloader, which is expected and is not a reason to doubt the match.
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
let mut hits = Vec::new();
for entry in fs::read_dir("/proc")? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
if comm.trim() == comm_name {
hits.push(pid);
}
}
}
match hits.len() {
0 => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
)),
1 => Ok(hits[0]),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
)),
}
}
pub fn human(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Read-only access to another process's address space.
//!
//! # The safety property this module exists to guarantee
//!
//! A live FIFA 17 session may be running while this tool is used. Corrupting it
//! costs the user their progress and their patience. So the guarantee here is
//! structural, not a matter of being careful:
//!
//! * `/proc/<pid>/mem` is opened with [`File::open`], which is `O_RDONLY`.
//! There is no [`std::fs::OpenOptions`] anywhere in this crate.
//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File`
//! and no raw fd, so no caller outside this module can upgrade the handle.
//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any
//! path under `/proc`.
//!
//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY`
//! descriptor. The type system and the open mode agree, which is the point.
//!
//! # Why pread and not seek + read
//!
//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument
//! instead of mutating a shared file cursor. That means a `&ProcMem` can be
//! shared across threads later without a mutex and without one thread's seek
//! corrupting another's read. It also removes a whole class of "forgot to seek"
//! bugs. There is never a reason to prefer seek+read here.
use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;
/// The page size we assume when stepping over an unreadable hole. Every x86-64
/// mapping is a multiple of this, so it is a safe granularity for recovery.
pub const PAGE: u64 = 4096;
/// A read-only handle on a process's memory.
pub struct ProcMem {
file: File,
}
/// What a single chunk read produced.
pub enum ChunkRead {
/// `n` bytes landed in the buffer. May be shorter than requested when the
/// read ran into an unmapped hole partway through.
Got(usize),
/// Nothing readable at this address at all.
Hole,
}
impl ProcMem {
/// Open the target read-only. See the module docs for why this is
/// `File::open` and must stay that way.
pub fn open(pid: i32) -> io::Result<Self> {
let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| {
io::Error::new(
e.kind(),
format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"),
)
})?;
Ok(Self { file })
}
/// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather
/// than propagating an error, because in a 3 GB sweep unreadable regions are
/// the normal case, not an exceptional one.
///
/// Guard pages, Wine's special mappings and pages Denuvo has not faulted in
/// are all marked readable in `/proc/<pid>/maps` yet return `EIO` here. The
/// caller counts these and reports the total so the user knows the sweep was
/// partial.
pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead {
match self.file.read_at(buf, va) {
Ok(0) | Err(_) => ChunkRead::Hole,
Ok(n) => ChunkRead::Got(n),
}
}
/// Strict read for cases where a short read is genuinely an error, such as
/// an explicit `futmem read <va> <len>` the user asked for by hand.
pub fn read_exact(&self, va: u64, len: usize) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; len];
self.file.read_exact_at(&mut buf, va).map_err(|e| {
io::Error::new(
e.kind(),
format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"),
)
})?;
Ok(buf)
}
/// Read up to `len` bytes, returning however many were actually available.
/// Used for printing context around a hit that sits near the end of a region.
pub fn read_partial(&self, va: u64, len: usize) -> Vec<u8> {
let mut buf = vec![0u8; len];
match self.file.read_at(&mut buf, va) {
Ok(n) => {
buf.truncate(n);
buf
}
Err(_) => Vec::new(),
}
}
}
+376
View File
@@ -0,0 +1,376 @@
//! Chunked sweeping of a remote address space, plus the two things we sweep
//! for: byte patterns and printable strings.
//!
//! # Why chunking, and the off-by-one that ruins scanners
//!
//! The target has roughly 3 GB resident. Reading a region in one allocation is
//! wasteful and can fail outright, so regions are walked in 4 MiB chunks.
//!
//! The classic bug in every hand-rolled scanner is that a pattern straddling a
//! chunk boundary is never found: the tail of chunk N holds the first few bytes
//! and the head of chunk N+1 holds the rest, and neither buffer contains the
//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1`
//! bytes.
//!
//! That specific overlap is exactly right, and it is worth showing why it is
//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern
//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so
//! the last match fully inside the chunk starts at `s = n - P`. Any match
//! starting at `s > n - P` runs off the end and must be caught by the next
//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by
//! `n - (P - 1)` starts it at precisely `n - P + 1`:
//!
//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`,
//! which is inside the next chunk.
//! * Nothing is double-reported: the first index of the overlap is
//! `n - P + 1`, which is strictly greater than `n - P`, the last index that
//! can host a complete match in this chunk. The two windows of *reportable*
//! match starts are disjoint even though the byte windows overlap.
//!
//! Overlapping by `P` instead would report every boundary-straddling match
//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`.
//!
//! # Holes
//!
//! A region marked readable in `/proc/<pid>/maps` is frequently not readable in
//! practice: guard pages, Wine's special mappings, and pages Denuvo has not
//! faulted in all return `EIO`. These are counted and stepped over a page at a
//! time, never propagated as errors, because in a sweep this size they are
//! routine. The counts are reported so the user knows the sweep was partial and
//! does not read a zero-hit result as proof of absence.
use crate::image::Module;
use crate::maps::Region;
use crate::mem::{ChunkRead, ProcMem, PAGE};
pub const CHUNK: usize = 4 * 1024 * 1024;
#[derive(Default, Debug)]
pub struct SweepStats {
pub regions_scanned: usize,
/// Regions from which not a single byte could be read.
pub regions_skipped: usize,
/// Individual chunk reads that hit an unreadable hole.
pub holes: usize,
pub bytes_read: u64,
}
impl SweepStats {
pub fn summary(&self) -> String {
format!(
"scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over",
self.regions_scanned,
crate::maps::human(self.bytes_read),
self.regions_skipped,
self.holes
)
}
}
fn align_up(va: u64, align: u64) -> u64 {
va.div_ceil(align) * align
}
/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`.
///
/// `contiguous` is true when this chunk's data continues directly from the
/// previous callback with no gap, which string extraction needs in order to
/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern
/// search and 0 for stateful scanners that track continuity themselves.
///
/// Returns early (`false`) if `f` signals it has seen enough.
fn sweep_region<F>(
mem: &ProcMem,
region: &Region,
overlap: usize,
buf: &mut [u8],
stats: &mut SweepStats,
f: &mut F,
) -> bool
where
F: FnMut(u64, &[u8], bool) -> bool,
{
let mut pos = region.start;
let mut contiguous = false;
let mut read_anything = false;
while pos < region.end {
let want = (buf.len() as u64).min(region.end - pos) as usize;
match mem.read_chunk(pos, &mut buf[..want]) {
ChunkRead::Hole => {
stats.holes += 1;
contiguous = false;
// Step to the next page; the current one is unreadable.
pos = align_up(pos + 1, PAGE);
}
ChunkRead::Got(n) => {
read_anything = true;
stats.bytes_read += n as u64;
if !f(pos, &buf[..n], contiguous) {
return false;
}
if pos + n as u64 >= region.end {
break;
}
if n < want {
// Short read: an unmapped hole begins at pos + n. No pattern
// can span a hole, so no overlap is needed here; resume on
// the next page boundary.
contiguous = false;
pos = align_up(pos + n as u64 + 1, PAGE);
} else {
if n <= overlap {
break; // cannot make forward progress
}
contiguous = true;
pos += (n - overlap) as u64;
}
}
}
}
if read_anything {
stats.regions_scanned += 1;
} else {
stats.regions_skipped += 1;
}
true
}
/// Which regions a sweep should touch.
pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec<Region> {
regions
.iter()
.filter(|r| r.readable() && !r.pseudo())
.filter(|r| !anon_only || r.anonymous())
.filter_map(|r| match module {
None => Some(r.clone()),
// Clip the region to the module's image span rather than dropping
// it: under Wine a module's sections live in large anonymous
// regions that may extend past the image.
Some(m) => {
let start = r.start.max(m.base);
let end = r.end.min(m.end());
if start < end {
let mut clipped = (*r).clone();
clipped.start = start;
clipped.end = end;
Some(clipped)
} else {
None
}
}
})
.collect::<Vec<_>>()
}
/// Search every target region for `pattern`. Calls `hit(va)` per match.
pub fn find_pattern<F>(
mem: &ProcMem,
targets: &[Region],
pattern: &[u8],
max: Option<usize>,
mut hit: F,
) -> SweepStats
where
F: FnMut(u64),
{
let mut stats = SweepStats::default();
if pattern.is_empty() {
return stats;
}
let finder = memchr::memmem::Finder::new(pattern);
let overlap = pattern.len() - 1;
// The buffer must comfortably exceed the overlap or progress stalls.
let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)];
let mut found = 0usize;
for region in targets {
let keep_going = sweep_region(
mem,
region,
overlap,
&mut buf,
&mut stats,
&mut |base, data, _contiguous| {
for off in finder.find_iter(data) {
hit(base + off as u64);
found += 1;
if max.is_some_and(|m| found >= m) {
return false;
}
}
true
},
);
if !keep_going {
break;
}
}
stats
}
fn printable(b: u8) -> bool {
(0x20..=0x7e).contains(&b)
}
/// Extracts printable runs, carrying an unfinished run across contiguous chunks
/// so a string straddling a boundary is still emitted whole.
struct StringScanner {
utf16: bool,
min: usize,
run: Vec<u8>,
run_start: u64,
open: bool,
/// UTF-16 only: a low byte at the very end of a chunk whose high byte will
/// arrive in the next one.
carry: Option<(u64, u8)>,
}
impl StringScanner {
fn new(utf16: bool, min: usize) -> Self {
Self {
utf16,
min,
run: Vec::with_capacity(256),
run_start: 0,
open: false,
carry: None,
}
}
fn flush<F: FnMut(u64, &str)>(&mut self, emit: &mut F) {
if self.open && self.run.len() >= self.min {
// Runs are printable ASCII by construction, so this cannot fail.
if let Ok(s) = std::str::from_utf8(&self.run) {
emit(self.run_start, s);
}
}
self.run.clear();
self.open = false;
}
fn push<F: FnMut(u64, &str)>(&mut self, va: u64, b: u8, emit: &mut F) {
if !self.open {
self.open = true;
self.run_start = va;
}
self.run.push(b);
// Guard against a pathological all-printable megabyte eating memory.
if self.run.len() >= 4096 {
self.flush(emit);
}
}
fn feed<F: FnMut(u64, &str)>(
&mut self,
base: u64,
data: &[u8],
contiguous: bool,
emit: &mut F,
) {
if !contiguous {
self.flush(emit);
self.carry = None;
}
if self.utf16 {
self.feed_utf16(base, data, emit);
} else {
for (i, &b) in data.iter().enumerate() {
if printable(b) {
self.push(base + i as u64, b, emit);
} else {
self.flush(emit);
}
}
}
}
fn feed_utf16<F: FnMut(u64, &str)>(&mut self, base: u64, data: &[u8], emit: &mut F) {
let mut i = 0usize;
// A pair split across the chunk boundary: complete it if the high byte
// is the expected 0x00, otherwise the run ends here.
if let Some((addr, lo)) = self.carry.take() {
if data.first() == Some(&0) && printable(lo) {
self.push(addr, lo, emit);
i = 1;
} else {
self.flush(emit);
}
}
while i + 1 < data.len() {
let (lo, hi) = (data[i], data[i + 1]);
if hi == 0 && printable(lo) {
self.push(base + i as u64, lo, emit);
i += 2;
} else {
self.flush(emit);
i += 1;
}
}
if i < data.len() {
self.carry = Some((base + i as u64, data[i]));
}
}
}
/// Extract strings from every target region. Calls `emit(va, text)`.
pub fn find_strings<F>(
mem: &ProcMem,
targets: &[Region],
utf16: bool,
min: usize,
grep: Option<&str>,
max: Option<usize>,
mut emit: F,
) -> SweepStats
where
F: FnMut(u64, &str),
{
let mut stats = SweepStats::default();
let mut buf = vec![0u8; CHUNK];
let grep_lower = grep.map(|g| g.to_ascii_lowercase());
let mut count = 0usize;
for region in targets {
let mut scanner = StringScanner::new(utf16, min);
let mut stop = false;
// overlap 0: the scanner tracks continuity itself via `contiguous`.
let keep_going = sweep_region(
mem,
region,
0,
&mut buf,
&mut stats,
&mut |base, data, contiguous| {
scanner.feed(base, data, contiguous, &mut |va, s| {
let matches = match &grep_lower {
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
None => true,
};
if matches {
emit(va, s);
count += 1;
if max.is_some_and(|m| count >= m) {
stop = true;
}
}
});
!stop
},
);
scanner.flush(&mut |va, s| {
let matches = match &grep_lower {
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
None => true,
};
if matches {
emit(va, s);
}
});
if !keep_going || stop {
break;
}
}
stats
}
+1
View File
@@ -0,0 +1 @@
gvenv/
Regular → Executable
+115 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
import glob, time, struct
import glob, time, struct, sys
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
import glob, time, os
@@ -24,7 +24,46 @@ STORE_PATCHES = {
0x1800175aa: NOP2,
}
LOG="/tmp/autopatch.log"
# Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
#
# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
# positive-category resolution (EDI>0 branch) while routing zero/negative categories through
# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
#
# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
# invariant in the client-fix plan).
#
# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
# overwritten), so an unrecognised CardsDLL build is not patched.
STORE_PATCHES_GUARDED = {
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
}
# Capability advertised to the launcher/backend once the resolver guard is VERIFIED
# live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
EMPTY_MYPACKS_RESOLVER_VERSION = 1
# The guarded site whose verified enforcement backs the capability above.
RESOLVER_GUARD_VA = 0x180014858
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}"
@@ -52,11 +91,55 @@ def wr(pid,va,b):
with open(f'/proc/{pid}/mem','r+b') as f:
f.seek(va); f.write(b)
def guarded_action(cur, orig, patch):
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
Returns "noop" when the live bytes are already patched, "patch" when they are the
known original (safe to apply), or "skip" for anything else -- an unrecognised
CardsDLL build that must never be blindly overwritten.
"""
if cur == patch:
return "noop"
if cur == orig:
return "patch"
return "skip"
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
Mirrors guarded_action's decision, extended with post-write verification so the
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
"""
if cur_before == patch:
return GUARD_VERIFIED
if cur_before == orig:
if not wrote_ok:
return GUARD_WRITE_FAILED
if cur_after == patch:
return GUARD_VERIFIED
return GUARD_VERIFY_FAILED
return GUARD_UNSUPPORTED_BUILD
patched=set()
store_patched=set()
guard_reported=set()
log("=== AUTOPATCH watching for FIFA17.exe ===")
while True:
if __name__ == "__main__":
launcher_pid = None
if "--launcher-pid" in sys.argv:
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
log("=== AUTOPATCH watching for FIFA17.exe ===")
while True:
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
break
for pid in find_pids():
if pid not in patched:
try:
@@ -82,6 +165,34 @@ while True:
if rd(pid, live, len(data)) != data:
wr(pid, live, data)
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
live = cbase + (va - IMG_BASE)
cur = rd(pid, live, len(patch))
action = guarded_action(cur, orig, patch)
wrote_ok = True
cur_after = cur
if action == "patch":
try:
wr(pid, live, patch)
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
except Exception as e:
wrote_ok = False
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
if wrote_ok:
try:
cur_after = rd(pid, live, len(patch))
except Exception:
cur_after = b""
elif action == "skip":
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
# action == "noop": already patched; nothing to write.
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
if state == GUARD_VERIFIED:
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
else:
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
guard_reported.add(pid)
if pid not in store_patched:
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
store_patched.add(pid)
+147 -30
View File
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
PLATFORM = ACCOUNT.PLATFORM
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
# ================================================================== config
HOST = "127.0.0.1"
def refresh_account_identity():
"""Refresh launcher-selected identity before constructing a Blaze session.
The account sync endpoint runs in the separate UTAS process and atomically
replaces the shared active-account file. Blaze snapshots these aliases for
its response builders, so refresh them once at each new TCP session.
"""
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
ACCOUNT.load(force=True)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id
EXT_ID = ACCOUNT.ext_id
EMAIL = ACCOUNT.email
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
import os as _os_cfg
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
def _ip_str_to_u32(ip):
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
try:
a, b, c, d = (int(x) for x in ip.split("."))
return (a << 24) | (b << 16) | (c << 8) | d
except Exception:
return (127 << 24) | 1
HOST = _BIND
REDIR_PORT = 42127
BLAZE_PORT = 42130
NUCLEUS_PORT = 42131
BLAZE_IP_STR = "127.0.0.1"
BLAZE_IP_U32 = (127 << 24) | 1
BLAZE_IP_STR = _ADVERTISE
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
LOG = "/tmp/blaze_responder.log"
RXDIR = "/tmp/blaze_rx"
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
# (grid-blaze order) or after (pamplona order). Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False
DUMP_FRAMES = True
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
# reverse-engineering capture path, but require an explicit opt-in for it.
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
_log_lock = threading.Lock()
@@ -525,7 +565,8 @@ OSDK_TICKER = []
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081"
ROSTER_HOST = "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
@@ -562,7 +603,6 @@ IDENTITY_PARAMS = [
# FUT_POW=1 ./openfut-fut.sh restart
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
OSDK_POW = [
("FIFA_POW_URL", "http://%s/" % POW_HOST),
@@ -571,6 +611,14 @@ OSDK_POW = [
("POW_IS_ON", "1"),
] if _POW_ON else []
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
# POW API remains opt-in through FUT_POW/POW_IS_ON.
FUT_CONTENT_CONFIG = [
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
]
CLIENT_CONFIGS = {
"BlazeSDK": None, # built dynamically, see below
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
@@ -595,7 +643,7 @@ CLIENT_CONFIGS = {
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
UTAS_BASE = "http://127.0.0.1:8099/"
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
@@ -669,6 +717,55 @@ FUT_RS4_CONFIG = (
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
)]
# FUT_TRADING: the transfer-market equivalent of the store block above.
#
# WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on
# Transfer Market" are greyed out because the TO_TRADE_PILE predicate
# FUN_1801a7260 needs a service gate at vtable+0x270, which is
# `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it
# reads 0.
#
# Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06:
# the arm is right (case 0x336 writes param_2[10]) and the applier is right
# (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is
# not reachable from the settings deserializer. The decisive measurement: we served
# maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0,
# +0x1fd54=480). Every gate byte is a constructor default. That also explains
# storeEnabled reading 1: a default, never our value.
#
# REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on
# expecting an effect, and do not reason from it.
#
# The reasoning above was wrong in two places and the flag is inert:
#
# 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a
# PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte
# 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under
# that name. The only rip-relative reference to the literal 0x1801fc118 in the
# whole of .text is that lea. There is no comparison against it anywhere, so a
# client-config key of that name cannot be read as an input by anything. The same
# is true of the IS_* store keys above, which means the store block may also be
# inert and its apparent success was never actually attributed.
# 2. The gate byte was briefly measured as 1 and that was over-claimed as a success.
# On a fresh session it reads 0, and a thorough re-measurement read 0 on the very
# pid where it had read 1. Either the first read was transient or something clears
# it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91.
#
# What IS now known, and supersedes the "/settings is dead" claim in the note above:
# FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot
# +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found
# no callers. The real chain is
# settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
# -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes
# and FUN_180173e00 bails before applying anything unless the int at response+0x1c
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
#
# Default OFF and it should stay off.
# NOTE: FUT_TRADING no longer does anything here. These keys are inert (output
# names the DLL emits, never reads). The REAL trading fix is in utas_server.py:
# userInfo.feature was banning trade. Left disabled so the flag has one meaning.
+ ([] if True else
[(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")])
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
@@ -682,18 +779,21 @@ FUT_RS4_CONFIG = (
def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
still wrap in a present CONF field -- never an empty frame).
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
CardsDLL reads is unproven, so serve them everywhere)."""
"""Return sorted config rows for one section.
Unknown CFIDs still receive the shared FUT/content/POW rows because those
consumers read the merged ``_all`` store and the contributing section is
unproven. The response always carries a present CONF field.
"""
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
# this is a no-op by default. (Putting the keys ONLY under a hypothetical
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW)
return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
+ FUT_CONTENT_CONFIG + OSDK_POW)
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
@@ -716,7 +816,7 @@ def qos_config() -> "OrderedDict":
has NO SVID, unlike Mirror's Edge Catalyst)."""
return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")),
("PSA", (STRING, _ADVERTISE)),
("PSP", (INT, 17502)),
]))),
("LNP", (INT, 10)),
@@ -1042,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
client to have a well-formed config and then fail to connect quietly rather
than resolve a real EA hostname."""
tele = OrderedDict([ # GetTelemetryServerResponse (15)
("ADRS", (STRING, "127.0.0.1")),
("ADRS", (STRING, _ADVERTISE)),
("ANON", (INT, 0)),
("DISA", (STRING, "")),
("EDCT", (INT, 0)),
@@ -1059,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
("SVNM", (STRING, "telemetry-openfut")),
])
tick = OrderedDict([ # GetTickerServerResponse (3)
("ADRS", (STRING, "127.0.0.1")),
("ADRS", (STRING, _ADVERTISE)),
("PORT", (INT, 8999)),
("SKEY", (STRING, "")),
])
@@ -1203,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
log(" -- client locale 0x%08x captured for ALOC" % loc)
resp = preauth_response_fields(service_name=sess.service_name)
payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
% (sess.service_name, len(payload), heat2.dump(resp)))
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
% (sess.service_name, len(payload)))
if DUMP_FRAMES:
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
return [reply_to(hdr, payload)]
if cmd == CMD_PING:
@@ -1218,6 +1320,7 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
n = len(resp["CONF"][1][2])
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
if DUMP_FRAMES:
for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
return [reply_to(hdr, encode_tdf(resp))]
@@ -1253,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
sess.auth_code = get_str(fields or {}, "AUTH", "")
sess.logged_in = True
sess.login_time = int(time.time())
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
"validation -- forged offline session)" % sess.auth_code)
log(" == Authentication::login AUTH=[REDACTED] "
"(accepted as an offline OpenFUT session)")
resp = login_response_fields(sess)
payload = encode_tdf(resp)
log(" -> LoginResponse (%d bytes):\n%s"
% (len(payload), heat2.dump(resp)))
log(" -> LoginResponse (%d bytes)" % len(payload))
if DUMP_FRAMES:
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
notifs = build_login_notifications(sess, sess.login_time)
out = []
if NOTIFY_BEFORE_LOGIN_REPLY:
@@ -1409,9 +1513,10 @@ _frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None:
refresh_account_identity()
log("*** BLAZE CONNECT from %s ***" % (addr,))
sess = Session()
log(" session key minted: %s" % sess.session_key)
log(" session key minted: [REDACTED]")
buf = bytearray()
raw.settimeout(300)
try:
@@ -1442,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
hdr["msg_num"], hdr["user_index"], hdr["options"],
hdr["metadata_len"], hdr["payload_len"]))
if DUMP_FRAMES:
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
if DUMP_FRAMES:
try:
os.makedirs(RXDIR, exist_ok=True)
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
@@ -1460,6 +1565,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
if payload:
try:
fields = decode_tdf(payload)
if DUMP_FRAMES:
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
@@ -1481,6 +1587,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
ohdr["msg_type"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"]))
if DUMP_FRAMES:
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,))
@@ -1579,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'.
def nucleus_sent_log(addr, size):
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
def nucleus_handle(raw: socket.socket, addr) -> None:
try:
raw.settimeout(10)
@@ -1591,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
head, _, rest = req.partition(b"\r\n\r\n")
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
log("NUCLEUS REQ %s: %s" % (addr, line0))
if head:
if head and DUMP_FRAMES:
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
if rest:
if rest and DUMP_FRAMES:
log("NUCLEUS BODY: %r" % rest[:512])
token = "OPENFUT_" + "".join(
@@ -1607,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
b"Cache-Control: no-store\r\nContent-Length: "
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
raw.sendall(out)
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
log(nucleus_sent_log(addr, len(out)))
except Exception as e:
log("NUCLEUS ERR %s: %s" % (addr, e))
finally:
@@ -1659,6 +1770,10 @@ def _selftest() -> None:
sess.account_locale = 0x656E5553
now = 1469000000
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
assert "[REDACTED]" in nucleus_summary
assert "OPENFUT_selftest_secret" not in nucleus_summary
# ---- 1. preAuth still round-trips (regression guard vs v2)
pre = preauth_response_fields()
p = _check_roundtrip("PreAuthResponse", pre)
@@ -1682,9 +1797,11 @@ def _selftest() -> None:
assert items == client_config_for(cfid), cfid
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
% (cfid, len(items), len(pb)))
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
"unknown CFID must carry only the shared merged-store rows"
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
"unknown CFID must still carry a CONF field"
# ---- 3. LoginResponse
lr = login_response_fields(sess)
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Offline check: every /settings flag we ship is one the client actually switches on.
A flag name is not validated by anything at runtime. The client hashes the string
we send and switches on the result, so a typo, a renamed field or a flag that
simply has no arm in the switch is INERT and looks exactly like "the fix did not
work". This asserts each shipped name against two independent sources:
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
real atom with NO arm, so source 1 alone would have passed it.
Run before shipping any settings change. No server needed.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
# both halves of the switch, coverage asserted by char count).
SWITCH_ARMS = {
0x18: "allowGracePeriodForSquadBuildingSets",
0x19: "allowUntradeableForSquadBuildingSets",
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
0x28A: "returningUserRewardsScreenEnabled",
0x2D0: "squadBuildingSetsGracePeriodMinutes",
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
0x2F3: "storyModeRewardEnabled",
0x2F5: "championsScheduleViewPeriodInMinutes",
0x30F: "enableFloatPointSquadRating",
0x310: "enableLegacyYearInfoInItemResourceId",
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
0x336: "tradingEnabled",
}
# Arms that do NOT simply store a value. Shipping these has side effects.
SPECIAL = {
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
"checkServerDbVersion": "makes the client go read a server_db_version config",
}
def main():
fails = []
atoms = {}
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
for line in fh:
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try:
atoms[p[2]] = int(p[1], 16)
except ValueError:
pass
# Cross-check the recovered table against the atom table both ways.
by_name = {v: k for k, v in SWITCH_ARMS.items()}
for name, aid in by_name.items():
if name not in atoms:
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
elif atoms[name] != aid:
fails.append("%s: switch says %#x, atom table says %#x"
% (name, aid, atoms[name]))
import utas_server as u
body = u.SETTINGS
if not isinstance(body, dict) or list(body) != ["configs"]:
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
return report(fails)
rows = body["configs"]
if not isinstance(rows, list):
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
return report(fails)
seen = set()
for r in rows:
if not isinstance(r, dict) or set(r) != {"type", "value"}:
fails.append("row must be exactly {type, value}: %r" % (r,))
continue
t, v = r["type"], r["value"]
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
# but the applier tests `== 1`, so a bool True would work and a string "1"
# would work -- ints keep it unambiguous. An object or array FREEZES.
if isinstance(v, (dict, list)):
fails.append("%s: value is %s -- an object/array here FREEZES the client"
% (t, type(v).__name__))
if not isinstance(t, str):
fails.append("type must be a string, got %r" % (t,))
continue
if t in seen:
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
seen.add(t)
if t not in by_name:
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
elif t in SPECIAL:
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
# Read the mode off the server module, never re-declare the default here: a
# checker with its own copy of a default tests the copy, not the server.
mode = u._SETTINGS_MODE
if mode == "gates":
for g in sorted(gates - seen):
fails.append("mode 'gates' but %s is missing" % g)
for t in sorted(seen & gates):
row = next(r for r in rows if r["type"] == t)
if row["value"] != 1:
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
"the gate" % (t, row["value"]))
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
% (mode, len(rows), len(seen)))
return report(fails)
def report(fails):
if fails:
print("\nFAIL (%d)" % len(fails))
for f in fails:
print(" - %s" % f)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
# FIFA 17 hook M1 staging/deployment helper.
#
# Safe defaults:
# inspect (the default) is read-only;
# stage writes only below the repository;
# deploy and launch require separate, exact confirmation variables.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hook_root="${repo_root}/openfut-launcher/openfut-hook"
default_dll="${hook_root}/target/x86_64-pc-windows-gnu/release/openfut_hook.dll"
stage_root="${repo_root}/fifa17-recon/staging/fifa17-hook-m1"
game_dir="${OPENFUT_FIFA17_GAME_DIR:-/mnt/games/FIFA 17}"
wine_prefix="${OPENFUT_FIFA17_WINEPREFIX:-/home/alex/Games/umu/fifa17}"
proton_path="${OPENFUT_FIFA17_PROTONPATH:-UMU-Proton-10.0-4}"
hook_dll="${OPENFUT_FIFA17_HOOK_DLL:-${default_dll}}"
system_version="${wine_prefix}/drive_c/windows/system32/version.dll"
deployed_dll="${game_dir}/version.dll"
required_exports=(
GetFileVersionInfoA GetFileVersionInfoExA GetFileVersionInfoExW
GetFileVersionInfoSizeA GetFileVersionInfoSizeExA GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW GetFileVersionInfoW VerFindFileA VerFindFileW
VerInstallFileA VerInstallFileW VerLanguageNameA VerLanguageNameW
VerQueryValueA VerQueryValueW
)
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
note() { printf '%s\n' "$*"; }
need_file() { [[ -f "$1" ]] || die "missing file: $1"; }
sha256() { sha256sum -- "$1" | awk '{print $1}'; }
pe_exports() {
x86_64-w64-mingw32-objdump -p "$1" |
awk '/\[Ordinal\/Name Pointer\] Table/{in_names=1; next} in_names && /\+base\[/ {print $NF}'
}
verify_pe64() {
local dll=$1
local format
format="$(x86_64-w64-mingw32-objdump -f "$dll" | awk '/file format/{print $NF}')"
[[ "$format" == "pei-x86-64" ]] || die "$dll is not a 64-bit PE DLL (format=${format:-unknown})"
}
verify_exports() {
local dll=$1 export_name
local exports
exports="$(pe_exports "$dll")"
for export_name in "${required_exports[@]}"; do
grep -Fxq "$export_name" <<<"$exports" ||
die "$dll lacks VERSION export $export_name; refusing to stage/deploy"
done
}
verify_inputs() {
command -v sha256sum >/dev/null || die "sha256sum is required"
command -v x86_64-w64-mingw32-objdump >/dev/null ||
die "x86_64-w64-mingw32-objdump is required"
need_file "$hook_dll"
need_file "$system_version"
verify_pe64 "$hook_dll"
}
inspect() {
verify_inputs
note "mode=inspect (read-only)"
note "hook=$hook_dll"
note "hook_sha256=$(sha256 "$hook_dll")"
note "system_version=$system_version"
note "system_version_sha256=$(sha256 "$system_version")"
note "game_dir=$game_dir"
if [[ -f "$deployed_dll" ]]; then
note "deployed_version_sha256=$(sha256 "$deployed_dll")"
else
note "deployed_version=absent"
fi
verify_exports "$hook_dll"
note "version_exports=complete"
}
build() {
command -v cargo >/dev/null || die "cargo is required"
note "Building the inert FIFA 17 hook into the package-local staging source path."
CARGO_TARGET_DIR="${hook_root}/target" \
cargo build --offline --release --features fifa17 \
--target x86_64-pc-windows-gnu --manifest-path "${hook_root}/Cargo.toml"
inspect
}
stage() {
verify_inputs
verify_exports "$hook_dll"
need_file "${game_dir}/CardsDLL_Win64_retail.dll"
need_file "${game_dir}/FIFA17.exe"
mkdir -p "$stage_root"
local staged="${stage_root}/version.dll"
cp -- "$hook_dll" "$staged"
{
printf 'artifact=%s\n' "$staged"
printf 'artifact_sha256=%s\n' "$(sha256 "$staged")"
printf 'source=%s\n' "$hook_dll"
printf 'source_sha256=%s\n' "$(sha256 "$hook_dll")"
printf 'system_version=%s\n' "$system_version"
printf 'system_version_sha256=%s\n' "$(sha256 "$system_version")"
printf 'cards_dll_sha256=%s\n' "$(sha256 "${game_dir}/CardsDLL_Win64_retail.dll")"
printf 'fifa17_exe_sha256=%s\n' "$(sha256 "${game_dir}/FIFA17.exe")"
} >"${stage_root}/manifest.txt"
note "staged=$staged"
note "manifest=${stage_root}/manifest.txt"
note "No game-directory file was changed."
}
require_game_stopped() {
if pgrep -fi '(FIFA17|_fifa17)\.exe' >/dev/null; then
die "FIFA 17 appears to be running; close it before deployment"
fi
}
deploy() {
[[ "${OPENFUT_FIFA17_DEPLOY:-}" == "I_ACCEPT_VERSION_DLL_REPLACEMENT" ]] ||
die "deploy requires OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT"
require_game_stopped
local staged="${stage_root}/version.dll"
local manifest="${stage_root}/manifest.txt"
need_file "$staged"
need_file "$manifest"
verify_pe64 "$staged"
verify_exports "$staged"
local recorded actual
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
actual="$(sha256 "$staged")"
[[ -n "$recorded" && "$recorded" == "$actual" ]] || die "staged artifact hash does not match manifest"
local backup_dir="${game_dir}/openfut-backups"
mkdir -p "$backup_dir"
if [[ -f "$deployed_dll" ]]; then
local old_hash backup
old_hash="$(sha256 "$deployed_dll")"
backup="${backup_dir}/version.dll.${old_hash}.bak"
if [[ ! -e "$backup" ]]; then
cp -- "$deployed_dll" "$backup"
fi
[[ "$(sha256 "$backup")" == "$old_hash" ]] || die "backup verification failed: $backup"
note "backup=$backup"
fi
cp -- "$staged" "$deployed_dll"
[[ "$(sha256 "$deployed_dll")" == "$actual" ]] || die "deployed DLL hash verification failed"
note "deployed=$deployed_dll"
note "deployed_sha256=$actual"
}
launch() {
local mode=${1:-baseline}
local hook_enabled=0
local trace_enabled=0
local request_trace_enabled=0
local notifier_trace_enabled=0
local commit_enabled=0
case "$mode" in
baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
die "launch requires OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH"
;;
resolve)
[[ "${OPENFUT_FIFA17_RESOLVE:-}" == "I_ACCEPT_M2_RESOLVE_LAUNCH" ]] ||
die "launch-resolve requires OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH"
hook_enabled=1
;;
trace)
[[ "${OPENFUT_FIFA17_TRACE:-}" == "I_ACCEPT_M3_PASSIVE_TRACE" ]] ||
die "launch-trace requires OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1
notifier_trace_enabled=1
;;
commit)
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1
notifier_trace_enabled=1
commit_enabled=1
;;
*) die "unknown launch mode: $mode" ;;
esac
need_file "$deployed_dll"
local staged="${stage_root}/version.dll"
local manifest="${stage_root}/manifest.txt"
need_file "$staged"
need_file "$manifest"
verify_pe64 "$deployed_dll"
verify_exports "$deployed_dll"
local recorded
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
[[ -n "$recorded" && "$(sha256 "$staged")" == "$recorded" ]] ||
die "staged artifact hash does not match manifest"
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
die "deployed version.dll does not match the staged M1 artifact"
command -v umu-run >/dev/null || die "umu-run is required"
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do
[[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch"
done
mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir"
env \
GAMEID=fifa17 \
PROTONPATH="$proton_path" \
WINEPREFIX="$wine_prefix" \
WINEDLLOVERRIDES='version=n,b' \
OPENFUT_SBC_HOOK="$hook_enabled" \
OPENFUT_SBC_TRACE="$trace_enabled" \
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH=0 \
OPENFUT_SBC_COMMIT="$commit_enabled" \
OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
}
usage() {
cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect.
stage Copy a verified DLL into repo-local staging and write a hash manifest.
deploy Back up and install version.dll; requires:
OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT
launch Start the M1 inert-hook baseline; requires:
OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH
launch-resolve
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace
Start the single M3 passive factory/deserializer trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-commit
Trace and arm the SBC cache only after a validated native parse; requires:
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
OPENFUT_FIFA17_WINEPREFIX, OPENFUT_FIFA17_PROTONPATH
EOF
}
case "${1:-inspect}" in
inspect) inspect ;;
build) build ;;
stage) stage ;;
deploy) deploy ;;
launch) launch baseline ;;
launch-resolve) launch resolve ;;
launch-trace) launch trace ;;
launch-commit) launch commit ;;
-h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;;
esac
+38 -1
View File
@@ -204,6 +204,7 @@ class Account:
def __init__(self, path=None):
self.path = path or ACCOUNT_PATH
self._loaded = False
self._file_signature = None
self._stored = {} # what is on disk (tier 2+3 only)
for f in _FIELDS:
setattr(self, "_" + f, None)
@@ -214,7 +215,8 @@ class Account:
save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting."""
with _LOCK:
if self._loaded and not force:
signature = self._signature()
if self._loaded and not force and signature == self._file_signature:
return self
stored = {}
if os.path.exists(self.path):
@@ -239,8 +241,22 @@ class Account:
% (self.path, e))
self._stored = stored
self._loaded = True
self._file_signature = self._signature()
return self
def _signature(self):
"""Identity of the active-account file across atomic replacements.
The launcher can select an account while Blaze/POW are already running
in separate processes. inode + mtime + size lets every process notice
the replacement on its next property read without restarting Docker.
"""
try:
st = os.stat(self.path)
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
except OSError:
return None
def _migrate_from_profile(self):
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
existing club name survives the move to this module. Read-only: the game
@@ -266,11 +282,32 @@ class Account:
return out
def _write(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._stored, f, indent=1, sort_keys=True)
f.write("\n")
os.replace(tmp, self.path)
self._file_signature = self._signature()
def replace(self, values):
"""Atomically replace the active identity with validated persisted values."""
with _LOCK:
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
if "persona_id" not in clean or "persona_name" not in clean:
raise ValueError("persona_id and persona_name are required")
clean["persona_id"] = int(clean["persona_id"])
clean["persona_name"] = str(clean["persona_name"]).strip()
if clean["persona_id"] <= 0 or not clean["persona_name"]:
raise ValueError("persona_id must be positive and persona_name must not be empty")
self._stored = clean
for field in _FIELDS:
setattr(self, "_" + field, None)
self._loaded = True
self._write()
return self
def save(self):
"""Persist tiers 2+3 (only fields that differ from the built-in default,
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Launcher-to-server active-account selection for the single-player stack."""
import json
import os
from fut_account import ACCOUNT
from fut_store import STORE, profile_path_for
def _existing_identity(persona_id):
path = profile_path_for(persona_id)
try:
with open(path) as f:
profile = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(profile, dict):
return {}
return {
"club_name": profile.get("clubName"),
"club_abbr": profile.get("clubAbbr"),
"established": profile.get("established"),
"pow_level": profile.get("powLevel"),
"pow_exp": profile.get("powExp"),
"pow_exp_max": profile.get("powExpMax"),
"pow_funds": profile.get("powFunds"),
"pow_funds_cap": profile.get("powFundsCap"),
}
def activate(payload):
"""Select/create one persistent profile and publish it to all responders."""
if not isinstance(payload, dict):
raise ValueError("account payload must be an object")
try:
persona_id = int(payload.get("personaId"))
except (TypeError, ValueError):
raise ValueError("personaId must be a positive integer") from None
persona_name = payload.get("personaName")
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
raise ValueError("personaId must be positive and personaName must not be empty")
values = _existing_identity(persona_id)
values.update(persona_id=persona_id, persona_name=persona_name.strip())
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
("established", "established"), ("squadName", "squad_name"),
("level", "pow_level"), ("experience", "pow_exp"),
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
("accountFundsCap", "pow_funds_cap")):
if payload.get(wire) not in (None, ""):
values[field] = payload[wire]
ACCOUNT.replace(values)
ACCOUNT.set_online_profile()
ACCOUNT.save()
profile = STORE.select_account(persona_id)
STORE.ensure_security_question()
return {
"personaId": ACCOUNT.persona_id,
"personaName": ACCOUNT.persona_name,
"clubName": ACCOUNT.club_name,
"clubAbbr": ACCOUNT.club_abbr,
"level": ACCOUNT.pow_level,
"experience": ACCOUNT.pow_exp,
"experienceMax": ACCOUNT.pow_exp_max,
"accountFunds": ACCOUNT.pow_funds,
"accountFundsCap": ACCOUNT.pow_funds_cap,
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
"coins": profile.get("coins", 0),
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
}
+46 -13
View File
@@ -47,16 +47,30 @@ _DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
# families were pointed at the trophy range.
#
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
# club-item id "cannot announce itself", and for these three that is false. A wrong
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
FAMILIES = [
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
]
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
# probe route the docs preferred would have spent a launch and returned nothing for
# three of the five families.
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
# How many of each family the starter club owns. Small on purpose: the point is to
# make the counter non-zero so the client asks, not to hand anyone a collection.
@@ -71,22 +85,35 @@ def _rows(fname):
return []
def _item(item_id, carddbid, cardassetid, subtype, extra=None):
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
"""One club item. Deliberately narrow: no rating, no position, no attributes,
no nation, no league, no team. A club item has none of those, and sending a
field the family does not have is how a wrong shape gets accepted and does
nothing."""
no nation, no league. A club item has none of those, and sending a field the
family does not have is how a wrong shape gets accepted and does nothing."""
it = {
"id": item_id,
"resourceId": carddbid,
"assetId": carddbid,
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
"cardsubtypeid": subtype,
"itemType": "club", # UNOBSERVED on the wire; see module docstring
"itemState": "free",
"owners": 1,
"untradeable": False,
}
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
# without teamid the name comes out as the caption alone. STADIUM (10) reads
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
# record +0x94: an established scalar field, not a new shape.
#
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
# response that crashed the client, and it was never bisected. `value` is the
# established suspect, because it is an OBJECT member elsewhere and a scalar where
# an object is expected is the 0x1801c7f1a busy loop, and that response also
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
# ONE family, with the subtypes now corrected. That is the narrow test the crash
# denied us, and it is why families are served one at a time.
if teamid is not None and subtype in (9, 11):
it["teamid"] = teamid
if extra:
it.update(extra)
return it
@@ -119,7 +146,13 @@ def shelf(next_id=CLUBITEM_ID_BASE, families=None):
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
# and then dies. Omission is safe; an unestablished field is not. None of
# the three was needed to draw a card.
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype))
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
# which are the two families whose caption is <name> + TeamName_Abbr15_
# <teamid>. It is the one field from the fcc row being reintroduced after
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
# the note in _item(). value and leagueid stay omitted.
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
teamid=r.get("teamid")))
nid += 1
out[name] = picked
return out
+373 -17
View File
@@ -17,7 +17,125 @@ sys.path.insert(0, HERE)
import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
def profile_path_for(persona_id):
explicit = os.environ.get("FUT_PROFILE")
if explicit:
return explicit
if PROFILE_ROOT:
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
return os.path.join(HERE, "fifa17_profile.json")
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
#
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
#
# The client computes the DISPLAYED value itself with the same table whenever our
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
# local computation when it is non-zero. So today the client shows the real value
# while the server pays a made-up one, and the two disagree on every card. This
# makes the paid value agree with the shown value.
#
# value = round_half_up(rating * price / 100)
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
# derived from rating, NOT a wire field)
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
# checked across every subtype 0..599 with zero disagreements
#
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
# changes. Default off per the house rule, but this is the one patch worth
# defaulting on after a single verification.
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
_DP = {}
def _dp(ct, rares, p1, p2, p3):
for r in rares:
_DP[(ct, 1, r)] = p1
_DP[(ct, 2, r)] = p2
_DP[(ct, 3, r)] = p3
_dp(1, [0], 30, 150, 400)
_dp(1, [1], 75, 350, 800)
_dp(1, [7], 1500, 5000, 9000)
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
_dp(1, [4, 8, 9], 6000, 10000, 18000)
_dp(1, [11], 10000, 15000, 24000)
_dp(1, [5, 6], 20000, 40000, 80000)
_dp(1, [12], 120000, 120000, 120000)
_dp(2, [0], 20, 70, 110)
_dp(2, [1], 25, 120, 320)
for _ct in (3, 4, 5, 10):
_dp(_ct, [0], 10, 55, 110)
_dp(_ct, [1], 50, 100, 300)
for _ct in (6, 7, 8, 9):
_dp(_ct, [0], 5, 20, 40)
_dp(_ct, [1], 20, 50, 70)
def _cardtype(sub):
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
if sub is None:
return 0
if 0 <= sub <= 3:
return 1
if sub == 4:
return 2
if sub == 5:
return 3
if sub == 6:
return 10
if sub == 7:
return 5
if sub == 8:
return 4
if 9 <= sub <= 11:
return 7
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
return 9
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
or (300 <= sub <= 341):
return 6
return 0
def discard_value(item):
"""round_half_up(rating * price / 100), price from fcc_discardcoins.
Returns None when the formula does not apply, so callers fall back instead of
paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA:
it was verified 22/22 against club items, all of which were rated players, and
`rating * price / 100` collapses to 0 for a staff card carrying no rating. Found
by running the whole save through it, where exactly one item (a staff card,
cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for
a card the previous code paid for is a regression, so unrated cards fall back.
What FUT really pays for staff and consumables is UNKNOWN and worth recovering;
the likely answer is the unscaled table price, but that is a guess and is not
shipped as one.
"""
r = item.get("rating")
if not r:
return None
ct = _cardtype(item.get("cardsubtypeid"))
r = int(r)
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
if not price:
return None # no table row: the client renders 0, we should not
n = r * price
return n // 100 + (1 if n % 100 >= 50 else 0)
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
@@ -62,9 +180,37 @@ ITEM_ID_BASE = 100000000
_SQUAD_FITNESS_TRAP = 219
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set.
#
# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card,
# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by
# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND
# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does
# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag.
#
# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding
# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons
# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the
# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and
# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because
# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this.
#
# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the
# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the
# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves
# the field toward the client's own default rather than away from it.
#
# Side effects, both permissive rather than restrictive: item+0x49 also feeds
# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT,
# which gates squad submission in FUN_1800bba10 (today that takes the
# couldNotSubmitSquad branch).
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
cardsubtypeid=0, rareflag=1):
return {
return _with_discard({
"id": item_id,
"resourceId": (version << 24) | asset,
"assetId": asset,
@@ -82,10 +228,112 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"itemState": "free",
"owners": 1,
"untradeable": True,
"untradeable": not TRADEABLE,
"contract": 7,
"fitness": 99,
}
})
# discardValue is stamped HERE, inside the single item factory, so every path that
# builds an item gets it: pack contents, the starter grant, club reads and market
# listings alike. Stamping it at one call site would leave the reveal screen and
# the club showing different numbers for the same card.
SPECIAL_CARD_TYPES = {
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
# stable identities; the client resolves the footballer from the low 24 bits.
"TOTW": (3, 1, 2, 34),
"PURPLE": (4, 2, 3, 7),
"TOTY": (5, 3, 6, 3),
"RECORD_BREAKER": (6, 4, 5, 2),
"TOTS": (11, 5, 5, 7),
"OTW": (21, 6, 2, 14),
"HALLOWEEN": (22, 7, 3, 8),
"MOVEMBER": (23, 8, 3, 8),
"SBC": (24, 9, 4, 17),
}
def choose_special_type(player, rng=None):
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
import random
rng = rng or random
rating = player[1]
eligible = []
for name, spec in SPECIAL_CARD_TYPES.items():
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
continue
if name == "TOTS" and rating < 75:
continue
eligible.append((name, spec[3]))
names, weights = zip(*eligible)
return rng.choices(names, weights=weights, k=1)[0]
def player_item(item_id, player, special=False):
"""Build a base or named FIFA 17 special revision from a pool row.
`special=True` remains supported and chooses a weighted eligible family;
callers and tests may also pass an explicit name such as ``"TOTY"``.
"""
asset, rating, pos, nation, league, team, attrs = player
if special:
special_name = choose_special_type(player) if special is True else special
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
rating = min(99, rating + boost)
attrs = [min(99, value + boost) for value in attrs]
else:
rareflag, version = 1, 0
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
version=version, rareflag=rareflag)
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
# the same number the server pays.
#
# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600
# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen
# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the
# figure was right and invisible, and the screen contradicted the wallet.
#
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
#
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
# object or array where a scalar was expected, never the reverse.
#
# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented
# tier on screen and make a wrong number authoritative-looking rather than merely paid.
DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE
def _with_discard(it):
"""Apply the read-path flags to one item.
Two things, both of which MUST happen on read and not only at creation: the
saved profile holds 246 items minted long before either flag existed, and the
club route serves them straight out of the save. Stamping only in _item() left
the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by
reading the served JSON rather than by unit-testing the factory.
Callers pass a COPY, so the save is never mutated by a read.
"""
if DISCARD_SEND:
# Omit the key entirely when the formula does not apply, rather than sending
# 0: a 0 makes the client fall back to its own lookup, and the tile binds our
# value anyway, so 0 renders as 0.
v = discard_value(it)
if v:
it["discardValue"] = v
if TRADEABLE:
it["untradeable"] = False
return it
def _new_profile():
@@ -107,6 +355,10 @@ def _new_profile():
"purchased": [], # unassigned/pending items from opened packs
"squads": [], # saved squads (raw squad objects from PUT /squad)
"packsOpened": 0,
# Owned reward packs are separate from purchased items. Pack 70 is a
# one-time migration grant used to bring the retail My Packs flow online.
"unopenedPackIds": [70],
"unopenedSeeded": True,
}
@@ -125,6 +377,10 @@ class Store:
self._p = _new_profile()
self._sync_identity()
self._save()
if not self._p.get("unopenedSeeded"):
self._p.setdefault("unopenedPackIds", []).append(70)
self._p["unopenedSeeded"] = True
self._save()
self._sync_identity()
return self._p
@@ -142,18 +398,51 @@ class Store:
p["clubName"] = ACCOUNT.club_name
p["clubAbbr"] = ACCOUNT.club_abbr
p["established"] = ACCOUNT.established
# EA/EASFC account-bar state belongs to the same persona as the FUT
# save, but remains a distinct balance from FUT coins.
p["powLevel"] = ACCOUNT.pow_level
p["powExp"] = ACCOUNT.pow_exp
p["powExpMax"] = ACCOUNT.pow_exp_max
p["powFunds"] = ACCOUNT.pow_funds
p["powFundsCap"] = ACCOUNT.pow_funds_cap
return p
def _save(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._p, f, indent=1)
os.replace(tmp, self.path)
def select_account(self, persona_id):
"""Switch the single active session to its isolated persistent FUT save."""
with _LOCK:
self.path = profile_path_for(persona_id)
self._p = None
return self.load()
# ---- accessors used by utas_server -------------------------------------
def profile(self):
return self.load()
def ensure_security_question(self):
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
need that value to emulate a retired service, so neither the clear text nor
the transformed value is stored. The only durable fact is that this
OpenFUT profile has an initialized, verified compatibility record.
"""
expected = {"version": 1, "verified": True}
with _LOCK:
p = self.load()
if p.get("securityQuestion") != expected:
p["securityQuestion"] = dict(expected)
self._save()
return dict(p["securityQuestion"])
def refresh_identity(self):
"""Re-mirror ACCOUNT into the save AND persist it.
@@ -182,7 +471,13 @@ class Store:
return self.load()["coins"]
def items(self):
return self.load()["items"]
# Stamp discardValue on READ as well as on creation. _item() only covers cards
# minted from now on, and the save already holds 246 items built before the
# flag existed; without this the reveal screen would show real values while
# the club showed 0 for everything older. Stamped on the way out and NOT
# persisted, so the save stays clean and turning the flag off is a true revert.
its = self.load()["items"]
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
def add_items(self, new_items):
with _LOCK:
@@ -215,6 +510,15 @@ class Store:
dv = it.get("discardValue") or 0
if dv:
return int(dv)
if DISCARD_TABLE:
# The real table. Matches what the client displays once
# FUT_DISCARD_SEND puts the value on the wire.
v = discard_value(it)
if v is not None:
return v
# else: unrated card, formula does not apply, fall through
# The invented tier. Wrong for every card, kept only as the live-proven
# default until FUT_DISCARD_TABLE has been in front of the game once.
r = it.get("rating") or 0
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
with _LOCK:
@@ -299,14 +603,45 @@ class Store:
return moved
def purchased(self):
# Stamped on read exactly like items(). Leaving this out was a real defect:
# the pending pile is the ONE place a quick-sell value is actually read, so
# the club showed real numbers while the reveal screen showed 0 for anything
# already sitting in the pile. Found by a verification pass, not by testing.
"""Items still held in the purchased/unassigned pile (returned by
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
return self.load().get("purchased", [])
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
def active_squad(self):
sq = self.load()["squads"]
return sq[0] if sq else None
def unopened_packs(self):
"""Owned reward-pack template IDs, including repeated grants."""
return list(self.load().get("unopenedPackIds", []))
def consume_unopened_pack(self, pack_id):
"""Atomically consume one owned instance of a reward pack."""
with _LOCK:
p = self.load()
owned = p.setdefault("unopenedPackIds", [])
try:
owned.remove(pack_id)
except ValueError:
return False
self._save()
return True
def grant_unopened_pack(self, pack_id):
"""Persist one additional owned reward-pack instance."""
if pack_by_id(pack_id) is None:
return False
with _LOCK:
p = self.load()
p.setdefault("unopenedPackIds", []).append(pack_id)
self._save()
return True
def reconstruct_squad(self, squad):
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
(a reference). Re-embed the FULL club item by id so the squad reloads with
@@ -351,7 +686,8 @@ class Store:
return i
def open_pack(self, price, count, gold=True, tiers=None):
def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
players_only=False):
"""Deduct `price` coins, generate `count` player items from the pool, and
place them in the PENDING purchased pile (unassigned). They are NOT owned
club items until moved there via FutMoveCard (PUT /item). Returns None if
@@ -373,19 +709,31 @@ class Store:
# fixed number so it scales from a 5-card bronze to an 11-card premium.
n_extra = 0
extras = []
if PACK_MIX and count >= 5:
if PACK_MIX and not players_only and count >= 5:
n_extra = max(1, count // 4)
extras = _pack_extras(n_extra, self)
n_extra = len(extras)
n_players = max(1, count - n_extra)
if tiers:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
for _ in range(n_players)]
# Draw each tier independently but reject duplicate asset IDs inside
# one pack. The real pool is large enough that this normally succeeds
# on the first attempt; the cap makes malformed tiny test pools safe.
picks = []
used_assets = set()
for _ in range(n_players):
tier_pool = fut_cards.pool_for(random.choice(tiers))
available = [p for p in tier_pool if p[0] not in used_assets]
pick = random.choice(available or tier_pool)
picks.append(pick)
used_assets.add(pick[0])
else:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(n_players)]
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
for (a, r, p, n, lg, tm, at) in picks]
picks = random.sample(pool, min(n_players, len(pool)))
while len(picks) < n_players:
picks.append(random.choice(pool))
items = [player_item(self.new_item_id(), pick,
special=random.random() < special_chance)
for pick in picks]
items += extras
random.shuffle(items)
with _LOCK:
@@ -396,7 +744,9 @@ class Store:
return items
def last_pack(self):
return self.load().get("purchased", [])
# Same stamping as purchased(); this is the reveal-screen read path.
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
@@ -469,11 +819,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [
# no silver or bronze players at all, so all three packs were identical in practice.
PACK_CATALOG = [
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
"tiers": ["bronze"] * 8 + ["silver"] * 2},
"tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4},
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"tiers": ["gold"] * 9 + ["silver"] * 1},
"tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True},
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True, "ownedOnly": True},
]
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
Why this exists: on 2026-08-05 the /settings gate plan concluded that
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
anything. Measured against the running client, both are 1, and have been all along.
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
is not "nothing writes the byte".
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
Nothing is assumed:
* the pid is resolved by exact /proc/*/comm match, never hardcoded
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
misread as "barely mapped")
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
the on-disk PE, before any other address is trusted
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
movzx eax, byte [rcx+disp32]) rather than taken from a table
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
Usage: python3 gate_byte_probe.py
"""
import os, struct, sys
pid=None
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
except Exception: pass
assert pid, "not running"
print("pid", pid)
base=None
for ln in open('/proc/%d/maps'%pid):
if 'CardsDLL' in ln:
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
assert base
slide = base - 0x180000000
print("base %#x slide %#x" % (base, slide))
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
def rd(va,n): return os.pread(fd, n, va)
# control: FNV prologue, bytes taken from the on-disk PE
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
# .text rva 0x1000 rawptr 0x400
def f(va): return va-0x180000000-0x1000+0x400
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
ctl_live=rd(0x180180d00+slide,32)
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
# model singleton
dat=0x1802e6398+slide
obj=struct.unpack('<Q', rd(dat,8))[0]
print("DAT_1802e6398 ->", hex(obj))
vt=struct.unpack('<Q', rd(obj,8))[0]
print("vtable live %#x static %#x" % (vt, vt-slide))
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
slot=struct.unpack('<Q', rd(vt+off,8))[0]
stub=rd(slot,8)
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
val=rd(obj+disp,1)[0] if disp is not None else None
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
# unopenedPacks total
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
os.close(fd)
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Decode the running-sum atom ladders in /hub parser FUN_180139610 and name each
atom from docs/fut_atoms.tsv.
The dispatch is `sub ecx,d0 / sub ecx,d1 / .../ cmp ecx,dN`: the atom that each
branch handles is the CUMULATIVE sum of the deltas up to and including that step
(a jz after each sub tests atom==running_sum). Plus there are direct `cmp esi,imm`.
"""
import subprocess, re
DLL = "/tmp/fut/cardsdll.dll"
TSV = "/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"
FUNC, STOP = 0x180139610, 0x18013e600
atoms = {}
for line in open(TSV):
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try: atoms[int(p[1], 16)] = p[2]
except ValueError: pass
out = subprocess.check_output(
["objdump", "-d", "-M", "intel",
"--start-address=%#x" % FUNC, "--stop-address=%#x" % STOP, DLL], text=True)
# linear list of (addr, mnem, dest_reg, imm) for sub/cmp on 32-bit regs, stop at int3 pad
seq = []
int3 = 0
for ln in out.splitlines():
parts = ln.split("\t")
if len(parts) < 3:
continue
addr_s = parts[0].strip().rstrip(":")
try:
addr = int(addr_s, 16)
except ValueError:
continue
instr = parts[2].strip()
bits = instr.split(None, 1)
mnem = bits[0]
ops = bits[1].strip() if len(bits) > 1 else ""
if mnem == "int3":
int3 += 1
if int3 >= 4: break
continue
int3 = 0
mo = re.match(r"(e?[a-d]x|e?si|e?di|e?bp|r\d+d?),\s*(0x[0-9a-f]+)$", ops)
if mnem in ("sub", "cmp") and mo:
seq.append((addr, mnem, mo.group(1), int(mo.group(2), 16)))
# walk ladders: consecutive sub/cmp on the SAME register form one ladder; the running
# sum at each element is the atom that element dispatches. A `cmp` closes the ladder.
found = {} # atom -> (addr, kind)
i = 0
while i < len(seq):
addr, mnem, reg, imm = seq[i]
# a ladder starts on a sub
if mnem == "sub":
run = 0
j = i
while j < len(seq) and seq[j][2] == reg and seq[j][1] in ("sub", "cmp"):
run += seq[j][3]
found.setdefault(run, (seq[j][0], "ladder"))
if seq[j][1] == "cmp":
j += 1
break
j += 1
i = j
else:
# a lone cmp reg,imm on an atom-holding reg is a direct atom test
if 0 < imm <= 0x400:
found.setdefault(imm, (addr, "direct"))
i += 1
TOKENS = {0x1, 0x6, 0x7, 0x9, 0xa, 0xb, 0xc, 0xd} # SAX token enum, not atoms
print("Atoms dispatched by hub parser FUN_%#x:" % FUNC)
print("=" * 70)
for a in sorted(found):
if a in TOKENS:
continue
tag = " <-- TOKEN?" if a < 0x10 else ""
print(" %#06x %-28s (%s @ %#x)%s" %
(a, atoms.get(a, "?"), found[a][1], found[a][0], tag))
print("\nKnown tile counters for reference: 0x33=auctionCount, 0x90=clubPlayers")
print("\nName-based tile-count candidates:")
KEYS = ("sell","sold","trade","auction","pile","list","count","num","offer",
"won","outbid","target","watch","transfer","active","unassigned")
for a in sorted(found):
if a in TOKENS: continue
n = atoms.get(a, "").lower()
if any(k in n for k in KEYS):
print(" %#06x %s" % (a, atoms.get(a, "?")))
@@ -0,0 +1,84 @@
"""ADVERSARIAL Q1.
HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by
an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds
displayGroup.value.
WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME
STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live
group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from
description (atom 0xd1). If the key is actually `description`, recommendation #2
(serve displayGroup.value="gold") silently does nothing.
METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL,
print len(src), and enumerate the atom dispatch. Explicitly search the raw
disassembly of the function for EVERY syntactic dispatch form the brief warns about:
== imm, != imm, switch case labels (jump table), and sub/dec ladders.
CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"),
so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb.
The control uses the SAME method (raw immediate scan over the same instruction
range), not a different one.
"""
import sys, traceback, re
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId",
0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority",
0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"}
for target in (0x18013af30,):
f = func(target)
P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody()))
src = dec(target, 300)
P("len(src) =", len(src))
P("---- FULL DECOMPILE BEGIN ----")
P(src)
P("---- FULL DECOMPILE END ----")
# raw instruction scan of the whole function body for every atom immediate
P()
P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===")
f = func(0x18013af30)
body = f.getBody()
it = listing.getInstructions(body, True)
ins = []
while it.hasNext():
i = it.next()
ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i)))
P("instruction count:", len(ins))
# collect all immediates appearing anywhere in the text form
found = {}
for a, mn, txt in ins:
for m in re.finditer(r'0x([0-9a-fA-F]+)', txt):
v = int(m.group(1), 16)
if v in ATOMS:
found.setdefault(v, []).append((a, mn, txt))
for v in sorted(ATOMS):
lst = found.get(v, [])
P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst)))
for a, mn, txt in lst:
P(" %#x %s" % (a, txt))
# dispatch-form census: CMP/SUB/DEC ladders on the atom register
P()
P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===")
forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0}
for a, mn, txt in ins:
if mn in forms: forms[mn]+=1
if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1
P(forms)
P("all CMP with a small immediate (candidate atom compares):")
for a, mn, txt in ins:
if mn in ("CMP","SUB","DEC","ADD") :
m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt)
if m:
v=int(m.group(1),16)
if 0x10 <= v <= 0x400:
P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,"")))
fh.close()
except Exception:
traceback.print_exc()
@@ -0,0 +1,97 @@
"""ADVERSARIAL Q2. Batch.
Targets under attack:
(a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then
dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK.
Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to
FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present.
Control: the same raw-listing method applied to FUN_180014380's call sites,
where the decompiler DOES show a null test, must show TEST/JZ. Same form.
(b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL".
objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c
that covers it. Resolve the containing function of every one and decide.
(c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority;
+0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full
and print their exact address ranges so the claim can be re-checked in objdump.
(d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full.
(e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate.
(f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal
(CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1
depends entirely on this.
"""
import sys, traceback, re
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0,
0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0,
0x18007e7f0, 0x18007d1a0, 0x18007dab0]
P("=== FUNCTION BOUNDS ===")
for t in TARGETS:
f = func(t)
if f is None:
P("%#x -> NO FUNCTION" % t); continue
P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(),
int(f.getBody().getMinAddress().getOffset()),
int(f.getBody().getMaxAddress().getOffset()),
int(f.getBody().getNumAddresses())))
# (b) resolve containing functions of every +0x290 write objdump found
P()
P("=== (b) containing functions of every raw +0x290 / +0x28c write ===")
W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564,
0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77,
0x18015b885,0x180067eb0,0x180067ebf]
for w in W:
f = func(w)
P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0))
# is any of those functions in the store-screen vtable?
P()
P("=== store screen vtable 0x1801ff690 (first 48 slots) ===")
ents = set()
for off, tgt, nm in vtable(0x1801ff690, 48):
P(" +%#04x %#x %s" % (off, tgt, nm))
ents.add(tgt)
P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:")
for off, tgt, nm in vtable(0x1801ff610, 24):
P(" +%#04x %#x %s" % (off, tgt, nm))
# (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0
P()
P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===")
f = func(0x1800147f0)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next(); n += 1
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
P("instruction count:", n)
P()
P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===")
f = func(0x180014610)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next(); n += 1
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
P("instruction count:", n)
for t in TARGETS:
P()
f = func(t)
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,72 @@
"""ADVERSARIAL Q3.
Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is
pushed to no Flash field ... Both are dead ends for this bug."
An objdump scan of the store cluster found 0x1800108cd/0x1800108d3
mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax
which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62
mov [rbx+0x1a0],esi
inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority.
Both were missed by "grep the push list".
Also decompile:
FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the
tile ordinals the user sees stop matching the group ordinals.
FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7).
FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip.
callers of FUN_1800147f0.
"""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c):
f = func(a)
P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0,
int(f.getBody().getNumAddresses()) if f else 0))
P()
P("=== callers of FUN_1800147f0 ===")
for frm, typ, fn, ent in xrefs_to(0x1800147f0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_180014610 ===")
for frm, typ, fn, ent in xrefs_to(0x180014610):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_18002c8b0 ===")
for frm, typ, fn, ent in xrefs_to(0x18002c8b0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of the comparator's containing function ===")
cf = func(0x1800108cd)
if cf:
for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
tg = []
if cf: tg.append(int(cf.getEntryPoint().getOffset()))
tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60]
for t in tg:
f = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
# full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip
P()
P("======== DECOMPILE FUN_18007e7f0 (full) ========")
src = dec(0x18007e7f0, 600)
P("len(src) =", len(src))
P(src)
P("======== END ========")
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,29 @@
"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and
what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets
neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim."""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===")
for frm, typ, fn, ent in xrefs_to(0x180010cd0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_180010890 ===")
for frm, typ, fn, ent in xrefs_to(0x180010890):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0):
f = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,30 @@
"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point
(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts,
and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are
initialised at all for group tiles (FUN_180014610 sets neither)."""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
f = func(0x180016f81)
P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()),
int(f.getBody().getNumAddresses())))
ent = int(f.getEntryPoint().getOffset())
P("=== callers of %s ===" % f.getName())
for frm, typ, fn, e in xrefs_to(ent):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e))
for t in (ent, 0x1800130c0):
g = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t))
src = dec(t, 300)
P("len(src) =", len(src)); P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,104 @@
"""ADVERSARIAL BATCH 1.
HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced):
H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00.
H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are
ENABLE flags.
H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD,
TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order.
H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType.
H5 the itemState table starts at 0x180229cc0 with 12 entries.
H6 FUN_180166660 has exactly one caller.
H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each.
CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a
function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and
on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan.
Everything is printed IN FULL; no truncation.
"""
import traceback, sys
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
f.write(s + "\n")
P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30)
for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0),
("FUN_180180d00 FNV hasher", 0x180180D00),
("FUN_1801c7620 BOOL prim", 0x1801C7620)):
xr = xrefs_to(a)
ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e))
P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents)))
P()
P("=" * 30, "H7 discard getters", "=" * 30)
for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620),
("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090),
("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)):
P("---", nm)
fn = fm.getFunctionAt(addr(a))
P(" function at addr:", fn.getName() if fn else None)
for frm, t, cf, e in xrefs_to(a):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(" BODY:")
P(dec(a))
P()
P("=" * 30, "H6 FUN_180166660 callers", "=" * 30)
for frm, t, cf, e in xrefs_to(0x180166660):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(dec(0x180166660))
P()
P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30)
a = 0x180229C00
for i in range(40):
p = qword(a + i * 0x10)
q = qword(a + i * 0x10 + 8)
s = ""
if 0x180000000 <= p < 0x181000000:
try:
s = rd_str(p, 60)
except Exception:
s = "?"
P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s))
P()
P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30)
for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250,
0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0):
fn = fm.getFunctionAt(addr(a))
P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a))))
for frm, t, cf, e in xrefs_to(a):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(dec(a))
P()
P()
P("=" * 30, "H3 publisher + filler, FULL", "=" * 30)
for a in (0x18003E370, 0x1800E2A40):
P("### %#x len-of-decompile follows" % a)
d = dec(a)
P(" len(src) =", len(d))
P(d)
P()
P()
P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30)
ins = listing.getInstructions(addr(0x180141E40), True)
n = 0
while ins.hasNext() and n < 60:
i = ins.next()
if int(i.getAddress().getOffset()) > 0x180141EC0:
break
P(" %#x %s" % (int(i.getAddress().getOffset()), i))
n += 1
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,20 @@
"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the
0xed84b12 service = the second gate on TO_TRADE_PILE)."""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex())
it = listing.getInstructions(addr(0x18011C670), True)
n = 0
while it.hasNext() and n < 40:
i = it.next(); a = int(i.getAddress().getOffset())
if a > 0x18011C6F0: break
P(" %#x %s" % (a, i)); n += 1
P()
for t in (0x18011C4C0, 0x18011C500):
P("### %#x" % t); P(dec(t)); P()
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,118 @@
"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method.
The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6
are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method
has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR
never shows the displacement at the compare site. FUN_1801a8940 is exactly such an
accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened.
MY METHOD (different): enumerate EVERY instruction in .text whose textual form
contains the displacement, with no filter on opcode class at all -- so ==, !=, switch
case labels and sub/dec ladders are all caught at the LOAD, and the containing
function is then read. Plus a byte-pattern census of the two-instruction accessor
shape 48 8b 4x 18 / <load disp> which finds getters my displacement scan would
attribute to the getter rather than to its caller.
CONTROLS (same syntactic form as the targets -- a raw displacement load):
0x38 and 0x3c : known-live fields, must come back non-zero
0x4c : the other agent reported 37 pairs, must come back >= 37
0xdeadbe : impossible displacement, must come back 0 (proves the scan can
return zero for a real absence rather than always finding noise)
"""
import re, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90]
pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS}
impossible = re.compile(r"\+\s*0xdeadbe\s*\]")
hits = {d: [] for d in TARGETS}
imp = []
n = 0
it = listing.getInstructions(True)
while it.hasNext():
i = it.next()
s = i.toString()
n += 1
for d, p in pats.items():
if p.search(s):
hits[d].append((int(i.getAddress().getOffset()), s))
if impossible.search(s):
imp.append(int(i.getAddress().getOffset()))
P("instructions scanned:", n)
P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)")
P()
for d in TARGETS:
fns = {}
for a, s in hits[d]:
fn = fm.getFunctionContaining(addr(a))
k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0)
fns.setdefault(k, []).append((a, s))
P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns)))
if d in (0x49, 0x48):
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
P(" %s @%#x (%d)" % (nm, e, len(lst)))
for a, s in lst:
P(" %#x %s" % (a, s))
elif d == 0x5c:
P(" functions:")
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
P(" %s @%#x n=%d" % (nm, e, len(lst)))
P()
P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30)
for a, s in hits[0x5C]:
fn = fm.getFunctionContaining(addr(a))
P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?"))
P()
P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30)
seen = {}
for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79):
pat = bytes([0x48, 0x8B, reg, 0x18])
for a in find_all(pat, blocks=(".text",)):
try:
nxt = read_bytes(a + 4, 8)
except Exception:
continue
seen.setdefault(a, nxt)
P("call-shape candidates:", len(seen))
interest = {}
for a, nxt in seen.items():
disp = None
if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40:
disp = nxt[3]
elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90):
fn = fm.getFunctionContaining(addr(a))
interest.setdefault(disp, []).append((a, fn.getName() if fn else "?",
int(fn.getEntryPoint().getOffset()) if fn else 0))
for d in sorted(interest):
P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d])))
for a, nm, e in sorted(interest[d], key=lambda x: x[2]):
P(" %#x in %s @%#x" % (a, nm, e))
if e:
nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t]
P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc))))
P()
P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30)
d = dec(0x1800BC580)
P("len(src) =", len(d))
P(d)
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,100 @@
"""ADVERSARIAL BATCH 3.
My batch-2 displacement census turned up FOUR +0x5c sites the other agent's
constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and
MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and
offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the
action "nothing reads them" are under direct attack here.
Also under attack:
- "no other code path can produce the greyout from wire data": FUN_1800bc580 is a
THIRD +0x49 consumer (it counts untradeable squad members). What uses that count?
- the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap.
- the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor
inside FUN_18013fe00 (printed IN FULL, with len).
CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND,
in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO
8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all
three or none tells me the search itself is sound.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25)
for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0):
d = dec(a)
P("### %#x len=%d xrefs:" % (a, len(d)))
for frm, t, cf, e in xrefs_to(a):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P(d)
P()
P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25)
for frm, t, cf, e in xrefs_to(0x1800BC580):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P("--- FUN_1801a8890 (the sibling predicate counted into param_2):")
P(dec(0x1801A8890))
P("--- FUN_1801a80a0:")
P(dec(0x1801A80A0))
P()
P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25)
for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260),
("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)):
pat = struct.pack("<Q", a)
hits = find_all(pat, blocks=(".rdata", ".data"))
P(" %s ptr8 hits: %s" % (nm, [hex(h) for h in hits]))
for h in hits:
# walk backwards to find the table start (first qword that is not a .text ptr)
start = h
while True:
try:
v = qword(start - 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
break
start -= 8
P(" table start %#x, slot +%#x" % (start, h - start))
for i in range(0, 40):
try:
v = qword(start + i * 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
P(" +%#04x %#x <END>" % (i * 8, v))
break
fn = fm.getFunctionAt(addr(v))
P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "",
" <== TARGET" if v == a else ""))
P()
P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25)
d = dec(0x18013FE00, timeout=600)
P("len(src) =", len(d))
P(d)
P()
P("=" * 25, "E. mappers", "=" * 25)
for nm, a in (("playStyle FUN_180136480", 0x180136480),
("pile FUN_180142650", 0x180142650),
("owners helper FUN_1800d7b50", 0x1800D7B50),
("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30),
("family FUN_1800d8330", 0x1800D8330)):
P("### " + nm)
dd = dec(a)
P(" len=%d" % len(dd))
P(dd)
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,120 @@
"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims.
- FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the
"DB Error" path? If only on the error path the whole level story changes.
- FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's
constant-collecting scan could not evaluate. If R15D can be 5 or 6 their
"forSale/offered are never tested" absence claim dies.
- FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own
enable predicate the eight-flag array does not cover?
- FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two
names, pushed from 0x1801a8620 / 0x1801a8090?
- 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT
method from decompiling all 134 skip-callers).
- itemState string-writer absence, re-tested by xrefs to every one of the 12 string
literals, with the ITEM-TYPE table strings ('player','staff') as a control that
has known extra users.
- FUN_180008190: resolve the indirect string compare through the global vtable.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25)
fn = fm.getFunctionAt(addr(0x180141660))
body = fn.getBody()
P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()),
int(body.getMaxAddress().getOffset())))
# every RET in the function, and every branch target landing at/after 0x180141e77
rets, brs = [], []
it = listing.getInstructions(body, True)
while it.hasNext():
i = it.next()
m = i.getMnemonicString()
a = int(i.getAddress().getOffset())
if m == "RET":
rets.append(a)
if m.startswith("J"):
for r in i.getFlows():
t = int(r.getOffset())
if 0x180141E70 <= t <= 0x180141EB0:
brs.append((a, m, t))
P("RET sites:", [hex(x) for x in rets])
P("branches into the tail 0x180141e70..0x180141eb0:")
for a, m, t in brs:
P(" %#x %s -> %#x" % (a, m, t))
P()
P("FUN_180141660 decompile:")
d = dec(0x180141660, timeout=600)
P("len =", len(d))
P(d)
P()
P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25)
ins = listing.getInstructions(addr(0x1801B3860), True)
n = 0
while ins.hasNext() and n < 90:
i = ins.next()
a = int(i.getAddress().getOffset())
if a > 0x1801B38E0:
break
P(" %#x %s" % (a, i))
n += 1
P()
P("R15 setup search 0x1801b3640..0x1801b3894:")
ins = listing.getInstructions(addr(0x1801B3640), True)
while ins.hasNext():
i = ins.next()
a = int(i.getAddress().getOffset())
if a > 0x1801B3894:
break
s = i.toString()
if "R15" in s:
P(" %#x %s" % (a, s))
P()
d = dec(0x1801B3640, timeout=600)
P("FUN_1801b3640 len =", len(d))
P(d)
P()
P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25)
for a in (0x18003E550, 0x1800EB850):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25)
for frm, t, cf, e in xrefs_to(0x180142650):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P()
P("=" * 25, "E. itemState string literals: every xref", "=" * 25)
names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered",
"activeBadge", "activeHomeKit", "activeAwayKit", "activeBall",
"activeStadium", "active",
"player", "staff"] # last two = CONTROL, known to be used elsewhere
for nm in names:
hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data"))
P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits]))
for h in hits:
for frm, t, cf, e in xrefs_to(h):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P()
P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25)
for a in (0x180008190, 0x180130D10, 0x1801C3480):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the
service gate the other agent left open.
1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that
byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active
squad. Who consumes that count, and does flipping it change anything else?
2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves.
Identify the service vtable and that slot if possible.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25)
for a in (0x1800BB2A0, 0x1800BBA10):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25)
for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80),
("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)):
P("### " + nm)
P(dec(a))
P()
P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25)
# find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough
for b in mem.getBlocks():
if b.getName() != ".rdata" or not b.isInitialized():
continue
s = int(b.getStart().getOffset())
e = int(b.getEnd().getOffset())
a = (s + 7) & ~7
run_start = None
while a + 8 <= e:
try:
v = qword(a)
except Exception:
break
ok = 0x180001000 <= v < 0x1801E5000
if ok and run_start is None:
run_start = a
elif not ok and run_start is not None:
ln = a - run_start
if ln >= 0x280:
P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" %
(run_start, a, ln, qword(run_start + 0x270),
(lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270))))))
run_start = None
a += 8
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,52 @@
"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose
vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online /
transfer-market-availability check it may block the menu even with untradeable:false,
which is the single biggest risk to the headline recommendation.
METHOD: the class that implements an interface references the same GUID constant when
it registers. Scan .text for the 4-byte immediates and report every function.
CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370
uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the
absence is about this GUID and not about the scan.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8):
pat = struct.pack("<I", g)
hits = find_all(pat, blocks=(".text", ".rdata", ".data"))
fns = {}
for h in hits:
fn = fm.getFunctionContaining(addr(h))
k = fn.getName() if fn else "(data)"
fns.setdefault(k, []).append(h)
P("### GUID %#x : %d byte hits in %d functions" % (g, len(hits), len(fns)))
for k, v in sorted(fns.items()):
P(" %-24s %s" % (k, [hex(x) for x in v]))
P()
P("=" * 25, "the registrar bodies", "=" * 25)
seen = set()
for g in (0xED84B11, 0xED84B12):
for h in find_all(struct.pack("<I", g), blocks=(".text",)):
fn = fm.getFunctionContaining(addr(h))
if fn is None:
continue
e = int(fn.getEntryPoint().getOffset())
if e in seen:
continue
seen.add(e)
P("### %s @%#x" % (fn.getName(), e))
P(dec(e))
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""ADVERSARIAL BATCH 7 -- finish the two open links.
(a) 0x10c80b96 appears as data at 0x1800e1662, inside the function at vtable slot
+0xa8 of the table 0x180215a80 -- the same table whose slot +0xd0 is
FUN_1800e2a40. If that holds it independently proves the FUN_18003e370 ->
FUN_1800e2a40 link the other agent could only infer semantically.
(b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the
service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
for a in (0x1800E1662, 0x180113F52):
fn = fm.getFunctionContaining(addr(a))
P("### data GUID at %#x -> containing function %s @%#x" %
(a, fn.getName() if fn else None,
int(fn.getEntryPoint().getOffset()) if fn else 0))
if fn:
e = int(fn.getEntryPoint().getOffset())
P(dec(e))
hits = find_all(struct.pack("<Q", e), blocks=(".rdata", ".data"))
P(" 8-byte pointer to it: %s" % [hex(h) for h in hits])
for h in hits:
start = h
while True:
try:
v = qword(start - 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
break
start -= 8
P(" run start %#x, this fn at slot +%#x" % (start, h - start))
# find the first non-stub entry -- the secondary vtable base
base = start
while qword(base) == 0x1801C577A:
base += 8
P(" first non-stub entry at %#x (offset +%#x from run start)" % (base, base - start))
P(" => slot of this fn relative to first non-stub: +%#x" % (h - base))
for i in range(0, 90):
v = qword(base + i * 8)
if not (0x180001000 <= v < 0x1801E5000):
break
f2 = fm.getFunctionAt(addr(v))
mark = ""
if i * 8 == 0x270:
mark = " <== SLOT 0x270"
if i * 8 == 0x40:
mark = " <== SLOT 0x40"
P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark))
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw
instructions and find the vtable that holds them. CONTROL: both stubs must decode to
'mov eax, <guid>; ret' -- if they do not, my reading of them as interface-id getters
is wrong and I say so."""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)):
P("### raw %#x..%#x" % (lo, hi))
P(" bytes:", read_bytes(lo, hi - lo).hex())
it = listing.getInstructions(addr(lo), True)
while it.hasNext():
i = it.next()
a = int(i.getAddress().getOffset())
if a >= hi: break
P(" %#x %s" % (a, i))
P()
for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40):
hits = find_all(struct.pack("<Q", cand), blocks=(".rdata", ".data"))
P("ptr8 to %#x : %s" % (cand, [hex(h) for h in hits]))
for h in hits:
start = h
while True:
try: v = qword(start - 8)
except Exception: break
if not (0x180001000 <= v < 0x1801E5000): break
start -= 8
base = start
while qword(base) == 0x1801C577A: base += 8
P(" run %#x, first non-stub %#x, this at +%#x from non-stub" % (start, base, h - base))
for i in range(0, 100):
v = qword(base + i * 8)
if not (0x180001000 <= v < 0x1801E5000): break
fn = fm.getFunctionAt(addr(v))
if i*8 in (0x40, 0x270, 0x308, 0x290, 0x2b0, 0x148, 0xd0, 0x20):
P(" +%#05x %#x %s" % (i*8, v, fn.getName() if fn else ""))
P(" table length: %#x" % (i*8))
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""BATCH 9. The cast helper is vtable slot +0x18 (FUN_180009c80 calls
(*(*svc))[0x18] with the interface GUID). So vtable_base = cast_stub_slot_addr - 0x18.
- 0x10c80b96 class: stub ptr at 0x180215b28 -> base 0x180215b10 -> slot +0x40 must be
FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.)
- 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the
other gate on TO_TRADE_PILE.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)),
("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))):
P("### %s vtable base %#x" % (nm, base))
for s in slots:
v = qword(base + s)
fn = fm.getFunctionAt(addr(v))
P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else ""))
P()
for a in (0x1801B1CE0,):
pass
v = qword(0x18021C2A0 + 0x270)
P("=== slot 0x270 body ===")
P(dec(v, timeout=300))
P("=== xrefs to it ===")
for frm, t, cf, e in xrefs_to(v):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,90 @@
"""ADVERSARIAL VERIFICATION BATCH 1 (dim4 + dim5).
HYPOTHESES UNDER ATTACK
H1 (dim5 f5/f7): the publisher FUN_18006cc60 maps model vtable slots to IS_* names,
and IS_TRADING_ENABLED (0x1801fc118) has exactly ONE rip-relative reference in
.text (the lea), i.e. the name is output-only.
CONTROL: run the same rip-relative scanner against a literal that IS known to be
compared, e.g. one of the ISOfferTrade error strings 0x180228f20, which must show
up in a *different* instruction context, and against IS_STORE_ENABLED.
H2 (dim5 f5 positive control): IS_STORE_ENABLED's accessor (vt+0x280) - what does it
actually compute? If it is a live-evaluable expression we can compare STORE vs
TRADING under the same publish mechanism.
H3 (dim4 f2): FutGetSuggestedPricing deser 0x180163ee0 top-level token is
START_ARRAY (loop terminates on 0xd) - CONTROL FUN_180165df0 (ISStart) must
terminate on 10.
H4 (dim4 f4): 0x1801642c0 is `return 1;`.
H5 (dim4 f6): tradeState table 0x180229e40 / bidState ladder FUN_180166380.
H6 (dim4 f9): IS_MAX_AUCTIONS publisher FUN_1800377c0 + GetAuctionCount deser
0x180163770.
H7 (dim4 f8): error mapper FUN_1801844c0.
Everything printed IN FULL with len(src).
"""
import traceback, struct
def full(tag, va):
try:
s = dec(va)
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
print("### H1: publisher FUN_18006cc60")
full("publisher", 0x18006cc60)
print("\n### model vtable slots")
VT = 0x18021c2a0
for off in (0x270, 0x280, 0x2b0, 0x988, 0x998, 0xa58, 0xa60, 0x130, 0x5b8, 0xa00):
t = qword(VT + off)
print(" vt+%#05x -> %#x %s" % (off, t, fname(t) if 'fname' in dir() else ''))
full("vt+0x280 IS_STORE_ENABLED accessor", qword(VT + 0x280))
full("vt+0x270 IS_TRADING_ENABLED accessor", qword(VT + 0x270))
full("vt+0xa58 TRADE_PILE_SIZE accessor", qword(VT + 0xa58))
print("\n### H1 rip-relative reference scan, form independent")
# Scan .text for any 4-byte little-endian rel32 whose target == literal VA,
# for every instruction end position. This catches lea/mov/cmp/push equally.
tblk = None
for b in mem.getBlocks():
if b.getName() == ".text":
tblk = b
TS = int(tblk.getStart().getOffset()); TE = int(tblk.getEnd().getOffset())
text = read_bytes(TS, TE - TS + 1)
print(" .text %#x..%#x len=%d" % (TS, TE, len(text)))
def ripscan(target, label):
hits = []
for i in range(0, len(text) - 4):
rel = struct.unpack_from('<i', text, i)[0]
# instruction end = TS + i + 4 (rel32 is the last field of the insn)
if TS + i + 4 + rel == target:
hits.append(TS + i)
print(" %-34s target %#x : %d candidate rel32 sites" % (label, target, len(hits)))
for h in hits[:20]:
print(" at %#x bytes %s fn %s" % (h - 3, text[h - 6:h + 6].hex(),
(fm.getFunctionContaining(addr(h)) or "?")))
return hits
lits = {}
for nm in (b"IS_TRADING_ENABLED\x00", b"IS_STORE_ENABLED\x00",
b"IS_DRAFT_MODE_ENABLED\x00", b"TRADE_PILE_SIZE\x00",
b"IS_MAX_AUCTIONS\x00", b"NUM_MAX_AUCTIONS\x00",
b"You are not allowed to bid on this trade\x00"):
f = find_all(nm, blocks=(".rdata", ".data", ".text"))
lits[nm] = f
print(" literal %-45r -> %s" % (nm[:40], [hex(x) for x in f]))
for nm, f in lits.items():
for a in f:
ripscan(a, nm[:30].decode(errors='replace'))
print("\n### H3 pricelimits vs ISStart control")
full("FutGetSuggestedPricing deser", 0x180163ee0)
full("FutISStart deser CONTROL", 0x180165df0)
print("\n### H4 generic ack deser")
full("ack deser", 0x1801642c0)
except Exception:
traceback.print_exc()
@@ -0,0 +1,84 @@
"""ADVERSARIAL VERIFICATION BATCH 2.
Everything printed IN FULL with len(src). No truncation, no absence claimed from
a partial print.
H8 dim4 f5: auctionInfo record deser 0x18013e410 has exactly 12 atoms + tradeId
identity lookup via model vt+0xa00.
H9 dim4 f7: shared IS-list body 0x18013e7f0, credits -> model vt+0x5b8.
H10 dim4 f6: tradeState table walk FUN_180166bd0 (table 0x180229e40) and bidState
ladder FUN_180166380 -- two DIFFERENT dispatch forms, read separately.
H11 dim4 f8: FUN_1801844c0 status map, FUN_180165050 461 override.
H12 dim4 f9: FUN_1800377c0 IS_MAX_AUCTIONS + FUN_180163770 GetAuctionCount deser.
CONTROL for the publisher form: FUN_18000d550 TRADE_PILE_SIZE.
H13 dim4 f11: deser VAs for FutISWatchList / FutGetAuctionCount / FutISStart via
RS4 name -> abs64 ptr -> installed vtable -> slot +0x08, with FutISSearch and
FutGetTradePile as the CONTROL pair (must come back 0x180163420 / 0x180170810).
"""
import traceback, struct
def full(tag, va):
try:
s = dec(va)
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
for tag, va in [("auctionInfo record deser", 0x18013e410),
("shared IS-list body", 0x18013e7f0),
("tradeState decoder", 0x180166bd0),
("bidState decoder", 0x180166380),
("status mapper", 0x1801844c0),
("ISOfferTrade 461 override", 0x180165050),
("IS_MAX_AUCTIONS publisher", 0x1800377c0),
("TRADE_PILE_SIZE publisher CONTROL", 0x18000d550),
("GetAuctionCount deser", 0x180163770),
("ISWatchList deser", 0x180166240),
("ISSearch deser CONTROL", 0x180163420),
("GetTradePile deser CONTROL", 0x180170810)]:
full(tag, va)
print("\n### tradeState table at 0x180229e40")
a = 0x180229e40
for i in range(10):
p = qword(a + i * 16); v = dword(a + i * 16 + 8)
if p == 0:
print(" [%d] NULL terminator, value=%d" % (i, v)); break
print(" [%d] %#x %r = %d" % (i, p, rd_str(p), v if v < 0x80000000 else v - (1 << 32)))
print("\n### H13 RS4 name -> installed vtable -> slot+0x08")
for nm, expect in [(b"RS4:FutISSearchServerResponse\x00", 0x180163420),
(b"RS4:FutGetTradePileServerResponse\x00", 0x180170810),
(b"RS4:FutISWatchListServerResponse\x00", None),
(b"RS4:FutGetAuctionCountServerResponse\x00", None),
(b"RS4:FutISStartServerResponse\x00", None),
(b"RS4:FutGetSuggestedPricingServerResponse\x00", None),
(b"RS4:FutRelistAllServerResponse\x00", None),
(b"RS4:FutISWatchTradeServerResponse\x00", None),
(b"RS4:FutISRemoveTradeServerResponse\x00", None),
(b"RS4:FutISRemoveWatchServerResponse\x00", None),
(b"RS4:FutISViewTradeServerResponse\x00", None),
(b"RS4:FutISOfferTradeServerResponse\x00", None)]:
locs = find_all(nm, blocks=(".rdata", ".data"))
print("\n %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
for L in locs:
xs = xrefs_to(L)
print(" xrefs: %s" % [(hex(a), t, f) for a, t, f, _ in xs])
for a, t, f, ent in xs:
if ent:
s = dec(ent)
# find the vtable it installs: look for PTR_ / &DAT_ assignment
import re
m = re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s)
print(" fn %s @%#x len=%d installs %s" % (f, ent, len(s), set(m)))
for cand in set(m):
try:
vt = int(cand, 16)
if 0x180200000 <= vt < 0x180290000:
slot = qword(vt + 8)
print(" vtable %#x slot+0x08 = %#x (expect %s)"
% (vt, slot, hex(expect) if expect else "?"))
except Exception:
pass
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""ADVERSARIAL BATCH 3 -- the relaunch-critical path.
H14: does the settings deser FUN_18013c6d0 pre-initialise its struct fields
+0x28..+0x40 to 1 before parsing? If it zero-inits them, then the observed
live pattern (model+0x1fd2e=0 surrounded by 1s) cannot have come from the
applier, i.e. the applier NEVER RAN -- which decides "never set" vs
"set then cleared".
Also: which atom writes struct+0x1c (the field FUN_180173e00 gates on)?
H15: FUN_180173e00 in full -- the test rdx / cmp [rdx+0x1c],0 gate.
H16: dim5 f8 -- FUN_180180770 blaze client-config reader, full key list.
"""
import traceback
def full(tag, va):
try:
s = dec(va)
print("\n===== %s %#x len=%d =====" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
full("settings deser FUN_18013c6d0", 0x18013c6d0)
full("settings completion FUN_180173e00", 0x180173e00)
full("blaze config reader FUN_180180770", 0x180180770)
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""ADVERSARIAL BATCH 4 -- the settings RESPONSE object, not the model-side deser.
FUN_180173e00 reads its param_2 (the FutGetSettings response) at +0x1c (error gate),
copies +0x28..+0xc0 and hands &<copy of +0x28> to the gate applier vt+0x988, and
copies +0xc8..+0xd4 and hands &<copy of +0xc8> to vt+0x998.
So model+0x1fd2e <- response+0x50, and model+0x1fd1c <- response+0xd0.
HYPOTHESIS: the FutGetSettings response deserializer writes response+0x50 and +0xd0
from specific atoms. Find them.
CONTROL: the same RS4-name -> vtable -> slot+0x08 resolution that reproduced
FutISSearch 0x180163420 and FutGetTradePile 0x180170810 in batch 2.
"""
import traceback, re
try:
for nm in (b"RS4:FutGetSettingsServerResponse\x00", b"RS4:FutSettingsServerResponse\x00",
b"RS4:FutISSearchServerResponse\x00"):
locs = find_all(nm, blocks=(".rdata", ".data"))
print("\n### %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
for L in locs:
for a, t, f, ent in xrefs_to(L):
if not ent: continue
s = dec(ent)
m = set(re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s))
print(" fn %s @%#x installs %s" % (f, ent, m))
for c in m:
v = int(c, 16)
if 0x180200000 <= v < 0x180290000:
print(" vtable %#x slot+0x08 = %#x" % (v, qword(v + 8)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,10 @@
"""BATCH 5: which atom writes FutGetSettings response+0x50 (-> IS_TRADING_ENABLED)
and +0xd0 (-> TRADE_PILE_SIZE)? Two candidate desers resolved in batch 4."""
import traceback, re
try:
for va in (0x18014e590, 0x180153060):
s = dec(va)
print("\n===== deser %#x len=%d =====" % (va, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,74 @@
"""ADVERSARIAL BATCH 1.
HYPOTHESES UNDER TEST (all from another agent, assumed WRONG until reproduced):
H1 FUN_1800d8330 maps cardsubtypeid -> cardtype and returns 9 for exactly
{0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}; and returns 7 for 9,10,11.
H2 FUN_180119bd0 arms: 9 -> KITS, 10 -> Stadium, 0xb -> Badge, else "".
H3 FUN_1801a8640 == *(u32*)(*(u64*)(param_1+0x18)+0x50) i.e. cardsubtypeid.
H4 FUN_1800f6c40 calls vtable+0x498 only when item+0x4c == 7, args
(item+0x50, item+0x94, item+0x20); and sets IS_KIT_%d when item+0x50==9.
H5 FUN_180141660 tail writes item+0x54 = level(rating@+0xb4): 3 if >=0x4b,
else 2 - (rating < 0x41). <-- CONTRADICTS the live-map "+0x54 = itemType".
CONTROL: FUN_1800d8330 must decompile non-empty and its case labels must be
recoverable; it is a jump table, which is the form that DEFEATED an earlier scan.
Every decompile is written to disk IN FULL with its length printed, so no claim
here can rest on a truncated body.
Output: /tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
os.makedirs(OUT, exist_ok=True)
TARGETS = {
"FUN_1800d8330": 0x1800d8330,
"FUN_180119bd0": 0x180119bd0,
"FUN_1801a8640": 0x1801a8640,
"FUN_1800f6c40": 0x1800f6c40,
"FUN_180141660": 0x180141660,
"FUN_1801a8570": 0x1801a8570,
"FUN_1801a8560": 0x1801a8560,
"FUN_1801a8800": 0x1801a8800,
"FUN_1801a8040": 0x1801a8040,
"FUN_180136480": 0x180136480,
}
for name, a in TARGETS.items():
src = dec(a)
p = os.path.join(OUT, name + ".c")
open(p, "w").write(src)
print("WROTE %-16s len=%6d -> %s" % (name, len(src), p))
print()
print("=== small functions printed IN FULL ===")
for name in ("FUN_1800d8330", "FUN_1801a8640", "FUN_1801a8570", "FUN_1801a8560",
"FUN_1801a8800", "FUN_1801a8040", "FUN_180119bd0"):
src = open(os.path.join(OUT, name + ".c")).read()
print("\n----------8<---------- %s (len=%d) ----------" % (name, len(src)))
print(src)
print()
print("=== CONTROL: case labels of FUN_1800d8330 via the listing ===")
f = func(0x1800d8330)
print("entry 0x%x body %s" % (int(f.getEntryPoint().getOffset()), f.getBody()))
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
ins = it.next()
n += 1
print("instruction count: %d" % n)
# enumerate caseD_ labels inside the body
st = prog.getSymbolTable()
labs = []
rng = f.getBody()
for sym in st.getAllSymbols(True):
a2 = sym.getAddress()
if a2 is not None and rng.contains(a2) and str(sym.getName()).startswith("caseD_"):
labs.append((str(sym.getName()), int(a2.getOffset())))
print("caseD_ labels in FUN_1800d8330: %d -> %s" % (len(labs), sorted(set(l[0] for l in labs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,124 @@
"""ADVERSARIAL BATCH 2.
MAIN ATTACK: the claim "cardtype 9 has NO resolver at all, so ball and leaguelogo
display strings must come off the wire (localizedName + description)". That claim
CHANGES WHAT WE SERVE, so it is priority 1.
Counter-evidence to chase: .rdata at 0x1802041d0 holds 'fcc_leaguelogos' and
0x1802041e0 holds 'LeagueName_Abbr_15_%d', sitting immediately beside 'FUT_UC_KITS'
(0x180204180) which IS a resolver literal. If some function formats
LeagueName_Abbr_15_%d for a league logo, the "must come off the wire" claim is wrong.
H6 vtable+0x490 = FUN_18011a860 is a GENERIC name resolver taking
(cardtype@+0x4c, cardsubtypeid@+0x50, resourceId@+0x18). Does it have a
cardtype-9 arm?
H7 'fcc_leaguelogos' / 'LeagueName_Abbr_15_%d' are referenced by some function.
H8 FUN_18012ee20 has EXACTLY ONE caller (the club URL builder). [absence claim]
H9 FUN_1800fed90 is the ONLY function whose switch case set is exactly
{0x91..0x96}. [absence claim -- re-tested here by a DIFFERENT method than
the original caseD_ symbol enumeration: I enumerate switch tables from the
instruction/flow side via getBasicBlocks + scalar operands, AND repeat the
symbol method, and compare the two.]
H10 FUN_180141660 (the merge) is called on every deserialized item.
CONTROL for the xref questions: 'FUT_UC_KITS' at 0x180204180 MUST come back with
>=1 referencing function (we already know FUN_180119bd0 uses it). If the xref
method returns 0 for FUT_UC_KITS the method is broken and every negative is void.
Same syntactic form (a .rdata string address referenced by a LEA) as the targets.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
print("=== CONTROL + targets: xrefs to .rdata string addresses ===")
STRS = {
"FUT_UC_KITS (CONTROL)": 0x180204180,
"FUT_UC_BALL": 0x180239120,
"fcc_leaguelogos": 0x1802041d0,
"LeagueName_Abbr_15_%d": 0x1802041e0,
"leagues": 0x1802041b0,
"Badge (0x1802041b8)": 0x1802041b8,
"countryid": 0x1802041c0,
"fcc_myclubs": 0x180204190,
"TeamName_Abbr15_%d?": None,
}
for name, a in STRS.items():
if a is None:
continue
try:
xs = xrefs_to(a)
except Exception as e:
print(" %-24s XREF ERROR %s" % (name, e)); continue
fns = sorted(set((x[2], x[3]) for x in xs))
print(" %-24s 0x%x %d refs, funcs: %s" %
(name, a, len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
print()
print("=== find TeamName_Abbr15_%d and StadiumName_%d addresses then xref ===")
for lit in (b"TeamName_Abbr15_%d\x00", b"StadiumName_%d\x00", b"LeagueName_Abbr_15_%d\x00",
b"fcc_leaguelogos\x00", b"fcc_balls\x00", b"fcc_stadium\x00",
b"fcc_badgecards\x00", b"fcc_kitcards\x00", b"fcc_misccards\x00"):
hits = find_all(lit, blocks=(".rdata", ".data", ".text"))
print(" %-26s %d hit(s) at %s" % (lit.rstrip(b"\x00").decode(), len(hits),
[hex(h) for h in hits]))
for h in hits:
xs = xrefs_to(h)
fns = sorted(set((x[2], x[3]) for x in xs))
print(" -> %d refs: %s" % (len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
print()
print("=== H6: generic resolver FUN_18011a860 (vtable +0x490) FULL ===")
src = dec(0x18011a860)
open(os.path.join(OUT, "FUN_18011a860.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== H8: callers of FUN_18012ee20 (itemState code -> atom) ===")
for fa in (0x18012ee20, 0x180141660, 0x180166660, 0x1800fed90):
try:
cs = callers(fa)
except Exception:
cs = [(x[0], x[2], x[3]) for x in xrefs_to(fa)]
print(" FUN_%x callers: %s" % (fa, cs))
print()
print("=== H9: switch case-set enumeration, TWO methods ===")
st = prog.getSymbolTable()
# method 1: caseD_ symbols grouped by containing function
import collections
bysym = collections.defaultdict(set)
n = 0
for sym in st.getAllSymbols(True):
nm = str(sym.getName())
if not nm.startswith("caseD_"):
continue
n += 1
a2 = sym.getAddress()
f = fm.getFunctionContaining(a2)
if f is None:
continue
try:
v = int(nm.split("_")[-1], 16)
except ValueError:
continue
bysym[int(f.getEntryPoint().getOffset())].add(v)
print(" method1: %d caseD_ symbols over %d functions" % (n, len(bysym)))
TARGET = set(range(0x91, 0x97))
exact = [hex(k) for k, v in bysym.items() if v == TARGET]
superset = [hex(k) for k, v in bysym.items() if TARGET <= v and v != TARGET]
overlap = [hex(k) for k, v in bysym.items() if (TARGET & v) and not (TARGET <= v)]
print(" functions with case set EXACTLY {0x91..0x96}: %s" % exact)
print(" functions whose case set is a SUPERSET: %s" % superset)
print(" functions with PARTIAL overlap: %s" % overlap)
print(" CONTROL FUN_1800d8330 present in method1? %s -> %s" %
(0x1800d8330 in bysym, sorted(hex(x) for x in bysym.get(0x1800d8330, []))))
print()
print("=== H10: callers of the merge FUN_180141660 ===")
xs = xrefs_to(0x180141660)
print(" %d refs: %s" % (len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,92 @@
"""ADVERSARIAL BATCH 3.
PRIORITY-1 ATTACK: FUN_180098f20 is the ONLY referencer of both 'fcc_leaguelogos'
and 'LeagueName_Abbr_15_%d'. If it resolves a league-logo display name from the DB,
then the claim "cardtype 9 has no resolver at all, so ball and leaguelogo need
localizedName + description off the wire" is WRONG, and that claim changes what we
serve.
ALSO:
H11 FUN_180108c00 deserializes atom 0x32f (tournamentType) and computes
subtype = value + 0x91. (the trophy claim)
H12 FUN_1801bfac0 arm iVar5 == 0x1e -> FUT_UC_BALL, and the 0x1f arm.
H13 DAT_18022315c is the string "rare" (supports low-dword-of-uStack_130 = rareflag)
H14 the deser's stack struct -> record copy: which stack slot becomes record+0x58.
CONTROL for the "who calls X" questions: FUN_180119bd0 must come back with >=1
caller (we already proved FUN_1800f6c40 calls it through vtable slot +0x498 --
though that is an INDIRECT call, so a direct-xref method may legitimately return 0;
that is exactly why the control matters and why a 0 here is NOT an absence).
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
print("=== H13: strings at the DAT_ addresses used as DB column names ===")
for a in (0x18022315c, 0x1801eeeb0, 0x1802ef590, 0x18021ce7c, 0x18021ce7f, 0x1801e9caf):
try:
print(" 0x%x -> %r" % (a, rd_str(a, 40)))
except Exception as e:
print(" 0x%x -> ERR %s" % (a, e))
print()
print("=== PRIORITY 1: FUN_180098f20 FULL (the fcc_leaguelogos referencer) ===")
src = dec(0x180098f20)
open(os.path.join(OUT, "FUN_180098f20.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== who calls FUN_180098f20 ? ===")
for fa, label in ((0x180098f20, "leaguelogo resolver"),
(0x180119bd0, "CONTROL kit/stadium/badge resolver (indirect-only expected)"),
(0x18011a860, "generic resolver +0x490"),
(0x180094580, "third FUT_UC_KITS user"),
(0x1800991a0, "fcc_myclubs user"),
(0x180099490, "leagues/countryid/Badge user")):
xs = xrefs_to(fa)
print(" 0x%x %-52s %d refs: %s" %
(fa, label, len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
print()
print("=== H11: FUN_180108c00 FULL (tournamentType -> subtype 0x91+) ===")
src = dec(0x180108c00)
open(os.path.join(OUT, "FUN_180108c00.c"), "w").write(src)
print("len=%d" % len(src))
print(src[:9000])
if len(src) > 9000:
print("... [remainder in FUN_180108c00.c]")
print()
print("=== FUN_1800fed90 FULL (the 0x91..0x96 switch) ===")
src = dec(0x1800fed90)
open(os.path.join(OUT, "FUN_1800fed90.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== re-decompile the item deser MYSELF (do not trust the other agent's copy) ===")
src = dec(0x18013fe00, timeout=600)
p = os.path.join(OUT, "FUN_18013fe00.c")
open(p, "w").write(src)
print("len=%d -> %s" % (len(src), p))
# print only the lines that matter for H14
for i, ln in enumerate(src.splitlines(), 1):
if ("uStack_130" in ln or "local_100" in ln or "FUN_180141660" in ln
or "local_13c" in ln or "local_138" in ln):
print(" %4d: %s" % (i, ln))
print()
print("=== also dump the card-detail builder for the 0x1e / 0x1f arms ===")
src = dec(0x1801bfac0, timeout=600)
open(os.path.join(OUT, "FUN_1801bfac0.c"), "w").write(src)
print("len=%d" % len(src))
for i, ln in enumerate(src.splitlines(), 1):
if ("0x1e" in ln or "0x1f" in ln or "FUT_UC_BALL" in ln or "FUN_1801a8640" in ln
or "Stadium" in ln or "Badge" in ln or "FUT_UC_KITS" in ln
or "LeagueName" in ln or "fcc_" in ln):
print(" %4d: %s" % (i, ln))
except Exception:
traceback.print_exc()
@@ -0,0 +1,116 @@
"""ADVERSARIAL VERIFICATION BATCH 1.
HYPOTHESES UNDER ATTACK (from the D4 report):
H-A record+0x54 is card LEVEL derived from rating by an unconditional ladder in
the tail of FUN_180141660, NOT itemType.
H-B FUN_1801a87f0 is a one-byte read of record+0xb4 and all four OVERALL_RATING
publishers call it.
H-C playStyle lands at record+0x88, FUN_180136480 accepts only 0xfb..0x111.
H-D atom 0x173 itemType never becomes an int.
CONTROLS.
* For every "no such thing" statement I enumerate case labels, `== 0x`, `!= 0x`
AND sub/dec ladders, and I state which form the positive control used.
* Positive control for the dispatch enumeration: atoms 0x274 (rating) and 0x287
(resourceId), both known-present, must be found by the SAME enumerator.
* Positive control for the literal-xref method: a literal whose xref count is
independently known.
Everything is written to files; nothing is truncated.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
import re
def dump(name, s):
p = OUT + name
open(p, "w").write(s)
print("WROTE %s (%d chars)" % (p, len(s)))
targets = {
"merge_141660": 0x180141660,
"deser_13fe00": 0x18013FE00,
"playersmerge_135890": 0x180135890,
"acc_rating_1a87f0": 0x1801A87F0,
"acc_cardlevel_1a80c0": 0x1801A80C0,
"acc_playstyle_1a85c0": 0x1801A85C0,
"acc_league_1a8550": 0x1801A8550,
"acc_attr_1a8450": 0x1801A8450,
"acc_dream_1a8830": 0x1801A8830,
"acc_assetid_1a8010": 0x1801A8010,
"acc_asset2_1a8020": 0x1801A8020,
"mapper_playstyle_136480": 0x180136480,
"family_d8330": 0x1800D8330,
"resid_166ca0": 0x180166CA0,
}
blob = []
src = {}
for nm, a in targets.items():
f = func(a)
s = dec(a, 600)
src[nm] = s
blob.append("=" * 78)
blob.append("### %s @ %#x ghidra_fn=%s entry=%#x len=%d" % (
nm, a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0, len(s)))
blob.append(s)
dump("v1_bodies.txt", "\n".join(blob))
# ---- dispatch-form enumeration over the item deser, ALL FOUR FORMS
d = src["deser_13fe00"]
print("\n--- deser FUN_18013fe00 len=%d ---" % len(d))
cases = sorted(set(int(x, 16) for x in re.findall(r"case\s+0x([0-9a-fA-F]+)", d)))
cases += sorted(set(int(x) for x in re.findall(r"case\s+(\d+)", d)))
eq = sorted(set(int(x, 16) for x in re.findall(r"==\s*0x([0-9a-fA-F]+)", d)))
ne = sorted(set(int(x, 16) for x in re.findall(r"!=\s*0x([0-9a-fA-F]+)", d)))
lt = sorted(set(int(x, 16) for x in re.findall(r"<\s*0x([0-9a-fA-F]+)", d)))
sub = sorted(set(int(x, 16) for x in re.findall(r"-\s*0x([0-9a-fA-F]+)", d)))
print("case labels (%d): %s" % (len(cases), [hex(c) for c in cases]))
print("== 0x (%d): %s" % (len(eq), [hex(c) for c in eq]))
print("!= 0x (%d): %s" % (len(ne), [hex(c) for c in ne]))
print("< 0x (%d): %s" % (len(lt), [hex(c) for c in lt]))
print("- 0x ladders (%d): %s" % (len(sub), [hex(c) for c in sub]))
for probe, label in [(0x274, "rating CONTROL"), (0x287, "resourceId CONTROL"),
(0x173, "itemType"), (0x23F, "playStyle"),
(0x172, "itemState"), (0x207, "owners"),
(0x361, "untradeable"), (0x1B, "amount"),
(0x226, "pile"), (0x6B, "cardassetid"), (0x23, "assetId"),
(0x18A, "leagueId"), (0x1D1, "nation"), (0x6C, "cardsubtypeid")]:
forms = []
if probe in cases:
forms.append("case")
if probe in eq:
forms.append("==")
if probe in ne:
forms.append("!=")
print(" atom %#x %-18s dispatch forms: %s" % (probe, label, forms or "NONE FOUND"))
# ---- who writes offset 0x54 anywhere in the two functions?
print("\n--- textual writes to +0x54 / 0x54 in merge and deser ---")
for nm in ("merge_141660", "deser_13fe00", "playersmerge_135890"):
for ln_no, ln in enumerate(src[nm].split("\n")):
if "0x54" in ln or "0xb4" in ln:
print(" %-20s %4d| %s" % (nm, ln_no, ln.strip()))
# ---- OVERALL_RATING literal: locate it MYSELF, then xref
print("\n--- OVERALL_RATING literal census ---")
hits = find_all(b"OVERALL_RATING\x00")
print("occurrences of 'OVERALL_RATING\\0':", [hex(h) for h in hits])
for h in hits:
xs = xrefs_to(h)
print(" %#x xrefs=%d" % (h, len(xs)))
for frm, t, fn, ent in xs:
print(" from %#x %s in %s @%#x" % (frm, t, fn, ent))
# control: a literal with an obviously different xref profile
for lit in (b"CARD_LEVEL\x00", b"PLAY_STYLE\x00", b"LEAGUE_ID\x00",
b"ATTRIBUTE_VALUE\x00", b"IS_DREAM_PLAYER\x00", b"ASSET_ID\x00"):
hs = find_all(lit)
print("\n%s occurrences: %s" % (lit, [hex(x) for x in hs]))
for h in hs:
xs = xrefs_to(h)
print(" %#x xrefs=%d -> %s" % (h, len(xs), sorted(set(x[2] for x in xs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,88 @@
"""ADVERSARIAL VERIFICATION BATCH 2.
Q1 COMPLETENESS GAP the D4 report admitted: are there raw, non-accessor reads of
record+0xb4 anywhere in the binary? 0xb4 cannot be encoded as a signed disp8,
so EVERY [reg+0xb4] reference must carry the literal disp32 bytes b4 00 00 00.
Scanning .text for those four bytes and decoding the containing instruction is
therefore an EXHAUSTIVE search, not a sample. Same scan for 0x54 and 0x88.
Positive control: the scan must find FUN_1801a87f0 (+0xb4), FUN_180141660's
ladder (+0xb4 and +0x54) and FUN_1801a85c0 (+0x88).
Q2 FUN_18013f4d0 -- the family-6 handler the deser tail calls with (record,
resourceId, AMOUNT). If it stores amount in the record, the standing
"amount is dropped" verdict is wrong.
Q3 the +0xe0 mystery: FUN_1801a8540, FUN_1800e5940 (manager publisher),
FUN_1800e6e20 (player publisher) in full.
Q4 FUN_180166660 itemState mapper, FUN_1800d7b50/b30/b10/af0 value readers.
Q5 who calls FUN_18013fe00 and FUN_180141660 (is the ladder really on every path).
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
def scan_disp(off):
pat = bytes([off & 0xFF, (off >> 8) & 0xFF, (off >> 16) & 0xFF, (off >> 24) & 0xFF])
hits = find_all(pat, blocks=(".text",))
rows = []
for h in hits:
ins = listing.getInstructionContaining(addr(h))
if ins is None:
continue
a = int(ins.getAddress().getOffset())
txt = str(ins)
if ("0xb4]" in txt or "0x54]" in txt or "0x88]" in txt or
hex(off) in txt.lower()):
f = fm.getFunctionContaining(ins.getAddress())
rows.append((a, txt, f.getName() if f else "?"))
return rows
for off, label in ((0xB4, "record+0xb4 rating"),
(0x54, "record+0x54 disputed"),
(0x88, "record+0x88 playStyle")):
rows = scan_disp(off)
print("\n==== EXHAUSTIVE disp32 scan for [reg+%#x] (%s): %d instructions"
% (off, label, len(rows)))
seen = {}
for a, txt, fn in rows:
seen.setdefault(fn, []).append((a, txt))
for fn in sorted(seen):
print(" %-24s" % fn, ["%#x %s" % (a, t) for a, t in seen[fn]])
bodies = []
for nm, a in (("f_13f4d0_family6", 0x18013F4D0),
("acc_1a8540", 0x1801A8540),
("acc_1a86b0", 0x1801A86B0),
("acc_1a8590_nation", 0x1801A8590),
("acc_1a86a0_team", 0x1801A86A0),
("pub_mgr_1800e5940", 0x1800E5940),
("pub_player_1800e6e20", 0x1800E6E20),
("itemstate_166660", 0x180166660),
("rd_d7b50", 0x1800D7B50), ("rd_d7b30", 0x1800D7B30),
("rd_d7b10", 0x1800D7B10), ("rd_d7af0", 0x1800D7AF0),
("stamp_d84e0", 0x1800D84E0)):
f = func(a)
s = dec(a, 600)
bodies.append("=" * 78)
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
bodies.append(s)
open(OUT + "v2_bodies.txt", "w").write("\n".join(bodies))
print("\nWROTE v2_bodies.txt")
print("\n==== callers ====")
for nm, a in (("FUN_18013fe00 item deser", 0x18013FE00),
("FUN_180141660 merge", 0x180141660),
("FUN_180135890 players merge", 0x180135890),
("FUN_1801a87f0 rating acc", 0x1801A87F0),
("FUN_1801a80c0 cardlevel acc", 0x1801A80C0),
("FUN_1801a85c0 playstyle acc", 0x1801A85C0),
("FUN_1801a8550 league acc", 0x1801A8550),
("FUN_1801a8540", 0x1801A8540)):
xs = xrefs_to(a)
cs = sorted(set("%s@%#x" % (x[2], x[3]) for x in xs if x[1].startswith("UNCONDITIONAL_CALL") or "CALL" in x[1]))
print("%-30s xrefs=%d callers=%s" % (nm, len(xs), cs))
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""ADVERSARIAL BATCH 3: exhaustive [reg+disp32] scan, tightened.
0xb4 / 0x54 / 0x88 / 0xe0 cannot be a signed disp8, so every [reg+off] reference
must carry the disp32 bytes literally. The scan is therefore exhaustive over .text.
Filter: keep only instructions whose printed operand ends in "+ 0x<off>]", drop LEA
and the unwind-stub noise.
Positive controls that MUST appear: FUN_1801a87f0 (+0xb4 read),
FUN_180141660 (+0xb4 read and +0x54 write), FUN_1801a85c0 (+0x88 read),
FUN_1801a80c0 (+0x54 read and write).
"""
import traceback
try:
for off in (0xB4, 0x54, 0x88, 0xE0):
pat = bytes([off, 0, 0, 0])
hits = find_all(pat, blocks=(".text",))
rows = []
for h in hits:
ins = listing.getInstructionContaining(addr(h))
if ins is None:
continue
txt = str(ins)
if ("+ %s]" % hex(off)) not in txt:
continue
mn = txt.split()[0]
if mn in ("LEA", "NOP"):
continue
f = fm.getFunctionContaining(ins.getAddress())
fn = f.getName() if f else "?"
if fn.startswith("Unwind") or fn.startswith("_guard"):
continue
rows.append((int(ins.getAddress().getOffset()), txt, fn))
rows = sorted(set(rows))
print("\n==== [reg+%#x] exhaustive disp32 scan: %d non-LEA, non-unwind instructions"
% (off, len(rows)))
byf = {}
for a, t, fn in rows:
byf.setdefault(fn, []).append((a, t))
for fn in sorted(byf):
print(" %-26s %s" % (fn, "; ".join("%#x %s" % x for x in byf[fn])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,79 @@
"""ADVERSARIAL BATCH 4.
CARD SIDE
A. FUN_1801aa7f0 and FUN_1800e6410 read [reg+0xb4] as a byte but sit OUTSIDE the
accessor range [0x1801a7000,0x1801a9000) the D4 report swept. Do they read an
item record? If so the "OVERALL_RATING has exactly four publishers, all through
FUN_1801a87f0" completeness argument has a hole.
B. FUN_1801356c0 -- the family-2 (manager) merge. Does it clobber +0xdd..+0xfb the
way the players merge does? That decides whether leagueId at +0xe0 survives for
managers.
C. FUN_180134cb0 -- writes +0xfc..+0x101, which FUN_1801a86b0 reads as the
per-attribute chemistry delta.
D. disp8 scan for [reg+0x54]: 0x54 fits a signed disp8 so the disp32 trick does
NOT apply; iterate EVERY instruction in .text instead. Positive control:
FUN_180141660 and FUN_1801a80c0 must appear.
ROUTE SIDE
E. FUN_18012ec50 club ?type= switch, FUN_18012f4f0 club/stats switch,
FUN_1801308c0 consumables suffix, FUN_18012ddf0 query builder -- full, so the
"exactly 30 / exactly 7 / no /stats/team" absences can be re-tested against
case labels AND == AND != AND ladders.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
import re
bodies = []
src = {}
for nm, a in (("rating_reader_1aa7f0", 0x1801AA7F0),
("rating_reader_e6410", 0x1800E6410),
("mgr_merge_1356c0", 0x1801356C0),
("chem_134cb0", 0x180134CB0),
("clubtype_12ec50", 0x18012EC50),
("clubstats_12f4f0", 0x18012F4F0),
("consum_1308c0", 0x1801308C0),
("clubsearch_12ddf0", 0x18012DDF0)):
s = dec(a, 600)
src[nm] = s
bodies.append("=" * 78)
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
bodies.append(s)
open(OUT + "v4_bodies.txt", "w").write("\n".join(bodies))
print("WROTE v4_bodies.txt")
for nm in ("clubtype_12ec50", "clubstats_12f4f0"):
s = src[nm]
cases = re.findall(r"case\s+(0x[0-9a-fA-F]+|\d+):", s)
eq = re.findall(r"==\s*(0x[0-9a-fA-F]+|\d+)", s)
ne = re.findall(r"!=\s*(0x[0-9a-fA-F]+|\d+)", s)
sub = re.findall(r"-\s*(0x[0-9a-fA-F]+|\d+)U?\s*<", s)
print("\n%s len=%d cases=%d %s\n ==%s !=%s ladders=%s"
% (nm, len(s), len(cases), cases, eq, ne, sub))
# ---- D: exhaustive instruction walk for [reg+0x54]
print("\n==== EVERY instruction in .text referencing [reg + 0x54] ====")
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
it = listing.getInstructions(blk.getStart(), True)
n = 0
found = []
while it.hasNext():
ins = it.next()
if ins.getAddress().getOffset() > int(blk.getEnd().getOffset()):
break
n += 1
t = str(ins)
if "+ 0x54]" in t:
f = fm.getFunctionContaining(ins.getAddress())
found.append((int(ins.getAddress().getOffset()), t,
f.getName() if f else "?"))
print("instructions walked: %d ; hits: %d" % (n, len(found)))
byf = {}
for a, t, fn in found:
byf.setdefault(fn, []).append("%#x %s" % (a, t))
for fn in sorted(byf):
print(" %-26s %s" % (fn, "; ".join(byf[fn])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""Q1 recon: the itemState enum table and the club?type= strings.
HYPOTHESIS: the itemState enum table at 0x180229d20 (stride 0x10, 10 entries) is
referenced by (a) a string->enum mapper in the deserializer and (b) an equip path
that WRITES activeBadge/activeHomeKit/... The equip path is the place most likely
to switch on cardsubtypeid for cardtype 9.
CONTROL: the table dump itself. The doc states the ten names; if the dump does not
reproduce WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active in that order, my table read is
wrong and every conclusion downstream is void.
Also: locate the literals for club?type= singular names (stadium/ball/equippables)
and the family caption keys, with occurrence counts, so later queries can pick a
unique anchor.
"""
import traceback
try:
print("=== A: itemState enum table 0x180229d20, stride 0x10, 14 entries ===")
T = 0x180229D20
for i in range(14):
e = T + i * 0x10
q0 = qword(e)
q1 = qword(e + 8)
s = ""
if 0x180000000 <= q0 < 0x181000000:
try:
s = rd_str(q0, 64)
except Exception:
s = "?"
print(" [%2d] %#x: q0=%#018x %-24r q1=%#x" % (i, e, q0, s, q1))
print()
print("=== B: xrefs to the table start and to each row ===")
for i in range(12):
e = T + i * 0x10
xs = xrefs_to(e)
if xs:
print(" row %d @%#x:" % (i, e))
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== C: string literals of interest, all occurrences ===")
pats = [
b"activeBadge", b"activeHomeKit", b"activeAwayKit", b"activeBall",
b"activeStadium", b"itemState", b"forSale", b"inGame",
b"equippables", b"stadium", b"Stadium", b"ball", b"Ball",
b"badge", b"Badge", b"kit", b"Kit", b"clubLogo", b"leagueLogo",
b"CLUBLOGO", b"LEAGUELOGO", b"BADGE", b"STADIUM", b"BALL", b"KIT",
]
for p in pats:
hits = find_all(p)
print(" %-16r n=%d %s" % (p.decode(), len(hits),
" ".join("%#x" % h for h in hits[:12])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,56 @@
"""Q10: the fcc_ table vocabulary and the classifier's neighbourhood.
Q8/Q9 changed the picture: inside the item deserializer, cardtype 9 items whose
cardsubtypeid is in [0x91,0x95) take a custom-image path, and a SEPARATE
deserializer FUN_180108c00 computes subtype = wireValue + 0x91 and then picks the
loc format by range:
0x91 <= s < 0x95 -> "TOURNY_LOC_%d"
0x95 <= s < 0x97 -> "SEASON_LOC_%d"
so 0x91..0x96 look like TROPHIES, not badges/kits/stadia/balls. Also, cardtype 7
(subtypes 9,10,11) has an arm that defaults a field to 0x23 = 35, and 35 is the
kit cardassetid recorded in tools/fut_clubitems.py.
This query gathers the vocabulary needed to test that:
A. every "fcc_" table name literal in the binary, with the function that queries
it -- the merge's per-family table map;
B. every literal starting "cardsubtype" / "cardtype" (column names);
C. the small helpers around the classifier: FUN_1800d84e0 (called right after it
in the deser), FUN_1800d7b30/b50/af0/b10, FUN_1800d7170.
CONTROL: "fcc_discardcoins" must appear in A, and its query site must be
FUN_18013fe00 (line 784 of the Q8 decompile). If it does not, the literal scan is
not seeing the same code the decompiler is.
"""
import traceback
try:
print("=== A: fcc_ table literals ===")
seen = set()
for h in find_all(b"fcc_"):
s = rd_str(h, 64)
if not s or s in seen:
continue
seen.add(s)
xs = xrefs_to(h)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-28r <- %s" % (h, s, who or "-"))
print(" total distinct: %d" % len(seen))
print()
print("=== B: cardtype / cardsubtype column literals ===")
for pat in (b"cardtype", b"cardsubtype", b"carddbid", b"cardassetid"):
for h in find_all(pat):
s = rd_str(h, 64)
xs = xrefs_to(h)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-28r <- %s" % (h, s, who or "-"))
print()
print("=== C: helpers ===")
for a in (0x1800D84E0, 0x1800D7B30, 0x1800D7B50, 0x1800D7AF0, 0x1800D7B10):
src = dec(a)
print("-" * 70)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 2500 else src[:2500] + "\n...[TRUNCATED, len above]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""Q11: dump the candidate functions to files for local analysis.
Rationale: the interesting functions are 3k-27k chars each and printing them all to
the transcript is wasteful. Write each decompile to
/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec/FUN_<addr>.c and print only name+length here.
Set chosen from Q10:
FUN_180098f20 queries fcc_leaguelogos AND uses carddbid + cardassetid
FUN_180098560 / FUN_1800989f0 / FUN_180042440 / FUN_180043350 fcc_myclubscategories
FUN_1800991a0 fcc_myclubs
FUN_180141660 the merge (carddbid)
FUN_18011a860 / FUN_1801356c0 / FUN_1801362e0 other carddbid users
FUN_18013fe00 the shared item deserializer (full, for local grep)
FUN_18011e9d0 the <0x95 callback from Q9
FUN_18013af30 the remaining scan hit
CONTROL: FUN_18013fe00 must come out at 26234 chars, the length Q8 measured. A
different length means a different function or a different decompiler setting.
"""
import os
import traceback
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
try:
os.makedirs(OUT, exist_ok=True)
for a in (0x180098F20, 0x180098560, 0x1800989F0, 0x180042440, 0x180043350,
0x1800991A0, 0x180141660, 0x18011A860, 0x1801356C0, 0x1801362E0,
0x18013FE00, 0x18011E9D0, 0x18013AF30, 0x180096670, 0x1801017E0):
src = dec(a)
p = os.path.join(OUT, "FUN_%x.c" % a)
with open(p, "w") as f:
f.write(src)
print(" %-14s len=%d -> %s" % ("FUN_%x" % a, len(src), p))
except Exception:
traceback.print_exc()
@@ -0,0 +1,76 @@
"""Q12: the UI group tables around 0x180203260 and every family caption key.
Known: consumables group table at 0x180203260 (7 rows, stride 0x18, indexed by the
switch in FUN_180096670 case 0xb) and staff at 0x180203310 (5 rows, case 8). The
club-item claim "there is no equivalent table" is exactly the kind of absence this
project keeps getting wrong, so walk the WHOLE region 0x180203100..0x180203700 as
stride-0x18 triples and print anything string-shaped, then xref each candidate
table start.
Also print every .rdata literal containing BADGE / STADIUM / BALL / KIT / LOGO /
TROPHY (upper case, i.e. loc keys) with its xrefs. Q4 of the brief.
CONTROL: the consumables table at 0x180203260 must come out as the seven rows
already recorded (TRAINING/CONTRACT/FITNESS/HEALING/PLAYSTYLE/MANAGER_LEAGUE/
TACTIC_TRAINING with codes 0,1,4,3,0x17,0x18,0x11). If the walk does not reproduce
it, the stride/layout assumption is wrong and nothing else in this query counts.
"""
import traceback
def walk(lo, hi, stride):
a = lo
while a < hi:
cells = []
for k in range(0, stride, 8):
try:
q = qword(a + k)
except Exception:
q = 0
s = ""
if 0x180000000 <= q < 0x181000000:
try:
t = rd_str(q, 80)
if t and all(0x20 <= ord(c) < 0x7F for c in t):
s = t
except Exception:
pass
cells.append("%#x%s" % (q, (" %r" % s) if s else ""))
print(" %#x %s" % (a, " | ".join(cells)))
a += stride
try:
print("=== stride-0x18 walk 0x180203200..0x180203460 ===")
walk(0x180203200, 0x180203460, 0x18)
print()
print("=== xrefs to plausible table starts ===")
for a in range(0x180203200, 0x180203460, 8):
xs = xrefs_to(a)
if xs:
print(" %#x:" % a)
for frm, typ, fn, ent in xs:
print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== upper-case family loc keys ===")
seen = set()
for pat in (b"BADGE", b"STADIUM", b"BALL", b"KIT", b"LOGO", b"TROPHY"):
for h in find_all(pat):
# walk back to the start of the C string
p = h
for _ in range(80):
try:
if mem.getByte(addr(p - 1)) & 0xFF == 0:
break
except Exception:
break
p -= 1
s = rd_str(p, 120)
if p in seen or len(s) < 4:
continue
seen.add(p)
xs = xrefs_to(p)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-52r <- %s" % (p, s, who or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""Q13: every FUT_MYCLUB_ loc key, and the table row that carries it.
Q12 reproduced the consumables table (control passed) and showed the staff table's
middle column IS the cardtype (manager 2, headcoach 3, fitnesscoach 4, gkcoach 0xa,
physio 5 -- exactly the merge's switch arms), and a trophies pair:
0x180203380 {0x05, 0, FUT_MYCLUB_OFFLINE_TROPHIES_EARNED}
0x180203398 {0x15, 1, FUT_MYCLUB_ONLINE_TROPHIES_EARNED}
If a badges/kits/stadia/balls row exists in the same shape, its middle column is the
answer. Enumerate EVERY FUT_MYCLUB_ literal, find the pointer to it in .rdata/.data,
and print the 0x18-byte row it sits in for all three possible cell positions, plus
the rows either side.
CONTROL: FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED must resolve to the row
{0, ptr, 'training'} at 0x180203260. Any layout guess that cannot reproduce that row
is wrong.
"""
import traceback
try:
keys = []
for h in find_all(b"FUT_MYCLUB_"):
s = rd_str(h, 120)
keys.append((h, s))
keys.sort()
print("=== %d FUT_MYCLUB_ literals ===" % len(keys))
for h, s in keys:
print(" %#x %r" % (h, s))
print()
print("=== pointer rows ===")
for h, s in keys:
ptrs = find_all(h.to_bytes(8, "little"), blocks=(".rdata", ".data"))
if not ptrs:
print(" %-46r no pointer" % s)
continue
for pa in ptrs:
ctx = []
for off in (-0x18, -0x10, -8, 0, 8, 0x10, 0x18):
try:
q = qword(pa + off)
except Exception:
continue
t = ""
if 0x180000000 <= q < 0x181000000:
try:
u = rd_str(q, 80)
if u and all(0x20 <= ord(c) < 0x7F for c in u):
t = u
except Exception:
pass
ctx.append("%+#5x:%#x%s" % (off, q, (" %r" % t) if t else ""))
print(" %-46r @%#x" % (s, pa))
print(" " + " ".join(ctx))
except Exception:
traceback.print_exc()
@@ -0,0 +1,41 @@
"""Q14: the club URL format strings and their builders.
Q6 found 'type=%s' at 0x180224c7a and 0x180224ff9 with no direct xref, which means
each is the TAIL of a longer literal whose start is what the code references. Dump
every C string in 0x180224a00..0x180225300 and 0x18021e200..0x18021e800 with xrefs,
so the club request builder can be identified and decompiled.
Also dump 0x180228400..0x18022b200 for the transfermarket/club parameter strings.
CONTROL: '&cat=%s' at 0x1802285b8 is already known to be referenced by
FUN_180162c90; it must show that xref here too.
"""
import traceback
def dump(lo, hi, tag):
print("=== %s %#x..%#x ===" % (tag, lo, hi))
p = lo
while p < hi:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 160)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-66r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
try:
dump(0x180224A00, 0x180225300, "club/url block")
dump(0x18021E200, 0x18021E800, "route table")
dump(0x180228400, 0x180229000, "params block")
except Exception:
traceback.print_exc()
@@ -0,0 +1,67 @@
"""Q15: find the equip path by the atoms it must name.
The itemState vocabulary is also in the atom table:
0xc activeAwayKit 0xd activeBadge 0xe activeBall 0x10 activeHomeKit
0x12 activeStadium 0xa active 0x12e free 0x164 inGame 0x1e5 offered
and the club ?type= taxonomy switch FUN_18012ec50 shows how a name reaches the wire:
FUN_180180cd0(atom) returns the atom's name string. So whatever chooses which of the
five active* states to send must call FUN_180180cd0 with 0xc/0xd/0xe/0x10/0x12, and
the choice is made from the item's family. That is the mapping the brief wants.
Method: enumerate every caller of FUN_180180cd0, decompile each once, and report the
call sites whose literal argument is one of the atoms of interest:
equip states 0xc 0xd 0xe 0x10 0x12
club families 0x49 badge, 0x179 kit, 0x2d8 stadium, 0x4d ball,
0x18d leaguelogos, 0x10a equippables, 0x4b badges, 0x4f balls,
0x17c kits, 0x18e leagueLogos, 0x2d7 stadia
Print the matching lines with context so the surrounding switch is visible.
CONTROL: FUN_18012ec50 is a known caller and must be reported with its family atoms
(0x49, 0x179, 0x2d8, 0x4d, 0x18d, 0x10a). If it is not in the output, the caller
enumeration or the literal matching is broken.
"""
import re
import traceback
WANT = {0xC: "activeAwayKit", 0xD: "activeBadge", 0xE: "activeBall",
0x10: "activeHomeKit", 0x12: "activeStadium", 0xA: "active",
0x12E: "free", 0x164: "inGame", 0x1E5: "offered",
0x49: "badge", 0x179: "kit", 0x2D8: "stadium", 0x4D: "ball",
0x18D: "leaguelogos", 0x10A: "equippables", 0x4B: "badges",
0x4F: "balls", 0x17C: "kits", 0x18E: "leagueLogos", 0x2D7: "stadia"}
try:
ents = {}
for frm, typ, fn, ent in xrefs_to(0x180180CD0):
if ent:
ents[ent] = fn
print("callers of FUN_180180cd0: %d" % len(ents))
pat = re.compile(r"FUN_180180cd0\((0x[0-9a-f]+|\d+)\)")
nhit = 0
for ent, fn in sorted(ents.items()):
src = dec(ent)
lines = src.splitlines()
found = []
for i, l in enumerate(lines):
for m in pat.finditer(l):
v = int(m.group(1), 0)
if v in WANT:
found.append((i, v))
if not found:
continue
nhit += 1
print("=" * 70)
print("%s @%#x len=%d atoms=%s" %
(fn, ent, len(src),
sorted({"%#x=%s" % (v, WANT[v]) for _i, v in found})))
shown = set()
for i, _v in found:
for j in range(max(0, i - 4), min(len(lines), i + 2)):
if j in shown:
continue
shown.add(j)
print(" %4d: %s" % (j, lines[j].strip()))
print(" ---")
print("functions with hits: %d" % nhit)
except Exception:
traceback.print_exc()
@@ -0,0 +1,35 @@
"""Q16: the equip path -- callers of the itemState serializer.
Q15 found FUN_18012ee20: itemState code -> atom name, with
1->free 2->inGame 5->0x1f8 6->offered 100->activeBadge 0x65->activeHomeKit
0x66->activeAwayKit 0x67->activeBall 0x68->activeStadium 0xff->active
Whoever CALLS it with 0x64..0x68 is the equip path, and the code that picks which of
those five to pass must know the item's family.
Dump: every caller of FUN_18012ee20 in full, plus FUN_18012ddf0 (the club URL
builder) in full, plus FUN_18012ec50's caller chain context.
CONTROL: FUN_18012ddf0 must contain the five-way if/else on *(param_1+0x30) that
Q15 printed (0xa badge, 0xb kit, 0x15 stadium, 0x16 ball, else equippables). If the
full decompile lacks it, this is not the same function.
"""
import traceback
try:
ents = {}
for frm, typ, fn, ent in xrefs_to(0x18012EE20):
if ent:
ents[ent] = fn
print("callers of FUN_18012ee20 (itemState->atom): %d -> %s" %
(len(ents), ["%s(%#x)" % (v, k) for k, v in ents.items()]))
for ent in sorted(ents):
src = dec(ent)
print("=" * 78)
print("CALLER %s @%#x len=%d" % (ents[ent], ent, len(src)))
print(src)
print("=" * 78)
src = dec(0x18012DDF0)
print("CLUB URL BUILDER FUN_18012ddf0 len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""Q17: enumerate EVERY switch case label in the binary, then find the ones that
distinguish club subtypes.
Q5's scalar scan failed its control because jump-table case labels are not
instruction immediates. Ghidra, however, names them: it creates symbols of the form
switchD_<addr>_caseD_<n> (and caseD_<n>) at each case target. Walking the symbol
table therefore enumerates switch dispatch in the one form a scalar scan cannot see.
Report every function whose case-value set intersects the club-subtype candidates
{0x1e,0x1f,9,10,11,0x91..0x96} and print the full case set for each.
CONTROL: FUN_1800d8330 must appear with case labels including 0x1e, 0x1f, 0x91..0x96,
0xe7..0xe9 and 0xec. If it does not, the symbol-based enumeration is broken and no
absence claim may be made from it.
"""
import re
import traceback
try:
st = prog.getSymbolTable()
it = st.getAllSymbols(True)
pat = re.compile(r"caseD_([0-9a-fA-F]+)$")
per = {}
n = 0
while it.hasNext():
s = it.next()
m = pat.search(s.getName())
if not m:
continue
n += 1
try:
v = int(m.group(1), 16)
except ValueError:
continue
f = fm.getFunctionContaining(s.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
per.setdefault(key, set()).add(v)
print("case labels found: %d in %d functions" % (n, len(per)))
CAND = {0x1E, 0x1F, 9, 10, 11, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
print()
print("=== functions whose case set meets the club-subtype candidates ===")
rows = []
for (name, ent), vals in per.items():
inter = vals & CAND
if len(inter) >= 2:
rows.append((len(inter), name, ent, vals))
rows.sort(reverse=True)
for k, name, ent, vals in rows:
print(" %-26s %#x hits=%d cases=%s" %
(name, ent, k, sorted("%#x" % v for v in vals)))
print()
print("=== control: FUN_1800d8330 ===")
for (name, ent), vals in per.items():
if ent == 0x1800D8330:
print(" YES cases=%s" % sorted("%#x" % v for v in vals))
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""Q18: FUN_1800fed90 -- a switch whose case set is EXACTLY {0x91..0x96}.
Q17's case-label enumeration (control passed on FUN_1800d8330) found exactly one
function whose switch discriminates the six high club subtypes and nothing else:
FUN_1800fed90. If 0x91..0x96 are trophies, this is where each one is turned into a
concrete thing, and the six arms should be distinguishable.
Also decompile FUN_1800f4bc0 and FUN_1800f2f70 (case sets 0xa..0x14, i.e. they
distinguish 10 and 11, the other two cardtype-7 subtypes) and FUN_1800d8260 /
FUN_1800d86c0 / FUN_1800d8b50 (small enum->string helpers next to the classifier).
CONTROL: FUN_1800d8b50 is called by the club URL builder FUN_18012ddf0 to render a
value for query key atom 0x243; it should decompile to a code->string table, which
is a known shape. If it comes out as something else, my reading of the URL builder
is wrong.
"""
import traceback
try:
for a in (0x1800FED90, 0x1800F4BC0, 0x1800F2F70, 0x1800D8260, 0x1800D86C0,
0x1800D8B50):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 9000 else src[:9000] + "\n...[cut at 9000, len above]")
print("=" * 78)
print("=== callers ===")
for a in (0x1800FED90, 0x1800F4BC0):
print(" callers of %#x:" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""Q19: every constant the code compares a +0x50 (cardsubtypeid) or +0x4c (cardtype)
field against.
The parsed item record has cardsubtypeid at +0x50 and cardtype at +0x4c. Instead of
searching for a constant (which misses jump tables) or for a syntactic form (which
misses != and ladders), search for the FIELD ACCESS and then collect every immediate
that touches the loaded register within the next 8 instructions, whatever the
mnemonic. Both the direct form (CMP dword [reg+0x50], imm) and the load-then-test
form (MOV r32,[reg+0x50]; SUB r32,imm; CMP r32,imm) are covered.
CONTROL: FUN_18011e3c0 is known to do `*(int *)(x + 0x50) - 0x91U < 6`, so it must
appear with 0x91 (and 6) attached to a +0x50 access. If the control is absent the
scan is broken and nothing may be concluded from what it does not find.
"""
import traceback
try:
block = None
for b in mem.getBlocks():
if b.getName() == ".text":
block = b
break
per = {}
it = listing.getInstructions(block.getStart(), True)
window = [] # [(reg_name, remaining_instrs)]
n = 0
while it.hasNext():
ins = it.next()
n += 1
txt = str(ins)
# 1) direct: memory operand with disp 0x50/0x4c and an immediate
for disp in ("0x50", "0x4c"):
if ("+ " + disp + "]") in txt or ("+" + disp + "]") in txt:
imms = []
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
imms.append(int(o.getValue()))
except Exception:
pass
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
rec = per.setdefault(key, {"direct": set(), "near": set()})
for v in imms:
if v not in (0x50, 0x4C) and 0 <= v < 0x1000:
rec["direct"].add((disp, v))
# start a window: whatever register this instruction defines
for r in ins.getResultObjects():
window.append([str(r), 8, key, disp])
# 2) decay window and attach immediates that touch the tracked register
nxt = []
for w in window:
reg, left, key, disp = w
if left <= 0:
continue
if reg in txt:
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if 0 <= v < 0x1000:
per.setdefault(key, {"direct": set(), "near": set()})
per[key]["near"].add((disp, v))
w[1] = left - 1
nxt.append(w)
window = nxt[-40:]
print("instructions scanned: %d" % n)
print()
CAND = {9, 10, 11, 0x1E, 0x1F, 7, 0x91}
print("=== functions whose +0x50 / +0x4c constants meet {9,10,11,0x1e,0x1f,7,0x91} ===")
for (name, ent), rec in sorted(per.items()):
vals = rec["direct"] | rec["near"]
hit = {v for _d, v in vals} & CAND
if not hit:
continue
print(" %-24s %#x hits=%s" % (name, ent, sorted("%#x" % h for h in hit)))
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])[:40])
print()
print("=== control FUN_18011e3c0 ===")
for (name, ent), rec in per.items():
if ent == 0x18011E3C0:
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,85 @@
"""Q2: two string clusters that look like family-name tables.
Q1 found 'badge' 0x18022a220, 'kit' 0x18022a228, 'leagueLogo' 0x18022a230,
'ball' 0x18022a278 packed together, and a second cluster 'badge' 0x1802303c8,
'ball' 0x1802303dc, 'equippables' 0x180230f48, 'leagueLogo' 0x180231608.
HYPOTHESIS: cluster 1 is the value list of a {name -> code} enum table like the
itemState one (stride 0x10: char* then int). Cluster 2 is the club?type= route
vocabulary.
CONTROL: the itemState table itself. My Q1 read started mid-table (row0 =
activeBadge with code 0x64, while the doc's list starts at WAITING_FOR_GAME), so
this query re-walks BACKWARDS from 0x180229d20 to find the real table start and
prints it in full. If the ten documented names do not appear in order, my table
walker is wrong.
Then: for every string in each cluster, find the .rdata qword that points at it
(the table row) and print the row's neighbours, plus xrefs.
"""
import traceback
def dump_strings(lo, hi, label):
print("=== strings %s %#x..%#x ===" % (label, lo, hi))
p = lo
while p < hi:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 96)
if len(s) >= 2:
print(" %#x %r" % (p, s))
p += max(1, len(s)) + 1
else:
p += 1
def walk_table(start, n, back=0):
print("--- table walk from %#x, %d rows (stride 0x10) ---" % (start, n))
for i in range(-back, n):
e = start + i * 0x10
try:
q0, q1 = qword(e), qword(e + 8)
except Exception:
continue
s = ""
if 0x180000000 <= q0 < 0x181000000:
try:
s = rd_str(q0, 64)
except Exception:
s = "?"
print(" [%3d] %#x ptr=%#x %-26r val=%#x" % (i, e, q0, s, q1))
try:
walk_table(0x180229D20, 8, back=14)
print()
dump_strings(0x18022A200, 0x18022A380, "cluster1")
print()
dump_strings(0x180230300, 0x180230420, "cluster2a")
print()
dump_strings(0x180230F00, 0x180230FA0, "cluster2b")
print()
dump_strings(0x180231380, 0x180231680, "cluster2c")
print()
print("=== xrefs / pointer-rows for cluster strings ===")
for name, a in [("badge", 0x18022A220), ("kit", 0x18022A228),
("leagueLogo", 0x18022A230), ("ball", 0x18022A278),
("badge2", 0x1802303C8), ("ball2", 0x1802303DC),
("equippables", 0x180230F48), ("leagueLogo2", 0x180231608),
("itemState", 0x180231490)]:
print(" %s @%#x" % (name, a))
for frm, typ, fn, ent in xrefs_to(a):
print(" xref from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
ptrs = find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data"))
for pa in ptrs[:8]:
print(" ptr-row @%#x next-q=%#x prev-q=%#x" %
(pa, qword(pa + 8), qword(pa - 8)))
for frm, typ, fn, ent in xrefs_to(pa):
print(" row xref %#x %s in %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""Q20: the functions that test cardtype==7 / cardsubtypeid in {9,10,11}.
Q19 (control passed: it recovered FUN_18011e3c0's 0x91/0x94/0x96 on a +0x50 field)
flagged:
FUN_1800f6c40 direct [+0x4c]==7 AND [+0x50]==9
FUN_180084720 direct [+0x50]==9 and [+0x50]==0xb
FUN_180094580 near [+0x50] 9 / 0xb / 3
FUN_18015fa80 direct [+0x50]==9
FUN_1801362e0 direct [+0x4c] 1 / 2 / 7
Decompile each. Whatever these do with subtypes 9/10/11 is the club-family
behaviour, and a loc key or asset id in any arm names the family.
CONTROL: FUN_1801362e0 is one of the merge's arms (called from FUN_180141660 case 2,
the manager arm) so it must be a DB lookup on carddbid; if it is not, the +0x4c
attribution is on a different struct and these hits are noise.
"""
import traceback
try:
for a in (0x1800F6C40, 0x180084720, 0x180094580, 0x18015FA80, 0x1801362E0):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 12000 else src[:12000] + "\n...[cut, len above]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,47 @@
"""Q21: the FUT data-manager vtable slots that name a club item.
FUN_1800f6c40 (the pack/award tile builder) does:
if (item+0x4c == 1) -> ITEM_RARITY / ITEM_LEVEL
else if (item+0x50 == 9) -> "IS_KIT_%d" = 1 <-- names subtype 9
name = mgr->vt[0x490](out, item+0x4c cardtype, item+0x50 subtype, item+0x18)
if (name empty && item+0x4c == 7)
name = mgr->vt[0x498](out, item+0x50 subtype, item+0x94 teamid, item+0x20)
where mgr = FUN_18011a830(). Slots 0x490 and 0x498 are therefore the club-item name
resolvers and must switch on the subtype.
Resolve the manager's vtable, then decompile slots 0x490, 0x498, 0xa08, 0xa38, 0xa40.
CONTROL: slot 0xa08 is the one the item deserializer calls to file a parsed item
(FUN_18013fe00 line 825), and slot 0xa40 is the lookup FUN_18011e3c0 uses with a
resourceId. If the resolved vtable's 0xa08/0xa40 are not functions, the vtable
resolution is wrong.
"""
import traceback
try:
src = dec(0x18011A830)
print("=== FUN_18011a830 (manager accessor) len=%d ===" % len(src))
print(src)
# find the vtable it installs / the object's class
print()
print("=== candidate vtables referenced from FUN_18011a830 and its callees ===")
f = func(0x18011A830)
cands = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x1802891FF:
cands.add(t)
for t in sorted(cands):
try:
v0, v1 = qword(t), qword(t + 8)
except Exception:
continue
print(" %#x -> %#x %#x (%s / %s)" %
(t, v0, v1, fname(v0) if 0x180000000 <= v0 < 0x181000000 else "-",
fname(v1) if 0x180000000 <= v1 < 0x181000000 else "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q22: resolve the manager object's vtable through the global DAT_1802e6398.
FUN_18011a830 just returns DAT_1802e6398, so the vtable is installed wherever that
global is written. Find the writers, decompile the smallest, and read the vtable it
stores. Then dump slots 0x490 / 0x498 / 0xa08 / 0xa38 / 0xa40.
CONTROL: the recovered vtable's slot 0xa08 and 0xa40 must both be real functions
(the item deserializer calls 0xa08 to file an item; FUN_18011e3c0 calls 0xa40 with a
resourceId). If either is not a function, the vtable is wrong.
"""
import traceback
try:
print("=== writers/readers of DAT_1802e6398 ===")
ents = {}
for frm, typ, fn, ent in xrefs_to(0x1802E6398):
ents.setdefault(ent, []).append((frm, typ, fn))
for ent, lst in sorted(ents.items()):
print(" %s(%#x) n=%d types=%s" %
(lst[0][2], ent, len(lst), sorted({t for _f, t, _n in lst})))
# the constructor is a function that WRITES it
writers = [e for e, lst in ents.items()
if any(t == "WRITE" for _f, t, _n in lst)]
print("writers: %s" % ["%#x" % w for w in writers])
for w in writers:
src = dec(w)
print("=" * 70)
print("writer FUN_%x len=%d" % (w, len(src)))
print(src if len(src) < 6000 else src[:6000] + "\n...[cut]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,40 @@
"""Q23: callers of the manager setter FUN_18011d780 -> the manager's vtable.
CONTROL: the vtable found must have real functions at slots 0xa08 and 0xa40.
"""
import traceback
try:
for frm, typ, fn, ent in xrefs_to(0x18011D780):
print("caller %s(%#x) via %#x %s" % (fn, ent, frm, typ))
ents = {ent for _f, _t, _n, ent in xrefs_to(0x18011D780) if ent}
for ent in ents:
src = dec(ent)
print("=" * 70)
print("FUN_%x len=%d" % (ent, len(src)))
print(src if len(src) < 7000 else src[:7000] + "\n...[cut]")
f = func(ent)
cands = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x1802891FF:
cands.add(t)
print("--- .rdata refs, checked for vtable shape ---")
for t in sorted(cands):
try:
v0, v1 = qword(t), qword(t + 8)
s90, s98 = qword(t + 0x490), qword(t + 0x498)
a08, a40 = qword(t + 0xA08), qword(t + 0xA40)
except Exception:
continue
ok = all(fm.getFunctionAt(addr(x)) is not None
for x in (v0, v1) if 0x180000000 <= x < 0x181000000)
print(" %#x v0=%s v1=%s | +0x490=%s +0x498=%s +0xa08=%s +0xa40=%s%s" %
(t, fname(v0), fname(v1), fname(s90), fname(s98),
fname(a08), fname(a40), " <== VTABLE?" if ok else ""))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q24: FUN_180119bd0 -- the cardtype-7 name resolver. This should BE the mapping.
The manager vtable was read out of the live process (read-only): DAT_1802e6398 ->
object -> vtable static 0x18021c2a0, with
+0x490 = FUN_18011a860 (cardtype switch 1,2,3,4,5,10 -- no club arm)
+0x498 = FUN_180119bd0 (called ONLY when +0x490 returned empty AND cardtype==7,
with args (cardsubtypeid, teamid, assetId))
+0xa08 = FUN_18011cca0 (file a parsed item)
+0xa38 = FUN_180113e40 (register trophy: (tournamentId, subtype, name))
+0xa40 = FUN_18011bf40 (lookup by resourceId)
Decompile 0x498, 0xa38, 0xa40 and 0xa08.
CONTROL: FUN_18011a860 must be the same function Q11 dumped (12905 chars) with the
cardtype switch; that is what makes the 0x498 fallback meaningful.
"""
import traceback
try:
for a, tag in [(0x180119BD0, "+0x498 club-item name resolver"),
(0x180113E40, "+0xa38 trophy register"),
(0x18011BF40, "+0xa40 lookup by resourceId"),
(0x18011CCA0, "+0xa08 file parsed item")]:
src = dec(a)
print("=" * 78)
print("%s FUN_%x len=%d" % (tag, a, len(src)))
print(src if len(src) < 14000 else src[:14000] + "\n...[cut, len above]")
print("=" * 78)
print("control: FUN_18011a860 len=%d" % len(dec(0x18011A860)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,53 @@
"""Q25: is there a cardtype-9 sibling of FUN_180119bd0 for balls and league logos?
FUN_180119bd0 settles cardtype 7: 9 -> "FUT_UC_KITS"+TeamName_Abbr15_<teamid>
10 -> "Stadium"+StadiumName_<assetId>
11 -> "Badge"+TeamName_Abbr15_<teamid>
That leaves 0x1e and 0x1f (the only other cardtype-9 subtypes besides trophies
0x91..0x96 and misc 0xe7..0xec) for ball and league logo.
Dump the loc-key string neighbourhood the resolver draws from (0x1801ec700..
0x1801ed400 holds 'Stadium'/'Ball' literals) with xrefs, and xref the exact literals
"Stadium", "Badge", "FUT_UC_KITS" to find any sibling resolver. A function that
references a ball or league-logo caption is the cardtype-9 equivalent.
CONTROL: the literals "Stadium" and "Badge" must show FUN_180119bd0 as an xref. If
they do not, I am looking at different copies of those strings.
"""
import traceback
try:
print("=== atoms 0xd1 and 0x19c (the two club-item wire strings) ===")
for a in (0xD1, 0x19C):
print(" %#x = %d" % (a, a))
print()
print("=== string dump 0x1801ec700..0x1801ed400 ===")
p = 0x1801EC700
while p < 0x1801ED400:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 120)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-46r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
print()
print("=== exact-literal xrefs ===")
for lit in (b"Stadium\x00", b"Badge\x00", b"FUT_UC_KITS\x00", b"Ball\x00",
b"BallName", b"LeagueLogo", b"leaguelogo", b"FUT_UC_"):
for h in find_all(lit):
s = rd_str(h, 80)
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(h)}))
print(" %-14r %#x %-30r <- %s" % (lit.rstrip(b"\x00").decode(), h, s, who or "-"))
except Exception:
traceback.print_exc()

Some files were not shown because too many files have changed in this diff Show More