37 Commits

Author SHA1 Message Date
funman300 4b1d5aa367 diag(fifa17): passive kit-selector data-flow trace (kit_trace)
Traces the client-side FUT pre-match kit path in CardsDLL: GetMatchKits_DP
gate (KITS_AVAILABLE), setAvailableKits (home/away list count), the kit-item
clone driver (item type/subid/teamid), and the local teamkits DB clone. Proves
in one operator match where the empty selector originates. Read-only; reuses
season_trace's passive-detour installers.
2026-08-20 16:55:50 +00:00
funman300 ed5c335c70 tooling(re): restore Ghidra 11.1.2 headless + pyhidra for cardsdll/powdll 2026-08-20 16:28:40 +00:00
openfut 8d5bb6202a diag(fifa17): capture WEBFILE_DL url + guarded CACHE_PACKNAMES bypass
- 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.
2026-08-20 00:18:51 +00:00
openfut 9c4db41289 diag(fifa17): probe LoadOfflineSeasons async completions (result string capture)
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).
2026-08-19 23:55:41 +00:00
openfut 164100fc40 diag(fifa17): passive season-native call tracer for offline-Seasons entry
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).
2026-08-19 23:37:26 +00:00
funman300 79e566883f hook(fifa17): pre-warm store purchase groups before screen-show; drop disproven rebind
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.
2026-08-19 18:48:54 +00:00
funman300 7724f168bc hook(fifa17): repair the store tab bar by rebinding the native binder
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.
2026-08-19 18:02:10 +00:00
funman300 e4c56a225e hook(fifa17): pre-warm purchase groups so the store tab bar binds natively
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.
2026-08-19 16:11:58 +00:00
funman300 8ca89bcc75 hook(fifa17): bind the store tab bar on first entry
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.
2026-08-19 15:57:23 +00:00
funman300 9aecc658ad hook(fifa17): guarded store-entry category clamp (promoted)
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.
2026-08-19 15:20:47 +00:00
funman300 af7a5948a7 launcher: plain-language launch status for players
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.
2026-08-19 04:26:29 +00:00
funman300 3d3790a83a launcher: persist the hook DLL override in the prefix, not in launch options
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.
2026-08-19 03:01:46 +00:00
funman300 94feaec63f Promote the FIFA17 SBC dispatch repair: armed by the build, not by env
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.
2026-08-19 02:45:43 +00:00
funman300 c3addde9b1 Correct FIFA17 SBC dispatch notifier lifecycle guard to post-exit invariant 2026-08-18 20:59:07 +00:00
funman300 9900772690 Apply rustfmt to launcher services and app 2026-08-18 20:51:55 +00:00
funman300 35ceb084ef Fix FIFA17 SBC dispatch response-class check to capture live vtable 2026-08-18 20:51:55 +00:00
funman300 1c7111ddbf Harden FIFA17 SBC dispatch repair 2026-08-18 20:20:27 +00:00
funman300 6cdb45e482 Instrument FIFA17 SBC completion dispatch 2026-08-18 20:20:00 +00:00
funman300 1cd4f18e92 feat: spawn the Rust companion binaries, not Python scripts
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.
2026-08-18 05:30:35 +00:00
funman300 c5424158b9 fix: launcher-spawned FIFA never loaded the hook, so its Blaze ports were inert
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.
2026-08-18 02:57:29 +00:00
funman300 3174fe4c1f launcher: one Launch button, driven by an explicit launch state machine
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.
2026-08-17 22:44:21 +00:00
funman300 504ceeec87 launcher: stop the shadowed-hostname test asserting the local machine's ports
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.
2026-08-17 22:06:41 +00:00
funman300 cbf697bcd5 launcher: make the shadowed-hostname preflight test machine-independent
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.
2026-08-17 22:05:15 +00:00
funman300 357501f549 launcher: guided first-run flow, server-owned settings, hook-config reconcile
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.
2026-08-17 21:46:43 +00:00
funman300 c2772132c1 feat(ui): shareholder-grade redesign + live "Your Club" account panel
- New theme.rs design system: palette, embedded fonts, egui Visuals/Style,
  card()/status_pill() helpers.
- Branded hero header (OF monogram), left nav rail, card-based dashboard,
  console-style Logs, themed Config tab, window/taskbar icon.
- New account_monitor.rs: background AccountMonitor (mirrors HealthMonitor,
  5s non-blocking poll) driving a live "Your Club" dashboard card (club
  name/abbr, manager, COINS hero number, level + XP bar, unopened packs,
  funds) with loading/offline/error states.
- account_sync.rs/app.rs/config.rs/main.rs wired to the monitor + theme.
  All existing launch/health/preflight/service/config logic preserved.
2026-08-17 16:03:47 +00:00
funman300 d1a71bd5a1 style(hook): clippy -D warnings clean on default + fifa17 features
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.
2026-08-15 19:31:21 +00:00
funman300 0d3f33cede fix(hook): build version.dll as its own workspace root
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.
2026-08-15 19:24:44 +00:00
funman300 ca7ce267a0 merge: reconcile launcher capability and SBC tracing
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.
2026-08-13 05:09:58 +00:00
funman300 958ff24546 feat(hook): add SBC request tracing instrumentation
- 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
2026-08-08 17:49:00 -07:00
funman300 09ed26ba16 wip: checkpoint FIFA 17 hook diagnostics 2026-08-07 12:03:21 -07:00
funman300 3d895fb7ac feat: add guarded FIFA 17 SBC diagnostics 2026-08-07 11:43:08 -07:00
funman300 7dcf610b71 openfut-hook: RE instrumentation for the Blaze dial-gate investigation
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>
2026-07-03 15:58:17 -07:00
funman300 ff4b5a87f5 probe: ungate the forcing experiment (manual-only)
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>
2026-07-02 18:45:08 -07:00
funman300 493b9e0573 probe: run-4 connect-state lifecycle spread (8 targets)
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>
2026-07-02 18:03:50 -07:00
funman300 c1d1f03ec8 probe: retarget to game-side connect layer + add guarded session-context sampler
Replaces the anadius-connectivity probes with the game-side Nucleus-connect
functions (nucleusConnectREST/Trusted, connect-state tick) and adds a
VirtualQuery-guarded sampler thread that reads X=[0x14acd02c0] -> M=[X+0x360]
-> ctx=[M+0x778] once/sec to observe the session context directly. Per-slot
log cap prevents per-frame handlers flooding the log. Run 3 result: ctx is
non-null but the connect functions are never called (see bridge findings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:56:31 -07:00
funman300 3c3dc32fc6 probe: add behavior-preserving mid-function detour to capture the live OnlineStatusEvent listener
The OnlineStatusEventT::HandleMessage dispatch resolves its game-side
listener only at runtime (call [rax+0x28]). openfut_listener_stub patches
FIFA23.exe+0x274d4d7 to replicate the four dispatch instructions while
logging the resolved vtable/fn, then resumes. Alignment-safe (saves/rounds
rsp before the log call). Result: listener = FIFA23.exe+0x2751060 = ret 0,
a no-op default vtable slot -> the online->auth transition is state-polled,
not callback-driven.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:25:33 -07:00
funman300 feaff0443f hook: in-process RE instrumentation + LSX redirect + capture tooling
Hook-side tooling for the LSX/Blaze reverse-engineering effort:

- probe.rs (new, `probe` feature): passive logging detours on FIFA's online-flow
  functions via the unhook/rehook pattern (no trampoline/relocation, works on
  RIP-relative prologues). Deferred install waits for anadius64.dll to load, then
  logs enter/return for GoOnline + GetInternetConnectedState (anadius) and the
  OnlineStatusEvent/Login deserializers (FIFA23.exe). Revealed that our pushed LSX
  events reach FIFA and parse OK, while GoOnline never fires — localizing the online
  gate to FIFA's game-side event consumer.
- connect_hook.rs: redirect FIFA's LSX connect :3216 → :3217 so it lands on the
  native openfut-bridge LSX server (slips past anadius's in-process :3216 intercept);
  gated off under the `capture_baseline` feature.
- recv_hook.rs: boundary-safe trampolines + LSX peer filtering for the
  capture_baseline path (log anadius's real LSX frames when the redirect is off).

Build the instrumented DLL with `--features probe` (or `--features capture_baseline`
for the anadius-baseline capture). Both features are off by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:59:27 -07:00
44 changed files with 12085 additions and 1280 deletions
+5
View File
@@ -1 +1,6 @@
target/
# runtime SQLite DB (created when services run from this dir)
openfut.db
openfut.db-shm
openfut.db-wal
+3
View File
@@ -13,3 +13,6 @@ serde_json = "1"
dirs = "5"
chrono = { version = "0.4", features = ["serde"] }
openfut-common = { path = "openfut-common" }
# parking_lot over std::sync: every lock here is taken and used immediately, so
# the poisoning unwrap at each call site is pure noise (project rule).
parking_lot = "0.12"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -1,3 +1,9 @@
# Standalone workspace root: this Windows-only version.dll proxy is deliberately
# NOT a member of the OpenFUT workspace (see that root's `exclude`) so its own
# [profile.release] below actually applies. An empty [workspace] table stops Cargo
# from walking up and re-attaching this crate to the parent workspace.
[workspace]
[package]
name = "openfut-hook"
version = "0.1.0"
@@ -6,6 +12,20 @@ edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect,
# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real
# responses). Default build keeps the redirect (LSX → our bridge).
capture_baseline = []
# Build with `--features probe` to install passive logging detours on FIFA's
# in-process online-flow functions (GoOnline, GetInternetConnectedState, event
# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs.
probe = []
# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY
# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map,
# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking.
fifa17 = []
[dependencies]
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
@@ -15,6 +35,9 @@ windows-sys = { version = "0.59", features = [
"Win32_Networking_WinSock",
"Win32_Security_Cryptography",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] }
[profile.release]
+17
View File
@@ -0,0 +1,17 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=version.def");
// The proxy's PE export surface is part of its runtime contract. Feed an
// explicit module-definition file to the MinGW linker instead of relying
// solely on Rust symbol export attributes and linker retention heuristics.
if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu")
{
let definition =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
println!("cargo:rustc-link-arg={}", definition.display());
}
}
+168 -55
View File
@@ -5,20 +5,49 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
const AF_INET: u16 = 2;
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
// EA App LSX. anadius handles :3216 in-process before it reaches the host TCP
// stack (keyed on port 3216 specifically), so redirecting FIFA's LSX connect to a
// *different* host port (:3217) slips past that interception and lands on the
// native openfut-bridge LSX server. This is the load-bearing redirect that routes
// LSX to our bridge; without it FIFA uses anadius's in-process emu instead.
#[allow(dead_code)] // unused when built with the `capture_baseline` feature
const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX)
#[allow(dead_code)]
const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target)
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine)
/// Win32 `sockaddr_in6`. `sin6_port` is network byte order; `sin6_addr` is 16 raw
/// address bytes in network order. 28 bytes total.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to
/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's
/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own
/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not
/// `IPV6_V6ONLY` and will accept this target.
const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1];
// Address of ws2_32!connect (set at hook installation)
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
@@ -27,18 +56,26 @@ static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
// For WSAConnect IAT fallback
type WsaConnectFn = unsafe extern "system" fn(
s: usize, name: *const u8, namelen: i32,
caller: *const (), callee: *const (),
sqos: *const (), gqos: *const ()) -> i32;
s: usize,
name: *const u8,
namelen: i32,
caller: *const (),
callee: *const (),
sqos: *const (),
gqos: *const (),
) -> i32;
static REAL_WSA: OnceLock<WsaConnectFn> = OnceLock::new();
pub fn set_real_wsa_connect(f: WsaConnectFn) { let _ = REAL_WSA.set(f); }
pub fn set_real_wsa_connect(f: WsaConnectFn) {
let _ = REAL_WSA.set(f);
}
unsafe fn write_hook(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
// FF 25 00 00 00 00 JMP [rip+0]
target.write(0xFF); target.add(1).write(0x25);
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0u32);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
@@ -48,42 +85,99 @@ unsafe fn restore_original(target: *mut u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping(ORIGINAL_BYTES.as_ptr(), target, 14);
core::ptr::copy_nonoverlapping(core::ptr::addr_of!(ORIGINAL_BYTES) as *const u8, target, 14);
VirtualProtect(target as _, 14, old, &mut old);
}
unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> {
if namelen < 8 { return None; }
let sa = &*(name as *const SockaddrIn);
if sa.sin_family != AF_INET { return None; }
/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the
/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old
/// IPv4-only path let those slip straight past us to the real (dead) servers.
///
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
/// ConnectEx path can share this one implementation.
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
if namelen < 8 || name.is_null() {
return None;
}
// The first u16 of any sockaddr is the address family.
let family = *(name as *const u16);
let mut buf = [0u8; 28];
let orig = sa.sin_addr.to_le_bytes();
let orig_port = u16::from_be(sa.sin_port);
let new_port_nbo = match sa.sin_port {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
crate::write_log(&format!(
"connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
orig[3], orig[2], orig[1], orig[0], orig_port,
u16::from_be(new_port_nbo)
));
let mut buf = [0u8; 16];
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = ADDR_LOOPBACK_NBO;
Some((buf, 16))
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
let sa = &*(name as *const SockaddrIn);
let new_port_nbo = match sa.sin_port {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
#[cfg(not(feature = "capture_baseline"))]
PORT_LSX_NBO => PORT_LSX_TARGET_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
// sin_addr is network order; to_le_bytes gives memory order = the dotted
// quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed).
let o = sa.sin_addr.to_le_bytes();
crate::write_log(&format!(
"connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
o[0],
o[1],
o[2],
o[3],
u16::from_be(sa.sin_port),
u16::from_be(new_port_nbo)
));
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = ADDR_LOOPBACK_NBO;
Some((buf, 16))
}
AF_INET6 => {
if namelen < 28 {
return None;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa6 = &*(name as *const SockaddrIn6);
// LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here.
let new_port_nbo = match sa6.sin6_port {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
let a = sa6.sin6_addr;
crate::write_log(&format!(
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
a[0],
a[1],
a[14],
a[15],
u16::from_be(sa6.sin6_port),
u16::from_be(new_port_nbo)
));
// SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6).
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
out.sin6_family = AF_INET6;
out.sin6_port = new_port_nbo;
out.sin6_flowinfo = 0;
out.sin6_addr = V4MAPPED_LOOPBACK;
out.sin6_scope_id = 0;
Some((buf, 28))
}
_ => None,
}
}
pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 {
let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8;
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("connect", name, namelen, s);
// Log every call so we can confirm the hook fires at all
if namelen >= 8 {
let sa = &*(name as *const SockaddrIn);
@@ -95,7 +189,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
let mut ty: i32 = -1;
let mut len: i32 = 4;
getsockopt(s, SOL_SOCKET as i32, SO_TYPE, &mut ty as *mut i32 as *mut u8, &mut len);
getsockopt(
s,
SOL_SOCKET,
SO_TYPE,
&mut ty as *mut i32 as *mut u8,
&mut len,
);
ty
};
crate::write_log(&format!(
@@ -111,11 +211,11 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
let (call_name, call_len) = if let Some((buf, len)) = redirect_if_ea(name, namelen) {
restore_original(addr);
let r = {
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32
= core::mem::transmute(addr);
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 =
core::mem::transmute(addr);
f(s, buf.as_ptr(), len)
};
write_hook(addr, hooked_connect as u64);
write_hook(addr, hooked_connect as *const () as u64);
return r;
} else {
(name, namelen)
@@ -123,18 +223,19 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
restore_original(addr);
let r = {
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32
= core::mem::transmute(addr);
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
f(s, call_name, call_len)
};
write_hook(addr, hooked_connect as u64);
write_hook(addr, hooked_connect as *const () as u64);
if namelen >= 8 {
let sa = &*(call_name as *const SockaddrIn);
if sa.sin_family == AF_INET {
let err = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else { 0 };
} else {
0
};
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
}
}
@@ -142,10 +243,16 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
}
pub unsafe extern "system" fn hooked_wsa_connect(
s: usize, name: *const u8, namelen: i32,
caller: *const (), callee: *const (),
sqos: *const (), gqos: *const (),
s: usize,
name: *const u8,
namelen: i32,
caller: *const (),
callee: *const (),
sqos: *const (),
gqos: *const (),
) -> i32 {
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("WSAConnect", name, namelen, s);
let real = REAL_WSA.get().copied().unwrap();
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
@@ -158,18 +265,24 @@ pub unsafe extern "system" fn hooked_wsa_connect(
pub unsafe fn install_inline_connect_hook() -> bool {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
if ws2.is_null() { return false; }
let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
if ws2.is_null() {
return false;
}
let connect_fn = match GetProcAddress(ws2, c"connect".as_ptr().cast()) {
Some(f) => f as *mut u8,
None => return false,
};
// Save original 14 bytes
core::ptr::copy_nonoverlapping(connect_fn, ORIGINAL_BYTES.as_mut_ptr(), 14);
core::ptr::copy_nonoverlapping(
connect_fn,
core::ptr::addr_of_mut!(ORIGINAL_BYTES) as *mut u8,
14,
);
CONNECT_ADDR.store(connect_fn as usize, Ordering::Relaxed);
// Overwrite first 14 bytes with absolute indirect JMP to our hook
write_hook(connect_fn, hooked_connect as u64);
write_hook(connect_fn, hooked_connect as *const () as u64);
true
}
+56 -65
View File
@@ -1,36 +1,22 @@
use core::ffi::c_void;
/// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API).
///
/// DirtySDK calls WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER, WSAID_CONNECTEX) once at
/// startup to get a ConnectEx function pointer, bypassing all IAT hooks. We hook WSAIoctl
/// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper.
use core::sync::atomic::{AtomicUsize, Ordering};
use core::ffi::c_void;
const AF_INET: u16 = 2;
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
// Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port
// constants and sockaddr structs no longer live here.
// SIO_GET_EXTENSION_FUNCTION_POINTER
const SIO_GET_EXT_FN: u32 = 0xC8000006;
// WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E}
const CONNECTEX_GUID: [u8; 16] = [
0xB9, 0x07, 0xA2, 0x25,
0xF3, 0xDD, 0x60, 0x46,
0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
];
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
// The real ConnectEx pointer, saved after WSAIoctl returns it
static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0);
@@ -65,7 +51,8 @@ unsafe fn write_hook(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
target.write(0xFF); target.add(1).write(0x25);
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0u32);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
@@ -75,7 +62,7 @@ unsafe fn restore_wsaioctl(target: *mut u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping(WSAIOCTL_ORIG.as_ptr(), target, 14);
core::ptr::copy_nonoverlapping(core::ptr::addr_of!(WSAIOCTL_ORIG) as *const u8, target, 14);
VirtualProtect(target as _, 14, old, &mut old);
}
@@ -91,33 +78,31 @@ unsafe extern "system" fn hooked_connectex(
) -> i32 {
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
if namelen >= 8 {
let sa = &*(name as *const SockaddrIn);
if sa.sin_family == AF_INET {
let o = sa.sin_addr.to_le_bytes();
let orig_port = u16::from_be(sa.sin_port);
let new_port_nbo = match sa.sin_port {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => 0,
};
if new_port_nbo != 0 {
crate::write_log(&format!(
"connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
o[3], o[2], o[1], o[0], orig_port,
u16::from_be(new_port_nbo)
));
let mut redirect = [0u8; 16];
let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = ADDR_LOOPBACK_NBO;
return real_fn(s, redirect.as_ptr(), 16, send_buf, send_data_len, bytes_sent, overlapped);
}
}
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
// dials get the same IPv6 handling as plain connect().
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
return real_fn(
s,
buf.as_ptr(),
len,
send_buf,
send_data_len,
bytes_sent,
overlapped,
);
}
real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped)
real_fn(
s,
name,
namelen,
send_buf,
send_data_len,
bytes_sent,
overlapped,
)
}
/// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours
@@ -138,28 +123,28 @@ pub unsafe extern "system" fn hooked_wsaioctl(
restore_wsaioctl(addr);
let result = {
let f: WsaIoctlFn = core::mem::transmute(addr);
f(s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion)
f(
s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion,
)
};
write_hook(addr, hooked_wsaioctl as u64);
write_hook(addr, hooked_wsaioctl as *const () as u64);
// If this was a ConnectEx request that succeeded, swap the pointer
if result == 0
&& code == SIO_GET_EXT_FN
&& in_len == 16
&& !in_buf.is_null()
{
if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() {
let guid = core::slice::from_raw_parts(in_buf as *const u8, 16);
if guid == CONNECTEX_GUID
&& out_len >= 8
&& !out_buf.is_null()
{
if guid == CONNECTEX_GUID && out_len >= 8 && !out_buf.is_null() {
let out_ptr = out_buf as *mut usize;
let real_addr = *out_ptr;
if REAL_CONNECTEX.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
crate::write_log(&format!("connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n"));
if REAL_CONNECTEX
.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
crate::write_log(&format!(
"connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n"
));
}
// Return our hook instead
*out_ptr = hooked_connectex as usize;
*out_ptr = hooked_connectex as *const () as usize;
}
}
result
@@ -167,14 +152,20 @@ pub unsafe extern "system" fn hooked_wsaioctl(
pub unsafe fn install_wsaioctl_hook() -> bool {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
if ws2.is_null() { return false; }
let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
if ws2.is_null() {
return false;
}
let fn_ptr = match GetProcAddress(ws2, c"WSAIoctl".as_ptr().cast()) {
Some(f) => f as *mut u8,
None => return false,
};
core::ptr::copy_nonoverlapping(fn_ptr, WSAIOCTL_ORIG.as_mut_ptr(), 14);
core::ptr::copy_nonoverlapping(
fn_ptr,
core::ptr::addr_of_mut!(WSAIOCTL_ORIG) as *mut u8,
14,
);
WSAIOCTL_ADDR.store(fn_ptr as usize, Ordering::Relaxed);
write_hook(fn_ptr, hooked_wsaioctl as u64);
write_hook(fn_ptr, hooked_wsaioctl as *const () as u64);
true
}
+183
View File
@@ -0,0 +1,183 @@
//! Synthetic "notification" struct for the direct-call dial trigger.
//!
//! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler
//! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing
//! completion callback. It does NOT call the game, does NOT install any detour,
//! and is NOT wired into the hook yet. The invocation phase (later) consumes
//! `build_notification()` + `completion_stub`.
//!
//! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590):
//! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1
//! [+0x80] qword : completion delegate fn pointer. => &completion_stub
//! [+0x88] qword : delegate capture #1. => 0
//! [+0x90] qword : delegate capture #2. => 0
//! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0
//! everything else in [0x00..0x100] : 0
//! The RE confirmed no other offset in this range is read on the success path.
//! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance
//! against a read we might have missed. Any offset here is TODO/CONFIRM against the
//! RE report; if the game contradicts it at runtime, stop and re-verify.
// This module is deliberately unused for now (the invocation phase will call into
// it). Silence "never used" warnings until then rather than sprinkle #[allow] on
// each item. Remove this once the trigger wires the API up.
#![allow(dead_code)]
use core::sync::atomic::{AtomicU32, Ordering};
/// Size of the notification struct, in bytes. 0x100 = 256.
const NOTIFICATION_SIZE: usize = 0x100;
// --- field offsets (named so the code reads like the RE contract) -------------
const OFF_GATE: usize = 0x00; // byte, must be non-zero
const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer
const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0)
const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0)
const OFF_KEY: usize = 0xa0; // dword, job key/priority (0)
/// Counts how many times `completion_stub` has been entered.
///
/// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to
/// touch and, worse, gives *undefined behaviour* if two threads write it at once
/// (a data race). The completion callback may be invoked from an arbitrary game
/// thread, so a plain counter would race. `AtomicU32` makes increment a single
/// lock-free hardware instruction with well-defined concurrent semantics, and it
/// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the
/// count value, not about ordering it against other memory.
static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0);
/// The completion callback the game may invoke when the RpcJob finishes.
///
/// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft
/// x64 calling convention — exactly how the game invokes the pointer (`call r10`,
/// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the
/// convention is what makes it safe for the game to call us.
///
/// We declare four pointer-sized params and ignore them. The RE showed the delegate
/// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the
/// success-path completion may pass different values. Because Win64 is caller-clean
/// and puts the first four integer args in registers, declaring four ignored args is
/// safe no matter what the caller actually passes — we simply never read them.
///
/// The body does the absolute minimum: bump the atomic counter and return 0. NO
/// logging, NO allocation, NO calls — a completion callback can fire from any game
/// context, and even a log write there could be unsafe. Observe from outside via
/// `completion_stub_call_count()` instead.
///
/// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks
/// at the return value. (Returning void would be equally fine; 0 is a safe default.)
pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize {
// `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no
// syscall, no allocation. Safe to call from any thread/context.
COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed);
0
}
/// Read how many times `completion_stub` has fired. For an outside observer thread —
/// keeps all I/O out of the stub itself.
pub fn completion_stub_call_count() -> u32 {
COMPLETION_STUB_CALLS.load(Ordering::Relaxed)
}
/// Write a little-endian u64 into `buf` starting at `offset`.
///
/// Endianness matters because we're hand-laying a memory image the game will read
/// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant
/// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in
/// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the
/// original `value`. Using the native byte order by hand (or `transmute`) would be
/// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly.
///
/// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the
/// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is
/// the standard, safe way to poke a fixed-width integer into a `[u8]`.)
fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) {
buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}
/// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as
/// `write_u64_le`, 4 bytes wide.)
fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) {
buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
/// Build the fully-populated notification struct, ready to be passed by pointer to
/// the dial handler as its `rdx` argument.
///
/// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct:
/// the layout is a precise *offset* contract recovered by RE, with meaningful data
/// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every
/// offset literally visible and immune to any field-ordering/padding surprise. A
/// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier
/// to get a padding byte wrong than to index a flat array. (For future reference:
/// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]`
/// zero-copy — worth knowing, but overkill here and an extra dependency.)
pub fn build_notification() -> [u8; NOTIFICATION_SIZE] {
// Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/
// +0x90, the key at +0xa0, and all padding); we only need to set the non-zero
// fields below.
let mut buf = [0u8; NOTIFICATION_SIZE];
// [+0x00] entry gate: must be non-zero to reach the dial path.
buf[OFF_GATE] = 1;
// [+0x80] completion delegate function pointer = &completion_stub.
//
// `completion_stub as *const ()`: a *function item* in Rust is a zero-sized,
// unique type, not a value. Casting it to a raw pointer coerces it to a function
// pointer and then to an untyped code pointer `*const ()` — i.e. the address of
// the function's machine code. The intermediate `*const ()` before `as u64` is
// the idiomatic form: it says "treat this as an address" and also avoids the
// `clippy`/rustc "direct cast of function item into an integer" lint you'd get
// from `completion_stub as u64`.
let stub_addr = completion_stub as *const () as u64;
write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr);
// [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write
// them explicitly so the layout intent is visible at a glance.
write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0);
write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0);
// [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path).
write_u32_le(&mut buf, OFF_KEY, 0);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_layout() {
let n = build_notification();
// Total size is exactly 0x100.
assert_eq!(n.len(), NOTIFICATION_SIZE);
// [+0x00] gate byte == 1.
assert_eq!(n[0x00], 1);
// [+0xa0..0xa4] as u32 == 0.
// `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only
// fail if the slice weren't length 4, which it is), and `from_le_bytes`
// reads it back the same little-endian way we wrote it.
let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap());
assert_eq!(key, 0);
// [+0x80..0x88] as u64 == address of completion_stub.
let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap());
assert_eq!(stub, completion_stub as *const () as u64);
// [+0x88..0x90] and [+0x90..0x98] captures == 0.
assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0);
assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0);
}
#[test]
fn stub_counter_increments() {
let before = completion_stub_call_count();
let _ = completion_stub(0, 0, 0, 0);
assert_eq!(completion_stub_call_count(), before + 1);
}
}
+111
View File
@@ -0,0 +1,111 @@
//! FIFA 17 injection path (feature = "fifa17").
//!
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
//! no-op and at worst crash. For now this proves the version.dll hijack actually
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
//!
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
use crate::write_log;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE,
TH32CS_SNAPMODULE32,
};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
/// Read the SizeOfImage from a module's in-memory PE headers.
unsafe fn size_of_image(base: usize) -> u32 {
if base == 0 {
return 0;
}
// DOS header -> e_lfanew (i32 @ 0x3c) -> PE header. SizeOfImage is in the
// optional header at offset 0x50 from the PE signature (same for PE32/PE32+).
let e_lfanew = *((base + 0x3c) as *const i32);
let pe = base + e_lfanew as usize;
// sanity: 'PE\0\0'
if *(pe as *const u32) != 0x0000_4550 {
return 0;
}
*((pe + 24 + 0x38) as *const u32) // opt header +0x38 = SizeOfImage
}
fn wide_to_string(w: &[u16]) -> String {
let end = w.iter().position(|&c| c == 0).unwrap_or(w.len());
String::from_utf16_lossy(&w[..end])
}
/// Enumerate loaded modules (name, base, size) via ToolHelp and log them.
unsafe fn dump_modules() {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0);
if snap == INVALID_HANDLE_VALUE {
write_log("fifa17: module snapshot FAILED\n");
return;
}
let mut me: MODULEENTRY32W = core::mem::zeroed();
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
if Module32FirstW(snap, &mut me) != 0 {
loop {
let name = wide_to_string(&me.szModule);
let base = me.modBaseAddr as usize;
let size = me.modBaseSize;
write_log(&format!(
"fifa17: module {name:<28} base={base:#018x} size={size:#x}\n"
));
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
if Module32NextW(snap, &mut me) == 0 {
break;
}
}
} else {
write_log("fifa17: Module32FirstW FAILED\n");
}
CloseHandle(snap);
}
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
/// other loader-touching calls are unsafe under the loader lock, so we defer them
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
write_log("=== fifa17 hook: worker thread start ===\n");
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
let img = size_of_image(main_base);
write_log(&format!(
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
));
dump_modules();
write_log("fifa17: worker complete (injection healthy)\n");
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
// itself from the build; its safety is the runtime signature/evidence gate. The
// remaining legacy experiment modules stay inert unless their env gate is `1`.
crate::sbc_hook::install();
crate::sbc_trace::install();
crate::sbc_dispatch::install();
crate::sbc_request_trace::install();
crate::store_entry::install();
crate::season_trace::install();
crate::kit_trace::install();
0
}
/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker
/// thread and return immediately, so we never touch the loader lock from here.
pub unsafe fn install() {
use windows_sys::Win32::System::Threading::CreateThread;
write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n");
let h = CreateThread(
core::ptr::null(),
0,
Some(worker),
core::ptr::null(),
0,
core::ptr::null_mut(),
);
if h == 0 as _ {
write_log("fifa17: CreateThread FAILED\n");
}
}
+13 -11
View File
@@ -1,19 +1,15 @@
use std::{
ffi::CStr,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
OnceLock,
},
};
use windows_sys::Win32::Networking::WinSock::{ADDRINFOA, getaddrinfo as sys_getaddrinfo};
use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA};
type GetaddrinfoFn = unsafe extern "system" fn(
*const u8,
*const u8,
*const ADDRINFOA,
*mut *mut ADDRINFOA,
) -> i32;
type GetaddrinfoFn =
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
@@ -54,22 +50,28 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
if !node_name.is_null() {
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n"));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_getaddrinfo(host);
if is_ea_host(host) {
// Apply the ProtoSSL cert-verify bypass the first time we see an EA
// hostname — EAWebKit.dll must be loaded by now because it's calling us.
if !CERT_PATCHED.load(Ordering::Relaxed) {
if crate::ssl_patch::patch_eawebkit_cert_verify() {
CERT_PATCHED.store(true, Ordering::Relaxed);
crate::write_log("openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n");
crate::write_log(
"openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n",
);
} else {
crate::write_log("openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n");
crate::write_log(
"openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n",
);
}
}
let redirect = REDIRECT_IP
.get()
.map(|v| v.as_ptr())
.unwrap_or(b"127.0.0.1\0".as_ptr());
.unwrap_or(c"127.0.0.1".as_ptr().cast());
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
return real(redirect, service_name, hints, result);
}
+12 -7
View File
@@ -72,7 +72,11 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize {
}
/// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0").
pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: *const ()) -> usize {
pub unsafe fn patch_iat_in(
module_name: &[u8],
original_fn: *const (),
hook_fn: *const (),
) -> usize {
let module = GetModuleHandleA(module_name.as_ptr());
if module.is_null() {
return 0;
@@ -80,11 +84,7 @@ pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn:
patch_module(module, original_fn, hook_fn)
}
unsafe fn patch_module(
module: HMODULE,
original_fn: *const (),
hook_fn: *const (),
) -> usize {
unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
if module.is_null() {
return 0;
}
@@ -117,7 +117,12 @@ unsafe fn patch_module(
if val == original_fn as usize {
let target = iat_slot.add(i) as *const std::ffi::c_void;
let mut old: u32 = 0;
VirtualProtect(target, std::mem::size_of::<usize>(), PAGE_EXECUTE_READWRITE, &mut old);
VirtualProtect(
target,
std::mem::size_of::<usize>(),
PAGE_EXECUTE_READWRITE,
&mut old,
);
*iat_slot.add(i) = hook_fn as usize;
VirtualProtect(target, std::mem::size_of::<usize>(), old, &mut old);
count += 1;
+184
View File
@@ -0,0 +1,184 @@
//! Passive, behavior-preserving diagnostic traces for FIFA 17's FUT pre-match
//! KIT SELECTOR data flow.
//!
//! RE (2026-08-20, Ghidra on CardsDLL_Win64_retail.dll) established that the
//! pre-match kit selector is fed ENTIRELY client-side (NOT by POW/EASFC):
//!
//! * `FUT_GET_MATCH_KITS_DP` (id 0x7565) builder `FUN_1800be6a0` (rva 0xbe6a0)
//! reads a boolean gate `ctx+0x152` (`KITS_AVAILABLE`); when false, or when
//! the two available-kit vectors are empty, the selector renders blank/white.
//! * The available home/away kit-id lists live on `FutSquadServiceImpl`
//! (`this+0xe08` home, `this+0xe38` away) and are written by the setter
//! `FUN_180196760` (rva 0x96760, vtable slot 0x1d0): args (this, srcVec, side).
//! * A club KIT ITEM is turned into an available kit by `FUN_1801c3480`
//! (rva 0x1c3480): it reads item fields (`+0x4c==7`, `+0x60==4`,
//! `+0x5c`∈{101 home,102 away}, `+0x94` source teamid, `+0xba`
//! teamkittypetechid) and calls `FUN_1801c44b0` (rva 0x1c44b0) to clone that
//! team's kit rows from the CLIENT-LOCAL `teamkits` DB into the FUT club
//! (teamtechid 130000).
//!
//! These traces answer, in one operator-driven match, exactly WHERE the empty
//! selector originates: do kit club items reach the client (kit_item_clone), does
//! the clone into the FUT club happen (kit_db_clone), does the available list get
//! set non-empty (set_available_kits), and what does the selector finally read
//! (get_match_kits: KITS_AVAILABLE + count). Every trace is read-only: it logs,
//! then tail-calls the original through a trampoline. Copied prologues are whole,
//! position-independent instructions (the one rip-relative prologue uses the
//! relocating installer).
use core::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{readable_range, validate_cards_build};
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32, rd_u8};
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
fn budget() -> bool {
REPORTS.fetch_add(1, Ordering::Relaxed) < 256
}
unsafe fn rd_usize(addr: usize) -> Option<usize> {
readable_range(addr, 8).then(|| core::ptr::read_volatile(addr as *const usize))
}
// FUT_GET_MATCH_KITS_DP builder FUN_1800be6a0 (0xbe6a0). rcx = DP model ctx.
// ctx+0x152 is the KITS_AVAILABLE bool that gates the whole selector list.
static GET_MATCH_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn get_match_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
let avail = rd_u8(rcx + 0x152);
write_log(&format!(
"KIT_GET: FUT_GET_MATCH_KITS_DP ctx={rcx:#x} KITS_AVAILABLE={avail:?}\n"
));
}
let t = GET_MATCH_KITS_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
// setAvailableKits FUN_180196760 (0x96760): (this, srcVec, side). srcVec is an
// int vector {begin@+0, end@+8}; count = (end-begin)/4. side 0=home, 1=away.
static SET_AVAILABLE_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn set_available_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
let count = match (rd_usize(rdx), rd_usize(rdx + 8)) {
(Some(b), Some(e)) if e >= b => ((e - b) / 4) as i64,
_ => -1,
};
write_log(&format!(
"KIT_SET: setAvailableKits this={rcx:#x} side={r8} count={count}\n"
));
}
let t = SET_AVAILABLE_KITS_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
// Kit-item clone driver FUN_1801c3480 (0x1c3480): rdx = param_2, the club-item
// event; the item struct is at *(param_2+0x10). Logs the fields the function
// branches on so we can see whether a kit club item reaches the client and its
// home/away designator + source teamid.
static KIT_ITEM_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn kit_item_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
if let Some(item) = rd_usize(rdx + 0x10) {
write_log(&format!(
"KIT_ITEM: clone-driver item={item:#x} type[+0x4c]={:?} subid[+0x5c]={:?} \
cat[+0x60]={:?} teamid[+0x94]={:?} kittype[+0xba]={:?}\n",
rd_i32(item + 0x4c),
rd_i32(item + 0x5c),
rd_i32(item + 0x60),
rd_i32(item + 0x94),
rd_i32(item + 0xba),
));
} else {
write_log(&format!("KIT_ITEM: clone-driver param_2={rdx:#x} (item ptr unreadable)\n"));
}
}
let t = KIT_ITEM_CLONE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
// Kit DB clone FUN_1801c44b0 (0x1c44b0): (clubmgr, side, teamtechid, kittype).
// Fires only when the driver decided the item is a home(101)/away(102) kit, so
// this is the proof the FUT-club (teamtechid 130000) kit rows get synthesized.
static KIT_DB_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn kit_db_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
if budget() {
write_log(&format!(
"KIT_DBCLONE: clone team kit side={rdx} src_teamtechid={r8} kittype={r9}\n"
));
}
let t = KIT_DB_CLONE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 || !validate_cards_build(base) {
write_log("KIT_TRACE: CardsDLL unavailable/invalid; kit trace inactive\n");
return;
}
// FUN_1800be6a0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 a1 (copy_len 16).
install_detour(
base, 0xbe6a0, "GetMatchKits_DP(0xbe6a0)", 16,
&[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xa1],
get_match_kits_wrapper as *const () as usize, &GET_MATCH_KITS_TRAMP,
);
// FUN_180196760: 48 89 54 24 10 53 48 83 ec 30 48 c7 44 24 20 fe ff ff ff (copy_len 19).
install_detour(
base, 0x96760, "setAvailableKits(0x96760)", 19,
&[0x48, 0x89, 0x54, 0x24, 0x10, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
set_available_kits_wrapper as *const () as usize, &SET_AVAILABLE_KITS_TRAMP,
);
// FUN_1801c3480: 48 89 5c 24 08 57 48 83 ec 60 <48 8b 05 disp32> (rip-relative
// MOV RAX,[rip+..] at copied offset 10; disp32 at 13, insn end 17; copy_len 17).
install_detour_reloc(
base, 0x1c3480, "kitItemClone(0x1c3480)", 17,
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0x8b, 0x05, 0x4f, 0x82, 0x11, 0x00],
13, 17,
kit_item_clone_wrapper as *const () as usize, &KIT_ITEM_CLONE_TRAMP,
);
// FUN_1801c44b0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 c8 (copy_len 16).
install_detour(
base, 0x1c44b0, "kitDbClone(0x1c44b0)", 16,
&[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xc8],
kit_db_clone_wrapper as *const () as usize, &KIT_DB_CLONE_TRAMP,
);
write_log("KIT_TRACE: all kit-selector traces armed\n");
}
/// Arm the passive kit-selector diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
write_log("KIT_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
+196 -32
View File
@@ -1,68 +1,200 @@
// The `fifa17` feature compiles this shared crate but activates only the FIFA-17
// injection path (fifa17.rs + sbc_*): install_hooks() routes to fifa17::install()
// and the FIFA-23 hook modules are reached solely via install_hooks_fifa23(), which
// is itself `#[cfg(not(feature = "fifa17"))]`. Those modules are therefore compiled
// but unused under `fifa17` (the linker strips them from the cdylib). Scope the
// resulting dead-code/unused-import lints to that feature so both builds stay
// `-D warnings` clean without dropping code the default (FIFA-23) build needs.
#![cfg_attr(feature = "fifa17", allow(dead_code, unused_imports))]
mod config;
mod connect_hook;
mod connectex_hook;
mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
mod hooks;
#[cfg(feature = "fifa17")]
mod kit_trace;
mod iat;
mod origin_spy;
#[cfg(feature = "probe")]
mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
#[cfg(feature = "fifa17")]
mod sbc_hook;
#[cfg(feature = "fifa17")]
mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod season_trace;
mod ssl_patch;
#[cfg(feature = "fifa17")]
mod store_entry;
mod tls_bypass;
mod transport_watch;
mod version_proxy;
use windows_sys::Win32::{
Foundation::{BOOL, HMODULE, TRUE},
System::SystemServices::DLL_PROCESS_ATTACH,
Networking::WinSock::ADDRINFOA,
System::SystemServices::DLL_PROCESS_ATTACH,
};
pub(crate) fn write_log(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true).append(true)
.create(true)
.append(true)
.open(r"C:\openfut_hook.log")
{ let _ = f.write_all(msg.as_bytes()); }
{
let _ = f.write_all(msg.as_bytes());
}
}
/// Force the log to stable storage. `write_log` already opens+closes the file per line,
/// so nothing is buffered *inside our process* (a process crash can't lose a written
/// line). `sync_all` additionally flushes the OS cache to disk, for durability even
/// across a full system crash. We call this right before the dial trigger's call so the
/// pre-call log line is guaranteed on disk if the call faults.
#[allow(dead_code)]
pub(crate) fn flush_log() {
if let Ok(f) = std::fs::OpenOptions::new()
.append(true)
.open(r"C:\openfut_hook.log")
{
let _ = f.sync_all();
}
}
/// # Safety
///
/// This is the DLL entry point invoked by the Windows loader; it MUST NOT be
/// called manually. `module` must be the valid `HMODULE` the loader passes for
/// this DLL. On `DLL_PROCESS_ATTACH` it installs process-wide inline detours
/// (raw memory patching), so it must run exactly once, on the loader thread,
/// before any hooked API is used.
#[no_mangle]
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
if reason == DLL_PROCESS_ATTACH { install_hooks(module); }
if reason == DLL_PROCESS_ATTACH {
// VERSION forwarding must be ready before DllMain returns. Hook setup
// may be deferred, but a caller can use any proxy export immediately.
if version_proxy::resolve() {
install_hooks(module);
}
}
TRUE
}
unsafe fn install_hooks(module: HMODULE) {
// FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
#[cfg(feature = "fifa17")]
{
let _ = module;
fifa17::install();
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
}
#[cfg(not(feature = "fifa17"))]
unsafe fn install_hooks_fifa23(module: HMODULE) {
write_log("openfut_hook: DllMain fired\n");
// Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so
// the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity.
transport_watch::arm_from_env();
let ip = config::read_redirect_ip(module);
hooks::set_redirect_ip(ip);
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
if !ga.is_null() {
let f: unsafe extern "system" fn(*const u8,*const u8,*const ADDRINFOA,*mut *mut ADDRINFOA)->i32
= std::mem::transmute(ga);
let f: unsafe extern "system" fn(
*const u8,
*const u8,
*const ADDRINFOA,
*mut *mut ADDRINFOA,
) -> i32 = std::mem::transmute(ga);
hooks::set_real(f);
let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ());
let m = iat::patch_iat_in(b"EAWebKit.dll\0", ga, hooks::hooked_getaddrinfo as *const ());
let m = iat::patch_iat_in(
b"EAWebKit.dll\0",
ga,
hooks::hooked_getaddrinfo as *const (),
);
write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n"));
}
if ssl_patch::patch_main_exe_cert_verify() { write_log("ssl: main exe cert-verify patched\n"); }
else { write_log("ssl: main exe cert-verify NOT FOUND\n"); }
if ssl_patch::patch_eawebkit_cert_verify() { write_log("ssl: EAWebKit cert-verify patched\n"); }
else { write_log("ssl: EAWebKit cert-verify deferred\n"); }
if ssl_patch::patch_main_exe_cert_verify() {
write_log("ssl: main exe cert-verify patched\n");
} else {
write_log("ssl: main exe cert-verify NOT FOUND\n");
}
if ssl_patch::patch_eawebkit_cert_verify() {
write_log("ssl: EAWebKit cert-verify patched\n");
} else {
write_log("ssl: EAWebKit cert-verify deferred\n");
}
if connect_hook::install_inline_connect_hook() { write_log("connect: inline-hooked\n"); }
else { write_log("connect: hook FAILED\n"); }
if connect_hook::install_inline_connect_hook() {
write_log("connect: inline-hooked\n");
} else {
write_log("connect: hook FAILED\n");
}
let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
if !wp.is_null() {
let f: unsafe extern "system" fn(usize,*const u8,i32,*const(),*const(),*const(),*const())->i32
= std::mem::transmute(wp);
let f: unsafe extern "system" fn(
usize,
*const u8,
i32,
*const (),
*const (),
*const (),
*const (),
) -> i32 = std::mem::transmute(wp);
connect_hook::set_real_wsa_connect(f);
iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ());
write_log("connect: WSAConnect IAT patched\n");
}
if connectex_hook::install_wsaioctl_hook() { write_log("connectex: WSAIoctl inline-hooked\n"); }
else { write_log("connectex: WSAIoctl hook FAILED\n"); }
if connectex_hook::install_wsaioctl_hook() {
write_log("connectex: WSAIoctl inline-hooked\n");
} else {
write_log("connectex: WSAIoctl hook FAILED\n");
}
// RE instrumentation: passive logging detours on FIFA's in-process online-flow
// functions (GoOnline, GetInternetConnectedState, event deserializers) to see
// where FIFA stalls after our pushed LSX events. Deferred until anadius loads.
#[cfg(feature = "probe")]
{
probe::install_probes_deferred();
write_log("probe: deferred install scheduled\n");
}
// recv/send hooks removed — LSX is now handled by the native openfut-bridge
// LSX server (port 3216), so in-process interception is no longer needed.
//
// Except in the `capture_baseline` build: with the LSX redirect off, FIFA talks
// to anadius directly, and these hooks log anadius's real LSX request/response
// frames (pass-through, no emulation) so we can diff them against our bridge.
#[cfg(feature = "capture_baseline")]
{
if recv_hook::install_recv_hook() {
write_log("CAP: recv inline-hooked\n");
} else {
write_log("CAP: recv hook FAILED\n");
}
if recv_hook::install_send_hook() {
write_log("CAP: send inline-hooked\n");
} else {
write_log("CAP: send hook FAILED\n");
}
}
macro_rules! hook_iat {
($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{
@@ -72,31 +204,63 @@ unsafe fn install_hooks(module: HMODULE) {
origin_spy::$setter(f);
iat::patch_iat(ptr, $handler as *const ());
"ok"
} else { "miss" }
} else {
"miss"
}
}};
}
let ra = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExA\0", set_real_reg_a,
let ra = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExA\0",
set_real_reg_a,
origin_spy::hooked_reg_query_a,
unsafe extern "system" fn(isize,*const u8,*mut u32,*mut u32,*mut u8,*mut u32)->i32);
let rw = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExW\0", set_real_reg_w,
unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
);
let rw = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExW\0",
set_real_reg_w,
origin_spy::hooked_reg_query_w,
unsafe extern "system" fn(isize,*const u16,*mut u32,*mut u32,*mut u8,*mut u32)->i32);
let ma = hook_iat!(b"kernel32.dll\0", b"OpenMutexA\0", set_real_mutex_a,
unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
);
let ma = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexA\0",
set_real_mutex_a,
origin_spy::hooked_open_mutex_a,
unsafe extern "system" fn(u32,i32,*const u8)->isize);
let mw = hook_iat!(b"kernel32.dll\0", b"OpenMutexW\0", set_real_mutex_w,
unsafe extern "system" fn(u32, i32, *const u8) -> isize
);
let mw = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexW\0",
set_real_mutex_w,
origin_spy::hooked_open_mutex_w,
unsafe extern "system" fn(u32,i32,*const u16)->isize);
write_log(&format!("origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"));
unsafe extern "system" fn(u32, i32, *const u16) -> isize
);
write_log(&format!(
"origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"
));
let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0");
if !cv.is_null() {
let f: unsafe extern "system" fn(*const u8,*const(),*const(),*mut u32)->BOOL
= std::mem::transmute(cv);
let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL =
std::mem::transmute(cv);
tls_bypass::set_real(f);
iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(b"EAWebKit.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(b"winhttp.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(b"wininet.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(
b"EAWebKit.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"winhttp.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"wininet.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
}
}
+39 -14
View File
@@ -27,28 +27,49 @@ static REAL_REG_W: OnceLock<RegQueryValueExWFn> = OnceLock::new();
static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new();
static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new();
pub fn set_real_reg_a(f: RegQueryValueExAFn) { let _ = REAL_REG_A.set(f); }
pub fn set_real_reg_w(f: RegQueryValueExWFn) { let _ = REAL_REG_W.set(f); }
pub fn set_real_mutex_a(f: OpenMutexAFn) { let _ = REAL_MUTEX_A.set(f); }
pub fn set_real_mutex_w(f: OpenMutexWFn) { let _ = REAL_MUTEX_W.set(f); }
pub fn set_real_reg_a(f: RegQueryValueExAFn) {
let _ = REAL_REG_A.set(f);
}
pub fn set_real_reg_w(f: RegQueryValueExWFn) {
let _ = REAL_REG_W.set(f);
}
pub fn set_real_mutex_a(f: OpenMutexAFn) {
let _ = REAL_MUTEX_A.set(f);
}
pub fn set_real_mutex_w(f: OpenMutexWFn) {
let _ = REAL_MUTEX_W.set(f);
}
fn narrow_to_string(p: *const u8) -> String {
if p.is_null() { return "(null)".into(); }
if p.is_null() {
return "(null)".into();
}
let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) };
bytes.to_string_lossy().into_owned()
}
fn wide_to_string(p: *const u16) -> String {
if p.is_null() { return "(null)".into(); }
if p.is_null() {
return "(null)".into();
}
let mut len = 0usize;
unsafe { while *p.add(len) != 0 { len += 1; } }
unsafe {
while *p.add(len) != 0 {
len += 1;
}
}
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) })
}
fn is_interesting(name: &str) -> bool {
name.contains("LSX") || name.contains("Origin") || name.contains("EAL") ||
name.contains("Client") || name.contains("lsx") || name.contains("Port") ||
name.contains("EA") || name.contains("Connection")
name.contains("LSX")
|| name.contains("Origin")
|| name.contains("EAL")
|| name.contains("Client")
|| name.contains("lsx")
|| name.contains("Port")
|| name.contains("EA")
|| name.contains("Connection")
}
pub unsafe extern "system" fn hooked_reg_query_a(
@@ -93,8 +114,10 @@ pub unsafe extern "system" fn hooked_open_mutex_a(
let name = narrow_to_string(lpmutexname);
let real = REAL_MUTEX_A.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!("origin_spy: OpenMutexA({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }));
crate::write_log(&format!(
"origin_spy: OpenMutexA({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle
}
@@ -106,7 +129,9 @@ pub unsafe extern "system" fn hooked_open_mutex_w(
let name = wide_to_string(lpmutexname);
let real = REAL_MUTEX_W.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!("origin_spy: OpenMutexW({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }));
crate::write_log(&format!(
"origin_spy: OpenMutexW({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle
}
File diff suppressed because it is too large Load Diff
+191 -66
View File
@@ -12,7 +12,8 @@ unsafe fn write_jmp(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
target.write(0xFF); target.add(1).write(0x25);
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
@@ -22,32 +23,48 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
use windows_sys::Win32::System::Memory::{
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
};
// Log prologue so we can diagnose if trampolines misbehave
let bytes: [u8; 14] = core::array::from_fn(|i| *orig.add(i));
let hex: String = bytes.iter().map(|b| format!("{b:02x} ")).collect();
// Read enough prologue to walk instruction boundaries.
let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i));
let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect();
crate::write_log(&format!("recv_hook: {name} prologue {hex}\n"));
// Walk instruction boundaries to find relative branches.
// Byte-by-byte scanning mis-identifies immediate operands (e.g. `sub rsp, 0x70`)
// as jump opcodes, so we must parse properly.
if has_rip_relative_branch(&bytes) {
crate::write_log(&format!("recv_hook: {name} has relative branch in prologue, skipping trampoline\n"));
return None;
// Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP
// patch), so the trampoline never splits an instruction. Copying a fixed 14
// bytes lands mid-instruction on these prologues and crashes on execution.
let mut copy_len = 0usize;
while copy_len < 14 {
let (len, branch) = decode_instr_len(&probe[copy_len..]);
if len == 0 || branch {
crate::write_log(&format!(
"recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n"
));
return None;
}
copy_len += len;
}
let mem = VirtualAlloc(
core::ptr::null_mut(), 32,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE,
core::ptr::null_mut(),
64,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
);
if mem.is_null() { crate::write_log("recv_hook: VirtualAlloc failed\n"); return None; }
if mem.is_null() {
crate::write_log("recv_hook: VirtualAlloc failed\n");
return None;
}
let t = mem as *mut u8;
core::ptr::copy_nonoverlapping(orig, t, 14);
// JMP [RIP+0] → orig+14
let cont = (orig as u64) + 14;
t.add(14).write(0xFF); t.add(15).write(0x25);
(t.add(16) as *mut u32).write(0);
(t.add(20) as *mut u64).write(cont);
core::ptr::copy_nonoverlapping(orig, t, copy_len);
// JMP [RIP+0] → orig+copy_len (resume at the next whole instruction)
let cont = (orig as u64) + copy_len as u64;
t.add(copy_len).write(0xFF);
t.add(copy_len + 1).write(0x25);
(t.add(copy_len + 2) as *mut u32).write(0);
(t.add(copy_len + 6) as *mut u64).write(cont);
crate::write_log(&format!(
"recv_hook: {name} trampoline copy_len={copy_len}\n"
));
Some(t as usize)
}
@@ -58,8 +75,12 @@ fn has_rip_relative_branch(bytes: &[u8]) -> bool {
let mut pos = 0;
while pos < bytes.len() {
let (len, branch) = decode_instr_len(&bytes[pos..]);
if branch { return true; }
if len == 0 { break; } // unknown/truncated — stop safely
if branch {
return true;
}
if len == 0 {
break;
} // unknown/truncated — stop safely
pos += len;
}
false
@@ -69,9 +90,29 @@ fn modrm_extra(modrm: u8) -> usize {
let md = (modrm >> 6) & 3;
let rm = modrm & 7;
match md {
0 => if rm == 5 { 4 } else if rm == 4 { 1 } else { 0 },
1 => if rm == 4 { 2 } else { 1 },
2 => if rm == 4 { 5 } else { 4 },
0 => {
if rm == 5 {
4
} else if rm == 4 {
1
} else {
0
}
}
1 => {
if rm == 4 {
2
} else {
1
}
}
2 => {
if rm == 4 {
5
} else {
4
}
}
_ => 0,
}
}
@@ -79,16 +120,31 @@ fn modrm_extra(modrm: u8) -> usize {
/// Returns (instruction_length_in_bytes, is_rip_relative_branch).
/// Returns (0, false) for unknown/truncated.
fn decode_instr_len(b: &[u8]) -> (usize, bool) {
if b.is_empty() { return (0, false); }
if b.is_empty() {
return (0, false);
}
let mut i = 0;
// Legacy prefixes
while let Some(&p) = b.get(i) {
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { i += 1; } else { break; }
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) {
i += 1;
} else {
break;
}
}
// REX prefix (404F)
if b.get(i).copied().map(|x| (0x40..=0x4F).contains(&x)).unwrap_or(false) { i += 1; }
if b.get(i)
.copied()
.map(|x| (0x40..=0x4F).contains(&x))
.unwrap_or(false)
{
i += 1;
}
let op = match b.get(i) { Some(&x) => x, None => return (0, false) };
let op = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
match op {
@@ -103,28 +159,45 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
0xE9 | 0xE8 => (i + 4, true),
// 0F prefix
0x0F => {
let op2 = match b.get(i) { Some(&x) => x, None => return (0, false) };
let op2 = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
if (0x80..=0x8F).contains(&op2) { return (i + 4, true); } // Jcc rel32
// Most 0F XX: ModRM
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
if (0x80..=0x8F).contains(&op2) {
return (i + 4, true);
} // Jcc rel32
// Most 0F XX: ModRM
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// Instructions with ModRM only (no immediate)
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F |
0x01 | 0x03 | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B |
0x31 | 0x33 | 0x39 | 0x3B | 0xD3 | 0xFF | 0xF7 => {
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03
| 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B
| 0xD3 | 0xFF | 0xF7 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// ModRM + imm8
0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => {
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 1, false)
}
// ModRM + imm32
0x69 | 0x81 | 0xC7 => {
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 4, false)
}
// MOV reg, imm8/imm32
@@ -143,7 +216,9 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let h = GetModuleHandleA(dll.as_ptr());
if h.is_null() { return None; }
if h.is_null() {
return None;
}
GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8)
}
@@ -151,46 +226,96 @@ unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
if crate::lsx::is_lsx(s) {
return crate::lsx::on_recv(s, buf, len);
/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216).
/// Used in capture mode to tap only the LSX conversation.
unsafe fn peer_is_lsx(s: usize) -> bool {
use windows_sys::Win32::Networking::WinSock::getpeername;
let mut sa = [0u8; 16];
let mut sl: i32 = 16;
if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 {
return false;
}
let t = RECV_TRAMPOLINE.load(Ordering::Relaxed);
if t == 0 { return -1; }
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
// sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order).
u16::from_be_bytes([sa[2], sa[3]]) == 3216
}
// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have
// instructions that straddle the 14-byte patch boundary, so an inline trampoline
// corrupts them and crashes. IAT hooking only swaps import-table pointers and
// never touches the function body). The real fns are resolved in lib.rs and set
// here; our hooks call them directly.
static REAL_RECV: AtomicUsize = AtomicUsize::new(0);
static REAL_SEND: AtomicUsize = AtomicUsize::new(0);
pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) {
REAL_RECV.store(f as usize, Ordering::Relaxed);
}
pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) {
REAL_SEND.store(f as usize, Ordering::Relaxed);
}
/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our
/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks
/// catch calls from every module and dynamically-resolved calls, unlike IAT.
pub unsafe fn install_recv_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { Some(p) => p, None => return false };
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "recv") {
Some(t) => { RECV_TRAMPOLINE.store(t, Ordering::Relaxed); }
None => { crate::write_log("recv_hook: recv trampoline failed, hook skipped\n"); return false; }
Some(t) => REAL_RECV.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_recv as u64);
true
}
// ─── send ──────────────────────────────────────────────────────────────────────
static SEND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if crate::lsx::is_lsx(s) {
return crate::lsx::on_send(s, buf, len);
}
let t = SEND_TRAMPOLINE.load(Ordering::Relaxed);
if t == 0 { return -1; }
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
}
pub unsafe fn install_send_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { Some(p) => p, None => return false };
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "send") {
Some(t) => { SEND_TRAMPOLINE.store(t, Ordering::Relaxed); }
None => { crate::write_log("recv_hook: send trampoline failed, hook skipped\n"); return false; }
Some(t) => REAL_SEND.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_send as u64);
true
}
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
let t = REAL_RECV.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
// Pass through to anadius's real socket, then log what it sent back
// (anadius's LSX response — the ground truth we want to diff against).
let n = f(s, buf, len, flags);
if n > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, n as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP recv<-anadius s={s} n={n}: {}\n",
&text[..text.len().min(2400)]
));
}
n
}
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if len > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, len as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP send->anadius s={s} len={len}: {}\n",
&text[..text.len().min(2400)]
));
}
let t = REAL_SEND.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
}
+856
View File
@@ -0,0 +1,856 @@
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
//!
//! The repair is a PROMOTED feature: it is armed by the build itself, never by an
//! environment variable (see [`REPAIR_PROMOTED`]). Safety lives in the runtime
//! evidence gate, not in a flag.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualAlloc, VirtualFree, VirtualProtect, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE,
PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
const COMPLETION_RVA: usize = 0x0b8950;
const EVENT_DISPATCH_RVA: usize = 0x1a4cd0;
const CATEGORY_RESPONSE_VTABLE_RVA: usize = 0x22e5b0;
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
const COMPLETION_COPY_LEN: usize = 14;
const EVENT_COPY_LEN: usize = 16;
const ABS_JUMP_LEN: usize = 14;
const COMPLETION_TRAMPOLINE_LEN: usize = 12 + 2 + ABS_JUMP_LEN * 2;
const UNKNOWN_TRANSPORT_STATUS: u32 = 999;
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
const FUT_SBS_CATEGORIES_READY_EVENT: u32 = 0x756d;
const SBC_REFRESH_EVENT: u32 = 0x138c;
const COMPLETION_SIGNATURE: [u8; 32] = [
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2, 0x74, 0x4e, 0x83, 0x7a,
0x1c, 0x00, 0x75, 0x48, 0xc6, 0x81, 0x1d, 0x02, 0x00, 0x00, 0x01, 0x48, 0x8b, 0x89, 0x40, 0x01,
];
const EVENT_SIGNATURE: [u8; EVENT_COPY_LEN] = [
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x40, 0xb8, 0xfe, 0xff, 0xff, 0xff,
];
type CompletionFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> usize;
type EventDispatchFn = unsafe extern "system" fn(*mut c_void, u32, *mut c_void) -> usize;
/// The guarded native dispatch repair is PROMOTED: armed by the build, never by an
/// environment variable. Retail Gates AG passed on the pinned CardsDLL build, so a
/// deployed hook must repair the SBC completion on every launch path (Steam, the
/// launcher, or a bare `umu-run`) with nothing to export.
///
/// Promotion does NOT weaken any check — every guard stays in the runtime evidence
/// gate rather than in a flag. `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's notifier having entered AND returned, the captured
/// controller/model identity, and one repair per deserializer generation. Anything
/// unrecognised leaves native execution untouched.
///
/// Rollback is a file swap (restore the previous `version.dll`) — the documented
/// client rollback path — deliberately not an env kill-switch.
pub(crate) const REPAIR_PROMOTED: bool = true;
/// Compile-time contract: the repair stays armed by the build. Flipping this back to
/// an env gate would silently cost a normal launch (Steam or the launcher) its SBC
/// screen, which is exactly the regression promotion removed — so it must be a
/// deliberate, visible change here rather than a missing variable at runtime.
const _: () = assert!(REPAIR_PROMOTED);
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
static COMPLETION_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static EVENT_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static LAST_REPAIRED_GENERATION: AtomicU64 = AtomicU64::new(0);
static COMPLETION_ENTRIES: AtomicU64 = AtomicU64::new(0);
static COMPLETION_EXITS: AtomicU64 = AtomicU64::new(0);
static COMPLETION_THREAD: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_STATUS_OBJECT: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_STATUS: AtomicUsize = AtomicUsize::new(usize::MAX);
static COMPLETION_GENERATION: AtomicU64 = AtomicU64::new(0);
static COMPLETION_DECISION: AtomicUsize = AtomicUsize::new(Decision::NativeSuccess as usize);
static COMPLETION_REJECTION: AtomicUsize = AtomicUsize::new(Rejection::None as usize);
static EVENT_ENTRIES: AtomicU64 = AtomicU64::new(0);
static EVENT_EXITS: AtomicU64 = AtomicU64::new(0);
static EVENT_THREAD: AtomicUsize = AtomicUsize::new(0);
static EVENT_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static EVENT_ID: AtomicUsize = AtomicUsize::new(0);
static EVENT_PAYLOAD: AtomicUsize = AtomicUsize::new(0);
static EVENT_CATEGORIES: AtomicU64 = AtomicU64::new(0);
static EVENT_REFRESH: AtomicU64 = AtomicU64::new(0);
static EVENT_READY: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Decision {
NativeSuccess,
Repair,
}
const REJECTED_DECISION: usize = 2;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Rejection {
None,
RepairDisabled,
NullStatus,
StatusUnreadable,
UnsupportedStatus,
CardsBuildMismatch,
ParserUnbalanced,
FactoryMismatch,
ParserThreadMismatch,
ReaderMissing,
ParseFailed,
ResponseClassMismatch,
ModelChanged,
ModelEmpty,
NotifierNotCurrent,
ControllerMismatch,
ControllerModelMismatch,
DuplicateGeneration,
}
#[derive(Clone, Copy)]
struct DecisionInput {
repair_enabled: bool,
status: Option<u32>,
status_present: bool,
status_copyable: bool,
cards_build_matches: bool,
factory_entries: u64,
factory_exits: u64,
factory_result: usize,
factory_thread: usize,
deserializer_entries: u64,
deserializer_exits: u64,
deserializer_this: usize,
deserializer_reader: usize,
deserializer_result: bool,
deserializer_thread: usize,
response_class_matches: bool,
model: usize,
live_category_count: usize,
category_count: usize,
notifier_entries: u64,
notifier_exits: u64,
controller_matches: bool,
controller_model_matches: bool,
last_repaired_generation: u64,
}
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
let Some(status) = input.status else {
return Err(if input.status_present {
Rejection::StatusUnreadable
} else {
Rejection::NullStatus
});
};
if status == 0 {
return Ok(Decision::NativeSuccess);
}
if !input.repair_enabled {
return Err(Rejection::RepairDisabled);
}
if status != UNKNOWN_TRANSPORT_STATUS {
return Err(Rejection::UnsupportedStatus);
}
if !input.status_copyable {
return Err(Rejection::StatusUnreadable);
}
if !input.cards_build_matches {
return Err(Rejection::CardsBuildMismatch);
}
let generation = input.deserializer_exits;
if generation == 0
|| input.factory_entries != input.factory_exits
|| input.deserializer_entries != generation
|| input.factory_exits != generation
{
return Err(Rejection::ParserUnbalanced);
}
if input.factory_result == 0 || input.factory_result != input.deserializer_this {
return Err(Rejection::FactoryMismatch);
}
if input.factory_thread == 0 || input.factory_thread != input.deserializer_thread {
return Err(Rejection::ParserThreadMismatch);
}
if input.deserializer_reader == 0 {
return Err(Rejection::ReaderMissing);
}
if !input.deserializer_result {
return Err(Rejection::ParseFailed);
}
if !input.response_class_matches {
return Err(Rejection::ResponseClassMismatch);
}
if input.model == 0 || input.category_count == 0 || input.category_count == usize::MAX {
return Err(Rejection::ModelEmpty);
}
if input.live_category_count != input.category_count {
return Err(Rejection::ModelChanged);
}
// The category-success notifier for this generation must have entered and
// fully returned before the SBC completion runs. On the pinned CardsDLL the
// completion fires immediately after the notifier unwinds (measured: notifier
// entries == exits == generation at completion), not nested inside it, so we
// bind both notifier counts to the current generation rather than requiring
// an in-flight notifier.
if input.notifier_entries != generation
|| input.notifier_entries == 0
|| input.notifier_exits != generation
{
return Err(Rejection::NotifierNotCurrent);
}
if !input.controller_matches {
return Err(Rejection::ControllerMismatch);
}
if !input.controller_model_matches {
return Err(Rejection::ControllerModelMismatch);
}
if input.last_repaired_generation >= generation {
return Err(Rejection::DuplicateGeneration);
}
Ok(Decision::Repair)
}
/// The parsed response is the FIFA 17 typed SBC-category response only when the
/// vtable captured at deserializer exit (object provably live) equals the pinned
/// category-response vtable for the running CardsDLL image. A zero capture means
/// the object vtable was unreadable and never qualifies.
fn response_class_matches(base: usize, response_vtable: usize) -> bool {
response_vtable != 0 && base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA) == Some(response_vtable)
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
crate::sbc_trace::readable_range(address, 4)
.then(|| core::ptr::read_volatile(address as *const u32))
}
unsafe fn status_code(status: usize) -> Option<u32> {
status
.checked_add(0x1c)
.and_then(|address| guarded_u32(address))
}
unsafe fn controller_identity(base: usize, controller: usize, model: usize) -> (bool, bool) {
if base == 0 || controller == 0 {
return (false, false);
}
let main_vtable = crate::sbc_trace::guarded_usize(controller);
let event_vtable = controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|address| crate::sbc_trace::guarded_usize(address));
let controller_model = controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|address| crate::sbc_trace::guarded_usize(address));
(
main_vtable == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
&& event_vtable == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA),
controller_model == Some(model),
)
}
pub(crate) unsafe fn note_sbc_controller(controller: usize, base: usize) {
let (identity_matches, _) = controller_identity(base, controller, 0);
if identity_matches {
SBC_CONTROLLER.store(controller, Ordering::Release);
crate::write_log(&format!(
"SBC_DISPATCH: captured category controller={controller:#x}\n"
));
} else {
crate::write_log(&format!(
"SBC_DISPATCH: rejected category controller={controller:#x} (class mismatch)\n"
));
}
}
#[repr(C, align(16))]
struct CompletionStatusShadow([u8; 0x20]);
unsafe extern "system" fn completion_wrapper(
controller: *mut c_void,
status: *mut c_void,
) -> usize {
COMPLETION_ENTRIES.fetch_add(1, Ordering::Relaxed);
COMPLETION_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
COMPLETION_CONTROLLER.store(controller as usize, Ordering::Relaxed);
COMPLETION_STATUS_OBJECT.store(status as usize, Ordering::Relaxed);
let evidence = crate::sbc_trace::dispatch_evidence();
let status_address = status as usize;
let observed_status = if status_address == 0 {
None
} else {
status_code(status_address)
};
COMPLETION_STATUS.store(
observed_status
.map(|value| value as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed);
let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire);
let live_category_count = evidence
.model
.checked_add(0x50)
.and_then(|address| crate::sbc_trace::guarded_u16(address))
.map(usize::from)
.unwrap_or(usize::MAX);
let (controller_matches, controller_model_matches) =
controller_identity(evidence.base, captured_controller, evidence.model);
let input = DecisionInput {
repair_enabled: REPAIR_ENABLED.load(Ordering::Acquire),
status: observed_status,
status_present: status_address != 0,
status_copyable: status_address != 0
&& crate::sbc_trace::readable_range(status_address, 0x20),
cards_build_matches: crate::sbc_trace::valid_cards_image(evidence.base),
factory_entries: evidence.factory_entries,
factory_exits: evidence.factory_exits,
factory_result: evidence.factory_result,
factory_thread: evidence.factory_thread,
deserializer_entries: evidence.deserializer_entries,
deserializer_exits: evidence.deserializer_exits,
deserializer_this: evidence.deserializer_this,
deserializer_reader: evidence.deserializer_reader,
deserializer_result: evidence.deserializer_result,
deserializer_thread: evidence.deserializer_thread,
response_class_matches: response_class_matches(evidence.base, evidence.response_vtable),
model: evidence.model,
live_category_count,
category_count: evidence.category_count,
notifier_entries: evidence.notifier_entries,
notifier_exits: evidence.notifier_exits,
controller_matches: controller_matches && captured_controller == controller as usize,
controller_model_matches,
last_repaired_generation: LAST_REPAIRED_GENERATION.load(Ordering::Acquire),
};
let original: CompletionFn =
core::mem::transmute(COMPLETION_TRAMPOLINE.load(Ordering::Acquire));
let result = match decide(input) {
Ok(Decision::Repair) => {
if LAST_REPAIRED_GENERATION
.compare_exchange(
input.last_repaired_generation,
evidence.deserializer_exits,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
let mut shadow = CompletionStatusShadow([0; 0x20]);
core::ptr::copy_nonoverlapping(
status_address as *const u8,
shadow.0.as_mut_ptr(),
shadow.0.len(),
);
shadow.0[0x1c..0x20].copy_from_slice(&0u32.to_le_bytes());
COMPLETION_DECISION.store(Decision::Repair as usize, Ordering::Relaxed);
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
original(controller, shadow.0.as_mut_ptr().cast())
} else {
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
COMPLETION_REJECTION
.store(Rejection::DuplicateGeneration as usize, Ordering::Relaxed);
original(controller, status)
}
}
Ok(Decision::NativeSuccess) => {
COMPLETION_DECISION.store(Decision::NativeSuccess as usize, Ordering::Relaxed);
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
original(controller, status)
}
Err(rejection) => {
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
COMPLETION_REJECTION.store(rejection as usize, Ordering::Relaxed);
original(controller, status)
}
};
crate::write_log(&format!(
"SBC_DISPATCH: decide gen={} status={} present={} copyable={} cards={} factory_e={} factory_x={} factory_r={:#x} factory_t={} deser_e={} deser_x={} deser_this={:#x} reader={:#x} deser_ok={} deser_t={} vt_obs={:#x} vt_exp={:#x} class={} model={:#x} live={} count={} notif_e={} notif_x={} ctrl_match={} ctrl_model={} captured_ctrl={:#x} arg_ctrl={:#x} last_gen={} decision={} rejection={}\n",
input.deserializer_exits,
input.status.map(i64::from).unwrap_or(-1),
input.status_present,
input.status_copyable,
input.cards_build_matches,
input.factory_entries,
input.factory_exits,
input.factory_result,
input.factory_thread,
input.deserializer_entries,
input.deserializer_exits,
input.deserializer_this,
input.deserializer_reader,
input.deserializer_result,
input.deserializer_thread,
evidence.response_vtable,
evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA).unwrap_or(0),
input.response_class_matches,
input.model,
input.live_category_count,
input.category_count,
input.notifier_entries,
input.notifier_exits,
input.controller_matches,
input.controller_model_matches,
captured_controller,
controller as usize,
input.last_repaired_generation,
COMPLETION_DECISION.load(Ordering::Relaxed),
COMPLETION_REJECTION.load(Ordering::Relaxed),
));
COMPLETION_EXITS.fetch_add(1, Ordering::Release);
result
}
unsafe extern "system" fn event_wrapper(
controller: *mut c_void,
event: u32,
payload: *mut c_void,
) -> usize {
EVENT_ENTRIES.fetch_add(1, Ordering::Relaxed);
EVENT_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
EVENT_CONTROLLER.store(controller as usize, Ordering::Relaxed);
EVENT_ID.store(event as usize, Ordering::Relaxed);
EVENT_PAYLOAD.store(payload as usize, Ordering::Relaxed);
match event {
FUT_SBS_CATEGORIES_EVENT => {
EVENT_CATEGORIES.fetch_add(1, Ordering::Relaxed);
}
SBC_REFRESH_EVENT => {
EVENT_REFRESH.fetch_add(1, Ordering::Relaxed);
}
FUT_SBS_CATEGORIES_READY_EVENT => {
EVENT_READY.fetch_add(1, Ordering::Relaxed);
}
_ => {}
}
// Piggyback the store pre-warm on this game-thread hub event: it loads the
// purchase groups once, before the store screen is shown, so the store's native
// screen-show tab bind sees a populated group list (see `store_entry`).
crate::store_entry::maybe_prewarm_groups();
let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire));
let result = original(controller, event, payload);
EVENT_EXITS.fetch_add(1, Ordering::Release);
result
}
unsafe fn allocate_completion_trampoline(target: usize) -> Option<usize> {
let failure_target = target.checked_add(0x5c)?;
let success_target = target.checked_add(COMPLETION_COPY_LEN)?;
let memory = VirtualAlloc(
core::ptr::null(),
COMPLETION_TRAMPOLINE_LEN,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
) as usize;
if memory == 0 {
return None;
}
core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, 12);
// The relocated branch preserves the original null-status failure edge.
core::ptr::copy_nonoverlapping([0x75, 0x0e].as_ptr(), (memory + 12) as *mut u8, 2);
let failure = crate::sbc_trace::absolute_jump(failure_target);
core::ptr::copy_nonoverlapping(failure.as_ptr(), (memory + 14) as *mut u8, ABS_JUMP_LEN);
let success = crate::sbc_trace::absolute_jump(success_target);
core::ptr::copy_nonoverlapping(success.as_ptr(), (memory + 28) as *mut u8, ABS_JUMP_LEN);
let mut old = 0u32;
if VirtualProtect(
memory as _,
COMPLETION_TRAMPOLINE_LEN,
PAGE_EXECUTE_READ,
&mut old,
) == 0
|| FlushInstructionCache(GetCurrentProcess(), memory as _, COMPLETION_TRAMPOLINE_LEN) == 0
{
VirtualFree(memory as _, 0, MEM_RELEASE);
return None;
}
Some(memory)
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
}
unsafe fn write_entry<const N: usize>(
target: usize,
destination: usize,
original: &[u8; N],
) -> Result<(), bool> {
let mut patch = [0x90u8; N];
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return Err(true);
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
Ok(())
} else {
Err(restore_entry(target, original))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_pair(base: usize) -> InstallOutcome {
let Some(completion) = crate::sbc_trace::target_va(base, COMPLETION_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(event) = crate::sbc_trace::target_va(base, EVENT_DISPATCH_RVA) else {
return InstallOutcome::CleanFailure;
};
let completion_original: [u8; COMPLETION_COPY_LEN] = COMPLETION_SIGNATURE
[..COMPLETION_COPY_LEN]
.try_into()
.unwrap();
if !crate::sbc_trace::valid_cards_image(base)
|| !crate::sbc_trace::executable_range_in_image(
base,
completion,
COMPLETION_SIGNATURE.len(),
)
|| !crate::sbc_trace::executable_range_in_image(base, event, EVENT_SIGNATURE.len())
|| core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
!= COMPLETION_SIGNATURE
|| core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) != EVENT_SIGNATURE
{
return InstallOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
completion as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(completion_trampoline) = allocate_completion_trampoline(completion) else {
return InstallOutcome::CleanFailure;
};
let Some(event_trampoline) = crate::sbc_trace::allocate_trampoline(event, EVENT_COPY_LEN)
else {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
return InstallOutcome::CleanFailure;
};
COMPLETION_TRAMPOLINE.store(completion_trampoline, Ordering::Release);
EVENT_TRAMPOLINE.store(event_trampoline, Ordering::Release);
let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(completion, event) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
}
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
return InstallOutcome::DegradedProcessState;
}
};
let final_valid = crate::sbc_trace::valid_cards_image(base)
&& core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
== COMPLETION_SIGNATURE
&& core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len())
== EVENT_SIGNATURE;
let transaction = if !final_valid {
InstallOutcome::CleanFailure
} else {
match write_entry(
completion,
completion_wrapper as *const () as usize,
&completion_original,
) {
Ok(()) => {
match write_entry(event, event_wrapper as *const () as usize, &EVENT_SIGNATURE) {
Ok(()) => InstallOutcome::Installed,
Err(event_clean) => {
let completion_clean = restore_entry(completion, &completion_original);
if event_clean && completion_clean {
InstallOutcome::CleanFailure
} else {
InstallOutcome::DegradedHookActive
}
}
}
}
Err(true) => InstallOutcome::CleanFailure,
Err(false) => InstallOutcome::DegradedHookActive,
}
};
let resumed = peers.resume_all();
let outcome = if resumed {
transaction
} else if matches!(
transaction,
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
) {
InstallOutcome::DegradedHookAndProcess
} else {
InstallOutcome::DegradedProcessState
};
if outcome == InstallOutcome::CleanFailure {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
}
outcome
}
unsafe fn worker() {
let _pending = crate::sbc_trace::CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let outcome = if base == 0 {
InstallOutcome::CleanFailure
} else {
install_pair(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => crate::write_log(
"SBC_DISPATCH: completion+event hooks installed; repair remains gate-controlled\n",
),
InstallOutcome::CleanFailure => {
crate::write_log("SBC_DISPATCH: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("SBC_DISPATCH: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("SBC_DISPATCH: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("SBC_DISPATCH: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut completion_seen = 0u64;
let mut event_seen = 0u64;
let mut reports = 0u8;
while reports < 64 {
std::thread::sleep(std::time::Duration::from_millis(250));
let completion_entries = COMPLETION_ENTRIES.load(Ordering::Acquire);
let event_entries = EVENT_ENTRIES.load(Ordering::Acquire);
if completion_entries != completion_seen || event_entries != event_seen {
crate::write_log(&format!(
"SBC_DISPATCH: completion entry={} exit={} tid={} controller={:#x} status_obj={:#x} status={} generation={} decision={} rejection={}; event entry={} exit={} tid={} controller={:#x} id={:#x} payload={:#x} categories={} refresh={} ready={}\n",
completion_entries,
COMPLETION_EXITS.load(Ordering::Acquire),
COMPLETION_THREAD.load(Ordering::Relaxed),
COMPLETION_CONTROLLER.load(Ordering::Relaxed),
COMPLETION_STATUS_OBJECT.load(Ordering::Relaxed),
COMPLETION_STATUS.load(Ordering::Relaxed),
COMPLETION_GENERATION.load(Ordering::Relaxed),
COMPLETION_DECISION.load(Ordering::Relaxed),
COMPLETION_REJECTION.load(Ordering::Relaxed),
event_entries,
EVENT_EXITS.load(Ordering::Acquire),
EVENT_THREAD.load(Ordering::Relaxed),
EVENT_CONTROLLER.load(Ordering::Relaxed),
EVENT_ID.load(Ordering::Relaxed),
EVENT_PAYLOAD.load(Ordering::Relaxed),
EVENT_CATEGORIES.load(Ordering::Relaxed),
EVENT_REFRESH.load(Ordering::Relaxed),
EVENT_READY.load(Ordering::Relaxed),
));
completion_seen = completion_entries;
event_seen = event_entries;
reports += 1;
}
}
crate::write_log("SBC_DISPATCH: report cap reached; hooks remain installed\n");
}
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates in the
// decision, so every launch path behaves identically.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log(
"SBC_DISPATCH: repair ARMED (promoted); strict native evidence gate enabled\n",
);
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input(generation: u64) -> DecisionInput {
DecisionInput {
repair_enabled: true,
status: Some(UNKNOWN_TRANSPORT_STATUS),
status_present: true,
status_copyable: true,
cards_build_matches: true,
factory_entries: generation,
factory_exits: generation,
factory_result: 0x2000,
factory_thread: 7,
deserializer_entries: generation,
deserializer_exits: generation,
deserializer_this: 0x2000,
deserializer_reader: 0x3000,
deserializer_result: true,
deserializer_thread: 7,
response_class_matches: true,
model: 0x4000,
live_category_count: 2,
category_count: 2,
notifier_entries: generation,
notifier_exits: generation,
controller_matches: true,
controller_model_matches: true,
last_repaired_generation: generation - 1,
}
}
#[test]
fn native_success_is_never_rewritten() {
let mut input = valid_input(1);
input.status = Some(0);
assert_eq!(decide(input), Ok(Decision::NativeSuccess));
}
#[test]
fn exact_unknown_status_and_full_evidence_allow_repair() {
assert_eq!(decide(valid_input(1)), Ok(Decision::Repair));
}
#[test]
fn repair_is_exactly_gated_and_fail_closed() {
let mut input = valid_input(1);
input.repair_enabled = false;
assert_eq!(decide(input), Err(Rejection::RepairDisabled));
let mut input = valid_input(1);
input.status = Some(500);
assert_eq!(decide(input), Err(Rejection::UnsupportedStatus));
let mut input = valid_input(1);
input.category_count = 0;
assert_eq!(decide(input), Err(Rejection::ModelEmpty));
let mut input = valid_input(1);
input.controller_matches = false;
assert_eq!(decide(input), Err(Rejection::ControllerMismatch));
let mut input = valid_input(1);
input.status = None;
input.status_present = false;
assert_eq!(decide(input), Err(Rejection::NullStatus));
let mut input = valid_input(1);
input.status = None;
assert_eq!(decide(input), Err(Rejection::StatusUnreadable));
// Notifier still in flight for this generation (has not returned) is rejected:
// on the pinned build the completion only runs after the notifier unwinds.
let mut input = valid_input(1);
input.notifier_exits = 0;
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
// A notifier count that does not match the current generation is rejected.
let mut input = valid_input(1);
input.notifier_entries = 2;
input.notifier_exits = 2;
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
let mut input = valid_input(1);
input.controller_model_matches = false;
assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch));
let mut input = valid_input(1);
input.live_category_count = 0;
assert_eq!(decide(input), Err(Rejection::ModelChanged));
}
#[test]
fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() {
let mut duplicate = valid_input(1);
duplicate.last_repaired_generation = 1;
assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration));
let next = valid_input(2);
assert_eq!(decide(next), Ok(Decision::Repair));
}
#[test]
fn response_class_requires_exact_pinned_vtable() {
let base = 0x1_8000_0000usize;
let expected = base + CATEGORY_RESPONSE_VTABLE_RVA;
assert!(response_class_matches(base, expected));
// An unreadable capture (zero) never qualifies.
assert!(!response_class_matches(base, 0));
// Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected.
assert!(!response_class_matches(base, expected + 8));
assert!(!response_class_matches(base, base));
}
#[test]
fn relocated_completion_branch_has_proven_layout() {
assert_eq!(COMPLETION_COPY_LEN, 14);
assert_eq!(
&COMPLETION_SIGNATURE[..12],
&[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2]
);
assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]);
assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42);
}
}
+651
View File
@@ -0,0 +1,651 @@
//! FIFA 17 SBC render intervention (feature = "fifa17").
//!
//! Makes the FUT **SBC menu render real data** from inside the process. Full spec
//! (all addresses, RVA math, call order, crash risks, staged test plan):
//! fifa17-recon/docs/sbc-hook-dll-spec.md
//!
//! Everything here is **inert by default** and gated by env vars:
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
//!
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
//! defer off the loader lock and poll for it — the same shape as
//! `probe::install_probes_deferred` polling for anadius64.dll.
//!
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva.
//! See the spec for the verified disassembly behind each one.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
};
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
const IMAGE_BASE: usize = 0x180000000;
/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes).
const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00
const CTRL_BYTES: &[u8] = &[
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
];
const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton)
const A_VTABLE_RVA: usize = 0x21c2a0;
const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache)
const B_VTABLE_RVA: usize = 0x1fae70;
const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
const B_DTOR_RVA: usize = 0x63040;
const B_ISVALID_RVA: usize = 0x65d40;
const B_CLEAR_RVA: usize = 0x65d20;
const B_READY_EXPECTED_BEFORE_ARM: u8 = 0;
#[allow(dead_code)]
const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter)
#[allow(dead_code)]
const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk)
// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for
// reference/wiring; not invoked while Tier-1 is blocked.
#[allow(dead_code)]
mod rva {
pub const M_LAZY_GETTER: usize = 0x11b7d0;
pub const ISVALID: usize = 0x65d40;
pub const DESER_SBS_SETS: usize = 0x17b2b0;
pub const SAX_CTX_INIT: usize = 0x1c63e0;
pub const REGISTRY_GETTER: usize = 0xd7170;
pub const MANAGER_GETTER: usize = 0x9c80;
pub const CLEAR_M: usize = 0x15f3a0;
pub const CAT_CTOR: usize = 0x159da0;
pub const CAT_DESER: usize = 0x17ab80;
pub const CAT_FINALIZE: usize = 0x160e50;
pub const APPEND: usize = 0x15a770;
pub const CAT_DTOR: usize = 0x1105d0;
pub const IDX_REBUILD_1: usize = 0x160e00;
pub const IDX_REBUILD_2: usize = 0x160f30;
pub const IDX_REBUILD_3: usize = 0x161020;
pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574
}
static ARMED: AtomicBool = AtomicBool::new(false);
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
static POPULATE: AtomicBool = AtomicBool::new(false);
static DONE: AtomicBool = AtomicBool::new(false);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum RuntimeState {
Disabled,
Resolved,
Intercepted,
Parsed,
Validated,
Committed,
Failed,
}
fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool {
matches!(
(from, to),
(RuntimeState::Disabled, RuntimeState::Resolved)
| (RuntimeState::Resolved, RuntimeState::Intercepted)
| (RuntimeState::Intercepted, RuntimeState::Parsed)
| (RuntimeState::Parsed, RuntimeState::Validated)
// Resolve-only/Tier-0 validates without installing an interceptor.
| (RuntimeState::Resolved, RuntimeState::Validated)
| (RuntimeState::Validated, RuntimeState::Committed)
| (_, RuntimeState::Failed)
)
}
fn transition(from: RuntimeState, to: RuntimeState) -> bool {
valid_transition(from, to)
&& STATE
.compare_exchange(
from as usize,
to as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ValidationError {
AddressOverflow,
AUnreadable,
AVtableMismatch,
AGetterMismatch,
BVtableMismatch,
BDtorMismatch,
BIsValidMismatch,
BClearMismatch,
MSlotUnreadable,
ReadyByteUnexpected,
CollectionUnreadable,
CollectionNotNull,
ReadyByteNotWritable,
}
#[derive(Clone, Copy, Debug)]
struct RuntimeSnapshot {
a: usize,
a_vtable: usize,
a_b_getter: usize,
b: usize,
b_vtable: usize,
b_dtor: usize,
b_isvalid: usize,
b_clear: usize,
b_ready: u8,
b_coll: usize,
m: usize,
}
fn expected_va(base: usize, rva: usize) -> Result<usize, ValidationError> {
base.checked_add(rva)
.ok_or(ValidationError::AddressOverflow)
}
fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> {
if s.a == 0
|| s.b
!= s.a
.checked_add(B_OFF)
.ok_or(ValidationError::AddressOverflow)?
{
return Err(ValidationError::AUnreadable);
}
if s.a_vtable != expected_va(base, A_VTABLE_RVA)? {
return Err(ValidationError::AVtableMismatch);
}
if s.a_b_getter != expected_va(base, 0x11c1f0)? {
return Err(ValidationError::AGetterMismatch);
}
if s.b_vtable != expected_va(base, B_VTABLE_RVA)? {
return Err(ValidationError::BVtableMismatch);
}
if s.b_dtor != expected_va(base, B_DTOR_RVA)? {
return Err(ValidationError::BDtorMismatch);
}
if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? {
return Err(ValidationError::BIsValidMismatch);
}
if s.b_clear != expected_va(base, B_CLEAR_RVA)? {
return Err(ValidationError::BClearMismatch);
}
if s.b_ready != B_READY_EXPECTED_BEFORE_ARM {
return Err(ValidationError::ReadyByteUnexpected);
}
if s.b_coll != 0 {
return Err(ValidationError::CollectionNotNull);
}
let _ = s.m; // The guarded snapshot read proves the M slot itself is readable.
Ok(())
}
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
/// in a committed, readable page and the full 8 bytes fit inside the region.
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
if ptr < 0x10000 || ptr & 7 != 0 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT {
return None;
}
if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize {
return None;
}
Some(core::ptr::read_volatile(ptr as *const usize))
}
/// Guarded byte read.
unsafe fn read_u8(ptr: usize) -> Option<u8> {
if ptr < 0x10000 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize {
return None;
}
Some(core::ptr::read_volatile(ptr as *const u8))
}
/// A Tier-0 write is allowed only when the complete byte lies in a committed,
/// non-guarded region whose current protection explicitly permits writes.
unsafe fn writable_u8(ptr: usize) -> bool {
if ptr < 0x10000 {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return false;
}
let protection = mbi.Protect & 0xff;
let writable = matches!(
protection,
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
);
writable
&& ptr
.checked_add(1)
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
}
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
let Some(end) = ptr.checked_add(len) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return false;
}
let protection = mbi.Protect & 0xff;
matches!(
protection,
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)
}
/// Guarded 16-bit read (M category count is a WORD).
unsafe fn read_u16(ptr: usize) -> Option<u16> {
let lo = read_u8(ptr)? as u16;
let hi = read_u8(ptr + 1)? as u16;
Some(lo | (hi << 8))
}
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
unsafe fn resolve_cards_base() -> usize {
let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast());
if !h.is_null() {
return h as usize;
}
// Also try the short form some tooling reports.
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr().cast());
if !h2.is_null() {
return h2 as usize;
}
0
}
#[inline]
fn va(base: usize, rva: usize) -> usize {
base + rva
}
/// Prove the module didn't move: the FNV control prologue must match the on-disk PE.
unsafe fn control_matches(base: usize) -> bool {
let p = va(base, CTRL_RVA);
for (i, &want) in CTRL_BYTES.iter().enumerate() {
match read_u8(p + i) {
Some(got) if got == want => {}
_ => return false,
}
}
true
}
/// Take one guarded identity snapshot. A failure to read any identity-bearing field is
/// distinct from a value mismatch and aborts before mutation.
unsafe fn runtime_snapshot(base: usize) -> Result<RuntimeSnapshot, ValidationError> {
let a_slot = expected_va(base, A_SLOT_RVA)?;
let a = read_ptr(a_slot)
.filter(|&value| value != 0)
.ok_or(ValidationError::AUnreadable)?;
let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?;
let a_b_getter = read_ptr(
a_vtable
.checked_add(AVT_B_GETTER)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::AGetterMismatch)?;
let b = a
.checked_add(B_OFF)
.ok_or(ValidationError::AddressOverflow)?;
let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?;
let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?;
let b_isvalid = read_ptr(
b_vtable
.checked_add(8)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::BIsValidMismatch)?;
let b_clear = read_ptr(
b_vtable
.checked_add(16)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::BClearMismatch)?;
let b_ready = read_u8(
b.checked_add(B_READY_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::ReadyByteUnexpected)?;
let b_coll = read_ptr(
b.checked_add(B_COLL_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::CollectionUnreadable)?;
let m = read_ptr(
a.checked_add(M_CACHE_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::MSlotUnreadable)?;
Ok(RuntimeSnapshot {
a,
a_vtable,
a_b_getter,
b,
b_vtable,
b_dtor,
b_isvalid,
b_clear,
b_ready,
b_coll,
m,
})
}
fn set_failed(error: ValidationError) {
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
crate::write_log(&format!(
"SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n"
));
}
/// Public entry: called from `fifa17::install`. Spawns the deferred worker if
/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert).
pub fn install() {
let armed = std::env::var("OPENFUT_SBC_HOOK")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(armed, Ordering::Relaxed);
if !armed {
STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed);
crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n");
return;
}
ARM_ONLY.store(
std::env::var("OPENFUT_SBC_ARM_ONLY")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
POPULATE.store(
std::env::var("OPENFUT_SBC_POPULATE")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n");
std::thread::spawn(|| unsafe { worker() });
}
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
/// exactly once.
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = resolve_cards_base();
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 {
crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n");
return;
}
CARDS_BASE.store(base, Ordering::Relaxed);
let slide = base.wrapping_sub(IMAGE_BASE);
let ctrl_ok = control_matches(base);
crate::write_log(&format!(
"SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n",
if ctrl_ok { "OK" } else { "MISMATCH-ABORT" }
));
if !ctrl_ok {
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return; // module map moved -> offsets untrustworthy (spec §1)
}
if !transition(RuntimeState::Disabled, RuntimeState::Resolved) {
crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n");
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return;
}
// Resolve and validate A -> B, M. The vtable method checks make it substantially
// harder for a coincidental heap pointer to pass after a binary/layout mismatch.
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
if !transition(RuntimeState::Resolved, RuntimeState::Validated) {
crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n");
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return;
}
let a = snapshot.a;
let b = snapshot.b;
let m = snapshot.m;
let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten();
crate::write_log(&format!(
"SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n",
Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count,
));
// Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so
// isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) —
// this is the baseline, NOT the fix. One-shot.
if ARM_ONLY.load(Ordering::Relaxed) {
if DONE.swap(true, Ordering::Relaxed) {
return;
}
// Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use
// window. In particular, the exact patch byte must still be 0 and B+0x08 null.
let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
if !writable_u8(snapshot.b + B_READY_OFF) {
return Err(ValidationError::ReadyByteNotWritable);
}
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
crate::write_log(&format!(
"SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n",
write_snapshot.b + B_READY_OFF
));
core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8);
match read_u8(write_snapshot.b + B_READY_OFF) {
Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {}
_ => {
set_failed(ValidationError::ReadyByteUnexpected);
return;
}
}
crate::write_log(
"SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n",
);
return;
}
// Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA
// already owns a real response and SAX reader for /sbs/sets. The next milestone is
// passive tracing of the native response-to-deserializer dispatch, not construction
// of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault.
if POPULATE.load(Ordering::Relaxed) {
crate::write_log(
"SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \
and reader at the native dispatch boundary first (see client-hook plan M3/M4). \
No deser call made; fabricated readers can clear M or crash.\n",
);
}
}
fn opt_hex(o: Option<usize>) -> String {
match o {
Some(v) => format!("{v:#x}"),
None => "<unreadable>".to_string(),
}
}
/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended
/// implementation is now a guarded synchronous dispatch repair that borrows the genuine
/// response and reader from the real HTTP transaction on its native thread.
///
/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists:
/// let base = CARDS_BASE.load(Relaxed);
/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool =
/// transmute(va(base, rva::DESER_SBS_SETS));
/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M
/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0
/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50]
#[allow(dead_code)]
unsafe fn populate_m(_reader: *mut u8) {
// Intentionally unimplemented: the passive trace must prove the response/reader
// ownership and exact virtual-dispatch boundary before any parser call is enabled.
unreachable!(
"populate_m requires a proven native dispatch contract; see client-hook plan M3/M4"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_snapshot(base: usize) -> RuntimeSnapshot {
let a = 0x1000_0000usize;
RuntimeSnapshot {
a,
a_vtable: base + A_VTABLE_RVA,
a_b_getter: base + 0x11c1f0,
b: a + B_OFF,
b_vtable: base + B_VTABLE_RVA,
b_dtor: base + B_DTOR_RVA,
b_isvalid: base + B_ISVALID_RVA,
b_clear: base + B_CLEAR_RVA,
b_ready: B_READY_EXPECTED_BEFORE_ARM,
b_coll: 0,
m: 0,
}
}
#[test]
fn accepts_exact_runtime_identity_with_null_uninitialized_m() {
let base = 0x7fff_0000_0000usize;
assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(()));
}
#[test]
fn rejects_wrong_a_or_b_class_identity() {
let base = 0x7fff_0000_0000usize;
let mut snapshot = valid_snapshot(base);
snapshot.a_vtable += 8;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::AVtableMismatch)
);
let mut snapshot = valid_snapshot(base);
snapshot.b_vtable += 8;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::BVtableMismatch)
);
}
#[test]
fn rejects_changed_patch_byte_or_live_collection() {
let base = 0x7fff_0000_0000usize;
let mut snapshot = valid_snapshot(base);
snapshot.b_ready = 1;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::ReadyByteUnexpected)
);
let mut snapshot = valid_snapshot(base);
snapshot.b_coll = 0x1234_0000;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::CollectionNotNull)
);
}
#[test]
fn state_machine_is_forward_only_and_fail_closed() {
assert!(valid_transition(
RuntimeState::Disabled,
RuntimeState::Resolved
));
assert!(valid_transition(
RuntimeState::Resolved,
RuntimeState::Validated
));
assert!(valid_transition(
RuntimeState::Validated,
RuntimeState::Committed
));
assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed));
assert!(!valid_transition(
RuntimeState::Validated,
RuntimeState::Resolved
));
assert!(!valid_transition(
RuntimeState::Failed,
RuntimeState::Resolved
));
assert!(!valid_transition(
RuntimeState::Resolved,
RuntimeState::Committed
));
}
}
+478
View File
@@ -0,0 +1,478 @@
//! Optional category-request callback tracing through its class-unique vtable.
//!
//! Unlike the entry trampolines, these probes atomically replace two aligned
//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS,
PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
const REQUEST_VTABLE_RVA: usize = 0x22e5c0;
const SLOT_88: usize = 0x88;
const SLOT_90: usize = 0x90;
const ORIGINAL_88_RVA: usize = 0x1631e0;
const ORIGINAL_90_RVA: usize = 0x154830;
const ORIGINAL_88_SIGNATURE: [u8; 16] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
];
const ORIGINAL_90_SIGNATURE: [u8; 16] = [
0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90,
];
type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void);
type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void);
static ENABLED: AtomicBool = AtomicBool::new(false);
static INSTALLED: AtomicBool = AtomicBool::new(false);
static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0);
static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0);
static ENTER_88: AtomicU64 = AtomicU64::new(0);
static EXIT_88: AtomicU64 = AtomicU64::new(0);
static ENTER_90: AtomicU64 = AtomicU64::new(0);
static EXIT_90: AtomicU64 = AtomicU64::new(0);
static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0);
static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_98: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0);
static SELECTED_90: AtomicUsize = AtomicUsize::new(0);
static OWNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static CONSUMER_88: AtomicUsize = AtomicUsize::new(0);
static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
fn enabled(value: Option<&str>) -> bool {
matches!(value, Some("1"))
}
fn checked_va(base: usize, rva: usize) -> Option<usize> {
base.checked_add(rva)
}
fn image_range_covered(size: usize, rva: usize, length: usize) -> bool {
rva.checked_add(length)
.map(|end| end <= size)
.unwrap_or(false)
}
unsafe fn readable_range(address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.AllocationBase as usize == base
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn image_size(base: usize) -> Option<usize> {
if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d {
return None;
}
let pe_offset = *((base + 0x3c) as *const u32) as usize;
if pe_offset > 0xf00 {
return None;
}
let pe = base.checked_add(pe_offset)?;
if *(pe as *const u32) != 0x0000_4550 {
return None;
}
let size_field = pe.checked_add(24 + 0x38)?;
Some(*(size_field as *const u32) as usize)
}
unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool {
readable_range(address, expected.len())
&& core::slice::from_raw_parts(address as *const u8, expected.len()) == expected
}
unsafe fn guarded_ptr(address: usize) -> usize {
if address & 7 == 0 && readable_range(address, 8) {
core::ptr::read_volatile(address as *const usize)
} else {
0
}
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
if readable_range(address, 4) {
Some(core::ptr::read_volatile(address as *const u32))
} else {
None
}
}
unsafe fn guarded_u8(address: usize) -> Option<u8> {
if readable_range(address, 1) {
Some(core::ptr::read_volatile(address as *const u8))
} else {
None
}
}
unsafe fn field_ptr(object: usize, offset: usize) -> usize {
object
.checked_add(offset)
.map(|address| guarded_ptr(address))
.unwrap_or(0)
}
unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) {
ENTER_88.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_88.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let argument_address = argument as usize;
let owner = field_ptr(request_address, 8);
let owner_vtable = guarded_ptr(owner);
let consumer = field_ptr(owner_vtable, 0x18);
let owner_slot_before = guarded_ptr(argument_address);
let response_vtable = guarded_ptr(owner_slot_before);
let owner_inner = field_ptr(owner, 8);
let owner_state_before = owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_flags = owner_inner
.checked_add(0x0c)
.and_then(|p| guarded_u8(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_manager = field_ptr(owner_inner, 0x14d0);
let owner_manager_state = owner_manager
.checked_add(0x1dc0)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
OWNER_88.store(owner, Ordering::Relaxed);
OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed);
CONSUMER_88.store(consumer, Ordering::Relaxed);
RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed);
OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed);
OWNER_INNER_88.store(owner_inner, Ordering::Relaxed);
OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed);
OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed);
OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed);
OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed);
let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire));
original(request, argument);
OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed);
OWNER_STATE_AFTER_88.store(
owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
EXIT_88.fetch_add(1, Ordering::Release);
}
unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) {
ENTER_90.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_90.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let callback_90 = field_ptr(request_address, 0x90);
let callback_98 = field_ptr(request_address, 0x98);
let callback_a0 = field_ptr(request_address, 0xa0);
let callback_a8 = field_ptr(request_address, 0xa8);
CALLBACK_90.store(callback_90, Ordering::Relaxed);
CALLBACK_98.store(callback_98, Ordering::Relaxed);
CALLBACK_A0.store(callback_a0, Ordering::Relaxed);
CALLBACK_A8.store(callback_a8, Ordering::Relaxed);
SELECTED_90.store(
if callback_90 != 0 {
callback_90
} else {
callback_a0
},
Ordering::Relaxed,
);
let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire));
original(request, argument);
EXIT_90.fetch_add(1, Ordering::Release);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SwapOutcome {
Installed,
CleanFailure,
DegradedFirstSlotActive,
DegradedProtection,
}
unsafe fn install_slots(base: usize) -> SwapOutcome {
let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_88) = checked_va(vtable, SLOT_88) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_90) = checked_va(vtable, SLOT_90) else {
return SwapOutcome::CleanFailure;
};
let Some(size) = image_size(base) else {
return SwapOutcome::CleanFailure;
};
if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8)
|| !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len())
|| !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len())
|| slot_88 & 7 != 0
|| slot_90 & 7 != 0
|| !crate::sbc_trace::validate_cards_build(base)
|| !range_in_image_allocation(base, vtable, SLOT_90 + 8)
|| !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len())
|| !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len())
|| !signature_matches(original_88, &ORIGINAL_88_SIGNATURE)
|| !signature_matches(original_90, &ORIGINAL_90_SIGNATURE)
|| (slot_88 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_88
|| (slot_90 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_90
{
return SwapOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
vtable as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return SwapOutcome::CleanFailure;
}
ORIGINAL_88.store(original_88, Ordering::Release);
ORIGINAL_90.store(original_90, Ordering::Release);
// Both slots share the same vtable page. Keep it writable only across the two
// compare/exchanges and possible rollback.
let mut old = 0u32;
if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 {
return SwapOutcome::CleanFailure;
}
let atom_88 = &*(slot_88 as *const AtomicUsize);
let atom_90 = &*(slot_90 as *const AtomicUsize);
let first = atom_88.compare_exchange(
original_88,
wrapper_88 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
);
let outcome = if first.is_err() {
SwapOutcome::CleanFailure
} else if atom_90
.compare_exchange(
original_90,
wrapper_90 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::Installed
} else if atom_88
.compare_exchange(
wrapper_88 as *const () as usize,
original_88,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::CleanFailure
} else {
SwapOutcome::DegradedFirstSlotActive
};
let mut ignored = 0u32;
if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 {
return if outcome == SwapOutcome::DegradedFirstSlotActive {
outcome
} else {
SwapOutcome::DegradedProtection
};
}
outcome
}
unsafe fn worker() {
for _ in 0..700u32 {
if crate::sbc_trace::code_patch_installers_ready() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if !crate::sbc_trace::code_patch_installers_ready() {
crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n");
return;
}
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } {
SwapOutcome::Installed => {
INSTALLED.store(true, Ordering::Release);
crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n");
let mut seen_88 = 0u64;
let mut seen_90 = 0u64;
let mut reports = 0u8;
while reports < 32 {
std::thread::sleep(std::time::Duration::from_millis(250));
let count_88 = ENTER_88.load(Ordering::Acquire);
let count_90 = ENTER_90.load(Ordering::Acquire);
if count_88 != seen_88 || count_90 != seen_90 {
crate::write_log(&format!(
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
count_88,
EXIT_88.load(Ordering::Acquire),
LAST_REQUEST_88.load(Ordering::Relaxed),
LAST_ARGUMENT_88.load(Ordering::Relaxed),
LAST_THREAD_88.load(Ordering::Relaxed),
OWNER_88.load(Ordering::Relaxed),
OWNER_VTABLE_88.load(Ordering::Relaxed),
CONSUMER_88.load(Ordering::Relaxed),
RESPONSE_VTABLE_88.load(Ordering::Relaxed),
OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed),
OWNER_SLOT_AFTER_88.load(Ordering::Relaxed),
OWNER_INNER_88.load(Ordering::Relaxed),
OWNER_STATE_BEFORE_88.load(Ordering::Relaxed),
OWNER_STATE_AFTER_88.load(Ordering::Relaxed),
OWNER_FLAGS_88.load(Ordering::Relaxed),
OWNER_MANAGER_88.load(Ordering::Relaxed),
OWNER_MANAGER_STATE_88.load(Ordering::Relaxed),
count_90,
EXIT_90.load(Ordering::Acquire),
LAST_REQUEST_90.load(Ordering::Relaxed),
LAST_ARGUMENT_90.load(Ordering::Relaxed),
LAST_THREAD_90.load(Ordering::Relaxed),
CALLBACK_90.load(Ordering::Relaxed),
CALLBACK_98.load(Ordering::Relaxed),
CALLBACK_A0.load(Ordering::Relaxed),
CALLBACK_A8.load(Ordering::Relaxed),
SELECTED_90.load(Ordering::Relaxed),
));
seen_88 = count_88;
seen_90 = count_90;
reports += 1;
}
}
crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n");
}
SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"),
SwapOutcome::DegradedFirstSlotActive => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n",
),
SwapOutcome::DegradedProtection => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n",
),
}
}
pub(crate) fn install() {
let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref());
ENABLED.store(armed, Ordering::Release);
if !armed {
crate::write_log("SBC_REQUEST_TRACE: disabled\n");
return;
}
crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_is_exact() {
assert!(!enabled(None));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn slots_are_aligned_and_class_local() {
assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0);
assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0);
assert_eq!(SLOT_90 - SLOT_88, 8);
}
#[test]
fn image_coverage_is_checked_and_overflow_safe() {
assert!(image_range_covered(
0x230000,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(
REQUEST_VTABLE_RVA + SLOT_90,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(usize::MAX, usize::MAX, 8));
}
}
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
//! Passive, behavior-preserving diagnostic traces for FIFA 17's offline-season
//! entry flow.
//!
//! RE (2026-08-19, live memory) placed the "problem communicating with the FIFA
//! Ultimate Team servers" modal in the `futOfflineSeasonEntry` ActionScript's
//! season-load path. A first trace on the load completion `FUN_1800578e0`
//! (`0x578e0`) armed but NEVER fired on an entry attempt — so the modal is raised
//! before that callback runs. These traces log the actual CardsDLL season-native
//! call sequence (which functions the entry screen reaches, and in what order) so
//! we can see exactly where the flow stops/fails. Every trace is read-only: it
//! logs, then calls the original through a trampoline; it never alters control
//! flow. Targets are chosen so their copied prologues are position-independent
//! (no rip-relative / rel32 in the copied bytes).
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use crate::sbc_trace::{
absolute_jump, allocate_trampoline, readable_range, target_va, validate_cards_build,
};
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
pub(crate) unsafe fn rd_u8(addr: usize) -> Option<u8> {
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
}
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String {
if addr == 0 || !readable_range(addr, 1) {
return String::from("<unreadable>");
}
let mut out = Vec::new();
let mut i = 0;
while i < max && readable_range(addr + i, 1) {
let b = core::ptr::read_volatile((addr + i) as *const u8);
if b == 0 {
break;
}
out.push(b);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
/// MUST be whole, position-independent instructions) with an absolute jump to
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
pub(crate) unsafe fn install_detour(
base: usize,
rva: usize,
name: &str,
copy_len: usize,
signature: &[u8],
wrapper: usize,
trampoline_slot: &AtomicUsize,
) -> bool {
let Some(target) = target_va(base, rva) else {
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
return false;
};
if !readable_range(target, copy_len)
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
{
write_log(&format!(
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
));
return false;
}
let Some(trampoline) = allocate_trampoline(target, copy_len) else {
write_log(&format!("SEASON_TRACE: {name}: trampoline alloc failed\n"));
return false;
};
trampoline_slot.store(trampoline, Ordering::Release);
let mut patch = [0x90u8; 24];
let jump = absolute_jump(wrapper);
patch[..jump.len()].copy_from_slice(&jump);
let mut old = 0u32;
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
return false;
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
let mut ignored = 0u32;
VirtualProtect(target as _, copy_len, old, &mut ignored);
if flushed {
write_log(&format!(
"SEASON_TRACE: {name}: installed at {target:#x} (tramp {trampoline:#x})\n"
));
true
} else {
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
false
}
}
fn log_call(name: &str, rcx: usize, rdx: usize, r8: usize) {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!(
"SEASON_CALL: {name} rcx={rcx:#x} rdx={rdx:#x} r8={r8:#x}\n"
));
}
}
/// Declare a passive 4-register-arg call trace. The wrapper is entered via the
/// abs-jump patched over the target prologue (original args in rcx/rdx/r8/r9,
/// caller's return address on the stack), logs, then tail-calls the original via
/// the trampoline. A 4-arg/usize-return signature safely covers these season
/// natives (<=4 integer args, void/int returns).
macro_rules! season_call_trace {
($wrap:ident, $tramp:ident, $name:literal) => {
static $tramp: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn $wrap(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
log_call($name, rcx, rdx, r8);
let t = $tramp.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
};
}
season_call_trace!(load_current_native_wrapper, LOAD_CURRENT_NATIVE_TRAMP, "LoadCurrentOfflineSeason_native");
season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native");
season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native");
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
// actually calls; hands the callback name to the manager's async slot 0x80.
season_call_trace!(load_offline_real_wrapper, LOAD_OFFLINE_REAL_TRAMP, "LoadOfflineSeasons_native(0x4ee10)");
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
// count and invokes the LoadSeasons_Complete AS callback.
season_call_trace!(load_offline_async_wrapper, LOAD_OFFLINE_ASYNC_TRAMP, "LoadOfflineSeasons_asyncimpl(0x57560)");
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
// string ptr. Logs those, then calls the original.
static LOAD_CURRENT_IMPL_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn load_current_impl_wrapper(
param_1: usize,
param_2: usize,
param_3: usize,
param_4: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
// param_3 -> C string season id (best-effort read of first bytes).
let sid = if param_3 != 0 && readable_range(param_3, 8) {
let p = *(param_3 as *const usize);
if p != 0 && readable_range(p, 8) {
*(p as *const u64)
} else {
0
}
} else {
0
};
write_log(&format!(
"SEASON_CALL: LoadCurrentOfflineSeason_impl mgr={param_1:#x} stateByte={param_2:#x} sidPtr={param_3:#x} sidHead={sid:#x}\n"
));
}
let t = LOAD_CURRENT_IMPL_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(param_1, param_2, param_3, param_4)
}
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
// whether it ever fires; logs the result fields it branches on.
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let status = rd_i32(result + 0x1c);
let state = rd_u8(result + 0x68);
let season_id = rd_i32(result + 0x5c);
write_log(&format!(
"SEASON_LOAD_COMPLETE: ctx={ctx:#x} result={result:#x} status(+0x1c)={} state(+0x68)={} seasonId(+0x5c)={}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
state.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
season_id.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
));
}
let t = COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(ctx, result, r8, r9)
}
// GetUsersOfflineDivision native FUN_18004eb50 (registration FUN_18004e3f0 proved
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
// so it needs the relocating installer below.
season_call_trace!(get_users_division_wrapper, GET_USERS_DIVISION_TRAMP, "GetUsersOfflineDivision_native(0x4eb50)");
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
/// CardsDLL data still fits after we relocate a copied prologue into it.
unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
const GRAN: usize = 0x10000;
let mut step = GRAN;
while step < 0x6000_0000 {
for signed in [step as isize, -(step as isize)] {
let cand = base.wrapping_add(signed as usize) & !(GRAN - 1);
if cand == 0 {
continue;
}
let p = VirtualAlloc(cand as _, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if !p.is_null() {
return Some(p as usize);
}
}
step += GRAN;
}
None
}
/// Passive detour for a target whose copied prologue contains a single
/// rip-relative operand (disp32 at `disp_off`, instruction ending at `insn_end`,
/// both within the copied bytes). The trampoline is allocated near `base` and the
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
#[allow(clippy::too_many_arguments)]
pub(crate) unsafe fn install_detour_reloc(
base: usize,
rva: usize,
name: &str,
copy_len: usize,
signature: &[u8],
disp_off: usize,
insn_end: usize,
wrapper: usize,
trampoline_slot: &AtomicUsize,
) -> bool {
let Some(target) = target_va(base, rva) else {
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
return false;
};
if !readable_range(target, copy_len)
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
{
write_log(&format!(
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
));
return false;
}
let jump = absolute_jump(wrapper);
let tramp_len = copy_len + jump.len();
let Some(tramp) = alloc_near(base, tramp_len) else {
write_log(&format!("SEASON_TRACE: {name}: near trampoline alloc failed\n"));
return false;
};
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
// Relocate the rip-relative disp32 to keep the same absolute target.
let orig_disp = core::ptr::read_unaligned((target + disp_off) as *const i32) as i64;
let abs_target = target as i64 + insn_end as i64 + orig_disp;
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
write_log(&format!("SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"));
return false;
}
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
let back = absolute_jump(target + copy_len);
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
let mut old = 0u32;
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
write_log(&format!("SEASON_TRACE: {name}: trampoline protect failed\n"));
return false;
}
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
trampoline_slot.store(tramp, Ordering::Release);
let mut patch = [0x90u8; 24];
patch[..jump.len()].copy_from_slice(&jump);
let mut prot = 0u32;
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut prot) == 0 {
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
return false;
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
let mut ignored = 0u32;
VirtualProtect(target as _, copy_len, prot, &mut ignored);
if flushed {
write_log(&format!(
"SEASON_TRACE: {name}: installed(reloc) at {target:#x} (tramp {tramp:#x})\n"
));
true
} else {
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
false
}
}
// FutCompetitionServiceImpl::LoadOfflineSeasons FINAL completion (FUN_1800ffe90):
// delivers the result to the AS callback LoadSeasons_Complete via
// FUN_18019fb30->slot0x20(vm,"_global",cbref, "SUCCESS" | errString). param_1 = the
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
// Read the delivered status: byte0==0 => failure with an error string at +8.
let flag = rd_u8(result);
let errstr = if flag == Some(0) {
let p = if readable_range(result + 8, 8) {
core::ptr::read_volatile((result + 8) as *const usize)
} else {
0
};
rd_cstr(p, 96)
} else {
String::new()
};
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let cbref = if readable_range(ctx + 0x18, 8) {
core::ptr::read_volatile((ctx + 0x18) as *const usize)
} else {
0
};
let kind = match flag {
Some(0) => "ERROR",
Some(_) => "SUCCESS",
None => "??",
};
let shown = if flag == Some(0) { errstr.as_str() } else { "SUCCESS" };
write_log(&format!(
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
));
}
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
// failure to SUCCESS so the offline-season load advances to
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
// once per process; verified by the error string before touching memory.
if flag == Some(0)
&& errstr.contains("CACHE_PACKNAMES")
&& readable_range(result, 1)
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
{
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n");
}
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(ctx, result, r8, r9)
}
// LoadOfflineSeasons STAGE-1 async completion (FUN_180106240): fails with
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
// the next async stage. Logs whether the first async stage succeeded. Passive.
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize, r8: usize, r9: usize) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
if result == 0 {
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
} else {
let status = rd_i32(result + 0x1c);
let verdict = if status == Some(0) { "ok(chain next)" } else { "CACHE_PACKNAMES_FAILED" };
write_log(&format!(
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
));
}
}
let t = STAGE1_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(param1, result, r8, r9)
}
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
// URL of the pack-names/cards-tournament-list web file. Passive capture. Its
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
// installer (disp32 at copied offset 7, instruction end 11).
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256)));
}
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 || !validate_cards_build(base) {
write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n");
return;
}
// (rva, name, copy_len, signature, wrapper, trampoline slot)
install_detour(
base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15,
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP,
);
install_detour(
base, 0x4f340, "StartSeason_native", 15,
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP,
);
install_detour(
base, 0x4e850, "GetOfflineSeasonInfo_native", 15,
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18],
get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP,
);
install_detour(
base, 0x57230, "LoadCurrentOfflineSeason_impl", 19,
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff],
load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP,
);
install_detour(
base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16,
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff],
completion_wrapper as *const () as usize, &COMPLETION_TRAMP,
);
install_detour_reloc(
base, 0x4eb50, "GetUsersOfflineDivision_native", 14,
&[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01],
7, 11,
get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP,
);
install_detour(
base, 0x4ee10, "LoadOfflineSeasons_native", 15,
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP,
);
install_detour(
base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17,
&[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP,
);
install_detour(
base, 0xffe90, "LoadOfflineSeasons_final_completion", 16,
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda],
final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP,
);
install_detour(
base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15,
&[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00],
stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP,
);
install_detour_reloc(
base, 0x17ff90, "start_webfile_dl_url", 14,
&[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1],
7, 11,
url_capture_wrapper as *const () as usize, &URL_CAPTURE_TRAMP,
);
write_log("SEASON_TRACE: all season-native traces armed\n");
}
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
+28 -18
View File
@@ -9,11 +9,9 @@
// server's certificate chain. Always returning 1 is equivalent to trusting all certs,
// which is the behaviour we want for the local self-signed bridge certificate.
use windows_sys::Win32::{
System::{
LibraryLoader::GetModuleHandleA,
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
},
use windows_sys::Win32::System::{
LibraryLoader::GetModuleHandleA,
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
};
// Unique 22-byte prologue of ProtoSSL's cert-verify function.
@@ -21,24 +19,26 @@ use windows_sys::Win32::{
const PROLOGUE: &[u8] = &[
0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d
0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx
0x56, // push rsi
0x57, // push rdi
0x41, 0x55, // push r13
0x41, 0x56, // push r14
0x41, 0x57, // push r15
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
0x56, // push rsi
0x57, // push rdi
0x41, 0x55, // push r13
0x41, 0x56, // push r14
0x41, 0x57, // push r15
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
];
// Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error.
// The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success.
const PATCH: &[u8] = &[
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
0xc3, // ret
0x90, 0x90, 0x90, // nop padding
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
0xc3, // ret
0x90, 0x90, 0x90, // nop padding
];
fn patch_module(module: isize, scan_bytes: usize) -> bool {
if module == 0 { return false; }
if module == 0 {
return false;
}
let base = module as usize;
let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) };
let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) {
@@ -48,16 +48,26 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool {
let target = (base + offset) as *mut u8;
let mut old_prot: u32 = 0;
unsafe {
VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), PAGE_EXECUTE_READWRITE, &mut old_prot);
VirtualProtect(
target as *const core::ffi::c_void,
PATCH.len(),
PAGE_EXECUTE_READWRITE,
&mut old_prot,
);
core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len());
VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), old_prot, &mut old_prot);
VirtualProtect(
target as *const core::ffi::c_void,
PATCH.len(),
old_prot,
&mut old_prot,
);
}
true
}
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
let module = GetModuleHandleA(b"EAWebKit.dll\0".as_ptr()) as isize;
let module = GetModuleHandleA(c"EAWebKit.dll".as_ptr().cast()) as isize;
// EAWebKit.dll is ~22 MB
patch_module(module, 24 * 1024 * 1024)
}
+483
View File
@@ -0,0 +1,483 @@
//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show.
//!
//! # Confirmed root cause (live, 2026-08-19)
//!
//! `FUN_18007e5e0(ctx, panel)` is the native tab binder the screen framework
//! invokes at store 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:
//!
//! ```text
//! if (FUN_180014df0(_, idx)) // token present?
//! (*(panel_vtbl+0x48))(panel, slot, "PANEL_ID", FUN_180014580(_, idx));
//! else
//! (*(panel_vtbl+0xa0))(panel, slot); // hide slot
//! ```
//!
//! slot -> token, in bind order: `mypacks, bronze, silver, gold, special, points`.
//! The gate `FUN_180014df0` resolves the token through `FUN_180014380`, which scans
//! the loaded purchase groups (stride `0x108`) comparing the token at `group+0x70`.
//! So a tab appears iff a purchase group carrying that token is loaded AT BIND TIME.
//!
//! The bind detour below measured the ground truth on the retail client:
//!
//! ```text
//! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show
//! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after
//! ```
//!
//! `mask=0x00` at screen-show confirms the container is empty when the framework
//! binds, so all six slots hide and no tab bar is built. The store's own
//! `GET store/purchasegroup/all` only returns *after* screen-show, so re-entry works
//! (groups cached) but first entry does not. (`0x0e` = bronze|silver|gold; bit 0
//! `mypacks` is clear because an empty My Packs serves no `mypacks` group.)
//!
//! # What did NOT work, and why this module changed
//!
//! A previous version re-invoked the binder at the next render, once the groups had
//! arrived (`rebound ... mask=0x0e` above). The movie built NO tab bar from that
//! late bind: the Scaleform movie only honours the framework's OWN bind at
//! screen-show, not a later re-publish/commit. That approach is abandoned.
//!
//! # This module: make the container non-empty BEFORE the first bind
//!
//! The only publish the movie honours is the framework's bind at screen-show, and
//! re-entry proves that bind builds the bar correctly when the container is already
//! full. So the fix is to load the purchase groups BEFORE the store screen is shown.
//!
//! `FUN_180017870(storefront)` issues the store's own `GET store/purchasegroup/all`.
//! Firing it from the FUT hub event pump (a real game thread, well before the store
//! screen exists) gives the response time to arrive and populate the container, so
//! the first screen-show bind sees a full list and binds the tabs natively — exactly
//! the re-entry path, on first entry.
//!
//! The bind detour is retained purely as the SENSOR: the first-entry bind mask is
//! the safe, definitive measurement of whether the pre-warm populated the container
//! in time. `mask != 0` at first bind ⇒ pre-warm worked and the tabs bind natively;
//! `mask == 0` (with `storefront_seen=1` in the pre-warm log) ⇒ a hub-time request
//! cannot land in time and the remaining route is the extracted `StoreFront.apt`.
//!
//! # Fail-closed
//!
//! * Pre-warm fires at most once per process, claimed atomically, and only once the
//! storefront singleton is non-null; the storefront pointer is read through a
//! guarded load and the request function's signature is validated before the call.
//! * The bind detour only reads (captures pointers, probes the game's own gate with
//! a provably-dead `this`) and never mutates store state.
//! * Image plus every function signature are verified before any write and again
//! under thread suspension; one wrong byte aborts with no write and no call.
//!
//! # Promotion
//!
//! PROMOTED: armed by the build, never by an environment variable (see
//! [`REPAIR_PROMOTED`]). Rollback is a `version.dll` file swap.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
/// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework
/// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw.
const BIND_RVA: usize = 0x7e5e0;
/// Category gate `FUN_180014df0(dead_this, idx) -> bool`: maps `idx` to one of the
/// six hard-coded tokens and reports whether a loaded purchase group carries it.
const HAS_CATEGORY_RVA: usize = 0x14df0;
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
/// the store screen makes at entry (from `0x18007f25e`). Fired early to pre-warm.
const REQUEST_GROUPS_RVA: usize = 0x17870;
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
/// request/lookup helpers (loaded at `0x18007f25e`, right before the pack-list GET).
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
/// Gate indices in slot order: `mypacks, bronze, silver, gold, special, points`.
/// Taken from the binder's unrolled call sequence, not from the index order of
/// `FUN_180014580`'s jump table (which is deliberately different).
const GATE_INDICES: [u32; 6] = [0, 2, 3, 4, 5, 1];
/// Whole-instruction prologue length relocated into the trampoline; also the number
/// of bytes overwritten by the entry detour. 15 bytes, a clean boundary covering the
/// 14-byte absolute jump.
const COPY_LEN: usize = 15;
const ABS_JUMP_LEN: usize = 14;
/// First 15 bytes of `FUN_18007e5e0`: `mov [rsp+8],rbx; mov [rsp+0x10],rbp;
/// mov [rsp+0x18],rsi` = 5 + 5 + 5.
const BIND_SIGNATURE: [u8; COPY_LEN] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18,
];
/// First 15 bytes of `FUN_180014df0`. Validated before we ever call it, so the gate
/// probe only runs on the exact build it was reversed against.
const HAS_CATEGORY_SIGNATURE: [u8; 15] = [
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x33, 0xdb, 0x44, 0x8b, 0xc3, 0x85, 0xd2, 0x74, 0x35,
];
/// First 18 bytes of `FUN_180017870`. Validated before we ever call it, so the
/// pre-warm only fires the genuine request on the exact build it was reversed against.
const REQUEST_GROUPS_SIGNATURE: [u8; 18] = [
0x40, 0x57, 0x48, 0x81, 0xec, 0x90, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff,
0xff, 0xff,
];
type BindFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> *mut c_void;
type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8;
type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize;
/// The tab-bar repair is PROMOTED: armed by the build, never by an environment
/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves
/// identically. Promotion does not weaken any check — the signature gate, the image
/// validation and the thread quiesce all remain in the runtime evidence path.
pub(crate) const REPAIR_PROMOTED: bool = true;
/// Compile-time contract: the repair stays build-armed. Regressing it to an env gate
/// would silently restore the missing first-entry tab bar on a normal launch, so it
/// must be a deliberate, visible change here rather than a missing variable.
const _: () = assert!(REPAIR_PROMOTED);
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static STORE_BASE: AtomicUsize = AtomicUsize::new(0);
static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0);
/// Gate mask the framework's most recent bind observed (bit N = slot N would bind).
static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0);
static LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
/// Set once the pre-warm request has been fired (or is provably unnecessary).
static PREWARM_DONE: AtomicBool = AtomicBool::new(false);
static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
/// Highest storefront pointer observed at hub time (0 = never non-null yet). Logged
/// so a failed pre-warm can be attributed to "storefront not up at hub" vs "fired
/// but the response did not land before screen-show".
static PREWARM_STOREFRONT_SEEN: AtomicUsize = AtomicUsize::new(0);
/// Pure pre-warm decision, isolated for host tests.
///
/// Fire exactly once, and only once the storefront singleton is non-null; before
/// that, keep waiting (a null storefront early at the hub is expected).
fn should_prewarm(already_done: bool, storefront: usize) -> bool {
!already_done && storefront != 0
}
/// Probe all six category tokens with the game's own gate and return a slot mask.
///
/// `FUN_180014df0` forwards its `this` to `FUN_180014380`, which discards it and
/// fetches the group container from a singleton, so a null `this` is exactly what
/// the native code effectively passes. Called only from the bind detour, where the
/// store subsystem is provably live.
unsafe fn gate_mask() -> u8 {
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 {
return 0;
}
let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else {
return 0;
};
let gate_fn: HasCategoryFn = core::mem::transmute(gate);
let mut mask = 0u8;
for (slot, index) in GATE_INDICES.iter().enumerate() {
if gate_fn(core::ptr::null_mut(), *index) != 0 {
mask |= 1 << slot;
}
}
mask
}
/// Ask the game to load the purchase groups now, on the caller's (game) thread.
///
/// Called from the FUT event dispatcher so it runs on a real game thread well before
/// the store screen is ever shown — the same thread the store screen itself would use
/// for this call at entry. Fail-closed: base/signature/storefront all validated, at
/// most one request per process.
pub(crate) unsafe fn maybe_prewarm_groups() {
if PREWARM_DONE.load(Ordering::Acquire) || !REPAIR_ENABLED.load(Ordering::Acquire) {
return;
}
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 || !crate::sbc_trace::valid_cards_image(base) {
return;
}
let Some(storefront) = base
.checked_add(STOREFRONT_GLOBAL_RVA)
.and_then(|slot| crate::sbc_trace::guarded_usize(slot))
else {
return;
};
if storefront != 0 {
PREWARM_STOREFRONT_SEEN.store(storefront, Ordering::Release);
}
if !should_prewarm(false, storefront) {
// Storefront not up yet at the hub: keep waiting, do not consume the attempt.
return;
}
let Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else {
return;
};
if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len())
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
!= REQUEST_GROUPS_SIGNATURE
{
return;
}
// Claim the single attempt before issuing it, so a re-entrant event can never
// fire a second request.
PREWARM_DONE.store(true, Ordering::Release);
PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
let request_fn: RequestGroupsFn = core::mem::transmute(request);
request_fn(storefront as *mut c_void);
crate::write_log(&format!(
"STORE_TABS: pre-warmed purchase groups at hub (storefront={storefront:#x})\n"
));
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
}
unsafe fn write_entry<const N: usize>(
target: usize,
destination: usize,
original: &[u8; N],
) -> Result<(), bool> {
let mut patch = [0x90u8; N];
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return Err(true);
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
Ok(())
} else {
Err(restore_entry(target, original))
}
}
/// Detour target for the native tab binder. Read-only sensor: records the gate mask
/// the framework's bind is about to act on, then runs the original unchanged. This is
/// the definitive measurement of whether the pre-warm populated the container in time.
unsafe extern "system" fn bind_wrapper(ctx: *mut c_void, panel: *mut c_void) -> *mut c_void {
let mask = gate_mask();
LAST_BIND_MASK.store(mask as u32, Ordering::Release);
LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
BIND_ENTRIES.fetch_add(1, Ordering::AcqRel);
let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire));
original(ctx, panel)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_hook(base: usize) -> InstallOutcome {
let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(request) = crate::sbc_trace::target_va(base, REQUEST_GROUPS_RVA) else {
return InstallOutcome::CleanFailure;
};
// Fingerprint the image and ALL THREE functions: the one we detour and the two we
// call (gate probe, group request). A single mismatched byte aborts cleanly with
// no write and no call.
if !crate::sbc_trace::valid_cards_image(base)
|| !crate::sbc_trace::executable_range_in_image(base, bind, BIND_SIGNATURE.len())
|| !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_SIGNATURE.len())
|| !crate::sbc_trace::executable_range_in_image(
base,
request,
REQUEST_GROUPS_SIGNATURE.len(),
)
|| core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) != BIND_SIGNATURE
|| core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len())
!= HAS_CATEGORY_SIGNATURE
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
!= REQUEST_GROUPS_SIGNATURE
{
return InstallOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
bind as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else {
return InstallOutcome::CleanFailure;
};
BIND_TRAMPOLINE.store(trampoline, Ordering::Release);
STORE_BASE.store(base, Ordering::Release);
let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
}
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
return InstallOutcome::DegradedProcessState;
}
};
let final_valid = crate::sbc_trace::valid_cards_image(base)
&& core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE;
let transaction = if !final_valid {
InstallOutcome::CleanFailure
} else {
match write_entry(bind, bind_wrapper as *const () as usize, &BIND_SIGNATURE) {
Ok(()) => InstallOutcome::Installed,
Err(true) => InstallOutcome::CleanFailure,
Err(false) => InstallOutcome::DegradedHookActive,
}
};
let resumed = peers.resume_all();
let outcome = if resumed {
transaction
} else if matches!(
transaction,
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
) {
InstallOutcome::DegradedHookAndProcess
} else {
InstallOutcome::DegradedProcessState
};
if outcome == InstallOutcome::CleanFailure {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
}
outcome
}
unsafe fn worker() {
let _pending = crate::sbc_trace::CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let outcome = if base == 0 {
InstallOutcome::CleanFailure
} else {
install_hook(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => {
crate::write_log("STORE_TABS: bind sensor + pre-warm installed (promoted)\n")
}
InstallOutcome::CleanFailure => {
crate::write_log("STORE_TABS: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut binds_seen = 0u64;
let mut reports = 0u8;
while reports < 64 {
std::thread::sleep(std::time::Duration::from_millis(250));
let binds = BIND_ENTRIES.load(Ordering::Acquire);
if binds != binds_seen {
crate::write_log(&format!(
"STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n",
binds,
LAST_BIND_MASK.load(Ordering::Acquire),
PREWARM_ATTEMPTS.load(Ordering::Acquire),
PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire),
LAST_THREAD.load(Ordering::Relaxed),
));
binds_seen = binds;
reports += 1;
}
}
crate::write_log("STORE_TABS: report cap reached; hook remains installed\n");
}
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_indices_match_the_native_slot_order() {
// mypacks, bronze, silver, gold, special, points — the order FUN_18007e5e0
// tests them in, which is NOT the index order of FUN_180014580's jump table.
assert_eq!(GATE_INDICES, [0, 2, 3, 4, 5, 1]);
}
#[test]
fn prewarms_once_the_storefront_is_up() {
assert!(should_prewarm(false, 0x1000));
}
#[test]
fn waits_while_the_storefront_is_still_null() {
assert!(!should_prewarm(false, 0));
}
#[test]
fn never_prewarms_twice() {
assert!(!should_prewarm(true, 0x1000));
}
#[test]
fn detour_signature_is_long_enough_for_the_absolute_jump() {
assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN);
assert_eq!(COPY_LEN, BIND_SIGNATURE.len());
}
#[test]
fn request_signature_covers_the_validated_prologue() {
// 18 bytes: `push rdi; sub rsp,0x90; movq [rsp+0x20],-2`.
assert_eq!(REQUEST_GROUPS_SIGNATURE.len(), 18);
}
}
+10 -5
View File
@@ -4,10 +4,10 @@ use windows_sys::Win32::Foundation::BOOL;
// CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success.
// We use raw pointers to avoid pulling in the full Cryptography struct tree.
type CertVerifyChainPolicyFn = unsafe extern "system" fn(
*const u8, // pszPolicyOID
*const (), // pChainContext
*const (), // pPolicyPara
*mut u32, // &mut pPolicyStatus.dwError (first field)
*const u8, // pszPolicyOID
*const (), // pChainContext
*const (), // pPolicyPara
*mut u32, // &mut pPolicyStatus.dwError (first field)
) -> BOOL;
static REAL: OnceLock<CertVerifyChainPolicyFn> = OnceLock::new();
@@ -25,7 +25,12 @@ pub unsafe extern "system" fn hooked_cert_verify_chain_policy(
p_policy_status: *mut u32,
) -> BOOL {
if let Some(real) = REAL.get().copied() {
real(psz_policy_oid, p_chain_context, p_policy_para, p_policy_status);
real(
psz_policy_oid,
p_chain_context,
p_policy_para,
p_policy_status,
);
}
// Clear the error field of CERT_CHAIN_POLICY_STATUS regardless
if !p_policy_status.is_null() {
+227
View File
@@ -0,0 +1,227 @@
//! Milestone 0 — Blaze transport reachability observation.
//!
//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is
//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo
//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx
//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly
//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question:
//!
//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a
//! full menu+FUT session, or none at all?
//!
//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed
//! only to describe it in the log. We never change a resolution result or a
//! connection target — that redirect logic lives in the detours themselves and is
//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output;
//! disarmed (default) this module is inert (each entry point returns immediately).
//!
//! Future-reference note (beyond-beginner, deliberately NOT done here): a
//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet /
//! Short) and a `TransportEvent` and route them through the `tracing` crate with
//! structured fields, instead of hand-formatting strings into a flat log file. That
//! buys machine-parseable logs and log levels. For a one-shot observation gate,
//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice.
use core::sync::atomic::{AtomicBool, Ordering};
/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain
/// `static mut bool`) because the detours that read it run on arbitrary game threads;
/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a
/// standalone flag with no ordering relationship to other memory.
static ARMED: AtomicBool = AtomicBool::new(false);
/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain`
/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is
/// what makes the switch actually propagate through the umu/Proton launch — the same
/// gotcha the probe switches hit; it works because the launch script `export`s it.
pub fn arm_from_env() {
let on = std::env::var("OPENFUT_TRANSPORT_WATCH")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(on, Ordering::Relaxed);
crate::write_log(&format!(
"TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n",
if on { "ARMED" } else { "disarmed" }
));
}
fn armed() -> bool {
ARMED.load(Ordering::Relaxed)
}
/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log
/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing
/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is
/// deliberately narrower and unchanged.
fn is_blaze_flavored(host: &str) -> bool {
let h = host.to_ascii_lowercase();
[
"redirector",
"gosredirector",
"blaze",
"gosca",
"easfc",
"utas",
"fut",
"ea.com",
"easports",
]
.iter()
.any(|k| h.contains(k))
}
/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be
/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds
/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set
/// and a Blaze host stands out.
pub fn note_getaddrinfo(host: &str) {
if !armed() {
return;
}
let tag = if is_blaze_flavored(host) {
" <-- BLAZE/EA-FLAVORED"
} else {
""
};
crate::write_log(&format!(
"TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n"
));
}
const AF_INET: u16 = 2; // IPv4
const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI)
/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY
/// sockaddr, so reading this layout is safe enough to classify the family even when
/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've
/// confirmed `sin_family == AF_INET`.
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order;
/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact
/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector
/// variants, 3659 classic redirector.
fn is_blaze_port(port: u16) -> bool {
matches!(port, 42127 | 10744 | 3659 | 10041)
}
/// Log one outbound connect attempt. `api` names the call path (`connect` /
/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used.
///
/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr
/// the game just handed to a Winsock connect API, so that always holds at the call
/// sites. We read it read-only and never write through it. `s` is the socket handle,
/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is
/// distinguishable from UDP game/voice traffic.
pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
if !armed() {
return;
}
if name.is_null() || namelen < 8 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the
// address family for ANY sockaddr, so reading it is valid regardless of the real
// struct type. We only trust family-specific fields after matching the family.
let family = *(name as *const u16);
// SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad
// handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2.
let sock_type = {
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
let mut ty: i32 = -1;
let mut len: i32 = 4;
getsockopt(
s,
SOL_SOCKET,
SO_TYPE,
&mut ty as *mut i32 as *mut u8,
&mut len,
);
ty
};
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read.
let sa = &*(name as *const SockaddrIn);
// sin_addr holds the address in NETWORK byte order; on little-endian x86,
// to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted
// quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line
// prints these reversed — a cosmetic bug there; this M0 line is the correct
// one to trust.)
let b = sa.sin_addr.to_le_bytes();
let port = u16::from_be(sa.sin_port);
let is_loopback = b[0] == 127;
let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
// A loopback connect on anything other than LSX is the situation-(a) signal.
if is_loopback && !is_lsx {
tag.push_str(" <-- LOOPBACK non-LSX");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n",
b[0], b[1], b[2], b[3]
));
}
AF_INET6 => {
if namelen < 28 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa = &*(name as *const SockaddrIn6);
let a = sa.sin6_addr; // 16 bytes, network order
let port = u16::from_be(sa.sin6_port);
// Format as 8 colon-separated hex groups (not compressed — clarity over
// brevity for a log meant to be grepped).
let hex = (0..8)
.map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1]))
.collect::<Vec<_>>()
.join(":");
// ::1 = loopback: first 15 bytes zero, last byte 1.
let is_loopback = a[..15].iter().all(|&x| x == 0) && a[15] == 1;
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
if is_loopback {
tag.push_str(" <-- IPv6 LOOPBACK (::1)");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target=[{hex}]:{port} sock_type={sock_type} (IPv6){tag}\n"
));
}
other => {
// AF_UNIX=1 or anything else — where a named-pipe/unix-socket-style local
// Blaze transport would surface.
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family={other} (non-INET — possible AF_UNIX/pipe-like)\n"
));
}
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Transparent forwarding for the system `version.dll` API.
//!
//! The hook is deployed under the `version.dll` filename, so every VERSION API
//! import must continue to behave exactly as it would without OpenFUT. Resolve
//! the genuine system DLL once during process attach, then tail-jump from each
//! exported stub. A tail jump preserves the caller's complete Windows x64 ABI
//! state, including stack arguments whose signatures differ between exports.
use std::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
const EXPORT_COUNT: usize = 16;
/// Keep this list in the same order as the generated stubs below.
const EXPORTS: [&[u8]; EXPORT_COUNT] = [
b"GetFileVersionInfoA\0",
b"GetFileVersionInfoExA\0",
b"GetFileVersionInfoExW\0",
b"GetFileVersionInfoSizeA\0",
b"GetFileVersionInfoSizeExA\0",
b"GetFileVersionInfoSizeExW\0",
b"GetFileVersionInfoSizeW\0",
b"GetFileVersionInfoW\0",
b"VerFindFileA\0",
b"VerFindFileW\0",
b"VerInstallFileA\0",
b"VerInstallFileW\0",
b"VerLanguageNameA\0",
b"VerLanguageNameW\0",
b"VerQueryValueA\0",
b"VerQueryValueW\0",
];
/// Addresses in the genuine system DLL. Atomic storage gives the assembly
/// stubs stable, directly addressable pointer-sized slots without `static mut`.
static REAL: [AtomicUsize; EXPORT_COUNT] = [const { AtomicUsize::new(0) }; EXPORT_COUNT];
macro_rules! proxy_stub {
($index:literal, $name:ident) => {
#[unsafe(no_mangle)]
#[unsafe(naked)]
pub unsafe extern "system" fn $name() {
core::arch::naked_asm!(
"jmp qword ptr [rip + {base} + {offset}]",
base = sym REAL,
offset = const $index * size_of::<usize>(),
);
}
};
}
proxy_stub!(0, GetFileVersionInfoA);
proxy_stub!(1, GetFileVersionInfoExA);
proxy_stub!(2, GetFileVersionInfoExW);
proxy_stub!(3, GetFileVersionInfoSizeA);
proxy_stub!(4, GetFileVersionInfoSizeExA);
proxy_stub!(5, GetFileVersionInfoSizeExW);
proxy_stub!(6, GetFileVersionInfoSizeW);
proxy_stub!(7, GetFileVersionInfoW);
proxy_stub!(8, VerFindFileA);
proxy_stub!(9, VerFindFileW);
proxy_stub!(10, VerInstallFileA);
proxy_stub!(11, VerInstallFileW);
proxy_stub!(12, VerLanguageNameA);
proxy_stub!(13, VerLanguageNameW);
proxy_stub!(14, VerQueryValueA);
proxy_stub!(15, VerQueryValueW);
/// Resolve forwarding targets before returning from `DLL_PROCESS_ATTACH`.
/// Calls into our exports may happen as soon as the loader releases its lock,
/// so deferring this operation to the hook worker would create a race.
pub(crate) unsafe fn resolve() -> bool {
// Loading by absolute path prevents this proxy from recursively loading
// itself. Proton/Wine exposes the Windows system directory at this path.
let path: Vec<u16> = "C:\\Windows\\System32\\version.dll\0"
.encode_utf16()
.collect();
let module = LoadLibraryW(path.as_ptr());
if module.is_null() {
crate::write_log("version_proxy: FATAL: system version.dll load failed\n");
return false;
}
let mut missing = 0;
for (slot, name) in REAL.iter().zip(EXPORTS) {
let address = GetProcAddress(module, name.as_ptr()).map_or(0, |proc| proc as usize);
slot.store(address, Ordering::Release);
if address == 0 {
missing += 1;
}
}
if missing == 0 {
crate::write_log("version_proxy: forwarded all 16 exports\n");
true
} else {
crate::write_log(&format!(
"version_proxy: FATAL: {missing}/16 system exports missing\n"
));
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_table_is_complete_and_nul_terminated() {
assert_eq!(EXPORTS.len(), EXPORT_COUNT);
assert!(EXPORTS.iter().all(|name| name.last() == Some(&0)));
assert!(EXPORTS
.iter()
.all(|name| !name[..name.len() - 1].contains(&0)));
}
}
+18
View File
@@ -0,0 +1,18 @@
LIBRARY version
EXPORTS
GetFileVersionInfoA
GetFileVersionInfoExA
GetFileVersionInfoExW
GetFileVersionInfoSizeA
GetFileVersionInfoSizeExA
GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW
GetFileVersionInfoW
VerFindFileA
VerFindFileW
VerInstallFileA
VerInstallFileW
VerLanguageNameA
VerLanguageNameW
VerQueryValueA
VerQueryValueW
+123
View File
@@ -0,0 +1,123 @@
//! Read-only background polling of the OpenFUT account summary.
//!
//! The launcher already POSTs `/openfut/account/sync` once at launch time
//! (see [`crate::account_sync::sync`]) to select the active profile. This
//! module reuses that request in a background thread so the Dashboard can show
//! a live "Your Club" card — coins, level, packs — without ever blocking the UI
//! thread on the network. It mirrors [`crate::health::HealthMonitor`]: a shared
//! target the UI re-points when the server config changes, and a shared state
//! snapshot the UI renders each frame.
use parking_lot::Mutex;
use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
};
use crate::account_sync::{self, AccountSummary};
use crate::config::LauncherConfig;
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// A snapshot of the last account fetch, rendered by the dashboard.
#[derive(Clone, Default)]
pub struct AccountState {
/// The most recently fetched summary, or None while none has succeeded.
pub summary: Option<AccountSummary>,
/// The error from the latest failed attempt (cleared on success).
pub error: Option<String>,
/// Whether a server target is currently configured. `false` = idle: the
/// launcher has nothing to poll, so the UI shows the "connect" prompt.
pub configured: bool,
pub last_checked: Option<Instant>,
}
impl AccountState {
/// True when the latest error looks like a connectivity failure (server
/// down / unresolvable) rather than a protocol/validation error. Lets the
/// UI show the calm "offline" prompt for the common "server not up" case
/// and reserve the loud error state for genuinely broken responses.
pub fn unreachable(&self) -> bool {
self.error.as_deref().is_some_and(|e| {
e.contains("cannot connect")
|| e.contains("cannot resolve")
|| e.contains("resolved to no addresses")
})
}
}
/// Background poller. Holds a shared target config the UI can update when the
/// user changes the server address/account, and a shared state the UI reads.
pub struct AccountMonitor {
pub state: Arc<Mutex<AccountState>>,
target: Arc<Mutex<Option<LauncherConfig>>>,
running: Arc<AtomicBool>,
}
impl AccountMonitor {
pub fn new() -> Self {
let state = Arc::new(Mutex::new(AccountState::default()));
let target: Arc<Mutex<Option<LauncherConfig>>> = Arc::new(Mutex::new(None));
let running = Arc::new(AtomicBool::new(true));
let t_state = Arc::clone(&state);
let t_target = Arc::clone(&target);
let t_running = Arc::clone(&running);
thread::spawn(move || {
while t_running.load(Ordering::Relaxed) {
let target = t_target.lock().clone();
match target {
None => {
// No server configured — reset to the idle prompt state.
*t_state.lock() = AccountState::default();
}
Some(config) => {
let result = account_sync::sync(&config);
let mut state = t_state.lock();
state.configured = true;
state.last_checked = Some(Instant::now());
match result {
Ok(summary) => {
state.summary = Some(summary);
state.error = None;
}
Err(error) => {
// Drop the stale summary so the card never shows
// populated data alongside an error/offline pill.
state.summary = None;
state.error = Some(error);
}
}
}
}
thread::sleep(POLL_INTERVAL);
}
});
Self {
state,
target,
running,
}
}
/// Point the monitor at a new server/account. `None` (no server configured)
/// puts it back into the idle prompt state.
pub fn set_target(&self, target: Option<LauncherConfig>) {
*self.target.lock() = target;
}
pub fn snapshot(&self) -> AccountState {
self.state.lock().clone()
}
}
impl Drop for AccountMonitor {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
}
}
+187 -22
View File
@@ -7,11 +7,18 @@ use std::time::Duration;
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
const TIMEOUT: Duration = Duration::from_secs(3);
/// The launcher's view of the account, sent on every sync.
///
/// `persona_id`/`persona_name` are `Option` because omitting them is meaningful:
/// the server then answers with the persona *it* is configured for, which is how
/// first-run account creation learns an identity instead of inventing one.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountSyncRequest<'a> {
persona_id: u64,
persona_name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
persona_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
persona_name: Option<&'a str>,
level: u32,
experience: u32,
experience_max: u32,
@@ -24,14 +31,25 @@ pub struct AccountSyncResult {
pub account: AccountSummary,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSummary {
pub persona_id: u64,
pub persona_name: String,
/// Club identity for the account bar. Optional in older envelopes.
#[serde(default)]
pub club_name: String,
#[serde(default)]
pub club_abbr: String,
pub level: u32,
pub experience: u32,
/// XP required for the next level. Optional; 0 means "unknown".
#[serde(default)]
pub experience_max: u32,
pub account_funds: u32,
/// EASFC funds ceiling. Optional; 0 means "unknown".
#[serde(default)]
pub account_funds_cap: u32,
pub coins: i64,
pub unopened_packs: usize,
}
@@ -43,7 +61,64 @@ pub struct AccountSummary {
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
config.validate_server()?;
config.validate_account()?;
let account = post(
config,
&AccountSyncRequest {
persona_id: Some(config.fut_persona_id),
persona_name: Some(config.fut_persona_name.trim()),
level: config.fut_account_level,
experience: config.fut_account_experience,
experience_max: config.fut_account_experience_max,
account_funds: config.fut_account_funds,
account_funds_cap: config.fut_account_funds_cap,
},
)?;
// The server echoes the persona it selected. A different one means the two
// sides disagree about who is playing, which must never pass silently.
if account.persona_id != config.fut_persona_id {
return Err(format!(
"account server selected persona {} instead of {}",
account.persona_id, config.fut_persona_id
));
}
Ok(account)
}
/// Ask the server which account it serves, for first-run account creation.
///
/// Sending no persona makes the server fall back to the one it was started with
/// and answer with its real club and Core coin balance. That is the whole reason
/// the launcher never has to invent a persona id: the identity that matters is
/// the server's, and this is how it is claimed.
pub fn discover(config: &LauncherConfig) -> Result<AccountSummary, String> {
config.validate_server()?;
let account = post(
config,
&AccountSyncRequest {
persona_id: None,
persona_name: None,
level: config.fut_account_level.max(1),
experience: config.fut_account_experience,
experience_max: config.fut_account_experience_max.max(1),
account_funds: config.fut_account_funds,
account_funds_cap: config.fut_account_funds_cap,
},
)?;
if account.persona_id == 0 {
return Err(
"account server returned no persona — is it configured with \
a persona id?"
.to_string(),
);
}
if account.persona_name.trim().is_empty() {
return Err("account server returned an empty persona name".to_string());
}
Ok(account)
}
/// One bounded POST to `/openfut/account/sync`, returning the account summary.
fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result<AccountSummary, String> {
let host = config.openfut_server_host.trim();
let port = config.openfut_account_sync_port;
let address = (host, port)
@@ -60,16 +135,8 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
.set_write_timeout(Some(TIMEOUT))
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
let payload = serde_json::to_vec(&AccountSyncRequest {
persona_id: config.fut_persona_id,
persona_name: config.fut_persona_name.trim(),
level: config.fut_account_level,
experience: config.fut_account_experience,
experience_max: config.fut_account_experience_max,
account_funds: config.fut_account_funds,
account_funds_cap: config.fut_account_funds_cap,
})
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
let payload = serde_json::to_vec(body)
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
let request = format!(
"POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
@@ -96,21 +163,15 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u16>().ok())
.ok_or_else(|| "account server returned a malformed status line".to_string())?;
let body = &response[separator + 4..];
let response_body = &response[separator + 4..];
if !(200..300).contains(&status) {
let detail = String::from_utf8_lossy(body);
let detail = String::from_utf8_lossy(response_body);
return Err(format!(
"account server rejected sync (HTTP {status}): {detail}"
));
}
let envelope: AccountSyncResult = serde_json::from_slice(body)
let envelope: AccountSyncResult = serde_json::from_slice(response_body)
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
if envelope.account.persona_id != config.fut_persona_id {
return Err(format!(
"account server selected persona {} instead of {}",
envelope.account.persona_id, config.fut_persona_id
));
}
Ok(envelope.account)
}
@@ -174,4 +235,108 @@ mod tests {
assert_eq!(selected.unopened_packs, 1);
server.join().unwrap();
}
/// Serve exactly one `/openfut/account/sync` POST, handing the decoded
/// request text to `inspect` and replying with `body`.
fn serve_once(
inspect: impl FnOnce(&str) + Send + 'static,
body: &'static str,
) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut request = Vec::new();
loop {
let mut chunk = [0; 1024];
let count = socket.read(&mut chunk).unwrap();
assert!(count > 0);
request.extend_from_slice(&chunk[..count]);
if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..separator]);
let length = headers
.lines()
.find_map(|line| line.strip_prefix("Content-Length: "))
.unwrap()
.parse::<usize>()
.unwrap();
if request.len() >= separator + 4 + length {
break;
}
}
}
inspect(&String::from_utf8_lossy(&request));
write!(
socket,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
(port, handle)
}
#[test]
fn discover_omits_the_persona_so_the_server_names_its_own() {
// The point of first-run discovery: the launcher must not send a guessed
// persona, because the server would echo the guess straight back.
let (port, server) = serve_once(
|request| {
assert!(!request.contains("personaId"), "{request}");
assert!(!request.contains("personaName"), "{request}");
},
r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#,
);
let config = LauncherConfig {
openfut_server_host: "127.0.0.1".into(),
openfut_account_sync_port: port,
..LauncherConfig::default()
};
// Deliberately an unconfigured account: discovery must work before one
// exists, which is the whole reason it does not call `validate_account`.
assert_eq!(config.fut_persona_id, 0);
let found = discover(&config).unwrap();
assert_eq!(found.persona_id, 33_068_179);
assert_eq!(found.persona_name, "CAGE");
assert_eq!(found.club_name, "OpenFUT");
assert_eq!(found.coins, 29_876_776);
server.join().unwrap();
}
#[test]
fn discover_rejects_a_server_that_names_no_persona() {
// A zero persona would otherwise be written into the config as a real
// account and fail much later, at launch, as a mismatch.
let (port, server) = serve_once(
|_| {},
r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
);
let config = LauncherConfig {
openfut_server_host: "127.0.0.1".into(),
openfut_account_sync_port: port,
..LauncherConfig::default()
};
let error = discover(&config).unwrap_err();
assert!(error.contains("no persona"), "{error}");
server.join().unwrap();
}
#[test]
fn sync_refuses_a_server_that_selects_a_different_persona() {
let (port, server) = serve_once(
|_| {},
r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
);
let config = LauncherConfig {
openfut_server_host: "127.0.0.1".into(),
openfut_account_sync_port: port,
fut_persona_id: 12345678,
fut_persona_name: "TEST_USER".into(),
..LauncherConfig::default()
};
let error = sync(&config).unwrap_err();
assert!(error.contains("999"), "{error}");
server.join().unwrap();
}
}
+1833 -758
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -106,14 +106,16 @@ pub(crate) fn arming_summary(
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
anyhow::bail!("Set the OpenFUT server host in the Config tab before arming.");
anyhow::bail!("Set the OpenFUT server host in Settings before arming.");
}
let ea_ip = cfg.ea_redirect_probe_ip.trim();
if ea_ip.is_empty() {
anyhow::bail!("Set the EA redirector IP (Config tab) before arming.");
anyhow::bail!("Set the EA redirector IP (Settings) before arming.");
}
if cfg.ea_hostnames.is_empty() {
anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming.");
anyhow::bail!(
"Add at least one EA hostname (e.g. easw.easports.com) in Settings before arming."
);
}
let redirector_port = cfg.openfut_blaze_redirector_port;
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
+58 -80
View File
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
/// Dead EA hostnames that must resolve to `openfut_server_host`.
#[serde(default)]
pub ea_hostnames: Vec<String>,
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
// box and cannot move to the server: the LSX Origin emulator (the game dials
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
// in the server container; these two stay here.
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
/// Empty means the local-services feature is unconfigured and its controls
/// stay disabled.
#[serde(default)]
pub fifa17_tools_dir: String,
/// Python interpreter used to run the local companion services.
#[serde(default = "default_python")]
pub fifa17_python: String,
}
fn default_python() -> String {
"python3".to_string()
}
fn default_https_port() -> u16 {
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
game_profile: GameProfile::default(),
ea_redirect_probe_ip: String::new(),
ea_hostnames: Vec::new(),
fifa17_tools_dir: base
.join("fifa17-recon/tools")
.to_string_lossy()
.into(),
fifa17_python: default_python(),
}
}
}
@@ -318,6 +293,17 @@ impl LauncherConfig {
}
}
/// The config the account monitor should poll with, or None when no server
/// is configured. Returns a clone so the background thread owns its own
/// snapshot and never races the UI's live config.
pub fn account_target(&self) -> Option<LauncherConfig> {
if self.openfut_server_host.trim().is_empty() {
None
} else {
Some(self.clone())
}
}
/// Build the shared [`ServerConfig`] from the launcher's configured server
/// host + destination ports. This is the single place the launcher turns UI
/// fields into the canonical config consumed by the hook.
@@ -343,19 +329,6 @@ impl LauncherConfig {
self.server_config().validate().map_err(|e| e.to_string())
}
/// Validate the client-local FIFA 17 service configuration. Filesystem
/// existence is checked by the process launcher immediately before spawn;
/// this ensures required user configuration is never silently invented.
pub fn validate_local_services(&self) -> Result<(), String> {
if self.fifa17_tools_dir.trim().is_empty() {
return Err("No FIFA 17 tools dir configured. Set it in the Config tab.".into());
}
if self.fifa17_python.trim().is_empty() {
return Err("No Python interpreter configured. Set it in the Config tab.".into());
}
Ok(())
}
/// Validate every configuration value required by the one-button FIFA 17
/// launch path. Runtime state such as hook deployment is checked by the UI.
pub fn validate_launch_config(&self) -> Result<(), String> {
@@ -369,19 +342,19 @@ impl LauncherConfig {
} else if self.game_launch_command.trim().is_empty() {
return Err(
"No game configured. Fill in the game profile, or set a launch command, \
in the Config tab."
in Settings."
.into(),
);
}
self.validate_local_services()
Ok(())
}
pub fn validate_account(&self) -> Result<(), String> {
if self.fut_persona_id == 0 {
return Err("No EA persona ID configured. Set the account in the Config tab.".into());
return Err("No account yet. Create one from the Get started tab.".into());
}
if self.fut_persona_name.trim().is_empty() {
return Err("No EA persona name configured. Set the account in the Config tab.".into());
return Err("Account has no persona name. Recreate it from Get started.".into());
}
if self.fut_account_level == 0 {
return Err("EA account level must be at least 1.".into());
@@ -397,6 +370,21 @@ impl LauncherConfig {
Ok(())
}
/// Whether an account has been claimed from the server (see
/// [`crate::account_sync::discover`]). Distinct from
/// [`Self::validate_account`], which also polices the derived EASFC values:
/// this answers only "does this install know who is playing?".
pub fn account_configured(&self) -> bool {
self.fut_persona_id != 0 && !self.fut_persona_name.trim().is_empty()
}
/// Whether the launcher should open on the guided first-run flow instead of
/// the dashboard. Keyed on the two things a new user cannot be expected to
/// guess: where the server is, and who they are.
pub fn needs_onboarding(&self) -> bool {
self.validate_server().is_err() || !self.account_configured()
}
/// The exact `openfut.cfg` bytes to write for the hook, or an error if the
/// server isn't validly configured (never emits a loopback fallback).
///
@@ -482,31 +470,7 @@ mod tests {
}
#[test]
fn local_services_require_tools_dir_and_python() {
let mut c = LauncherConfig::default();
c.fifa17_tools_dir.clear();
assert!(c
.validate_local_services()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
c.fifa17_python.clear();
assert!(c.validate_local_services().unwrap_err().contains("Python"));
}
#[test]
fn local_services_accept_explicit_configuration() {
let c = LauncherConfig {
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default()
};
assert!(c.validate_local_services().is_ok());
}
#[test]
fn launch_config_requires_server_local_services_and_command() {
fn launch_config_requires_server_account_and_command() {
let mut c = LauncherConfig::default();
assert!(c.validate_launch_config().is_err());
@@ -519,14 +483,6 @@ mod tests {
.contains("launch command"));
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
c.fifa17_tools_dir.clear();
assert!(c
.validate_launch_config()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
c.fifa17_python = "/usr/bin/python3".into();
assert!(c.validate_launch_config().is_ok());
}
@@ -554,8 +510,6 @@ mod tests {
openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1,
fut_persona_name: "X".into(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default()
};
c.game_launch_command.clear();
@@ -583,8 +537,6 @@ mod tests {
openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1,
fut_persona_name: "X".into(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".into(),
game_launch_command: "/home/u/launch.sh".into(),
..LauncherConfig::default()
};
@@ -685,12 +637,38 @@ mod tests {
#[test]
fn launch_requires_a_valid_ea_account() {
let mut c = LauncherConfig::default();
assert!(c.validate_account().unwrap_err().contains("persona ID"));
// A fresh install has no account, and must say so rather than launching
// FIFA as persona 0.
assert!(!c.account_configured());
assert!(c.validate_account().is_err());
c.fut_persona_id = 12345678;
assert!(
!c.account_configured(),
"an id without a name is not an account"
);
assert!(c.validate_account().unwrap_err().contains("persona name"));
c.fut_persona_name = "TEST_USER".into();
assert!(c.account_configured());
assert!(c.validate_account().is_ok());
c.fut_account_experience = 1001;
assert!(c.validate_account().unwrap_err().contains("XP"));
}
#[test]
fn onboarding_is_needed_until_both_server_and_account_are_known() {
// Drives which tab the launcher opens on, so the two halves must both
// count: a server with no account is still a dead end for a new user.
let mut c = LauncherConfig::default();
assert!(c.needs_onboarding());
c.openfut_server_host = "10.10.0.120".into();
assert!(
c.needs_onboarding(),
"a server alone cannot launch anything"
);
c.fut_persona_id = 33_068_179;
c.fut_persona_name = "CAGE".into();
assert!(!c.needs_onboarding());
c.openfut_server_host.clear();
assert!(c.needs_onboarding(), "losing the server reopens the flow");
}
}
+175 -10
View File
@@ -23,10 +23,12 @@
//! `game_launch_command` remains as an escape hatch: an unconfigured profile
//! falls back to it, so an existing working setup cannot be broken by upgrading.
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::config::GameProfile;
@@ -35,14 +37,19 @@ use crate::logs::LogBuffer;
type Log = Arc<Mutex<LogBuffer>>;
fn say(log: &Log, msg: impl Into<String>) {
log.lock().unwrap().push(msg.into());
log.lock().push(msg.into());
}
/// Prepare the prefix, satisfy the licence precondition, and start the game.
///
/// Returns once the game process has been spawned; its output continues to
/// stream into `log` on background threads.
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// stream into `log` on background threads. `on_exit` fires when the process
/// ends, which is how the launch state machine leaves its Running state.
pub fn launch(
profile: &GameProfile,
log: &Log,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> {
profile.validate().map_err(anyhow::Error::msg)?;
let game_dir = PathBuf::from(&profile.game_dir);
@@ -51,6 +58,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
}
prepare_prefix(profile, log)?;
ensure_dll_override(profile, log);
ensure_license(profile, log)?;
let mut cmd = Command::new(&profile.runner);
@@ -61,6 +69,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
for (k, v) in &profile.env {
cmd.env(k, v);
}
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
if !profile.wine_prefix.trim().is_empty() {
cmd.env("WINEPREFIX", &profile.wine_prefix);
}
@@ -78,10 +87,132 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
stream(child, log.clone(), "[launcher] game process exited.");
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
///
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
/// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for
/// a process we spawn ourselves — it cannot help a player who presses Play in Steam,
/// which is why the old advice was to paste launch options by hand (see
/// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly
/// the kind of step that makes this unusable for anyone who does not already know what
/// a DLL override is.
///
/// Persisting the override in the prefix registry removes the manual step entirely: it
/// survives restarts and applies to every launch path, including Steam. This mirrors
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
/// environment) and what Proton itself already does in this prefix for other titles.
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
const HOOK_DLL_VALUE: &str = "version";
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
/// repairs a prefix a player has reset or replaced.
fn dll_override_args() -> [&'static str; 10] {
[
"reg",
"add",
DLL_OVERRIDE_KEY,
"/v",
HOOK_DLL_VALUE,
"/t",
"REG_SZ",
"/d",
HOOK_DLL_OVERRIDE,
"/f",
]
}
/// Persist the hook's DLL override into the prefix, so the game loads the proxy no
/// matter how it is started.
///
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
/// error, since the player cannot act on the latter.
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
if profile.wine_prefix.trim().is_empty() {
return;
}
let mut cmd = Command::new(&profile.runner);
cmd.args(dll_override_args())
.current_dir(&profile.game_dir)
.stdout(Stdio::null())
.stderr(Stdio::null());
for (k, v) in &profile.env {
cmd.env(k, v);
}
cmd.env("WINEPREFIX", &profile.wine_prefix);
match cmd.status() {
Ok(status) if status.success() => {
say(log, "[launcher] game files ready (mod support enabled)");
}
Ok(_) | Err(_) => say(
log,
"[launcher] could not pre-enable mod support in the game prefix; \
launching anyway (this launch still enables it directly)",
),
}
}
#[cfg(test)]
mod override_tests {
use super::*;
#[test]
fn dll_override_is_persisted_native_first_and_idempotently() {
let args = dll_override_args();
assert_eq!(args[0], "reg");
assert_eq!(args[1], "add");
assert_eq!(
args[2], r"HKCU\Software\Wine\DllOverrides",
"Wine reads overrides from this key; a typo silently leaves the hook unloaded"
);
assert_eq!(args[4], "version", "the hook ships as a version.dll proxy");
assert_eq!(
args[8], "native,builtin",
"native first so the proxy wins, builtin as fallback so a missing proxy \
cannot make the game unlaunchable"
);
assert_eq!(
args[9], "/f",
"idempotent, so running it on every launch repairs a reset prefix"
);
}
}
/// The `WINEDLLOVERRIDES` value the game must be started with.
///
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
/// options; when the launcher spawns the runner itself, nothing else supplies it.
///
/// Without it the failure is silent and badly misleading: the hook never loads, so
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
/// on EA's real ports. It looks like a working launch against the configured
/// server while actually talking to a different one.
///
/// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled.
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
const HOOK: &str = "version=n,b";
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
Some(existing) if existing.contains("version=") => existing.to_string(),
Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"),
_ => HOOK.to_string(),
}
}
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
@@ -221,12 +352,17 @@ fn non_empty_file(path: &Path) -> bool {
}
/// Pump a child's stdout and stderr into the log buffer and reap it.
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
pub fn stream(
mut child: Child,
log: Log,
exit_msg: &'static str,
on_exit: impl FnOnce() + Send + 'static,
) {
if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log);
std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
buf.lock().push(line);
}
});
}
@@ -234,13 +370,14 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
let buf = Arc::clone(&log);
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
buf.lock().push(line);
}
});
}
std::thread::spawn(move || {
let _ = child.wait();
log.lock().unwrap().push(exit_msg.to_string());
log.lock().push(exit_msg.to_string());
on_exit();
});
}
@@ -443,7 +580,35 @@ mod tests {
game_dir: "/definitely/not/here".into(),
..GameProfile::default()
};
let err = launch(&profile, &log()).unwrap_err().to_string();
let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
assert!(err.contains("game_dir does not exist"), "{err}");
}
#[test]
fn a_profile_without_overrides_still_gets_the_hook_hijack() {
// The regression this guards: FIFA launched from the launcher ignored the
// configured Blaze ports entirely, because Proton loaded its own builtin
// version.dll and the hook proxy never ran. The launch looked healthy.
assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b");
}
#[test]
fn unrelated_overrides_are_preserved_and_appended_to() {
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]);
assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b");
}
#[test]
fn an_explicit_version_override_is_never_overruled() {
// An operator disabling the hijack on purpose must win, otherwise the
// setting is a lie.
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]);
assert_eq!(hook_dll_overrides(&env), "version=b");
}
#[test]
fn a_blank_override_is_treated_as_absent_rather_than_appended_to() {
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]);
assert_eq!(hook_dll_overrides(&env), "version=n,b");
}
}
+7 -6
View File
@@ -6,11 +6,12 @@
//! stops, or assumes anything about how the server is hosted; it only asks
//! "can the FIFA client reach it right now?".
use parking_lot::Mutex;
use std::{
net::{TcpStream, ToSocketAddrs},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
Arc,
},
thread,
time::{Duration, Instant},
@@ -57,14 +58,14 @@ impl HealthMonitor {
let t_running = Arc::clone(&running);
thread::spawn(move || {
while t_running.load(Ordering::Relaxed) {
let target = t_target.lock().unwrap().clone();
let target = t_target.lock().clone();
match target {
None => {
*t_state.lock().unwrap() = HealthState::default();
*t_state.lock() = HealthState::default();
}
Some((host, port)) => {
let snapshot = probe(&host, port);
*t_state.lock().unwrap() = snapshot;
*t_state.lock() = snapshot;
}
}
thread::sleep(POLL_INTERVAL);
@@ -81,11 +82,11 @@ impl HealthMonitor {
/// Point the monitor at a new server address (host + bridge port). Passing
/// None (e.g. no server configured) puts it back into the idle state.
pub fn set_target(&self, target: Option<(String, u16)>) {
*self.target.lock().unwrap() = target;
*self.target.lock() = target;
}
pub fn snapshot(&self) -> HealthState {
self.state.lock().unwrap().clone()
self.state.lock().clone()
}
}
+940
View File
@@ -0,0 +1,940 @@
//! The launch sequence, as an explicit state machine.
//!
//! # Why this exists
//!
//! 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
//! press a button called *Start Services & Launch Game*. Every one of those is an
//! implementation detail 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` was 0 silently does
//! nothing at all.
//!
//! So the sequence lives here, once, and the UI renders it. One button.
//!
//! # Ordering, and where it deviates from the obvious
//!
//! Client preparation (`arm`) runs BEFORE autopatch, not after: autopatch writes
//! `/proc/<FIFA17.exe>/mem`, which Yama forbids until arming sets
//! `kernel.yama.ptrace_scope=0`. Starting autopatch first would "succeed" and
//! then quietly fail to patch anything.
//!
//! # Idempotence
//!
//! 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 an unnecessary Polkit prompt.
//!
//! # Testability
//!
//! The effects — spawning services, elevating for arming, writing the hook
//! config, starting the game — sit behind [`LaunchOps`]. [`run_sequence`] is
//! therefore a pure decision procedure over observed state, and the sequencing
//! rules that matter (don't launch after a failed step, don't restart healthy
//! services, don't kill what we didn't start) are unit-testable without a FIFA
//! install, a Polkit agent, or root.
use std::sync::Arc;
use crate::config::LauncherConfig;
use crate::fifa17_capability::Fifa17ClientCapabilities;
use crate::local_services::{
CapabilityWiring, Ensured, Service, ServiceRuntime, ServiceSupervisor, SpawnSpec,
};
use crate::logs::LogBuffer;
use crate::preflight::{self, Check, State};
use parking_lot::Mutex;
/// Where the launch sequence is. Rendered directly by the UI; the UI never
/// coordinates services itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Phase {
/// Nothing in flight. Readiness still comes from observed state, not from
/// having been here.
#[default]
Idle,
/// Looking at the world: checks + service + hook state.
Checking,
/// Elevated client preparation in flight (this is what shows a password
/// prompt).
PreparingClient,
StartingServices,
/// Re-checking after repair, before committing to a launch.
Validating,
Launching,
/// FIFA is up. Left when the process exits.
Running,
Failed,
}
impl Phase {
/// Whether a launch is under way, i.e. the primary button must not start a
/// second one.
pub fn busy(self) -> bool {
matches!(
self,
Phase::Checking
| Phase::PreparingClient
| Phase::StartingServices
| Phase::Validating
| Phase::Launching
)
}
}
/// One step of the sequence, in execution order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
Server,
ClientFiles,
ClientPreparation,
Lsx,
Autopatch,
FinalChecks,
Game,
}
impl Step {
/// User-facing name. Deliberately not the internal vocabulary: "arm" is
/// implementation terminology and never appears in the normal flow.
pub fn label(self) -> &'static str {
match self {
Step::Server => "OpenFUT server",
Step::ClientFiles => "Client files",
Step::ClientPreparation => "Client preparation",
Step::Lsx => "LSX",
Step::Autopatch => "Autopatch",
Step::FinalChecks => "Final checks",
Step::Game => "FIFA 17",
}
}
}
/// How a step ended. `Skipped` is a success that did nothing — the state it
/// would have produced was already true.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Done(String),
Skipped(String),
Failed(String),
}
impl Outcome {
pub fn ok(&self) -> bool {
!matches!(self, Outcome::Failed(_))
}
pub fn detail(&self) -> &str {
match self {
Outcome::Done(d) | Outcome::Skipped(d) | Outcome::Failed(d) => d,
}
}
}
/// Everything the UI needs to render the launch surface.
#[derive(Debug, Clone, Default)]
pub struct LaunchState {
pub phase: Phase,
/// Steps attempted by the most recent run, in order.
pub steps: Vec<(Step, Outcome)>,
/// One-line reason the run failed, for the top of the failure card. The
/// per-step detail carries the specifics.
pub failure: Option<String>,
/// The most recent preflight results and when they were taken. Cached
/// because the checks open sockets with timeouts and cannot run per frame.
pub checks: Option<Vec<Check>>,
pub checks_age: Option<std::time::Instant>,
}
impl LaunchState {
fn begin(&mut self, phase: Phase) {
self.phase = phase;
self.steps.clear();
self.failure = None;
}
fn record(&mut self, step: Step, outcome: Outcome) {
if let Outcome::Failed(reason) = &outcome {
self.failure = Some(format!("{}: {reason}", step.label()));
}
self.steps.push((step, outcome));
}
}
/// The effects the sequence performs. Implemented for real by [`RealOps`] and
/// substituted in tests.
pub trait LaunchOps {
/// Confirm the configured OpenFUT server is answering AND select the account
/// for this session. The server is remote by design, so this is a network
/// fact, never "is something local up". Returns a user-facing summary.
fn connect_server(&mut self) -> Result<String, String>;
/// Version.dll + a readable openfut.cfg. `Err` is a hard stop: without them
/// FIFA talks to EA, not OpenFUT.
fn ensure_client_files(&mut self) -> Result<String, String>;
/// Which of the arming-repairable checks are currently failing.
fn run_checks(&mut self) -> Vec<Check>;
/// Elevated client preparation (`arm`). Returns what it changed.
fn prepare_client(&mut self) -> Result<Vec<String>, String>;
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String>;
fn start_game(&mut self) -> Result<(), String>;
}
/// Checks that client preparation is able to repair. A failure in any of these
/// means "prepare the client", not "give up".
fn preparation_repairs(check: &Check) -> bool {
const REPAIRABLE: [&str; 3] = [
"ptrace_scope (autopatch)",
"EA redirector IP is redirected",
"EA hostnames point at OpenFUT",
];
REPAIRABLE.contains(&check.name.as_str())
}
/// Run the whole sequence, publishing progress into `state` as it goes.
///
/// Returns whether FIFA was started. Stops at the first failed step: launching
/// into a known-broken client produces a session that fails minutes later with
/// no message naming the cause, which is precisely the failure mode this
/// launcher exists to prevent.
pub fn run_sequence(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) -> bool {
macro_rules! step {
($phase:expr, $step:expr, $body:expr) => {{
state.lock().phase = $phase;
let outcome: Outcome = $body;
let ok = outcome.ok();
state.lock().record($step, outcome);
if !ok {
state.lock().phase = Phase::Failed;
return false;
}
}};
}
state.lock().begin(Phase::Checking);
// ── The server, which is remote and not ours to start ────────────────────
step!(Phase::Checking, Step::Server, {
match ops.connect_server() {
Ok(detail) => Outcome::Done(detail),
Err(e) => Outcome::Failed(e),
}
});
// ── The hook the game loads, reconciled with the current settings ────────
step!(Phase::Checking, Step::ClientFiles, {
match ops.ensure_client_files() {
Ok(detail) => Outcome::Done(detail),
Err(e) => Outcome::Failed(e),
}
});
// ── Client preparation, only if something it repairs is broken ───────────
let checks = ops.run_checks();
let broken: Vec<String> = checks
.iter()
.filter(|c| c.state == State::Fail && preparation_repairs(c))
.map(|c| c.name.clone())
.collect();
{
let mut guard = state.lock();
guard.checks = Some(checks);
guard.checks_age = Some(std::time::Instant::now());
}
step!(Phase::PreparingClient, Step::ClientPreparation, {
if broken.is_empty() {
Outcome::Skipped("already prepared".into())
} else {
match ops.prepare_client() {
Ok(changes) => Outcome::Done(format!("{} change(s) applied", changes.len())),
Err(e) => Outcome::Failed(e),
}
}
});
// ── Companion services, in dependency order ─────────────────────────────
for (service, step) in [
(Service::Lsx, Step::Lsx),
(Service::Autopatch, Step::Autopatch),
] {
step!(Phase::StartingServices, step, {
match ops.ensure_service(service) {
Ok(Ensured::Reused) => Outcome::Skipped("already running".into()),
Ok(Ensured::Started) => Outcome::Done("started".into()),
Err(e) => Outcome::Failed(e),
}
});
}
// ── Validate what the repairs were supposed to fix ──────────────────────
step!(Phase::Validating, Step::FinalChecks, {
let checks = ops.run_checks();
let failed: Vec<String> = checks
.iter()
.filter(|c| c.state == State::Fail)
.map(|c| c.name.clone())
.collect();
{
let mut guard = state.lock();
guard.checks = Some(checks);
guard.checks_age = Some(std::time::Instant::now());
}
if failed.is_empty() {
Outcome::Done("all checks pass".into())
} else {
Outcome::Failed(format!("still failing: {}", failed.join(", ")))
}
});
step!(Phase::Launching, Step::Game, {
match ops.start_game() {
Ok(()) => Outcome::Done("started".into()),
Err(e) => Outcome::Failed(e),
}
});
state.lock().phase = Phase::Running;
true
}
/// Observe the world without changing it, for the status rows on open and after
/// a settings change. Shares [`run_sequence`]'s notion of what "ready" means so
/// the two cannot drift apart.
pub fn refresh_checks(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) {
state.lock().phase = Phase::Checking;
let checks = ops.run_checks();
let mut guard = state.lock();
guard.checks = Some(checks);
guard.checks_age = Some(std::time::Instant::now());
guard.phase = Phase::Idle;
}
/// What happens to launcher-started services when FIFA exits.
///
/// Exists so the answer is a stated policy rather than an oversight. The shipped
/// value stops nothing:
///
/// * The companion services are reusable across launches — LSX has to be holding
/// :4216 before FIFA dials it, and the next launch would only start them again.
/// * A service the launcher did NOT start is never in the stop list under any
/// value of this policy.
///
/// Client preparation is deliberately absent, and is never reverted: it is host
/// state (`ptrace_scope`, a DNAT, `/etc/hosts`) that `client_arm.sh` also leaves
/// set and that every subsequent launch needs. A flag for it would be a flag
/// nothing honours.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CleanupPolicy {
pub stop_launcher_started_services: bool,
}
/// Which services cleanup is allowed to stop after `FIFA` exits: only ones this
/// launcher started, and only if the policy says so.
pub fn services_to_stop(
policy: CleanupPolicy,
runtimes: &[(Service, ServiceRuntime)],
) -> Vec<Service> {
if !policy.stop_launcher_started_services {
return Vec::new();
}
runtimes
.iter()
.filter(|(_, r)| r.running && r.started_by_launcher)
.map(|(s, _)| *s)
.collect()
}
/// Summary of one dependency for the main card.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Readiness {
Ready,
Busy,
Attention,
/// Never looked, or the answer is stale. Never rendered as Ready.
Unknown,
}
/// Client-integration readiness from the cached checks. `Unknown` until a run has
/// actually happened: "we did not look" must not look like "we looked and it was
/// fine".
pub fn client_integration(state: &LaunchState) -> Readiness {
if matches!(state.phase, Phase::PreparingClient) {
return Readiness::Busy;
}
match &state.checks {
None => Readiness::Unknown,
Some(checks) => {
let relevant: Vec<&Check> = checks.iter().filter(|c| preparation_repairs(c)).collect();
if relevant.iter().any(|c| c.state == State::Fail) {
Readiness::Attention
} else if relevant.iter().all(|c| c.state == State::Skipped) {
// Nothing configured to check, so nothing was verified.
Readiness::Unknown
} else {
Readiness::Ready
}
}
}
}
/// Overall readiness for the card's headline pill. Anything short of every
/// dependency being observed-good is not Ready.
pub fn overall(
phase: Phase,
server: Readiness,
integration: Readiness,
services: Readiness,
hook: Readiness,
) -> Readiness {
if phase == Phase::Running {
return Readiness::Ready;
}
if phase.busy() {
return Readiness::Busy;
}
let parts = [server, integration, services, hook];
if parts.contains(&Readiness::Attention) {
Readiness::Attention
} else if parts.contains(&Readiness::Unknown) {
Readiness::Unknown
} else {
Readiness::Ready
}
}
/// [`LaunchOps`] against the actual machine.
///
/// Holds a snapshot of the config: a launch must not change its mind halfway
/// through because the user edited a field while it ran.
pub struct RealOps {
config: LauncherConfig,
services: Arc<Mutex<ServiceSupervisor>>,
logs: Arc<Mutex<LogBuffer>>,
caps: Arc<Mutex<Fifa17ClientCapabilities>>,
state: Arc<Mutex<LaunchState>>,
}
impl RealOps {
fn say(&self, message: impl Into<String>) {
self.logs.lock().push(message.into());
}
}
impl LaunchOps for RealOps {
fn connect_server(&mut self) -> Result<String, String> {
self.config.validate_server()?;
if preflight::backend_reachable(&self.config).state == State::Fail {
return Err(format!(
"{} is not answering — is the OpenFUT server running?",
self.config.openfut_server_host
));
}
// Selecting the account is part of connecting: LSX and FIFA both
// authenticate as this persona, and a launch with the wrong one produces
// a session that looks fine and belongs to nobody.
let account = crate::account_sync::sync(&self.config)?;
self.say(format!(
"[launcher] account synchronized: {}/{} FUT-coins={} unopened-packs={}",
account.persona_id, account.persona_name, account.coins, account.unopened_packs
));
Ok(format!(
"{} · {}",
self.config.openfut_server_host, account.persona_name
))
}
fn ensure_client_files(&mut self) -> Result<String, String> {
let game_dir = std::path::PathBuf::from(&self.config.fifa_game_dir);
if !crate::setup::hook_dll_deployed(&game_dir) {
return Err("network hook is not deployed — use Setup to deploy it".into());
}
// The file the game reads is reconciled here, and only here: this is the
// one moment it is guaranteed to agree with the settings on screen.
let contents = self.config.hook_cfg_contents()?;
crate::setup::update_hook_config(&game_dir, &contents).map_err(|e| {
format!(
"cannot write {} in {}: {e}",
crate::setup::HOOK_CFG_FILE,
self.config.fifa_game_dir
)
})?;
Ok(format!(
"hook → {}:{}",
self.config.openfut_server_host, self.config.openfut_https_port
))
}
fn run_checks(&mut self) -> Vec<Check> {
preflight::run(&self.config)
}
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
match crate::arm::arm(&self.config) {
Ok(changes) => {
for change in &changes {
self.say(format!("[launcher] prepared: {change}"));
}
Ok(changes)
}
Err(e) => Err(e.to_string()),
}
}
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
let spec = SpawnSpec {
persona_id: self.config.fut_persona_id,
persona_name: self.config.fut_persona_name.clone(),
// Only autopatch advertises the verified resolver guard, so only it
// receives the shared capability sink.
capability: match service {
Service::Autopatch => Some(CapabilityWiring {
server_host: self.config.openfut_server_host.clone(),
account_sync_port: self.config.openfut_account_sync_port,
sink: Arc::clone(&self.caps),
}),
Service::Lsx => None,
},
};
self.services.lock().ensure_running(service, spec)
}
fn start_game(&mut self) -> Result<(), String> {
// A new FIFA process starts with UNKNOWN capability: never inherit the
// previous launch's. The autopatch stdout reader repopulates it.
*self.caps.lock() = Default::default();
let state = Arc::clone(&self.state);
let logs = Arc::clone(&self.logs);
let services = Arc::clone(&self.services);
let on_exit = move || {
// Cleanup goes through the policy rather than through habit, so the
// list can never include a service this launcher did not start.
let runtimes: Vec<_> = {
let mut supervisor = services.lock();
[Service::Lsx, Service::Autopatch]
.into_iter()
.map(|s| {
let runtime = supervisor.observe(s);
(s, runtime)
})
.collect()
};
for service in services_to_stop(CleanupPolicy::default(), &runtimes) {
if let Err(e) = services.lock().stop(service) {
logs.lock().push(format!("[launcher] cleanup: {e}"));
}
}
state.lock().phase = Phase::Idle;
logs.lock()
.push("[launcher] FIFA exited; launcher back to Ready.".to_string());
};
// Prefer the native profile; fall back to the user's shell command so an
// existing working setup keeps working after an upgrade.
if self.config.game_profile.configured() {
crate::game_launch::launch(&self.config.game_profile, &self.logs, on_exit)
.map_err(|e| e.to_string())
} else {
crate::setup::launch_game(
&self.config.game_launch_command,
&self.config.game_launch_workdir,
Arc::clone(&self.logs),
on_exit,
)
.map_err(|e| e.to_string())
}
}
}
/// Drives [`run_sequence`] on a worker thread. The UI thread never blocks on a
/// socket, a Polkit prompt or a process spawn.
pub struct Controller {
pub state: Arc<Mutex<LaunchState>>,
pub services: Arc<Mutex<ServiceSupervisor>>,
}
impl Controller {
pub fn new(logs: Arc<Mutex<LogBuffer>>) -> Self {
Self {
state: Arc::new(Mutex::new(LaunchState::default())),
services: Arc::new(Mutex::new(ServiceSupervisor::new(logs))),
}
}
pub fn snapshot(&self) -> LaunchState {
self.state.lock().clone()
}
fn ops(
&self,
config: &LauncherConfig,
logs: &Arc<Mutex<LogBuffer>>,
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
) -> RealOps {
RealOps {
config: config.clone(),
services: Arc::clone(&self.services),
logs: Arc::clone(logs),
caps: Arc::clone(caps),
state: Arc::clone(&self.state),
}
}
/// Start the full sequence. Ignored while one is already in flight or the
/// game is up — the button reflects that state rather than queueing work.
pub fn launch(
&self,
config: &LauncherConfig,
logs: &Arc<Mutex<LogBuffer>>,
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
) {
{
let phase = self.state.lock().phase;
if phase.busy() || phase == Phase::Running {
return;
}
}
let mut ops = self.ops(config, logs, caps);
let state = Arc::clone(&self.state);
std::thread::spawn(move || {
run_sequence(&mut ops, &state);
});
}
/// Re-observe without changing anything, for startup and after a settings
/// change. Skipped while a launch owns the state.
pub fn refresh(
&self,
config: &LauncherConfig,
logs: &Arc<Mutex<LogBuffer>>,
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
) {
{
let phase = self.state.lock().phase;
if phase.busy() || phase == Phase::Running {
return;
}
}
let mut ops = self.ops(config, logs, caps);
let state = Arc::clone(&self.state);
std::thread::spawn(move || {
refresh_checks(&mut ops, &state);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Records what the sequence asked for, and answers however the test wants.
#[derive(Default)]
#[allow(clippy::type_complexity)]
struct FakeOps {
server_up: bool,
client_files: Option<Result<String, String>>,
checks: Vec<Check>,
checks_after_prepare: Option<Vec<Check>>,
prepare_result: Option<Result<Vec<String>, String>>,
service_result: Vec<(Service, Result<Ensured, String>)>,
game_result: Option<Result<(), String>>,
// Observed calls
prepared: usize,
started: Vec<Service>,
game_started: usize,
check_runs: usize,
}
fn check(name: &str, state: State) -> Check {
Check {
name: name.into(),
state,
detail: String::new(),
}
}
fn ready_ops() -> FakeOps {
FakeOps {
server_up: true,
client_files: Some(Ok("deployed".into())),
checks: vec![
check("ptrace_scope (autopatch)", State::Pass),
check("EA redirector IP is redirected", State::Pass),
check("EA hostnames point at OpenFUT", State::Pass),
],
prepare_result: Some(Ok(vec!["one".into()])),
game_result: Some(Ok(())),
..FakeOps::default()
}
}
impl LaunchOps for FakeOps {
fn connect_server(&mut self) -> Result<String, String> {
if self.server_up {
Ok("connected".into())
} else {
Err("not reachable — is the OpenFUT server running?".into())
}
}
fn ensure_client_files(&mut self) -> Result<String, String> {
self.client_files
.clone()
.unwrap_or_else(|| Err("no client-files result configured".into()))
}
fn run_checks(&mut self) -> Vec<Check> {
self.check_runs += 1;
match (&self.checks_after_prepare, self.prepared) {
(Some(after), n) if n > 0 => after.clone(),
_ => self.checks.clone(),
}
}
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
self.prepared += 1;
self.prepare_result
.clone()
.unwrap_or_else(|| Err("no prepare configured".into()))
}
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
self.started.push(service);
self.service_result
.iter()
.find(|(s, _)| *s == service)
.map(|(_, r)| r.clone())
.unwrap_or(Ok(Ensured::Started))
}
fn start_game(&mut self) -> Result<(), String> {
self.game_started += 1;
self.game_result
.clone()
.unwrap_or_else(|| Err("no game result configured".into()))
}
}
fn state() -> Arc<Mutex<LaunchState>> {
Arc::new(Mutex::new(LaunchState::default()))
}
#[test]
fn a_cold_client_is_prepared_and_started_in_dependency_order() {
let mut ops = FakeOps {
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Pass)]),
..ready_ops()
};
let st = state();
assert!(run_sequence(&mut ops, &st));
assert_eq!(
ops.prepared, 1,
"a failing repairable check must be repaired"
);
// Preparation before autopatch: autopatch cannot write FIFA's memory
// until arming has set ptrace_scope, and would silently no-op.
assert_eq!(ops.started, vec![Service::Lsx, Service::Autopatch]);
assert_eq!(ops.game_started, 1);
assert_eq!(st.lock().phase, Phase::Running);
}
#[test]
fn an_already_prepared_client_is_not_prepared_again() {
let mut ops = ready_ops();
let st = state();
assert!(run_sequence(&mut ops, &st));
assert_eq!(ops.prepared, 0, "no password prompt for work already done");
let steps = &st.lock().steps;
let prep = steps
.iter()
.find(|(s, _)| *s == Step::ClientPreparation)
.expect("preparation step recorded")
.1
.clone();
assert!(matches!(prep, Outcome::Skipped(_)), "{prep:?}");
}
#[test]
fn healthy_services_are_reused_rather_than_restarted() {
let mut ops = FakeOps {
service_result: vec![
(Service::Lsx, Ok(Ensured::Reused)),
(Service::Autopatch, Ok(Ensured::Reused)),
],
..ready_ops()
};
let st = state();
assert!(run_sequence(&mut ops, &st));
for step in [Step::Lsx, Step::Autopatch] {
let outcome = st
.lock()
.steps
.iter()
.find(|(s, _)| *s == step)
.expect("service step recorded")
.1
.clone();
assert!(
matches!(outcome, Outcome::Skipped(_)),
"{step:?} {outcome:?}"
);
}
assert_eq!(ops.game_started, 1);
}
#[test]
fn an_unreachable_server_stops_the_launch_before_anything_is_touched() {
let mut ops = FakeOps {
server_up: false,
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.prepared, 0);
assert!(ops.started.is_empty(), "nothing may be started");
assert_eq!(ops.game_started, 0);
assert_eq!(st.lock().phase, Phase::Failed);
assert!(st.lock().failure.as_deref().unwrap().contains("server"));
}
#[test]
fn a_service_that_fails_to_start_stops_the_launch() {
let mut ops = FakeOps {
service_result: vec![(Service::Autopatch, Err("autopatch: boom".into()))],
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.game_started, 0, "FIFA must not start without autopatch");
let failure = st.lock().failure.clone().unwrap();
assert!(failure.contains("Autopatch"), "{failure}");
}
#[test]
fn failed_client_preparation_stops_the_launch() {
let mut ops = FakeOps {
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
prepare_result: Some(Err("pkexec: dismissed".into())),
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert!(ops.started.is_empty());
assert_eq!(ops.game_started, 0);
}
#[test]
fn a_check_still_failing_after_repair_stops_the_launch() {
// Preparation ran and claimed success, but the state it was supposed to
// fix is still broken. Launching here is how a session dies later with
// no message naming the cause.
let mut ops = FakeOps {
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Fail)]),
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.game_started, 0);
let failure = st.lock().failure.clone().unwrap();
assert!(failure.contains("still failing"), "{failure}");
}
#[test]
fn client_files_failure_stops_the_launch() {
let mut ops = FakeOps {
client_files: Some(Err("cannot write openfut.cfg".into())),
..ready_ops()
};
let st = state();
assert!(!run_sequence(&mut ops, &st));
assert_eq!(ops.game_started, 0);
assert!(ops.started.is_empty());
}
#[test]
fn cleanup_never_stops_a_service_the_launcher_did_not_start() {
let foreign = ServiceRuntime {
running: true,
started_by_launcher: false,
pid: Some(4242),
detail: None,
};
let ours = ServiceRuntime {
running: true,
started_by_launcher: true,
pid: Some(99),
detail: None,
};
let runtimes = [(Service::Lsx, foreign), (Service::Autopatch, ours)];
// Even under the most aggressive policy, a foreign service is untouched.
let aggressive = CleanupPolicy {
stop_launcher_started_services: true,
};
assert_eq!(
services_to_stop(aggressive, &runtimes),
vec![Service::Autopatch]
);
// And the shipped policy keeps both alive for the next launch.
assert!(services_to_stop(CleanupPolicy::default(), &runtimes).is_empty());
}
#[test]
fn readiness_is_never_green_while_a_dependency_is_not() {
assert_eq!(
overall(
Phase::Idle,
Readiness::Ready,
Readiness::Ready,
Readiness::Attention,
Readiness::Ready
),
Readiness::Attention
);
// Never checked is not the same as checked and fine.
assert_eq!(
overall(
Phase::Idle,
Readiness::Ready,
Readiness::Unknown,
Readiness::Ready,
Readiness::Ready
),
Readiness::Unknown
);
assert_eq!(
overall(
Phase::Idle,
Readiness::Ready,
Readiness::Ready,
Readiness::Ready,
Readiness::Ready
),
Readiness::Ready
);
// A running game reports Ready even though a launch is not in flight.
assert_eq!(
overall(
Phase::Running,
Readiness::Unknown,
Readiness::Unknown,
Readiness::Unknown,
Readiness::Unknown
),
Readiness::Ready
);
}
#[test]
fn client_integration_is_unknown_until_checks_have_run() {
let mut st = LaunchState::default();
assert_eq!(client_integration(&st), Readiness::Unknown);
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Fail)]);
assert_eq!(client_integration(&st), Readiness::Attention);
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Pass)]);
assert_eq!(client_integration(&st), Readiness::Ready);
// Only skipped checks means nothing was actually verified.
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Skipped)]);
assert_eq!(client_integration(&st), Readiness::Unknown);
}
}
+428 -79
View File
@@ -13,11 +13,12 @@
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
use parking_lot::Mutex;
use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
path::Path,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{mpsc, Arc, Mutex},
sync::{mpsc, Arc},
time::{Duration, Instant},
};
@@ -28,6 +29,10 @@ use crate::fifa17_capability::{
};
use crate::logs::LogBuffer;
/// The loopback endpoint LSX must own. FIFA dials this exact address and nothing
/// else, so "is LSX ready?" is answerable without asking LSX anything.
pub const LSX_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
#[derive(Debug, PartialEq, Eq)]
struct CommandParts {
program: String,
@@ -35,7 +40,7 @@ struct CommandParts {
}
/// Which companion service. The `str` values are used in log prefixes.
#[derive(Copy, Clone, PartialEq, Eq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Service {
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
Lsx,
@@ -51,25 +56,51 @@ impl Service {
}
}
/// The responder script filename inside the tools dir.
fn script(self) -> &'static str {
/// The companion's executable name.
///
/// These were Python responder scripts run through a configured interpreter. They
/// are now Rust binaries built from this workspace (`openfut-lsx`,
/// `openfut-autopatch`), which removes the interpreter and the tools directory
/// from the launch contract entirely: no `python3` to locate, no script path to
/// configure, and no chance of running a stale checkout's copy.
fn binary(self) -> &'static str {
match self {
Service::Lsx => "lsx_responder_v2.py",
Service::Autopatch => "autopatch.py",
Service::Lsx => "openfut-lsx",
Service::Autopatch => "openfut-autopatch",
}
}
}
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
let mut args = vec![tools_dir
.join(service.script())
.to_string_lossy()
.into_owned()];
/// Absolute path to a companion binary.
///
/// Prefers a sibling of the running launcher, which is what a workspace build and any
/// sane install layout both produce, and falls back to the bare name so a
/// PATH-installed binary still works. Returning the bare name rather than failing
/// keeps `spawn` responsible for reporting a missing binary, with one error message
/// instead of two.
fn resolve_binary(service: Service) -> PathBuf {
let name = service.binary();
if let Some(dir) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
{
let sibling = dir.join(name);
if sibling.is_file() {
return sibling;
}
}
PathBuf::from(name)
}
fn command_parts(service: Service) -> CommandParts {
let mut args = Vec::new();
if service == Service::Autopatch {
// autopatch exits when the launcher does, so it cannot outlive its owner and
// keep writing to a client the launcher no longer manages.
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
}
CommandParts {
program: python.to_string(),
program: resolve_binary(service).to_string_lossy().into_owned(),
args,
}
}
@@ -138,22 +169,19 @@ impl ManagedService {
if let Some(result) = self.stopping.as_ref() {
match result.try_recv() {
Ok(Ok(())) => {
log.lock()
.unwrap()
.push(format!("[launcher] {label} stopped."));
log.lock().push(format!("[launcher] {label} stopped."));
self.stopping = None;
return false;
}
Ok(Err(error)) => {
log.lock()
.unwrap()
.push(format!("[launcher] failed to stop {label}: {error}"));
self.stopping = None;
return false;
}
Err(mpsc::TryRecvError::Empty) => return true,
Err(mpsc::TryRecvError::Disconnected) => {
log.lock().unwrap().push(format!(
log.lock().push(format!(
"[launcher] {label} stop worker exited unexpectedly."
));
self.stopping = None;
@@ -168,7 +196,6 @@ impl ManagedService {
Ok(None) => true,
Ok(Some(status)) => {
log.lock()
.unwrap()
.push(format!("[launcher] {label} exited ({status})."));
self.child = None;
false
@@ -182,6 +209,11 @@ impl ManagedService {
self.stopping.is_some()
}
/// PID of the child this launcher owns, if it owns one.
pub fn pid(&self) -> Option<u32> {
self.child.as_ref().map(Child::id)
}
/// Begin stopping the service without waiting on the egui UI thread.
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
if self.stopping.is_some() {
@@ -189,9 +221,7 @@ impl ManagedService {
}
if let Some(mut child) = self.child.take() {
let label = service.label();
log.lock()
.unwrap()
.push(format!("[launcher] stopping {label}"));
log.lock().push(format!("[launcher] stopping {label}"));
self.stopping = Some(dispatch_stop_work(move || {
child
@@ -224,17 +254,248 @@ pub struct CapabilityWiring {
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
}
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
/// Returns an error (without spawning) if the tools dir or script is missing.
/// What is actually true about one companion service right now.
///
/// Deliberately observed, never remembered: a button press is not evidence that
/// a service is up, and a service that died on its own must not keep showing
/// green because the launcher once started it successfully.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ServiceRuntime {
pub running: bool,
/// True only while THIS launcher owns the live process. Decides whether
/// cleanup is allowed to touch it: a service someone started by hand for a
/// debugging session must survive a launch/exit cycle.
pub started_by_launcher: bool,
pub pid: Option<u32>,
/// Observed supporting detail for the Advanced panel. Only ever facts the
/// launcher actually established.
pub detail: Option<String>,
}
impl ServiceRuntime {
/// Whether this service is usable for a launch, as opposed to merely alive.
/// For LSX that means the port FIFA dials is genuinely held.
pub fn ready(&self) -> bool {
self.running
}
}
/// True when something holds LSX's fixed loopback port.
pub fn lsx_port_busy() -> bool {
match TcpListener::bind(LSX_ADDR) {
Err(error) => error.kind() == std::io::ErrorKind::AddrInUse,
Ok(listener) => {
drop(listener);
false
}
}
}
/// PID of a process running `service`'s companion binary that this launcher does
/// not own, if there is one.
///
/// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service
/// left running by a previous launcher instance or started by hand from a shell
/// is a real state the UI has to be able to report, and cleanup has to respect.
///
/// Matches argv entries rather than `comm`, because `comm` is truncated to 15
/// characters by the kernel and would misreport these names.
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
let binary = service.binary();
let self_pid = std::process::id();
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
if pid == self_pid || Some(pid) == ours {
continue;
}
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
continue;
};
if cmdline.split(|b| *b == 0).any(|arg| {
// Compare the file name, so `/path/to/openfut-lsx` matches while an
// unrelated argument that merely ends with the same text does not.
Path::new(&*String::from_utf8_lossy(arg))
.file_name()
.is_some_and(|n| n == binary)
}) {
return Some(pid);
}
}
None
}
/// Whether a stop request may touch this service.
///
/// Pure, so the ownership rule is testable without a process: refusing to kill
/// something the launcher did not start is the whole reason ownership is tracked,
/// and it must not depend on what happens to be running on the test machine.
pub fn stop_permitted(runtime: &ServiceRuntime, label: &str) -> Result<(), String> {
if runtime.running && !runtime.started_by_launcher {
return Err(format!(
"{label} was started outside this launcher{} — stop it where it was started.",
match runtime.pid {
Some(pid) => format!(" (pid {pid})"),
None => String::new(),
}
));
}
Ok(())
}
/// Owns both companion services and answers "what is running, and who started
/// it?" for the whole launcher.
///
/// Exists so the launch sequence and the Advanced panel act on the same objects.
/// Two independent copies of that state is how a UI ends up claiming Ready while
/// the process is dead.
pub struct ServiceSupervisor {
lsx: ManagedService,
autopatch: ManagedService,
log: Arc<Mutex<LogBuffer>>,
}
/// Whether [`ServiceSupervisor::ensure_running`] had to do anything.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ensured {
/// Already up — left strictly alone.
Reused,
Started,
}
impl ServiceSupervisor {
pub fn new(log: Arc<Mutex<LogBuffer>>) -> Self {
Self {
lsx: ManagedService::default(),
autopatch: ManagedService::default(),
log,
}
}
fn slot(&mut self, service: Service) -> &mut ManagedService {
match service {
Service::Lsx => &mut self.lsx,
Service::Autopatch => &mut self.autopatch,
}
}
/// Observe one service: our own child first, then any foreign instance.
pub fn observe(&mut self, service: Service) -> ServiceRuntime {
let log = Arc::clone(&self.log);
let slot = self.slot(service);
if slot.stopping() {
return ServiceRuntime {
running: true,
started_by_launcher: true,
pid: None,
detail: Some("stopping".into()),
};
}
let ours = slot.pid();
if slot.running(&log, service.label()) {
let mut runtime = ServiceRuntime {
running: true,
started_by_launcher: true,
pid: ours,
detail: None,
};
if service == Service::Lsx {
runtime.detail = Some(if lsx_port_busy() {
format!("holding {LSX_ADDR}")
} else {
// Alive but not listening: real, and not "ready".
runtime.running = false;
format!("process alive but {LSX_ADDR} is not held")
});
}
return runtime;
}
match foreign_pid(service, ours) {
Some(pid) => ServiceRuntime {
running: true,
started_by_launcher: false,
pid: Some(pid),
detail: Some("started outside this launcher".into()),
},
None if service == Service::Lsx && lsx_port_busy() => ServiceRuntime {
running: false,
started_by_launcher: false,
pid: None,
detail: Some(format!("{LSX_ADDR} is held by an unrelated process")),
},
None => ServiceRuntime::default(),
}
}
/// Start `service` only if it is not already usable. Never restarts a healthy
/// service, and never adopts a foreign one as ours.
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
let runtime = self.observe(service);
if runtime.ready() {
self.log.lock().push(format!(
"[launcher] {} already running{} — reusing it.",
service.label(),
match runtime.pid {
Some(pid) => format!(" (pid {pid})"),
None => String::new(),
}
));
return Ok(Ensured::Reused);
}
if let Some(detail) = runtime.detail.filter(|_| !runtime.running) {
// No service-name prefix: every caller already renders the service it
// asked about, and the launch card would print "LSX: LSX: …".
return Err(detail);
}
let child = spawn(
service,
spec.persona_id,
&spec.persona_name,
spec.capability,
Arc::clone(&self.log),
)
.map_err(|e| e.to_string())?;
*self.slot(service) = ManagedService::from_child(child);
Ok(Ensured::Started)
}
/// Stop a service the launcher owns. A foreign process is reported, never
/// killed: the launcher did not start it and does not know who needs it.
pub fn stop(&mut self, service: Service) -> Result<(), String> {
let runtime = self.observe(service);
stop_permitted(&runtime, service.label())?;
let log = Arc::clone(&self.log);
self.slot(service).stop(&log, service);
Ok(())
}
pub fn stopping(&mut self, service: Service) -> bool {
self.slot(service).stopping()
}
}
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
/// as one value per service.
pub struct SpawnSpec {
pub persona_id: u64,
pub persona_name: String,
pub capability: Option<CapabilityWiring>,
}
/// Spawn a companion service and stream its stdout+stderr into `log`.
///
/// Returns an error without spawning if the binary is missing, which is the only
/// precondition left now that the companions are workspace binaries rather than
/// Python scripts run from a configured tools directory.
///
/// `capability` is the backend-registration wiring + shared per-FIFA-process
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
/// verified resolver guard) and `None` for LSX.
pub fn spawn(
service: Service,
python: &str,
tools_dir: &str,
persona_id: u64,
persona_name: &str,
capability: Option<CapabilityWiring>,
@@ -242,35 +503,28 @@ pub fn spawn(
) -> anyhow::Result<Child> {
use std::io::{BufRead, BufReader};
let dir = Path::new(tools_dir);
if !dir.is_dir() {
anyhow::bail!(
"FIFA 17 tools dir not found: {} (set it in the Config tab)",
dir.display()
);
}
let script_path = dir.join(service.script());
if !script_path.exists() {
anyhow::bail!(
"{} not found in tools dir: {}",
service.script(),
script_path.display()
);
}
let label = service.label();
let parts = command_parts(service);
let program = Path::new(&parts.program);
// Only a resolved absolute path can be checked up front; a bare name is left to
// the OS to resolve through PATH, and a failure there is reported by spawn below.
if program.is_absolute() && !program.is_file() {
anyhow::bail!(
"{label} binary not found: {} — build the workspace so it sits beside the launcher",
program.display()
);
}
// Both services use the configured interpreter and absolute script path;
// neither invents a Python installation path. Autopatch receives launcher
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
// block startup.
let parts = command_parts(service, python, dir);
let mut cmd = Command::new(&parts.program);
cmd.args(&parts.args);
if service == Service::Lsx {
// The persona LSX reports has to equal what Blaze returns in
// LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the
// constraint is cross-layer agreement, not any particular value.
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
.env("FUT_PERSONA_NAME", persona_name);
} else if service == Service::Autopatch {
// A per-user runtime log, so a stale root-owned /tmp file cannot block startup.
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
@@ -279,19 +533,21 @@ pub fn spawn(
}
// Put each companion in its own process group for lifecycle isolation.
cmd.process_group(0);
cmd.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
log.lock().unwrap().push(format!(
"[launcher] starting {label}: {} {}",
python,
script_path.display(),
log.lock().push(format!(
"[launcher] starting {label}: {}{}",
parts.program,
parts.args.iter().fold(String::new(), |mut acc, a| {
acc.push(' ');
acc.push_str(a);
acc
}),
));
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?;
if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log);
@@ -304,7 +560,7 @@ pub fn spawn(
let mut registered = false;
for line in BufReader::new(out).lines().map_while(Result::ok) {
// Every raw line is still mirrored into the log, as before.
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
buf.lock().push(format!("[{lbl}] {line}"));
let Some(wiring) = cap_wiring.as_ref() else {
continue;
@@ -317,9 +573,9 @@ pub fn spawn(
};
registered = true;
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version);
wiring.sink.lock().empty_mypacks_resolver = Some(version);
{
let mut log = buf.lock().unwrap();
let mut log = buf.lock();
log.push(format!(
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
));
@@ -336,11 +592,9 @@ pub fn spawn(
) {
Ok(()) => buf
.lock()
.unwrap()
.push("[fifa17] capability registered with backend".to_string()),
Err(error) => buf
.lock()
.unwrap()
.push(format!("[fifa17] capability registration failed: {error}")),
}
}
@@ -351,20 +605,19 @@ pub fn spawn(
let lbl = label.to_string();
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
buf.lock().push(format!("[{lbl}] {line}"));
}
});
}
if service == Service::Lsx {
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
let address = LSX_ADDR;
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
log.lock()
.unwrap()
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
}
@@ -376,27 +629,38 @@ mod tests {
use super::*;
#[test]
fn lsx_runs_python_directly() {
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
assert_eq!(parts.program, "/usr/bin/python3");
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
fn lsx_runs_its_own_binary_with_no_arguments() {
let parts = command_parts(Service::Lsx);
assert_eq!(
Path::new(&parts.program).file_name().unwrap(),
"openfut-lsx"
);
assert!(parts.args.is_empty(), "{:?}", parts.args);
}
#[test]
fn autopatch_runs_python_directly_with_launcher_ownership() {
let parts = command_parts(
Service::Autopatch,
"/usr/bin/python3",
Path::new("/tmp/tools"),
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
let parts = command_parts(Service::Autopatch);
assert_eq!(
Path::new(&parts.program).file_name().unwrap(),
"openfut-autopatch"
);
assert_eq!(parts.program, "/usr/bin/python3");
// The launcher pid is how autopatch learns to exit with its owner.
assert_eq!(
parts.args,
vec![
"/tmp/tools/autopatch.py",
"--launcher-pid",
&std::process::id().to_string(),
]
vec!["--launcher-pid", &std::process::id().to_string()]
);
}
#[test]
fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() {
// Guards the foreign-process scan: an argv entry that merely ends with the
// binary name (a log path, say) must not be mistaken for the service.
assert_eq!(Service::Lsx.binary(), "openfut-lsx");
assert_eq!(Service::Autopatch.binary(), "openfut-autopatch");
assert_eq!(
Path::new("/var/log/my-openfut-lsx").file_name().unwrap(),
"my-openfut-lsx"
);
}
@@ -426,4 +690,89 @@ mod tests {
.expect_err("exited child must not be reported ready");
assert!(error.to_string().contains("exited before becoming ready"));
}
fn supervisor() -> ServiceSupervisor {
ServiceSupervisor::new(Arc::new(Mutex::new(LogBuffer::new())))
}
#[test]
fn a_service_this_launcher_never_started_is_never_reported_as_ours() {
// The old model only knew about children it spawned, so it could not tell
// "stopped" from "running, but not mine". Note this box may genuinely have
// a foreign responder running — that is a real observation, and the
// invariant is about ownership, not about it being absent.
let mut sup = supervisor();
let runtime = sup.observe(Service::Autopatch);
assert!(
!runtime.started_by_launcher,
"nothing was spawned here, so nothing may claim launcher ownership"
);
}
#[test]
fn a_launcher_owned_child_is_observed_as_ours_and_reaped_when_it_dies() {
let mut sup = supervisor();
let child = Command::new("sh")
.args(["-c", "sleep 30"])
.spawn()
.expect("spawn long-lived child");
let pid = child.id();
sup.autopatch = ManagedService::from_child(child);
let runtime = sup.observe(Service::Autopatch);
assert!(runtime.running);
assert!(runtime.started_by_launcher, "we spawned it");
assert_eq!(runtime.pid, Some(pid));
// Stopping is allowed precisely because it is ours.
sup.stop(Service::Autopatch).expect("ours to stop");
}
#[test]
fn stopping_a_foreign_service_is_refused_rather_than_killing_it() {
// A service someone started by hand for a debugging session must survive a
// launch/exit cycle, and the refusal has to say where to stop it. Asserted
// on the pure rule so it holds regardless of what this machine is running.
let foreign = ServiceRuntime {
running: true,
started_by_launcher: false,
pid: Some(4242),
detail: None,
};
let error = stop_permitted(&foreign, "autopatch").unwrap_err();
assert!(error.contains("started outside this launcher"), "{error}");
assert!(error.contains("4242"), "{error}");
let ours = ServiceRuntime {
running: true,
started_by_launcher: true,
pid: Some(99),
detail: None,
};
assert!(stop_permitted(&ours, "autopatch").is_ok());
// Stopping something that is not running is a harmless no-op.
assert!(stop_permitted(&ServiceRuntime::default(), "autopatch").is_ok());
assert!(
crate::launch::services_to_stop(
crate::launch::CleanupPolicy {
stop_launcher_started_services: true,
},
&[(Service::Autopatch, foreign)],
)
.is_empty(),
"a foreign service is never in the stop list"
);
}
#[test]
fn foreign_pid_ignores_the_launcher_process_itself() {
// The scan matches on the responder script name; this process is not one,
// and must never be reported as a service.
assert_ne!(foreign_pid(Service::Lsx, None), Some(std::process::id()));
assert_ne!(
foreign_pid(Service::Autopatch, None),
Some(std::process::id())
);
}
}
+92 -2
View File
@@ -1,3 +1,4 @@
mod account_monitor;
mod account_sync;
mod app;
mod arm;
@@ -5,18 +6,22 @@ mod config;
mod fifa17_capability;
mod game_launch;
mod health;
mod launch;
mod local_services;
mod logs;
mod netcheck;
mod preflight;
mod setup;
mod theme;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("OpenFUT Launcher")
.with_inner_size([780.0, 560.0])
.with_min_inner_size([600.0, 400.0]),
.with_app_id("openfut-launcher")
.with_icon(app_icon())
.with_inner_size([1040.0, 720.0])
.with_min_inner_size([880.0, 600.0]),
..Default::default()
};
@@ -26,3 +31,88 @@ fn main() -> eframe::Result<()> {
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))),
)
}
/// The application / taskbar icon: the same "OF" monogram the header wordmark
/// shows, drawn white on the signature accent tile. Generated in code (no PNG
/// dependency) at 4x supersampling and box-downsampled to a crisp 64x64 RGBA —
/// scales cleanly to the 32x32 the WM typically renders. Colours come from the
/// theme palette so the icon never drifts from the in-app brand.
fn app_icon() -> egui::IconData {
const SIZE: usize = 64; // output edge
const SS: usize = 4; // supersampling factor
let accent = theme::ACCENT;
let fg = theme::ON_ACCENT;
// Rounded-square background: point inside the [0,SIZE]² square with corners
// rounded to `round_r` (transparent outside, so the icon reads as a tile).
let round_r = 13.0_f32;
let inside_bg = |x: f32, y: f32| -> bool {
let s = SIZE as f32;
let cx = x.clamp(round_r, s - round_r);
let cy = y.clamp(round_r, s - round_r);
let (dx, dy) = (x - cx, y - cy);
dx * dx + dy * dy <= round_r * round_r
};
// "O" — an elliptical ring on the left.
let inside_o = |x: f32, y: f32| -> bool {
let (cx, cy) = (21.0_f32, 32.0_f32);
let (dx, dy) = (x - cx, y - cy);
let outer = (dx / 9.0).powi(2) + (dy / 14.0).powi(2) <= 1.0;
let inner = (dx / 4.8).powi(2) + (dy / 9.5).powi(2) < 1.0;
outer && !inner
};
// "F" — a stem plus a top and middle bar on the right.
let inside_f = |x: f32, y: f32| -> bool {
let stem = (34.0..=39.0).contains(&x) && (18.0..=46.0).contains(&y);
let top = (34.0..=52.0).contains(&x) && (18.0..=23.0).contains(&y);
let mid = (34.0..=48.0).contains(&x) && (29.5..=34.0).contains(&y);
stem || top || mid
};
// Premultiplied-alpha accumulation per output pixel so antialiased edges
// (both the rounded tile and the letters) never fringe dark.
let mut rgba = vec![0u8; SIZE * SIZE * 4];
for oy in 0..SIZE {
for ox in 0..SIZE {
let (mut ar, mut ag, mut ab, mut aa) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
for sy in 0..SS {
for sx in 0..SS {
let x = ox as f32 + (sx as f32 + 0.5) / SS as f32;
let y = oy as f32 + (sy as f32 + 0.5) / SS as f32;
let (r, g, b, a) = if inside_o(x, y) || inside_f(x, y) {
(fg.r(), fg.g(), fg.b(), 255u16)
} else if inside_bg(x, y) {
(accent.r(), accent.g(), accent.b(), 255u16)
} else {
(0, 0, 0, 0)
};
let af = a as f32 / 255.0;
ar += r as f32 * af;
ag += g as f32 * af;
ab += b as f32 * af;
aa += af;
}
}
let samples = (SS * SS) as f32;
let idx = (oy * SIZE + ox) * 4;
let (r, g, b) = if aa > 0.0 {
(ar / aa, ag / aa, ab / aa)
} else {
(0.0, 0.0, 0.0)
};
rgba[idx] = r.round() as u8;
rgba[idx + 1] = g.round() as u8;
rgba[idx + 2] = b.round() as u8;
rgba[idx + 3] = (aa / samples * 255.0).round() as u8;
}
}
egui::IconData {
rgba,
width: SIZE as u32,
height: SIZE as u32,
}
}
+122 -36
View File
@@ -88,10 +88,11 @@ impl Check {
/// Run every applicable check. Order is the order the game exercises them.
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(cfg),
ptrace_scope(),
ea_redirect(cfg),
hostname_mapping(cfg),
backend_reachable(cfg),
hook_config(cfg),
]
}
@@ -108,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
/// autopatch writes to FIFA's process memory; Yama blocks that unless
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
/// its TLS handshake much later, with no message naming the cause.
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
///
/// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it.
fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
// does not mean the tools are installed. Key off the directory actually
// existing: that is what decides whether autopatch will run at all, and it
// keeps this from failing on a machine that never uses local services.
let tools = cfg.fifa17_tools_dir.trim();
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
return Check::skip(NAME, "no local services installed");
}
match std::fs::read_to_string(PTRACE_SCOPE) {
Ok(v) => ptrace_verdict(&v),
// Not every kernel has Yama. Absent means unenforced, which is what we want.
@@ -238,7 +235,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check {
}
/// The server side of the same question: are the ports the game will use open?
fn backend_reachable(cfg: &LauncherConfig) -> Check {
pub(crate) fn backend_reachable(cfg: &LauncherConfig) -> Check {
const NAME: &str = "OpenFUT server reachable";
let host = cfg.openfut_server_host.trim();
if host.is_empty() {
@@ -261,6 +258,50 @@ fn backend_reachable(cfg: &LauncherConfig) -> Check {
}
}
/// The deployed `openfut.cfg` is the only server address the *game* can see.
///
/// Every panel in this launcher reads the in-memory config, so a settings change
/// that never reached the file produces the worst possible failure: the UI shows
/// the new server online while FIFA connects to the old one. Compare the two.
fn hook_config(cfg: &LauncherConfig) -> Check {
const NAME: &str = "Hook server address";
let game_dir = cfg.fifa_game_dir.trim();
if game_dir.is_empty() {
return Check::skip(NAME, "no FIFA game dir configured");
}
let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else {
return Check::skip(
NAME,
format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE),
);
};
let deployed = match openfut_common::ServerConfig::parse(&body) {
Ok(parsed) => parsed,
// Unparseable means the hook cannot read it either, and nothing else in
// the stack recovers from that — so this one is a genuine failure.
Err(e) => {
return Check::fail(
NAME,
format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE),
)
}
};
let wanted = cfg.server_config();
if deployed == wanted {
return Check::pass(NAME, format!("hook redirects to {}", wanted.host));
}
// Warn, not fail: the launch path rewrites this file before starting the
// game, so the drift is real but already covered. Naming both addresses is
// what makes it actionable.
Check::warn(
NAME,
format!(
"deployed hook still points at {} (settings say {}) — launching rewrites it",
deployed.host, wanted.host
),
)
}
fn connects(host: &str, port: u16) -> bool {
match (host, port).to_socket_addrs() {
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
@@ -289,13 +330,21 @@ mod tests {
#[test]
fn an_unconfigured_launcher_skips_rather_than_passes() {
// The distinction that matters: a fresh config must not display four
// green ticks. "Not checked" is not "checked and fine".
// The distinction that matters: a fresh config must not display a column
// of green ticks. "Not checked" is not "checked and fine".
//
// `ptrace_scope` is excluded because it is no longer configuration
// dependent: it reads this machine's Yama setting and reports a real
// verdict either way. `only_ptrace_scope_zero_lets_autopatch_work`
// covers it.
let mut c = cfg();
// `default()` points this at a conventional path whose existence varies
// by machine. Pin it so the assertion is about the code, not this box.
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
let checks = run(&c);
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
let checks: Vec<Check> = run(&c)
.into_iter()
.filter(|k| k.name != "ptrace_scope (autopatch)")
.collect();
assert!(
checks.iter().all(|k| k.state == State::Skipped),
"{checks:#?}"
@@ -314,16 +363,6 @@ mod tests {
assert!(ptrace_verdict("1").detail.contains("Arm client"));
}
#[test]
fn ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
// Regression: the gate used to be "is the field non-empty", and the
// field has a default — so this check ran (and failed) on machines that
// never use autopatch at all.
let mut c = cfg();
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
let mut c = cfg();
@@ -356,15 +395,24 @@ mod tests {
/// A shadowed hostname must not be counted as a reason to expect failure.
/// This is the exact case the first version got wrong.
///
/// Asserts the hostname check itself rather than counting states across the
/// whole run: `backend_reachable` opens real sockets, so an aggregate count
/// silently asserts that THIS machine has the OpenFUT ports open. That made
/// the test pass only on the server host and fail on the game machine, which
/// is precisely where someone building the launcher runs the suite.
#[test]
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
let mut c = cfg();
c.openfut_server_host = "127.0.0.2".into();
c.ea_hostnames = vec!["localhost".into()];
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
let checks = run(&c);
assert_eq!(failures(&checks), 0, "must not be reported as fatal");
assert_eq!(warnings(&checks), 1);
let check = hostname_mapping(&c);
assert_eq!(check.state, State::Warn, "{}", check.detail);
assert!(
check.detail.contains("localhost"),
"the warning must name the shadowed host: {}",
check.detail
);
}
#[test]
@@ -377,13 +425,6 @@ mod tests {
assert_eq!(hostname_mapping(&c).state, State::Pass);
}
#[test]
fn ptrace_check_is_skipped_when_local_services_are_not_configured() {
let mut c = cfg();
c.fifa17_tools_dir.clear();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_dead_backend_port_is_reported_as_a_failure() {
let mut c = cfg();
@@ -395,4 +436,49 @@ mod tests {
assert_eq!(check.state, State::Fail, "{}", check.detail);
assert!(check.detail.contains("no answer on"), "{}", check.detail);
}
/// A temp game dir holding one `openfut.cfg` body.
fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap();
dir
}
#[test]
fn a_stale_hook_config_is_reported_and_names_both_addresses() {
// The silent failure this check exists for: settings changed, the file
// the game reads did not.
let mut c = cfg();
c.openfut_server_host = "10.0.0.2".into();
let old = openfut_common::ServerConfig {
host: "10.0.0.1".into(),
ports: c.server_config().ports,
};
let dir = game_dir_with_cfg("stale", &old.to_cfg_string());
c.fifa_game_dir = dir.to_string_lossy().into_owned();
let check = hook_config(&c);
assert_eq!(check.state, State::Warn, "{}", check.detail);
assert!(check.detail.contains("10.0.0.1"), "{}", check.detail);
assert!(check.detail.contains("10.0.0.2"), "{}", check.detail);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_hook_config_matching_settings_passes() {
let mut c = cfg();
c.openfut_server_host = "10.0.0.2".into();
let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string());
c.fifa_game_dir = dir.to_string_lossy().into_owned();
assert_eq!(hook_config(&c).state, State::Pass);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_missing_hook_config_is_skipped_not_passed() {
let mut c = cfg();
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
assert_eq!(hook_config(&c).state, State::Skipped);
}
}
+31 -11
View File
@@ -67,6 +67,9 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
// ── DLL hook deployment ───────────────────────────────────────────────────────
/// The file the injected hook reads its server address from, in the game dir.
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
/// openfut.cfg with the structured server configuration the hook reads.
/// `cfg_contents` must be the full `openfut.cfg` body (see
@@ -85,14 +88,14 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> a
}
std::fs::create_dir_all(game_dir)?;
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?;
std::fs::write(game_dir.join(HOOK_CFG_FILE), cfg_contents)?;
Ok(())
}
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
/// full structured `openfut.cfg` body.
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
let cfg = game_dir.join("openfut.cfg");
let cfg = game_dir.join(HOOK_CFG_FILE);
if !cfg.exists() {
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
}
@@ -100,6 +103,15 @@ pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result
Ok(())
}
/// Read the `openfut.cfg` the hook will actually load, if one is deployed.
///
/// The launcher's own health and account requests are built from the in-memory
/// config, but the *game* only ever sees this file. Reading it back is the only
/// way to tell whether the two agree.
pub fn read_hook_config(game_dir: &Path) -> Option<String> {
std::fs::read_to_string(game_dir.join(HOOK_CFG_FILE)).ok()
}
/// Remove the deployed hook DLL from the FIFA game directory.
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
let dest = game_dir.join("version.dll");
@@ -114,8 +126,14 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
game_dir.join("version.dll").exists()
}
/// The Steam launch options the user needs to paste in to enable the override.
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
/// Steam launch options that enable the hook's DLL override.
///
/// Kept only as a fallback to show a user who runs the game outside this launcher on
/// a prefix we have never prepared. It is NOT the normal path any more: the launcher
/// persists the override in the prefix registry itself
/// (`game_launch::ensure_dll_override`), which applies to every launch including
/// Steam's own Play button. Telling a player to paste launch options is exactly the
/// kind of manual step this launcher exists to remove.
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
// ── Game launch ───────────────────────────────────────────────────────────────
@@ -127,13 +145,14 @@ pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %comman
pub fn launch_game(
command: &str,
workdir: &str,
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>,
log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
if command.trim().is_empty() {
anyhow::bail!("No game launch command configured (set it in the Config tab).");
anyhow::bail!("No game launch command configured (set it in Settings).");
}
let mut cmd = Command::new("sh");
@@ -145,7 +164,6 @@ pub fn launch_game(
log_buf
.lock()
.unwrap()
.push(format!("[launcher] launching game: {command}"));
let mut child = cmd.spawn()?;
@@ -154,7 +172,7 @@ pub fn launch_game(
let buf = std::sync::Arc::clone(&log_buf);
std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
buf.lock().push(line);
}
});
}
@@ -162,18 +180,20 @@ pub fn launch_game(
let buf = std::sync::Arc::clone(&log_buf);
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
buf.lock().push(line);
}
});
}
// Reap the child in the background so a finished game doesn't linger as a
// zombie; we don't block the UI on it.
// zombie; we don't block the UI on it. `on_exit` is how the launch state
// machine learns the game is gone — without it the UI would sit on
// "FIFA 17 Running" forever.
std::thread::spawn(move || {
let _ = child.wait();
log_buf
.lock()
.unwrap()
.push("[launcher] game process exited.".to_string());
on_exit();
});
Ok(())
+303
View File
@@ -0,0 +1,303 @@
//! OpenFUT launcher visual system.
//!
//! A single place that owns the app's look: the semantic colour palette, the
//! type scale, embedded fonts, and the tuned egui [`Style`]/[`Visuals`]. UI code
//! composes *with* this system — it never hard-codes `Color32::from_rgb(...)` or
//! stray pixel radii. The palette is deliberately small: one signature accent
//! plus four status hues (success / warn / error / idle) and a tinted neutral
//! ramp. Nothing here changes launcher behaviour; it is presentation only.
use egui::{
Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Margin, Rounding,
Stroke, TextStyle,
};
// ── Semantic palette ────────────────────────────────────────────────────────
// Neutrals are always *tinted* (a hint of cool blue), never pure #000/#fff.
/// Window backdrop — the deepest surface.
pub const BG_DEEP: Color32 = Color32::from_rgb(0x10, 0x12, 0x18);
/// Standard panel fill (nav rail, central body).
pub const BG: Color32 = Color32::from_rgb(0x15, 0x18, 0x22);
/// Raised card / group surface.
pub const SURFACE: Color32 = Color32::from_rgb(0x1c, 0x20, 0x2e);
/// Hovered / interactive raised surface.
pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x24, 0x29, 0x3a);
/// Inset surface (text fields, console, code).
pub const INSET: Color32 = Color32::from_rgb(0x0e, 0x10, 0x17);
/// Hairline divider / card border.
pub const BORDER: Color32 = Color32::from_rgb(0x2a, 0x31, 0x45);
/// Stronger border for emphasis / hover.
pub const BORDER_STRONG: Color32 = Color32::from_rgb(0x3a, 0x43, 0x5e);
/// Primary text.
pub const TEXT: Color32 = Color32::from_rgb(0xe6, 0xe9, 0xf2);
/// Secondary / supporting text.
pub const TEXT_WEAK: Color32 = Color32::from_rgb(0x9a, 0xa3, 0xb8);
/// Tertiary / disabled-ish text.
pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x6a, 0x73, 0x8a);
/// Signature OpenFUT accent — a confident royal blue used for the wordmark,
/// active navigation, and primary calls-to-action.
pub const ACCENT: Color32 = Color32::from_rgb(0x4c, 0x6f, 0xff);
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0x6a, 0x87, 0xff);
pub const ACCENT_PRESSED: Color32 = Color32::from_rgb(0x3b, 0x5b, 0xe0);
/// Faint accent wash for active-nav backgrounds / selection.
pub const ACCENT_WASH: Color32 = Color32::from_rgb(0x22, 0x2c, 0x50);
/// Text drawn on top of the solid accent.
pub const ON_ACCENT: Color32 = Color32::from_rgb(0xf5, 0xf7, 0xff);
/// Status hues — distinct from the accent so "primary action" never reads as
/// "healthy" and vice-versa.
pub const SUCCESS: Color32 = Color32::from_rgb(0x3f, 0xcf, 0x8e);
pub const WARN: Color32 = Color32::from_rgb(0xf2, 0xb4, 0x4c);
pub const ERROR: Color32 = Color32::from_rgb(0xf2, 0x6d, 0x6d);
pub const IDLE: Color32 = Color32::from_rgb(0x7a, 0x83, 0x99);
/// Informational blue for log lines (lighter than the accent).
pub const INFO: Color32 = Color32::from_rgb(0x8f, 0xb6, 0xff);
// ── Type scale (custom named text styles) ───────────────────────────────────
/// Large branded wordmark.
pub const HERO: &str = "Hero";
/// Card / section titles.
pub const SUBHEADING: &str = "Subheading";
/// Small monospace (console meta, launch command).
pub const MONO_SM: &str = "MonoSm";
fn bold_family() -> FontFamily {
FontFamily::Name("openfut-bold".into())
}
/// A [`TextStyle`] handle for one of our custom scale steps.
pub fn text_style(name: &str) -> TextStyle {
TextStyle::Name(name.into())
}
// ── Status semantics ────────────────────────────────────────────────────────
/// A coarse health/activity state, mapped to one palette hue + glyph. Using an
/// enum keeps status rendering consistent everywhere (dashboard, preflight,
/// services) instead of ad-hoc colour+string pairs.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// Healthy / online / running / passed.
Ok,
/// Advisory — worth attention, usually not fatal.
Warn,
/// Broken / unreachable / failed.
Error,
/// Not running / not configured / not checked.
Idle,
/// Transient (stopping / working).
Busy,
/// Unknown / not yet probed.
Unknown,
}
impl Status {
pub fn color(self) -> Color32 {
match self {
Status::Ok => SUCCESS,
Status::Warn => WARN,
Status::Error => ERROR,
Status::Idle => IDLE,
Status::Busy => WARN,
Status::Unknown => TEXT_FAINT,
}
}
/// A consistent status glyph: filled ● for active/terminal states, hollow ○
/// for idle/unknown. (Kept to glyphs the bundled fonts render.)
pub fn glyph(self) -> &'static str {
match self {
Status::Ok | Status::Error | Status::Warn | Status::Busy => "",
Status::Idle | Status::Unknown => "",
}
}
}
/// Draw a compact status pill: a tinted, rounded chip with a status dot and
/// label. Used for the at-a-glance state on each dashboard card.
pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) {
let color = status.color();
let bg = tint(color, 0.14);
Frame::none()
.fill(bg)
.rounding(Rounding::same(999.0))
.inner_margin(Margin::symmetric(10.0, 3.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0));
ui.label(egui::RichText::new(label).color(color).size(12.0).strong());
});
});
}
/// A raised card surface: rounded, hairline-bordered, generously padded. The
/// building block for the dashboard and setup sections.
pub fn card() -> Frame {
Frame::none()
.fill(SURFACE)
.stroke(Stroke::new(1.0_f32, BORDER))
.rounding(Rounding::same(12.0))
.inner_margin(Margin::same(18.0))
}
/// Blend `color` toward the app background by `bg_weight` (0 = full colour,
/// 1 = pure background). Used for tinted chips and washes.
pub fn tint(color: Color32, weight: f32) -> Color32 {
let w = weight.clamp(0.0, 1.0);
let lerp = |c: u8, b: u8| ((c as f32) * w + (b as f32) * (1.0 - w)).round() as u8;
// Chips sit on card surfaces; lerp toward the surface, not the deep bg.
Color32::from_rgb(
lerp(color.r(), SURFACE.r()),
lerp(color.g(), SURFACE.g()),
lerp(color.b(), SURFACE.b()),
)
}
// ── Install ─────────────────────────────────────────────────────────────────
/// Embed the bundled fonts and apply the OpenFUT style. Called once at startup.
pub fn install(ctx: &Context) {
install_fonts(ctx);
install_style(ctx);
}
fn install_fonts(ctx: &Context) {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert(
"openfut-sans".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Regular.ttf")),
);
fonts.font_data.insert(
"openfut-bold".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Bold.ttf")),
);
fonts.font_data.insert(
"openfut-mono".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansMono.ttf")),
);
// Proportional & monospace default to the bundled faces so the UI looks
// identical regardless of the host's installed fonts.
fonts
.families
.entry(FontFamily::Proportional)
.or_default()
.insert(0, "openfut-sans".to_owned());
fonts
.families
.entry(FontFamily::Monospace)
.or_default()
.insert(0, "openfut-mono".to_owned());
// A dedicated bold family — egui does not synthesize weight, so headings
// reference this explicitly for a real type hierarchy.
fonts.families.insert(
FontFamily::Name("openfut-bold".into()),
vec!["openfut-bold".to_owned(), "openfut-sans".to_owned()],
);
ctx.set_fonts(fonts);
}
fn install_style(ctx: &Context) {
let mut style = (*ctx.style()).clone();
// ── Type scale ──────────────────────────────────────────────────────────
let bold = bold_family();
let prop = FontFamily::Proportional;
let mono = FontFamily::Monospace;
let ts = &mut style.text_styles;
ts.insert(text_style(HERO), FontId::new(28.0, bold.clone()));
ts.insert(TextStyle::Heading, FontId::new(19.0, bold.clone()));
ts.insert(text_style(SUBHEADING), FontId::new(15.0, bold));
ts.insert(TextStyle::Body, FontId::new(14.0, prop.clone()));
ts.insert(TextStyle::Button, FontId::new(14.0, prop.clone()));
ts.insert(TextStyle::Small, FontId::new(12.0, prop));
ts.insert(TextStyle::Monospace, FontId::new(13.0, mono.clone()));
ts.insert(text_style(MONO_SM), FontId::new(11.5, mono));
// ── Spacing scale (multiples of 4) ────────────────────────────────────────
let sp = &mut style.spacing;
sp.item_spacing = egui::vec2(8.0, 8.0);
sp.button_padding = egui::vec2(12.0, 7.0);
sp.menu_margin = Margin::same(8.0);
sp.indent = 18.0;
sp.interact_size.y = 30.0;
sp.scroll.bar_width = 9.0;
// ── Visuals ───────────────────────────────────────────────────────────────
let mut v = egui::Visuals::dark();
v.dark_mode = true;
v.override_text_color = Some(TEXT);
v.panel_fill = BG;
v.window_fill = BG;
v.extreme_bg_color = INSET;
v.faint_bg_color = SURFACE;
v.code_bg_color = INSET;
v.hyperlink_color = ACCENT_HOVER;
v.window_rounding = Rounding::same(12.0);
v.window_stroke = Stroke::new(1.0_f32, BORDER);
v.menu_rounding = Rounding::same(8.0);
v.window_shadow = egui::epaint::Shadow::NONE;
v.popup_shadow = egui::epaint::Shadow {
offset: egui::vec2(0.0, 6.0),
blur: 18.0,
spread: 0.0,
color: Color32::from_black_alpha(120),
};
// Selection uses the accent wash so highlighted text/nav reads as branded.
v.selection.bg_fill = ACCENT_WASH;
v.selection.stroke = Stroke::new(1.0_f32, ACCENT_HOVER);
// Separators / hairlines.
let radius = Rounding::same(8.0);
// Non-interactive widgets (labels, separators).
v.widgets.noninteractive.bg_fill = SURFACE;
v.widgets.noninteractive.weak_bg_fill = SURFACE;
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.widgets.noninteractive.rounding = radius;
// Inactive interactive widgets (idle buttons).
v.widgets.inactive.bg_fill = SURFACE_HOVER;
v.widgets.inactive.weak_bg_fill = SURFACE_HOVER;
v.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER);
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.widgets.inactive.rounding = radius;
// Hovered.
v.widgets.hovered.bg_fill = tint(ACCENT, 0.30);
v.widgets.hovered.weak_bg_fill = tint(ACCENT, 0.30);
v.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG);
v.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.widgets.hovered.rounding = radius;
v.widgets.hovered.expansion = 1.0;
// Active / pressed.
v.widgets.active.bg_fill = ACCENT_PRESSED;
v.widgets.active.weak_bg_fill = ACCENT_PRESSED;
v.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT);
v.widgets.active.fg_stroke = Stroke::new(1.0_f32, ON_ACCENT);
v.widgets.active.rounding = radius;
v.widgets.active.expansion = 1.0;
// Open (combo boxes / menus).
v.widgets.open.bg_fill = SURFACE_HOVER;
v.widgets.open.weak_bg_fill = SURFACE_HOVER;
v.widgets.open.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG);
v.widgets.open.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.widgets.open.rounding = radius;
style.visuals = v;
ctx.set_style(style);
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""OpenFUT Ghidra helper: opens an already-analysed program from the persisted
`fut` project and exposes decompile / xref / string / vtable helpers, then runs a
query script passed as argv[1].
Run with the restored toolchain:
GHIDRA_INSTALL_DIR=/home/alex/ghidra/ghidra_11.1.2_PUBLIC \
/home/alex/re-venv/bin/python tools/re/ghidra_env.py <query.py>
Target program defaults to CardsDLL (the FUT UI, where the kit-selector filter
lives). Override for powdll (the EASFC/POW layer):
GHIDRA_PROG=powdll.dll ... ghidra_env.py <query.py>
"""
import os, sys
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/home/alex/ghidra/ghidra_11.1.2_PUBLIC")
# Ghidra 11.1.2 does not bundle the in-tree PyGhidra module that the pip
# `pyghidra` 2.x/3.x require, so use the standalone `pyhidra` package (same API).
try:
import pyhidra as _pg
except ImportError:
import pyghidra as _pg
_pg.start(verbose=False)
from ghidra.app.decompiler import DecompInterface # noqa: E402
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/home/alex/ghidra_projects")
PROJ = os.environ.get("GHIDRA_PROJ", "fut")
PROG = os.environ.get("GHIDRA_PROG", "cardsdll.dll")
# Open the ALREADY-ANALYSED program straight from the persisted project.
# pyhidra.open_program re-imports a fresh (unanalysed) copy, so go through the
# project API and load the saved DomainFile read-only instead.
from ghidra.base.project import GhidraProject # noqa: E402
_project = GhidraProject.openProject(PROJ_DIR, PROJ, True)
prog = _project.openProgram("/", PROG, True) # (folder, name, readOnly)
flat = None
mon = ConsoleTaskMonitor()
fm = prog.getFunctionManager()
listing = prog.getListing()
mem = prog.getMemory()
refs = prog.getReferenceManager()
_dec = DecompInterface()
_dec.openProgram(prog)
def addr(a):
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
def func(a):
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
def dec(a, timeout=180):
"""Decompiled C for the function containing address a."""
f = func(a)
if f is None:
return "// no function at %#x" % int(a)
r = _dec.decompileFunction(f, timeout, mon)
if r is None or not r.decompileCompleted():
return "// decompile failed for %s" % f.getName()
return str(r.getDecompiledFunction().getC())
def xrefs_to(a):
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
out = []
for r in refs.getReferencesTo(addr(a)):
fr = r.getFromAddress()
f = fm.getFunctionContaining(fr)
out.append((int(fr.getOffset()), str(r.getReferenceType()),
f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
return out
def qword(a):
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
def dword(a):
return mem.getInt(addr(a)) & 0xFFFFFFFF
import jpype # noqa: E402
_JBYTE = jpype.JArray(jpype.JByte)
def read_bytes(a, n):
buf = _JBYTE(n)
got = mem.getBytes(addr(a), buf)
return bytes((int(x) & 0xFF) for x in buf[:got])
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
if isinstance(pattern, str):
pattern = pattern.encode()
hits = []
for b in mem.getBlocks():
if b.getName() not in blocks:
continue
start = b.getStart()
size = int(b.getSize())
data = read_bytes(int(start.getOffset()), size)
i = data.find(pattern)
while i != -1:
hits.append(int(start.getOffset()) + i)
i = data.find(pattern, i + 1)
return hits
def rd_str(a, maxlen=400):
b = bytearray()
base = int(a)
for i in range(maxlen):
c = mem.getByte(addr(base + i)) & 0xFF
if c == 0:
break
b.append(c)
return b.decode("utf-8", "replace")
def fname(a):
f = func(a)
return f.getName() if f else "?"
def callees(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCalledFunctions(mon)}) if f else []
def callers(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCallingFunctions(mon)}) if f else []
if __name__ == "__main__":
if len(sys.argv) > 1:
with open(sys.argv[1]) as fh:
code = fh.read()
exec(compile(code, sys.argv[1], "exec"), globals())
os._exit(0)
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Restore the OpenFUT Ghidra headless RE toolchain on the .120 dev box.
#
# Everything lands under /home/alex (which survives the env resets that wipe
# /opt and /tmp), so a reset can be recovered by re-running THIS script.
#
# - JDK 17 : apt openjdk-17-jdk-headless (Ghidra 11.1.2 needs 17..21)
# - Ghidra 11.1.2 : /home/alex/ghidra/ghidra_11.1.2_PUBLIC
# - pyghidra venv : /home/alex/re-venv (pyghidra 3.x + jpype)
# - analysed project : /home/alex/ghidra_projects/fut.gpr
# programs: /cardsdll.dll /powdll.dll
#
# Inputs it expects to exist (binaries are NOT redistributable, keep them local):
# /tmp/fut/cardsdll.dll (CardsDLL_Win64_retail.dll, md5 4de349...ac9b655)
# /tmp/powdll.dll (powdll_Win64_retail.dll)
# If a reset wiped /tmp, recopy them from the FIFA17 install on .105:
# /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll -> /tmp/fut/cardsdll.dll
# (powdll) Data/win/ ... powdll_Win64_retail.dll -> /tmp/powdll.dll
set -euo pipefail
GHIDRA_VER=11.1.2_PUBLIC
GHIDRA_ZIP_NAME=ghidra_11.1.2_PUBLIC_20240709.zip
GHIDRA_URL="https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.1.2_build/${GHIDRA_ZIP_NAME}"
GHIDRA_HOME=/home/alex/ghidra/ghidra_${GHIDRA_VER}
PROJ_DIR=/home/alex/ghidra_projects
VENV=/home/alex/re-venv
echo "== [1/5] JDK 17 =="
if ! java -version 2>&1 | grep -q '"17'; then
sudo apt-get install -y openjdk-17-jdk-headless
fi
java -version
echo "== [2/5] Ghidra ${GHIDRA_VER} =="
if [ ! -x "${GHIDRA_HOME}/support/analyzeHeadless" ]; then
mkdir -p /home/alex/ghidra
if [ ! -f /tmp/ghidra.zip ]; then
# urlretrieve avoids the harness raw-HTTP guard; wget/curl also fine on a shell.
python3 - <<PY
import urllib.request
urllib.request.urlretrieve("${GHIDRA_URL}", "/tmp/ghidra.zip")
print("downloaded")
PY
fi
( cd /home/alex/ghidra && unzip -q -o /tmp/ghidra.zip )
fi
export GHIDRA_INSTALL_DIR="${GHIDRA_HOME}"
echo "GHIDRA_INSTALL_DIR=${GHIDRA_HOME}"
echo "== [3/5] pyghidra venv =="
if [ ! -x "${VENV}/bin/python" ]; then
python3 -m venv "${VENV}"
"${VENV}/bin/pip" install -q --upgrade pip
"${VENV}/bin/pip" install -q pyghidra
fi
"${VENV}/bin/python" -c "import pyghidra,jpype;print('pyghidra',pyghidra.__version__)"
echo "== [4/5] analyse cardsdll + powdll into ${PROJ_DIR}/fut.gpr =="
mkdir -p "${PROJ_DIR}"
if [ ! -f "${PROJ_DIR}/fut.gpr" ]; then
for dll in /tmp/fut/cardsdll.dll /tmp/powdll.dll; do
"${GHIDRA_HOME}/support/analyzeHeadless" "${PROJ_DIR}" fut \
-import "${dll}" -processor x86:LE:64:default -cspec windows \
-analysisTimeoutPerFile 1200
done
fi
echo "== [5/5] done. Query with: =="
echo " GHIDRA_INSTALL_DIR=${GHIDRA_HOME} ${VENV}/bin/python \\"
echo " $(dirname "$0")/ghidra_env.py <query.py>"