Two client breakages in a row from detouring FUN_180033770 / sub_180033430 /
FUN_1800d73d0: the first cut froze FIFA at the "are both teams ready" prompt,
and the rate-limited rewrite CRASHED it at the same point. Rate limiting fixed
the I/O problem and the crash still happened, so the fault is the detours
themselves, not the logging.
Most likely cause: sub_180033430 is an address Ghidra never functionised, and at
least one of these is reached in a way a 14-byte inline patch cannot survive -
an interior branch target, or a callee taking stack arguments that the 4-register
wrapper silently drops when it tail-calls the original.
What the aborted runs did establish, and it is worth keeping:
- KIT_SCAN fires for cardtype 7 with subtype 9 selector 2 AND selector 3, so
BOTH the home and away club scans do run.
- The FUT club enumerate (teamId 130000) did NOT occur in the crashed run
before the kit screen, and KIT_DESC never fired at all.
- KITS_AVAILABLE remains 0.
- The "ret" value logged by kit_scan is the same constant for every call and
is not a usable item pointer, so that reading was wrong.
Next attempt must NOT patch this code path. Read the state from outside the
process instead - /proc/PID/mem plus objdump against the live client, which
cannot crash the game because it never writes to it.
The previous version of these three detours hung FIFA at the "are both teams
ready" prompt. FUN_180033770 is POLLED - about 150 calls alternating between the
two real match team ids - and the wrapper did a synchronous write_log on every
one. That is the whole cause; the detours themselves were sound.
Rebuilt so the hot path costs nothing:
- kit_enum returns immediately unless the team is the FUT club (130000), so
the polled case does no formatting and no I/O at all. When it is the FUT
club it reports the out-list length, i.e. how many kits were actually
offered - the number that decides whether the carousel has anything.
- kit_desc dedupes on the packed kit id, so a carousel that re-describes the
same kit logs it once. It decodes teamid/year/slot for comparison against
the active triple.
- kit_scan only reports cardtype 7 and dedupes on (subtype, selector).
Dedupe is a fixed 16-slot lock-free SeenSet - no allocation, no locks, safe to
consult from a polled game thread. A full set stops reporting rather than
evicting, because the point is a bounded log.
Rule this file broke once and must not break again: no trace may log per-call on
a polled function.
Motivation changed too. The kit selector is not cosmetic: it is what blocks
entering a match, which also explains why every match in the capture corpus is a
DNF with an unpopulated params object - entered and backed out of. A screenshot
shows the carousel with two tiles, one named HOME and one labelled "undefined",
both with untextured white shirts, which is exactly sub_180033430 writing NAME
on a match and nothing at all on a miss.
A live run refuted the model the existing traces were built on. KIT_SET never
fires, KIT_GET reports KITS_AVAILABLE=0, and FUN_1801c3480 is entered 810 times
without ever seeing a kit (772 players +0x60=1, 24 zeroed +0x60=4, 12 players
+0x60=6, 2 type-2 +0x60=4). So the DP command 0x7576 ->
FutSquadServiceImpl::setAvailableKits path is simply not the one in use.
The selector is fed by a provider CardsDLL registers into the FIFA engine -
singleton FUN_1800338f0, vtable 0x1801f1d68, slot +0x08 enumerate and +0x10
describe - and no trace covered it. Three passive detours added:
KIT_ENUM FUN_180033770 logs the teamId asked for; it answers only for the
FUT club 130000 and otherwise forwards to the engine
default, so a real team id here means our club items
were never in scope.
KIT_DESC sub_180033430 decodes the packed id into (teamid, year, slot). A
descriptor whose triple does not equal the active
home/away triple is left untouched, which is what
makes the engine substitute its own catalogue kit.
KIT_SCAN FUN_1800d73d0 the club scan behind getActiveKit: reports HIT with
the item fields, or MISS meaning the active triple
stays zero.
Prologues were dumped from the analysed Ghidra project (cardsdll.dll, base
0x180000000). Every copy length is instruction-aligned and none of the three
prologues is rip-relative, so the plain installer is correct for all of them -
unlike FUN_1801c3480, which needs the relocating installer for its
MOV RAX,[rip+...]. Note FUN_1800d73d0's prologue compares EDX against 2, so rdx
is the home/away selector (2/3), not the cardtype an earlier note assumed.
All three log then tail-call: behaviour is unchanged.
load() did:
read_to_string(&path).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
so ANY parse failure silently produced compiled defaults -- blaze_main 42130 and
account_sync 8099, both PRODUCTION -- with an empty game_profile, and the next
save() wrote that over the operator's real settings. The launcher then could not
start the game and was pointed at the live service.
Observed 2026-08-23 from nothing worse than a UTF-8 BOM: PowerShell 5.1's
Set-Content -Encoding UTF8 prepends EF BB BF and serde_json rejects it. A
staging config (42327/42330/8299) was destroyed and replaced with production
ports without a word.
Two changes:
- parse_json() strips a leading BOM, since Windows editors and PowerShell both
emit one. Split out from load() so it is testable without touching the real
config path.
- A file that EXISTS but does not parse is no longer treated like a missing one.
It is renamed to config.json.corrupt-<epoch> and the error is reported naming
the production risk, so defaults can never overwrite a recoverable config.
A missing file still yields defaults: that is genuine first-run.
Tests cover the exact incident (BOM-prefixed config keeps 42327/42330/8299 and
does NOT fall back to 42130/8099) with a precondition asserting raw serde_json
really does reject the BOM, so the guard cannot rot into a tautology.
FIFA 17's ProtoSSL verifies the roster certificate by dNSName only, so an
IP-addressed roster host is refused even with the IP in the SANs. The hostname
therefore has to survive into SNI while the connection lands on our server.
The connect/WSAConnect/ConnectEx detour already intercepted the dial; it just
did not rewrite it, because 8081 was absent from the EA port table. Adding
ea_ports::FIFA17_ROSTER plus an OpenFutPorts.roster destination makes the
existing, proven redirect handle it with no new hook surface, and removes the
need for any client-side DNS change.
to_cfg_string writes roster_port ONLY when it differs from the default: the
parser rejects unknown keys, so emitting it unconditionally would make an
already-deployed older hook reject the whole config and install no redirect at
all -- breaking the game instead of degrading.
Entering single-player Seasons showed "There was a problem communicating with
the FIFA Ultimate Team Servers". The deployed trace caught the whole chain:
SEASON_CALL: LoadOfflineSeasons_asyncimpl(0x57560)
SEASONS_WEBFILE_URL: url="packs/loc/storepackdescriptions.en_us.xml"
SEASONS_STAGE1: status(+0x1c)=999 -> CACHE_PACKNAMES_FAILED
SEASONS_LOAD_CALLBACK: final kind=ERROR result="CACHE_PACKNAMES_FAILED"
Not a server fault: no /season/* request is ever made. The client's
RS4::ServerSettings CDN base is EMPTY in the emulator, so the pack-names web
file is requested as a BARE relative path and 999s, and Seasons aborts on that
prerequisite.
Ports the base-supply rewriter from wip/seasons/base-supply-veh onto the
deployed lineage (that branch forked before the TLS work and cannot be rebased),
taking only the URL supply: absolute urls pass through untouched, and the
success-forcing CACHE_PACKNAMES bypass is deliberately NOT taken — masking the
failure would hide whether the supply actually worked.
Corrects the port while porting: the branch hardcoded 8110, where nothing
listens. The content server is POW (`pow_server.py`, kind "content") on 8085 —
the port Blaze already advertises to the client as its content host. Verified
live: GET http://10.10.0.120:8085/fut/packs/loc/storepackdescriptions.en_us.xml
returns 200 with a 180-byte XLIFF document. `default_ports::FUT_CONTENT` is now
8085 and the base is still derived from openfut.cfg, so no address is compiled
in (confirmed absent from the artifact).
Artifact keeps the roster TLS gate patch (11 fifa17_tls markers) and the kit
trace alongside the new base supply.
Cherry-pick of 4b1d5aa from wip/kit-selector-re, which forked before the TLS
work and cannot be rebased: that branch predates fifa17_tls.rs/patch_mem.rs and
carries a large unrelated lineage (probe/lsx/recv_hook). Only the kit_trace
commit's own contents are taken.
Traces the client-side FUT pre-match kit path in CardsDLL: the GetMatchKits_DP
gate (KITS_AVAILABLE), setAvailableKits (home/away list count), the kit-item
clone driver (item type/subid/teamid at FUN_1801c3480), and the local teamkits
DB clone. Read-only passive detours reusing season_trace's installers, which
this widens to pub(crate).
The one open unknown it answers: the selector requires item+0x60 == 4, a pile
value the server has never been observed to produce (/club emits 1,
/purchased 6).
Verified in the cross-built artifact that the roster TLS gate patch is intact
alongside the new trace (fifa17_tls markers present, KIT_* markers present).
Milestone B: move FIFA17 ProtoSSL certificate compatibility into version.dll so
the client-local contract is openfut.cfg + LSX + version.dll with no external
/proc-writing patcher. The proven external openfut-autopatch remains the oracle
and is NOT removed; this reaches behavioral parity for the fail-closed patches.
Patch set (ASLR-relocated at runtime; fail-closed byte-verified; one-shot):
- FIFA17.exe ProtoSSL cert gates (REQUIRED_FOR_TLS), preferred base 0x140000000:
GATE1 rva 0x6132548 0f85 76010000 (JNZ) -> 90*6 (NOP)
GATE2 rva 0x61361b0 48 89 5c (prologue) -> 31 c0 c3 (xor eax,eax; ret)
Applied as a pair only when BOTH read their known original, exactly like the
external patcher's cert_pass; polled until the STEAMPUNKS packer unpacks them.
- CardsDLL empty-My-Packs store crash-guard (REQUIRED_FOR_STORE_TLS, bug 6c),
preferred base 0x180000000: rva 0x14858 75 0f (JNZ) -> 7f 0f (JG). Applied once
CardsDLL maps (module-late).
Deliberately NOT ported: the external patcher's 8 unconditional STORE_PATCHES.
They carry no recovered original bytes (cannot be fail-closed) and are re-applied
every tick (would require the constant-rewrite loop this milestone forbids); the
external source records no rationale for them. Documented in the Vault ADR.
Architecture:
- patch_mem.rs: generic fail-closed primitive over a Mem trait — classify
(ORIGINAL/ALREADY_PATCHED/MISMATCH), apply_checked (read->classify->write only on
ORIGINAL->reread verify), VirtualQuery-guarded read + VirtualProtect/Flush write
(WinMem). Trait abstraction makes every outcome host-testable without FIFA.
- fifa17_tls.rs: FIFA17-specific patch table + bounded poll worker (250ms, 15min
cap, no busy-spin) started from fifa17::install() after the network redirect.
Never patches an absolute address; never blind-writes on mismatch; a write/verify
failure is reported, never pretended.
Phase 11: removed the season_trace CACHE_PACKNAMES_FAILED->SUCCESS force-success
bypass (a staging-only behavior-changer that was armed unconditionally in the
candidate); season_trace is now genuinely read-only passive tracing. sbc_dispatch
and store_entry remain the intended REPAIR_PROMOTED fixes.
Tests: 39 hook tests (26 baseline + 13 new: classify states, apply/idempotence,
no-blind-write on mismatch, unreadable-module wait, write-failure reporting, RVA/
live-addr relocation across bases, cert-gate pairing, patch-table integrity).
clippy --features fifa17 -D warnings clean; fmt clean; x86_64-pc-windows-gnu
cross-build. No network-config authority added (routing stays Milestone A).
Runtime validation (x64dbg site check + Windows/Linux retail) still outstanding.
FIFA 23 is not in development and was never a valid template for FIFA 17
(different game, different in-memory layout). Remove it as a build target and
as scaffolding, while preserving the per-game feature architecture so future
games plug in as new modules — never by copying retired reverse-engineering.
Hook (openfut-hook):
- Delete install_hooks_fifa23 and every FIFA23-only module: config, hooks,
transport_watch, ssl_patch, origin_spy, tls_bypass, dial_notification, probe
(+ probe feature), recv_hook (+ capture_baseline feature), plus the orphan
FIFA23 LSX/Origin files lsx.rs and ea_stub.rs. ~3.6k lines; git + Vault retain
the research.
- lib.rs is now game-generic: a per-game feature selects that game's module and
install_hooks dispatches to it. No game feature => compile_error!("select a
game, e.g. --features fifa17"). --features fifa17 remains the build invariant.
- Drop the crate-wide blanket (it existed only
to hide the compiled-but-unused FIFA23 modules). Replace with narrow, justified
#[allow(dead_code)] on the three FIFA17 SBC RE-scaffolding items it was masking,
so the candidate stays behavior-identical.
- connect_hook: the redirect is now always the config-driven path (openfut-common
target from openfut.cfg); the hardcoded-loopback rewrite and its dead consts are
gone. Removed the FIFA23-era transport_watch diagnostics from the shared
connect/WSAConnect/ConnectEx detours. Deleted unused iat::patch_iat_in.
Launcher:
- fifa_game_dir no longer defaults to a hardcoded '.../FIFA 23' Steam path; it is
empty by default, matching the launcher's own rule that it never invents a path
to somebody's game install (like openfut_server_host and game_profile).
- Generalise the remaining 'FIFA 23' doc literals in config.rs / setup.rs.
Proof: fifa17 clippy -D warnings clean; no-game build fails with the documented
compile_error; launcher 75 tests pass unchanged; launcher + hook cross-build
x86_64-pc-windows-gnu; cargo fmt --check clean; zero FIFA23 symbols/literals
remain. FIFA17 armed-module set unchanged (redirect + SBC/store/season).
Behavior-preserving cargo fmt of two FIFA17 diagnostic modules that carried
long unwrapped macro-invocation / argument lines predating this work. Split
out of the FIFA23-retirement commit to keep that focused. No logic change.
The fifa17 hook path installed no connection redirect (only a module-map dump),
so FIFA17 relied entirely on Linux iptables DNAT / hosts (and had no Windows
equivalent). Add an in-process, config-driven redirect for the fifa17 build:
- openfut-common gains ResolvedServer::redirect_for_ea_port(): the single shared
decision (EA source-port signature -> configured OpenFUT host+port, network
byte order), reused by the hook so it and openfut.cfg agree by construction.
Covers 443->https, 10041/42230->blaze_redirector, 42127->blaze_main.
- connect_hook: redirect_if_ea now dispatches to a config-driven rewrite when
armed (rewrites to the CONFIGURED, possibly remote, server -- not hardcoded
127.0.0.1), matching by EA source port so a hardcoded EA IP (159.153.51.20
redirector) and a DNS-resolved one both land on the server. Legacy loopback
path retained only for the not-yet-retired FIFA23 build.
- fifa17::install() reads openfut.cfg next to FIFA17.exe via openfut-common and
installs connect + WSAConnect (IAT) + ConnectEx (shared redirect). Fail-safe:
missing/invalid config installs NO redirect (traffic untouched), never a
corrupt sockaddr.
Tests: openfut-common redirect map/sockaddr/endian/remote-host/unknown-port.
Cross-builds x86_64-pc-windows-gnu --features fifa17; clippy -D warnings clean.
No Linux fallback removed (migration gate).
The prior Windows branch treated BOTH companions as in-process and started
neither. That is wrong for LSX: FIFA dials the Origin/LSX emulator on
127.0.0.1:4216 and it must run locally on the client (the STEAMPUNKS
stp-origin_emu.dll is the crack's activation emu, not OpenFUT's LSX). Only
autopatch is genuinely in-process on Windows (its ProtoSSL cert patch is done by
the version.dll hook), so skip just that one and spawn LSX through the normal
path. Also resolve the companion as openfut-lsx.exe on Windows.
The 60fps cap still left 16ms gaps with no present; on windowed G-Sync/FreeSync
DWM keeps moving the window in and out of the VRR path across those gaps and the
refresh rate swings, which the panel shows as flicker. Render continuously
(request_repaint every frame) with vsync on so the window stays continuously in
VRR at the display's own variable refresh.
The idle repaint was 500ms (~2fps), below the G-Sync/FreeSync VRR floor, so the
panel ran low-framerate compensation and every hover/animation spiked then
dropped the rate — the swinging refresh rate makes VRR displays flicker. Present
at a constant ~60fps (16ms) instead so VRR locks to one rate. Cheap for a UI
this small; vsync keeps present times regular.
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:
- game_launch: cfg(windows) launch spawns the game executable directly with its
working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
Wine prefix, or licence regen). Requires the launcher to run elevated so the
child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
version.dll hook), so ensure_running reports ready without spawning. Gated the
unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.
theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.
Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
- Passive: log FUN_18017ff90 param_1 = the pack-names/cards-tournament-list
WEBFILE_DL url (via relocating installer; rip-relative MOV R8,[DAT_1802e6580]).
- Guarded one-shot (staging client only): in the final completion FUN_1800ffe90,
when the delivered result string is CACHE_PACKNAMES_FAILED, rewrite result byte0
so it delivers SUCCESS -> LoadSeasons_Complete advances to LoadCurrentOfflineSeason.
String-verified, once per process.
Adds passive field-logging detours on the FutCompetitionServiceImpl::LoadOfflineSeasons
async chain resolved by static RE:
final completion FUN_1800ffe90 -> logs the exact status string delivered to the
AS LoadSeasons_Complete callback ("SUCCESS" vs error string at result+8);
stage-1 completion FUN_180106240 -> logs whether the first async stage's
status (+0x1c) is ok or CACHE_PACKNAMES_FAILED.
Read-only; safe bounded C-string reader (rd_cstr).
Adds openfut-hook/src/season_trace.rs: read-only CardsDLL detours that log the
FIFA17 FUT offline-season entry native call sequence (no behavior change; each
wrapper logs then calls the original via a trampoline). Traces the FUT_Season
natives proven by the registration table FUN_18004e3f0:
GetUsersOfflineDivision 0x4eb50 (NOT LoadOfflineSeasons),
LoadOfflineSeasons 0x4ee10 + async impl 0x57560,
LoadCurrentOfflineSeason 0x4eb70 + impl 0x57230 + completion 0x578e0,
StartSeason 0x4f340, GetOfflineSeasonInfo 0x4e850.
Includes a near-trampoline installer (install_detour_reloc) that relocates a
single rip-relative disp32 so functions with rip-relative prologues can be
detoured (trampoline allocated within +/-1.5GiB of CardsDLL).
The rebind approach was disproven live: the bind sensor measured mask=0x00 at
screen-show (container empty, all six slots hidden -> no tab bar), and a rebind
after the groups arrived (mask=0x0e = bronze|silver|gold) built NO tab bar. The
Scaleform movie only honours the framework's OWN bind at screen-show, not a later
re-publish/commit.
Root cause therefore stands confirmed: the store's GET store/purchasegroup/all
returns only after screen-show, so the first bind sees an empty container. Re-entry
works because the groups are cached by then.
Fix: load the purchase groups BEFORE the store screen is shown. FUN_180017870
(storefront) issues the store's own group request; firing it from the FUT hub event
pump (a real game thread, before the store screen exists) lets the response arrive
and populate the container so the first screen-show bind sees a full list and binds
the tabs natively -- the re-entry path, on first entry.
The bind detour is retained purely as the read-only SENSOR: the first-entry bind
mask is the definitive measurement of whether the pre-warm landed in time. mask!=0
=> pre-warm worked and the tabs bind natively; mask==0 with storefront_seen!=0 in
the pre-warm log => a hub-time request cannot land in time and the remaining route
is the extracted StoreFront.apt.
Removed: render detour, maybe_rebind/should_rebind, and all rebind state. Re-added
the hub-time maybe_prewarm_groups() call in sbc_dispatch::event_wrapper.
Promoted (build-armed). Deployed artifact 668e9324; profile-gated fifa17 build.
Replaces three disproven store-entry mechanisms (category clamp, late
*_CATEGORY_ID publish, purchase-group pre-warm) with the one repair the
reversing actually supports.
FUN_18007e5e0(ctx, panel) is the native tab binder the screen framework
invokes at screen-show. It is an unrolled six-slot loop; each slot gates on
one hard-coded category token and either publishes that group's id as
PANEL_ID for the slot or hides the slot:
slot 0 mypacks, 1 bronze, 2 silver, 3 gold, 4 special,
slot 5 points (extra gate: (*(store_vtbl+0x30))(store) must be false)
The gate FUN_180014df0(_, idx) resolves the token through FUN_180014380,
which linearly scans the loaded purchase groups (stride 0x108) comparing the
token at group+0x70. So a tab exists iff a purchase group carrying that
token is loaded AT BIND TIME. Our server emits mypacks/bronze/silver/gold
as displayGroup.value, so four tabs are expected.
On a cold session the store screen shows before its own
GET store/purchasegroup/all response arrives: every gate fails, all six
slots take the hide path, and the binder is never invoked again for that
screen. Re-entry works only because the groups are cached by then -- which
is exactly the reported symptom.
The repair re-invokes the binder once, with the framework's own (ctx, panel),
at the first render after the groups arrive, reproducing the re-entry
ordering on the first entry. Repeating the binder is safe: it only publishes
PANEL_ID or hides per slot, reads the group list from a process singleton,
and finishes by tail-calling panel->vtbl[0xd0](panel, true) -- the provider
commit that rebuilds the movie's bar.
Fail-closed: rebind only when the framework's bind observed an EMPTY mask and
at least one token now resolves (a store that already bound tabs is never
touched); one rebind per bind generation, claimed by compare-exchange; only
framework-supplied pointers are ever used; image plus all three function
signatures verified before any write and re-verified under thread suspension.
The gate probe passes a null this, which is sound because FUN_180014df0
forwards rcx to FUN_180014380, which discards it and uses a singleton.
Why the earlier attempts could not work: the clamp forced a single category
(regressing Browse Packs to bronze-only), the publish targeted FUN_18007df60
which does not bind panels, and the pre-warm ran from the FUT event
dispatcher -- after screen-show, so the slot decisions were already made.
Promoted (build-armed, no env var). Rollback is a version.dll file swap.
Fixes the ordering instead of fighting the movie. The tab bar is bound by
FUN_18007e5e0 (six caption tests -> PANEL_ID, else hide panel), which is
slot 0 of a secondary vtable invoked by the screen framework at
screen-show. On a cold session the /store/purchasegroup groups have not
arrived by then, so all six panels hide and no tab bar is drawn. Late
publishing does not fix it: deploying the 0x278a publish at render time
fired with its gate accepting (log: tabpublish=1 state=0x418) and the bar
still did not appear, i.e. the movie ignores late tab updates.
So load the groups BEFORE the store is ever opened. The store screen
issues its own pack-list request at 0x18007f25e as
FUN_180017870(*(base+0x2de0d0)) -- a single-argument call on the
storefront global. Issue exactly that call once per process from the FUT
event dispatcher, which already runs on a game thread long before the
store screen exists. When the user then opens the store, the native
screen-show bind sees a populated group list and binds the tabs itself --
the same reason a second entry has always worked.
Fail-closed: base + CardsDLL image validated, storefront read guarded,
FUN_180017870 fingerprinted before the first call, one request per
process claimed before issuing (no re-entrant double request), and
skipped entirely once groups exist. Logs prewarm= for evidence.
fmt/clippy -D warnings clean, 32 hook tests pass.
The category clamp fixed WHICH content the first store render draws, but
the tab bar was still missing on first entry (operator-observed: first
open = bronze packs with no tab bar; re-entry = same packs WITH
Bronze/Silver/Gold tabs).
Root cause: the store screen dispatcher 0x18007d880 publishes the tab bar
on a DIFFERENT event than it renders. Event 0x278a -> FUN_18007df60
resolves the six hardcoded tab tokens against the loaded purchase groups
and publishes *_CATEGORY_ID; event 0x753f -> FUN_18007dab0 renders. On
first entry the publish runs before /store/purchasegroup has landed, so
all six tokens resolve -1, every panel hides, and no tab bar is drawn;
re-entry only works because the groups are cached by then.
Re-run the native publish once per screen from the render detour, where
the groups are provably present (the ordinal-1 lookup already proves it),
reproducing the working re-entry order (publish, then render). Safe: it
is the same call the dispatcher makes with the same single argument, it
self-gates on screen+0x2cc == 0x418 (a mismatch is a native no-op, not a
fault), it is fingerprinted before the first call, and it runs at most
once per screen instance. Also logs the gate state so a no-op publish is
diagnosable. fmt/clippy -D warnings clean, 29 hook tests pass.
The FUT store flashes a Browse-Packs overview on first open: the store
screen ctor leaves screen+0x290 (CATEGORY_ID) at 0, and the resolver
FUN_1800147f0 treats 0 as list-all, so the first render draws the group
overview before the movie posts a tab ordinal.
Detour the store render FUN_18007dab0 (RVA 0x7dab0): when the incoming
category is 0, substitute the first present group ordinal (1) so the
first frame lands on a real tab. Provably crash-safe: it writes 1 only
after FUN_180014420(_, 1) (the resolver's own ordinal->group lookup,
whose first arg is dead) returns non-NULL, which is exactly the
resolver's non-crash precondition; the positive-invalid NULL deref at
0x14882 is thus unreachable. No group yet -> category left 0 -> Browse,
still safe.
Promoted like the SBC dispatch: build-armed (CLAMP_PROMOTED), no env.
Signature-gated on both the detoured render and the called lookup,
image-validated, installed under thread suspension, fail-closed. Only
the overview flash is addressed; the empty-My-Packs entry dialog is
movie-side (packed .apt) and out of CardsDLL reach (see Vault
Store Resolver Guard 2026-08-19). fmt/clippy -D warnings clean both
feature sets, 26 hook tests pass, x86_64-pc-windows-gnu release builds.
The dashboard named OpenFUT internals at a player: "Client integration", "Local
services", "Hook DLL", and a "Deployed -> 10.10.0.120" value that conflates a DLL
with a server address. None of it tells someone who just wants to play whether they
can press Play.
Rows are now Game files / Background helpers / Game patch, and the patch row states
Installed rather than echoing the host it will point FIFA at.
The important fix is the helper state. Two of the three cases returned labels that
sound like faults for what is the NORMAL idle condition - the helpers only run
alongside a session, and Launch starts whatever is missing - with "Partly running"
being the worst: it reads broken and offers nothing to act on. Both collapse to
"Start with the game", which says what will happen. Observed live: the dashboard
showed "Partly running" while genuinely healthy, and pressing Launch brought
autopatch up on its own.
No behaviour change: readiness values are untouched, so the overall verdict pill and
the launch gating are identical. fmt, clippy -D warnings, 75 tests clean.
Wine ignores the game-directory version.dll proxy unless an override names it.
WINEDLLOVERRIDES covers only a process the launcher spawns itself, so the documented
fallback was to have the user paste Steam launch options by hand - a step a normal
player cannot be expected to perform, and the reason the game had to be started
through a specific wrapper at all.
The launcher now persists version=native,builtin into the prefix registry via Wine
own reg tool before launching (ensure_dll_override). It is /f-idempotent, so it runs
on every launch and repairs a prefix the player has reset or replaced, and it applies
to EVERY launch path including Steam Play. This mirrors what BepInEx documents for
Proton (configure the proxy in winecfg rather than the environment) and what Proton
already does in this prefix for other titles. Best-effort: a failure is reported in
plain language and the launch still carries WINEDLLOVERRIDES.
STEAM_LAUNCH_OPTIONS is demoted to a fallback for prefixes we have never prepared.
Also fixes a pre-existing clippy manual_is_multiple_of in app.rs that was failing the
strict lint gate. fmt clean, clippy -D warnings clean, 75 tests pass.
Retail Gates A-G passed on the pinned CardsDLL build (4706a881), and the repair
has been live-proven repeatedly, so it is now a promoted feature. Arming it from
OPENFUT_SBC_DISPATCH meant any launch that did not export it (Steam, the launcher
Launch button, a bare umu-run) silently lost the SBC screen to the known
response-to-deserializer dispatch defect, leaving a harness script as the only
working entry point.
REPAIR_PROMOTED is now a build constant with a compile-time contract, and both
install sites derive from it: sbc_dispatch::install always arms, and
sbc_trace::install derives the parser/notifier/controller-registration traces from
it because those traces ARE the repair decision inputs, not optional diagnostics.
Promotion weakens no check. Safety stays in the runtime evidence gate rather than a
flag: the worker still validates the exact CardsDLL signatures before installing a
detour, and decide() still requires the transport sentinel status, the pinned
category-response vtable captured while the response object was provably live,
balanced parser counts on the one parser thread, this generation notifier having
entered AND returned, the captured controller/model identity, and one repair per
deserializer generation. An unrecognised build leaves native execution untouched.
Rollback is a file swap (restore the previous version.dll via the hook harness
backup), the documented client rollback path, deliberately not an env kill-switch.
fmt clean, strict clippy clean on default and fifa17 features, 22 tests pass,
release cross-build to x86_64-pc-windows-gnu produces artifact 3641d581.
The launcher shelled out to `python3 lsx_responder_v2.py` and `python3 autopatch.py`
from a configured tools directory. Both are now Rust binaries built from this
workspace (openfut-lsx, openfut-autopatch), so the launch contract loses the
interpreter and the script directory entirely: nothing to locate, nothing to
configure, and no way to run a stale checkout's copy of a responder.
Service::script() becomes Service::binary(), and resolve_binary() prefers a sibling
of the running launcher -- what a workspace build and any sane install layout both
produce -- falling back to the bare name so a PATH install still works. It returns the
bare name rather than failing so that spawn() stays the single place a missing binary
is reported, instead of two error paths for one condition.
foreign_pid() now matches an argv entry's FILE NAME rather than a suffix, so
`/path/to/openfut-lsx` matches while an unrelated argument that merely ends with the
same text does not. It deliberately still reads argv and not comm: comm is truncated
to 15 characters by the kernel, which would misreport both of these names -- the same
trap that made an earlier `pgrep -f` guard match its own shell.
Dead configuration removed rather than left vestigial: fifa17_python and
fifa17_tools_dir, their Settings controls, and validate_local_services(), whose only
two checks were those fields. A validation hook that can only return Ok(()) would
claim the launcher verifies local-service configuration when there is none. The
preflight tools-dir gate is gone too, while the ptrace_scope check it gated is kept --
that check is real and repairable via "Arm client"; only the gate died.
The env contract is unchanged, so the binaries are drop-in: LSX still receives
FUT_PERSONA_ID/FUT_PERSONA_NAME (the persona has to agree with Blaze's
LoginResponse.SESS.PDTL and UTAS's userInfo.personaId), autopatch still receives
OPENFUT_AUTOPATCH_LOG under XDG_RUNTIME_DIR and --launcher-pid so it cannot outlive
its owner, and each companion still gets its own process group.
74 tests green.
The hook ships as a `version.dll` proxy in the game directory, and Proton prefers a
local DLL over its builtin ONLY when WINEDLLOVERRIDES names it -- exactly what
setup::STEAM_LAUNCH_OPTIONS documents ("version=n,b"). Steam users get it from their
launch options. When the launcher spawns the runner itself it applied profile.env and
WINEPREFIX but never the override, so the hook silently did not load.
The failure mode is worse than "hook missing", which is why it went unnoticed: with
no hook there is no port rewrite, so the openfut.cfg the launcher writes two seconds
earlier is inert and the game falls back to EA's real Blaze ports, where /etc/hosts
(10.10.0.120 easw.easports.com) quietly routes it to whatever answers there. The
launch looks completely healthy -- correct log lines, game boots, FUT loads -- while
talking to a different server than the one configured. Production only works here by
accident of the hosts file.
Observed end-to-end: openfut.cfg written 19:50:00 with blaze 42327/42330, FIFA started
19:50:03, and the process was ESTAB to 10.10.0.120:42130 -- production.
/proc/<pid>/maps showed the mapped version.dll was Proton's own
(compatibilitytools.d/UMU-Proton-10.0-4/files/lib/wine/x86_64-windows/version.dll),
not the game-dir hook, and no hook log existed for the Proton prefix at all.
launch() now always sets WINEDLLOVERRIDES, appending to any profile value and
deferring to a profile that pins `version=` itself, so an operator disabling the
hijack on purpose is not silently overruled. Four tests cover absent, unrelated,
explicit and blank-string cases.
Also worth noting for future debugging: ~/.wine/drive_c/openfut_hook.log is stale and
belongs to a non-Proton prefix. It is not evidence about a umu/Proton launch, and
reading it as current is how this was nearly misdiagnosed.
The launcher used to make the user perform OpenFUT's internal launch order by
hand — Start LSX, Start autopatch, Run pre-launch checks, "Arm client", then a
button called *Start Services & Launch Game*. Those are implementation details
of how FIFA 17 is persuaded to talk to OpenFUT, and getting the order wrong
produced failures that surfaced much later as "the game crashed": autopatch
started before ptrace_scope is 0 silently patches nothing at all.
The normal flow is now: open the launcher, read one status card, press
**Launch FIFA 17**.
New `launch` module holds the sequence as a state machine (Phase: Idle,
Checking, PreparingClient, StartingServices, Validating, Launching, Running,
Failed) and runs it on a worker thread, so the UI thread never blocks on a
socket, a Polkit prompt or a process spawn. The UI renders that state; it does
not coordinate services.
Every step asks what is already true before acting:
- a healthy service is reused, never restarted;
- client preparation is skipped when the checks it would repair already pass,
which also avoids a pointless password prompt;
- the hook config is reconciled from the current settings.
It stops at the first failed step and never starts FIFA into a client it knows
is broken. Preparation deliberately runs BEFORE autopatch, against the order in
the brief, because autopatch cannot write FIFA's memory until arming has set
ptrace_scope and would otherwise "succeed" while doing nothing.
Ownership is now tracked, which the old model could not express: it only knew
about children it had spawned, so a service started by hand for a debugging
session read as "stopped" and starting it again just collided on the port.
`ServiceSupervisor` observes our own child first, then scans /proc for a foreign
instance, and reports `ServiceRuntime { running, started_by_launcher, pid,
detail }`. `stop_permitted` refuses to kill anything the launcher did not start,
under any cleanup policy. `CleanupPolicy` states the shipped behaviour — leave
launcher-started services running for the next launch — instead of leaving it to
chance, and the FIFA-exit path goes through it.
Readiness comes from observation, never from a button press: LSX is ready only
when the port FIFA dials is actually held, and "we have not looked" renders as
"Not checked yet", never as green.
Manual controls all survive under **Advanced / Diagnostics** — per-service
start/stop/restart with PIDs and ownership, "Prepare client" (the old "Arm
client", renamed; internals still say arm), "Run pre-launch checks", "View
logs", and a new "Launch game only" escape hatch for debugging a launch the
sequence refuses.
Tests: 73 pass (15 new). Sequencing and ownership are unit-tested through a
`LaunchOps` fake, so "don't launch after a failed step", "don't restart healthy
services" and "don't kill what we didn't start" hold without a FIFA install, a
Polkit agent or root.
Exercised live under Xvfb: the card shows four observed rows and one button; a
launch stopped at LSX with "127.0.0.1:4216 is held by an unrelated process",
listed every step's verdict, and did NOT start the game; Advanced showed a real
pre-existing autopatch as "Running (foreign) · pid 382382 · started outside this
launcher" with Stop/Restart disabled.
It counted Pass/Warn/Fail across the whole preflight run, and `backend_reachable`
opens real sockets — so the aggregate silently asserted that the machine running
the suite has the OpenFUT blaze-redirector and account ports open. True on the
server host, false everywhere else, including the game machine where anyone
building the launcher would run it. Predates this branch; found by running the
suite on .105 instead of only here. Now asserts the hostname check itself, which
is what the test is named for.
The new hook-config check warns when the deployed openfut.cfg disagrees with
the configured server, and this test counts warnings across every check. On the
game machine — which by definition has a hook deployed — that second warning
broke the assertion. Caught by running the suite on .105 rather than only on
the server host. Pins the game dir for the same reason the tools dir is pinned.
Release-readiness pass on the launcher, driven by the end state "open it,
create an account, launch the game".
Fixes a silent correctness bug. `openfut.cfg` in the game dir is the only
server address the *game* can see, but it was written only by Setup's deploy
and its "Save & Update hook" button. Changing the server anywhere else left
FIFA connecting to the previous host while every panel in the launcher showed
the new one online. Now:
- `write_hook_config` reconciles the file from the live config, and runs
fail-closed before every launch, so the file and the UI cannot disagree at
the moment it matters;
- saving Settings pushes the address into the hook immediately;
- a `hook_config` preflight check reads the file back and warns, naming both
addresses, instead of leaving the drift invisible;
- Settings shows the same fact inline, and Save is enabled by drift alone —
a message saying "Save to update it" beside a disabled button is a dead end.
Account creation is now server-authoritative. `account_sync::discover` POSTs
`/openfut/account/sync` with the persona fields *omitted*, which makes the host
answer with the persona it was started with, its club, and the Core coin
balance. The launcher adopts that answer, so it never invents an identity and
the persona the game authenticates with is by construction the one the server
expects. Claiming is gated on the address being valid, NOT on the health pill:
that pill probes the HTTPS port while this talks to the account port, so gating
on it disabled the button on servers that answer it perfectly well.
UX consolidation:
- new Welcome ("Get started") tab: three numbered steps — connect, claim an
account, connect FIFA — each showing live state, ending in the launch CTA;
a fresh install opens on it and it leaves the nav rail once satisfied;
- Config renamed Settings, and made the single owner of the server address:
Setup's duplicate editors (same fields, different save semantics) are now a
read-only summary with actions;
- the dashboard offers account creation in place instead of naming a tab, and
the stale "set the host in the Setup tab" pointers are corrected.
Locks move to parking_lot per project rule (already the convention in
openfut-utas-host and openfut-identity); 47 poisoning unwraps go away.
Verified: 58 tests pass, fmt clean, clippy clean apart from one pre-existing
lint. Driven through the real UI under Xvfb as a fresh install — typed a server,
clicked Create my account, and the config on disk came back with persona
33068179/CAGE claimed from the live host; clicking Save rewrote a stale
`openfut.cfg` from host=10.10.0.99 to host=127.0.0.1.
Modernize manual nul-terminated byte strings to C-string literals (c"...")
at all Win32 GetModuleHandleA/GetProcAddress/getaddrinfo call sites (byte-identical),
drop two redundant SOL_SOCKET-as-i32 casts, remove a needless return in the fifa17
install path, and add a # Safety section to DllMain. Scope the FIFA-23-path
dead-code/unused-import lints (unused only under the fifa17 feature, stripped by the
linker) with a documented crate-level cfg_attr allow. Cross-verified: both the
default and fifa17 builds now pass clippy -D warnings and compile; probe and
capture_baseline still build.
openfut-hook is a Windows version.dll proxy injected into the FIFA client, but
as a member of the parent OpenFUT workspace its [profile.release] was silently
ignored (cargo only honors profiles at the workspace root, and forbids per-package
`panic` overrides). The shipped DLL was therefore built opt-level=3 /
strip=debuginfo / panic=UNWIND -- and unwinding a Rust panic across the
DllMain/FFI boundary into the game process is UB.
Add an empty [workspace] table so the crate is its own root and its release
profile (panic=abort, strip=symbols, opt-level=s) applies. Paired with the
parent workspace `exclude`. Also lands the artifact in openfut-hook/target/
(matching the launcher config.rs default hook_dll_path) instead of the parent
target/. Cross-build verified: panic=abort now emitted; DLL 1200126 -> 861696 B.
Reconcile the two divergent launcher lineages that share merge base 87241ac:
- feat/launcher-arming (13339c1): one-click client arming, modular
preflight/services, and the FIFA 17 verified patched-client capability
reporting (fifa17_capability + local_services stdout parsing + backend
registration).
- feat/sbc-hook-tracing (958ff24): openfut-hook SBC request tracing / RE
instrumentation (sbc_hook, sbc_trace, probe, transport_watch, ...).
The lineages are almost disjoint (launcher crate vs openfut-hook crate); the
only overlap was src/process.rs, which launcher-arming removed (functionality
moved into local_services/game_launch, `mod process` dropped from main.rs) and
sbc-hook-tracing incidentally tidied (map_err->inspect_err). Resolved by keeping
the file DELETED: it is an orphan module in the refactored launcher and is not
part of the SBC feature (which lives entirely in openfut-hook). Both features are
retained in full.
Launcher side of the verified patched-client capability handshake. When
autopatch proves the CardsDLL empty-My-Packs resolver guard is active for the
CURRENT FIFA process, the launcher advertises that to the backend so the backend
may drop the synthetic 65534 sentinel for that session only. Additive and
fail-closed: any parse/registration failure leaves the backend on its default
sentinel path.
- New src/fifa17_capability.rs:
* Fifa17ClientCapabilities { empty_mypacks_resolver: Option<u32> } — per-FIFA-
process state, UNKNOWN at each launch, discarded when that process ends
(never persisted, so a prior launch's capability cannot leak).
* parse_capability_line() / parse_fifa_pid() — pure parsers for autopatch's
stdout token `[store-guard] verified capability fifa17.empty_mypacks_resolver=<v>
fifa_pid=<pid>`; the non-advertising `guard status=...` line yields None.
* register() — tiny stdlib-HTTP POST /openfut/fifa17/capability, modeled on
account_sync::sync (Connection: close, 3s timeouts, 2xx check).
- local_services::spawn: autopatch stdout reader parses each raw line; on the
first verified line it sets the shared capability sink, logs, and fires exactly
one backend register() for this FIFA process. Capability wiring is bundled in a
CapabilityWiring struct (Some for autopatch, None for LSX). LSX unchanged.
- app.rs: LauncherApp holds the shared Fifa17ClientCapabilities; it is reset to
UNKNOWN at the start of launch_game (and when autopatch is stopped) so a new
FIFA process never inherits a previous launch's capability.
- Tests: parse (verified/non-advertising/unrelated/version-2) + a register()
round-trip against an in-process listener.
Design + contract: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (superproject).
Pre-existing openfut-hook/* working-tree changes are intentionally left uncommitted.
Add a GUI "Arm client" button that reproduces client_arm.sh in a single
pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector
IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites
for every dead EA hostname (removing foreign shadow lines first, so glibc's
first-match resolution can't land on a stale loopback entry). All steps are
idempotent (delete-then-add) and injection-safe: config values are charset-
validated and rejected on a surprising character, never shell-escaped. arm()
returns the concrete change list, which the button logs line-by-line and
echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no
reuse of the local-services toast).
This necessarily lands the surrounding launcher modularization the arm
feature is built on, extracted from the former monolithic app.rs/process.rs:
- preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames,
backend reachability) that colour rows but never block Launch
- local_services: launcher-owned LSX/autopatch child processes
- game_launch, account_sync, health, netcheck helpers
- openfut-common: dependency-free shared server-destination/port mapping,
used by both the launcher and (separately) openfut_hook.dll
openfut-hook RE changes are intentionally left uncommitted (separate concern).
fmt + clippy -D warnings clean; 46 tests pass.
- sbc_hook.rs: trace SBC submission/response flow with request IDs
- sbc_request_trace.rs: capture request/response bodies for analysis
- sbc_trace.rs: runtime trace buffer with structured logging
Work in progress - needs validation against live FIFA 17 client
In-process, read-only probes and transport observation built while closing
the online/FUT route from both the memory and network sides.
- probe.rs / dial_notification.rs: menu-time ctx dump, connMgr enumerator,
synthetic dial-notification + direct-call dial trigger, and the
[element+0x40] container write-watchpoint. All env-gated, one-shot,
VirtualQuery-guarded; none alter game state by default.
- transport_watch.rs + connect/connectex/hooks/lib: M0 transport observation
(grep-friendly TRANSPORT_WATCH logging on the existing getaddrinfo/connect/
WSAConnect/ConnectEx detours) and an IPv6 (v4-mapped) EA-redirect so the
game's IPv6 :443 dials land on the bridge instead of the dead servers.
Findings: the game never initiates a Blaze connection offline; the dial
handler is registered by a self-registering, message-driven state machine
whose container stays empty with no Blaze exchange. See openfut-bridge
docs/closure-and-preservation.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
install_force_connect() is no longer auto-called from install_probes_deferred,
so normal probe builds don't poke the online flow. Kept for reference; re-enable
the call to reproduce the 2026-07-02 forcing experiment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expands to 8 entry probes across the connect-state lifecycle (ctor, controller
accessor, four vtable steps) to distinguish entered-but-stalled from never-entered.
Result: ctor fires x4, everything else 0 — subsystem created but dormant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>