18 Commits

Author SHA1 Message Date
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 13339c1478 feat(fifa17): report verified client patch capability
Launcher side of the verified patched-client capability handshake. When
autopatch proves the CardsDLL empty-My-Packs resolver guard is active for the
CURRENT FIFA process, the launcher advertises that to the backend so the backend
may drop the synthetic 65534 sentinel for that session only. Additive and
fail-closed: any parse/registration failure leaves the backend on its default
sentinel path.

- New src/fifa17_capability.rs:
  * Fifa17ClientCapabilities { empty_mypacks_resolver: Option<u32> } — per-FIFA-
    process state, UNKNOWN at each launch, discarded when that process ends
    (never persisted, so a prior launch's capability cannot leak).
  * parse_capability_line() / parse_fifa_pid() — pure parsers for autopatch's
    stdout token `[store-guard] verified capability fifa17.empty_mypacks_resolver=<v>
    fifa_pid=<pid>`; the non-advertising `guard status=...` line yields None.
  * register() — tiny stdlib-HTTP POST /openfut/fifa17/capability, modeled on
    account_sync::sync (Connection: close, 3s timeouts, 2xx check).
- local_services::spawn: autopatch stdout reader parses each raw line; on the
  first verified line it sets the shared capability sink, logs, and fires exactly
  one backend register() for this FIFA process. Capability wiring is bundled in a
  CapabilityWiring struct (Some for autopatch, None for LSX). LSX unchanged.
- app.rs: LauncherApp holds the shared Fifa17ClientCapabilities; it is reset to
  UNKNOWN at the start of launch_game (and when autopatch is stopped) so a new
  FIFA process never inherits a previous launch's capability.
- Tests: parse (verified/non-advertising/unrelated/version-2) + a register()
  round-trip against an in-process listener.

Design + contract: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (superproject).
Pre-existing openfut-hook/* working-tree changes are intentionally left uncommitted.
2026-08-13 04:03:08 +00:00
funman300 d619c992c1 feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single
pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector
IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites
for every dead EA hostname (removing foreign shadow lines first, so glibc's
first-match resolution can't land on a stale loopback entry). All steps are
idempotent (delete-then-add) and injection-safe: config values are charset-
validated and rejected on a surprising character, never shell-escaped. arm()
returns the concrete change list, which the button logs line-by-line and
echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no
reuse of the local-services toast).

This necessarily lands the surrounding launcher modularization the arm
feature is built on, extracted from the former monolithic app.rs/process.rs:
- preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames,
  backend reachability) that colour rows but never block Launch
- local_services: launcher-owned LSX/autopatch child processes
- game_launch, account_sync, health, netcheck helpers
- openfut-common: dependency-free shared server-destination/port mapping,
  used by both the launcher and (separately) openfut_hook.dll

openfut-hook RE changes are intentionally left uncommitted (separate concern).
fmt + clippy -D warnings clean; 46 tests pass.
2026-08-12 17:58:48 +00:00
36 changed files with 8388 additions and 855 deletions
Generated
+5
View File
@@ -2279,6 +2279,10 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openfut-common"
version = "0.1.0"
[[package]]
name = "openfut-launcher"
version = "0.1.0"
@@ -2288,6 +2292,7 @@ dependencies = [
"dirs",
"eframe",
"egui",
"openfut-common",
"serde",
"serde_json",
"tokio",
+4
View File
@@ -12,3 +12,7 @@ serde = { version = "1", features = ["derive"] }
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.
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "openfut-common"
version = "0.1.0"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "openfut-common"
version = "0.1.0"
edition = "2021"
description = "Shared OpenFUT server-destination configuration, port mapping, and address conversion used by both the launcher and openfut_hook.dll. Pure std, no dependencies, so it stays small inside the injected DLL and is unit-testable on any host."
[lib]
# Rlib only. Deliberately dependency-free so linking it into openfut_hook.dll
# (a cdylib) adds no runtime weight and no cross-platform risk.
[dependencies]
+479
View File
@@ -0,0 +1,479 @@
//! Shared OpenFUT destination configuration.
//!
//! This crate is the **single source of truth** for where FIFA's intercepted
//! EA traffic is redirected. It is consumed by both the launcher (which writes
//! `openfut.cfg`) and `openfut_hook.dll` (which reads it and rewrites sockets).
//!
//! Design rules enforced here:
//! * There is exactly ONE configured OpenFUT destination (host + ports).
//! * Missing/invalid configuration is a hard error — it NEVER silently
//! degrades to `127.0.0.1`/localhost. Loopback is only ever used if the
//! user explicitly configures it.
//! * The address/port -> WinSock representation helpers live here and are
//! unit-tested on the host, so the byte-order logic the socket hooks depend
//! on is verified without needing WinSock or FIFA.
//!
//! ## EA source ports vs OpenFUT destination ports
//!
//! FIFA connects to a handful of EA endpoints on fixed ports. Those source
//! ports are *signatures* used to recognise traffic that must be intercepted —
//! they are hardcoded on purpose (see [`ea_ports`]). Each recognised EA source
//! port maps to an OpenFUT *destination* port, which comes from configuration
//! ([`OpenFutPorts`]). Source port = fixed EA signature; destination port =
//! configurable OpenFUT service.
use std::fmt;
use std::net::{Ipv4Addr, ToSocketAddrs};
use std::str::FromStr;
/// EA source ports FIFA dials. These are fixed EA endpoint signatures used to
/// recognise traffic for interception — NOT OpenFUT configuration. Do not make
/// these configurable; changing them would stop us recognising EA traffic.
pub mod ea_ports {
/// EA HTTPS (UTAS / EAWebKit / footapi) — the game dials EA hosts on 443.
pub const HTTPS: u16 = 443;
/// EA Blaze redirector (gosredirector) source port.
pub const BLAZE_REDIRECTOR: u16 = 10041;
/// FIFA 17's `winter15.gosredirector.ea.com` endpoint source port. This is
/// an observed, fixed vendor-port signature; it maps to the same configured
/// OpenFUT redirector destination as the newer 10041 endpoint.
pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230;
/// EA Blaze main server source port.
pub const BLAZE_MAIN: u16 = 42127;
}
/// Default OpenFUT *destination* ports, derived from the current OpenFUT server
/// implementation (bridge listens on 8443 for HTTPS; Blaze services keep their
/// native ports). The launcher may pre-populate these; the user may change them
/// without recompiling anything.
pub mod default_ports {
/// OpenFUT bridge HTTPS listener (EA :443 is redirected here).
pub const HTTPS: u16 = 8443;
/// OpenFUT FIFA 17 Blaze redirector listener.
pub const BLAZE_REDIRECTOR: u16 = 42127;
/// OpenFUT FIFA 17 Blaze main listener.
pub const BLAZE_MAIN: u16 = 42130;
}
/// OpenFUT destination ports. Each field is where an intercepted EA source port
/// is redirected. Not collapsed into one port: OpenFUT runs distinct services
/// (bridge HTTPS + two Blaze listeners) that FIFA reaches on distinct ports.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenFutPorts {
/// Destination for EA :443 traffic (bridge HTTPS).
pub https: u16,
/// Destination for EA :10041 traffic (Blaze redirector).
pub blaze_redirector: u16,
/// Destination for EA :42127 traffic (Blaze main).
pub blaze_main: u16,
}
impl Default for OpenFutPorts {
fn default() -> Self {
Self {
https: default_ports::HTTPS,
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
blaze_main: default_ports::BLAZE_MAIN,
}
}
}
impl OpenFutPorts {
/// Map a recognised EA *source* port to its OpenFUT *destination* port.
/// Returns `None` for ports we don't intercept (the socket hook then leaves
/// the connection untouched).
pub fn map_source_port(&self, ea_source_port: u16) -> Option<u16> {
match ea_source_port {
ea_ports::HTTPS => Some(self.https),
ea_ports::BLAZE_REDIRECTOR | ea_ports::FIFA17_BLAZE_REDIRECTOR => {
Some(self.blaze_redirector)
}
ea_ports::BLAZE_MAIN => Some(self.blaze_main),
_ => None,
}
}
}
/// The parsed OpenFUT server configuration: what host to redirect EA traffic to
/// and which destination ports to use. `host` may be an IPv4 literal or a
/// hostname; resolution to a concrete [`Ipv4Addr`] happens in [`Self::resolve`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig {
/// User-configured OpenFUT server host (IPv4 literal or hostname). Never
/// defaulted to loopback — an empty host is a [`ConfigError::ServerMissing`].
pub host: String,
pub ports: OpenFutPorts,
}
/// A [`ServerConfig`] whose host has been resolved to a concrete IPv4 address.
/// This is what the socket hooks consume — all three interception layers share
/// exactly this resolved destination.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedServer {
/// The concrete IPv4 the EA connection's destination is rewritten to.
pub redirect_ip: Ipv4Addr,
pub ports: OpenFutPorts,
}
/// Errors loading/validating OpenFUT server configuration. Every one of these
/// must BLOCK operation — none of them may fall back to loopback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
/// No config file present (e.g. `openfut.cfg` missing).
ConfigMissing,
/// Config present but no server host set.
ServerMissing,
/// Host could not be parsed/resolved to an IPv4 address.
InvalidAddress(String),
/// A port value was not a valid `u16` / was zero.
InvalidPort(String),
/// Config bytes were structurally unparseable.
MalformedConfig(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::ConfigMissing => {
write!(f, "No OpenFUT server configured (config file missing)")
}
ConfigError::ServerMissing => write!(
f,
"No OpenFUT server configured. Enter the hostname or IP address of your OpenFUT server."
),
ConfigError::InvalidAddress(a) => write!(f, "Invalid OpenFUT server address: {a}"),
ConfigError::InvalidPort(p) => write!(f, "Invalid OpenFUT port: {p}"),
ConfigError::MalformedConfig(m) => write!(f, "Malformed OpenFUT config: {m}"),
}
}
}
impl std::error::Error for ConfigError {}
impl ServerConfig {
/// Parse `openfut.cfg` contents.
///
/// Two accepted formats:
/// * **Structured** (preferred), one `key=value` per line:
/// ```text
/// host=192.168.1.50
/// https_port=8443
/// blaze_redirector_port=10041
/// blaze_main_port=42127
/// ```
/// `host` is required; any omitted port uses its default.
/// * **Legacy**, a single bare line containing just the host
/// (IPv4 or hostname). Ports default. This keeps old deployments working.
///
/// An empty/whitespace-only body is [`ConfigError::ServerMissing`], NOT a
/// silent loopback fallback.
pub fn parse(contents: &str) -> Result<Self, ConfigError> {
let trimmed = contents.trim();
if trimmed.is_empty() {
return Err(ConfigError::ServerMissing);
}
// Legacy single-token form: no '=' anywhere and a single line.
let has_kv = trimmed.lines().any(|l| l.contains('='));
if !has_kv {
let host = trimmed.trim();
if host.is_empty() {
return Err(ConfigError::ServerMissing);
}
return Ok(Self {
host: host.to_string(),
ports: OpenFutPorts::default(),
});
}
let mut host: Option<String> = None;
let mut ports = OpenFutPorts::default();
for (lineno, raw) in trimmed.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=').ok_or_else(|| {
ConfigError::MalformedConfig(format!("line {}: expected key=value", lineno + 1))
})?;
let key = key.trim();
let value = value.trim();
match key {
"host" | "server" | "redirect_ip" => {
if value.is_empty() {
return Err(ConfigError::ServerMissing);
}
host = Some(value.to_string());
}
"https_port" => ports.https = parse_port(value)?,
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
other => {
return Err(ConfigError::MalformedConfig(format!(
"line {}: unknown key '{other}'",
lineno + 1
)));
}
}
}
let host = host.ok_or(ConfigError::ServerMissing)?;
Ok(Self { host, ports })
}
/// Serialize to the structured `openfut.cfg` format.
pub fn to_cfg_string(&self) -> String {
format!(
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
)
}
/// Validate the host is a syntactically acceptable IPv4 literal or hostname.
/// Does NOT perform DNS (no network) — use [`Self::resolve`] for that.
pub fn validate(&self) -> Result<(), ConfigError> {
let host = self.host.trim();
if host.is_empty() {
return Err(ConfigError::ServerMissing);
}
if Ipv4Addr::from_str(host).is_ok() {
return Ok(());
}
if is_plausible_hostname(host) {
Ok(())
} else {
Err(ConfigError::InvalidAddress(host.to_string()))
}
}
/// Resolve the configured host to a concrete IPv4 destination.
///
/// This is the single shared resolution path all hooks rely on: an IPv4
/// literal is used directly; a hostname is resolved via the platform
/// resolver ([`ToSocketAddrs`]). Only IPv4 results are used (WinSock
/// interception here is IPv4). Never falls back to loopback.
pub fn resolve(&self) -> Result<ResolvedServer, ConfigError> {
let host = self.host.trim();
if host.is_empty() {
return Err(ConfigError::ServerMissing);
}
// IPv4 literal: no DNS needed.
if let Ok(ip) = Ipv4Addr::from_str(host) {
return Ok(ResolvedServer {
redirect_ip: ip,
ports: self.ports,
});
}
// Hostname: resolve via the platform resolver. Port is irrelevant to
// the lookup; we only need an address. Take the first IPv4 answer.
let ip = (host, 0u16)
.to_socket_addrs()
.map_err(|e| ConfigError::InvalidAddress(format!("{host}: {e}")))?
.find_map(|sa| match sa.ip() {
std::net::IpAddr::V4(v4) => Some(v4),
std::net::IpAddr::V6(_) => None,
})
.ok_or_else(|| ConfigError::InvalidAddress(format!("{host}: no IPv4 address found")))?;
Ok(ResolvedServer {
redirect_ip: ip,
ports: self.ports,
})
}
}
fn parse_port(value: &str) -> Result<u16, ConfigError> {
let n: u16 = value
.parse()
.map_err(|_| ConfigError::InvalidPort(value.to_string()))?;
if n == 0 {
return Err(ConfigError::InvalidPort(value.to_string()));
}
Ok(n)
}
/// Lenient hostname sanity check (RFC-1123-ish). Not a full validator — just
/// enough to reject obvious garbage while accepting `.local`/`.home` names.
fn is_plausible_hostname(host: &str) -> bool {
if host.is_empty() || host.len() > 253 {
return false;
}
host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
&& !label.starts_with('-')
&& !label.ends_with('-')
})
}
// ── WinSock representation helpers ───────────────────────────────────────────
// These convert a resolved destination into the exact in-memory representation
// WinSock's `sockaddr_in` expects. Kept here (not in the DLL) so the byte-order
// contract the socket hooks depend on is unit-tested on the host.
/// Value to store in `sockaddr_in.sin_addr.S_un.S_addr`.
///
/// WinSock stores the four IPv4 octets in network byte order in memory. Reading
/// those bytes back as a native-endian `u32` (how the hook's `SockaddrIn.sin_addr`
/// field is typed) is exactly `from_ne_bytes(octets)`. On little-endian x86_64
/// this yields e.g. `127.0.0.1 -> 0x0100_007F`, matching the value the original
/// hooks hardcoded — confirming the conversion is correct.
pub fn sin_addr_from_ipv4(ip: Ipv4Addr) -> u32 {
u32::from_ne_bytes(ip.octets())
}
/// Value to store in `sockaddr_in.sin_port` — the port in network byte order.
pub fn sin_port_nbo(port: u16) -> u16 {
port.to_be()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configured_ipv4_survives_parsing() {
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
assert_eq!(c.host, "192.168.1.50");
assert_eq!(c.ports, OpenFutPorts::default());
}
#[test]
fn legacy_bare_host_line_parses() {
let c = ServerConfig::parse("10.0.0.7\n").unwrap();
assert_eq!(c.host, "10.0.0.7");
assert_eq!(c.ports, OpenFutPorts::default());
}
#[test]
fn missing_server_does_not_become_loopback() {
// Empty config is an explicit error, never 127.0.0.1.
assert_eq!(
ServerConfig::parse("").unwrap_err(),
ConfigError::ServerMissing
);
assert_eq!(
ServerConfig::parse(" \n\n").unwrap_err(),
ConfigError::ServerMissing
);
assert_eq!(
ServerConfig::parse("https_port=8443\n").unwrap_err(),
ConfigError::ServerMissing
);
}
#[test]
fn invalid_server_is_rejected() {
let c = ServerConfig {
host: "not a host!!".into(),
ports: OpenFutPorts::default(),
};
assert!(matches!(c.validate(), Err(ConfigError::InvalidAddress(_))));
}
#[test]
fn configured_port_survives_parsing() {
let c = ServerConfig::parse(
"host=srv.home\nhttps_port=9000\nblaze_redirector_port=11000\nblaze_main_port=42000\n",
)
.unwrap();
assert_eq!(c.ports.https, 9000);
assert_eq!(c.ports.blaze_redirector, 11000);
assert_eq!(c.ports.blaze_main, 42000);
}
#[test]
fn invalid_port_is_rejected() {
assert!(matches!(
ServerConfig::parse("host=x\nhttps_port=0\n"),
Err(ConfigError::InvalidPort(_))
));
assert!(matches!(
ServerConfig::parse("host=x\nhttps_port=99999\n"),
Err(ConfigError::InvalidPort(_))
));
}
#[test]
fn unknown_key_is_malformed() {
assert!(matches!(
ServerConfig::parse("host=x\nbogus=1\n"),
Err(ConfigError::MalformedConfig(_))
));
}
#[test]
fn roundtrip_cfg_string() {
let c = ServerConfig {
host: "192.168.1.50".into(),
ports: OpenFutPorts {
https: 8443,
blaze_redirector: 10041,
blaze_main: 42127,
},
};
let s = c.to_cfg_string();
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
}
#[test]
fn configured_ipv4_becomes_correct_sockaddr() {
// Resolve an IPv4 literal and confirm the sin_addr value.
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
let r = c.resolve().unwrap();
assert_eq!(r.redirect_ip, Ipv4Addr::new(192, 168, 1, 50));
// sin_addr is the octets as a native-endian u32.
assert_eq!(
sin_addr_from_ipv4(r.redirect_ip),
u32::from_ne_bytes([192, 168, 1, 50])
);
}
#[test]
fn loopback_sockaddr_matches_legacy_constant() {
// Guards the byte-order contract: the old hooks hardcoded 0x0100_007F
// for 127.0.0.1. Our helper must reproduce it on this (LE) host.
assert_eq!(sin_addr_from_ipv4(Ipv4Addr::new(127, 0, 0, 1)), 0x0100_007F);
}
#[test]
fn sin_port_is_network_byte_order() {
// 443 -> 0xBB01, 42127 -> 0x8FA4 (matches the legacy hook constants).
assert_eq!(sin_port_nbo(443), 0xBB01);
assert_eq!(sin_port_nbo(42127), 0x8FA4);
assert_eq!(sin_port_nbo(10041), 0x3927);
}
#[test]
fn port_mapping_source_to_destination() {
let p = OpenFutPorts::default();
assert_eq!(p.map_source_port(ea_ports::HTTPS), Some(8443));
assert_eq!(p.map_source_port(ea_ports::BLAZE_REDIRECTOR), Some(42127));
assert_eq!(
p.map_source_port(ea_ports::FIFA17_BLAZE_REDIRECTOR),
Some(42127)
);
assert_eq!(p.map_source_port(ea_ports::BLAZE_MAIN), Some(42130));
assert_eq!(p.map_source_port(12345), None);
}
#[test]
fn resolve_literal_ipv4_no_dns() {
let c = ServerConfig::parse("host=127.0.0.1\n").unwrap();
let r = c.resolve().unwrap();
assert_eq!(r.redirect_ip, Ipv4Addr::LOCALHOST);
}
#[test]
fn resolve_empty_host_errors() {
let c = ServerConfig {
host: " ".into(),
ports: OpenFutPorts::default(),
};
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
}
}
+6
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"
+3 -3
View File
@@ -191,7 +191,7 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
let mut len: i32 = 4;
getsockopt(
s,
SOL_SOCKET as i32,
SOL_SOCKET,
SO_TYPE,
&mut ty as *mut i32 as *mut u8,
&mut len,
@@ -265,11 +265,11 @@ 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());
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
if ws2.is_null() {
return false;
}
let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
let connect_fn = match GetProcAddress(ws2, c"connect".as_ptr().cast()) {
Some(f) => f as *mut u8,
None => return false,
};
+2 -2
View File
@@ -152,11 +152,11 @@ 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());
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
if ws2.is_null() {
return false;
}
let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
let fn_ptr = match GetProcAddress(ws2, c"WSAIoctl".as_ptr().cast()) {
Some(f) => f as *mut u8,
None => return false,
};
+4 -4
View File
@@ -79,12 +79,12 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
));
dump_modules();
write_log("fifa17: worker complete (injection healthy)\n");
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
// 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();
// Passive transaction tracing has a separate kill switch from cache resolution.
// It currently fails closed until safe relocating trampolines are proven.
crate::sbc_trace::install();
crate::sbc_dispatch::install();
crate::sbc_request_trace::install();
0
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
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);
}
+18 -1
View File
@@ -1,3 +1,12 @@
// 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;
@@ -12,6 +21,8 @@ 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;
@@ -54,6 +65,13 @@ pub(crate) fn flush_log() {
}
}
/// # 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 {
@@ -73,7 +91,6 @@ unsafe fn install_hooks(module: HMODULE) {
{
let _ = module;
fifa17::install();
return;
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
+852
View File
@@ -0,0 +1,852 @@
//! 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);
}
_ => {}
}
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);
}
}
+5 -218
View File
@@ -4,11 +4,9 @@
//! (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, so shipping the DLL
//! with this module compiled in changes nothing unless a var is set:
//! 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_COMMIT=1 -> after proven native parse success, arm populated M
//! 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
@@ -20,14 +18,11 @@
//! See the spec for the verified disassembly behind each one.
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::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
PAGE_WRITECOPY,
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
const IMAGE_BASE: usize = 0x180000000;
@@ -46,13 +41,6 @@ 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 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 SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
const B_DTOR_RVA: usize = 0x63040;
const B_ISVALID_RVA: usize = 0x65d40;
const B_CLEAR_RVA: usize = 0x65d20;
@@ -87,11 +75,9 @@ mod rva {
static ARMED: AtomicBool = AtomicBool::new(false);
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
static COMMIT: AtomicBool = AtomicBool::new(false);
static POPULATE: AtomicBool = AtomicBool::new(false);
static DONE: AtomicBool = AtomicBool::new(false);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -147,13 +133,6 @@ enum ValidationError {
CollectionUnreadable,
CollectionNotNull,
ReadyByteNotWritable,
ModelEmpty,
ControllerMissing,
ControllerVtableMismatch,
ControllerModelMismatch,
CompletionBranchMismatch,
CompletionBranchProtectFailed,
CompletionBranchFlushFailed,
}
#[derive(Clone, Copy, Debug)]
@@ -313,12 +292,12 @@ unsafe fn read_u16(ptr: usize) -> Option<u16> {
/// 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(b"CardsDLL_Win64_retail.dll\0".as_ptr());
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(b"CardsDLL.dll\0".as_ptr());
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr().cast());
if !h2.is_null() {
return h2 as usize;
}
@@ -428,12 +407,6 @@ pub fn install() {
.unwrap_or(false),
Ordering::Relaxed,
);
COMMIT.store(
std::env::var("OPENFUT_SBC_COMMIT")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
POPULATE.store(
std::env::var("OPENFUT_SBC_POPULATE")
.map(|v| v == "1")
@@ -444,192 +417,6 @@ pub fn install() {
std::thread::spawn(|| unsafe { worker() });
}
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
/// The registration hook is observational; all structural checks happen again on
/// the notifier thread before this address is trusted.
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
let base = CARDS_BASE.load(Ordering::Acquire);
let valid = base != 0
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
&& controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
if valid {
SBC_CONTROLLER.store(controller, Ordering::Release);
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
));
} else {
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
));
}
}
unsafe fn log_controller_model(native_model: usize) {
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
let controller_model = controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|p| read_ptr(p))
.unwrap_or(0);
let main_vtable = read_ptr(controller).unwrap_or(0);
let event_vtable = controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
.unwrap_or(0);
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
controller != 0 && controller_model == native_model,
));
}
unsafe fn validated_sbc_controller(
base: usize,
native_model: usize,
) -> Result<usize, ValidationError> {
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
if controller == 0 {
return Err(ValidationError::ControllerMissing);
}
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|| controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
{
return Err(ValidationError::ControllerVtableMismatch);
}
if controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|p| read_ptr(p))
!= Some(native_model)
{
return Err(ValidationError::ControllerModelMismatch);
}
Ok(controller)
}
/// Route the already-scheduled category completion through CardsDLL's own success
/// branch. The original function first rejects a non-zero status with a two-byte
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
/// to the stale scheduler completion rather than the category HTTP transaction.
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
let target = base
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
.ok_or(ValidationError::AddressOverflow)?;
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
!= SBC_COMPLETION_STATUS_JNE
{
return Err(ValidationError::CompletionBranchMismatch);
}
let mut old = 0u32;
if VirtualProtect(
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
PAGE_EXECUTE_READWRITE,
&mut old,
) == 0
{
return Err(ValidationError::CompletionBranchProtectFailed);
}
core::ptr::copy_nonoverlapping(
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
target as *mut u8,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
);
let flushed = FlushInstructionCache(
GetCurrentProcess(),
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
) != 0;
let mut ignored = 0u32;
let protected = VirtualProtect(
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
old,
&mut ignored,
) != 0;
if !flushed || !protected {
return Err(ValidationError::CompletionBranchFlushFailed);
}
crate::write_log(&format!(
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
GetCurrentThreadId(),
));
Ok(())
}
/// Commit the already-populated native SBC model after the category success notifier.
///
/// This is called synchronously by the passive notifier wrapper *after* the original
/// notifier returns. It never invokes a parser or constructs game objects. The only
/// mutation is the established cache-ready byte, and only when the normal parser has
/// produced at least one category and every pointer/vtable invariant still matches.
pub(crate) unsafe fn commit_after_native_parse() {
if !COMMIT.load(Ordering::Acquire) {
return;
}
let base = CARDS_BASE.load(Ordering::Acquire);
if base == 0 || !control_matches(base) {
set_failed(ValidationError::AUnreadable);
return;
}
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
if snapshot.m == 0
|| read_u16(snapshot.m + M_COUNT_OFF)
.filter(|&count| count > 0)
.is_none()
{
return Err(ValidationError::ModelEmpty);
}
if !writable_u8(snapshot.b + B_READY_OFF) {
return Err(ValidationError::ReadyByteNotWritable);
}
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
log_controller_model(snapshot.m);
if DONE.swap(true, Ordering::AcqRel) {
return;
}
crate::write_log(&format!(
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
snapshot.m,
count,
snapshot.b + B_READY_OFF,
));
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
{
set_failed(ValidationError::ReadyByteUnexpected);
return;
}
let _controller = match validated_sbc_controller(base, snapshot.m) {
Ok(controller) => controller,
Err(error) => {
set_failed(error);
return;
}
};
if let Err(error) = arm_native_completion_success(base) {
set_failed(error);
return;
}
crate::write_log(
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
);
}
/// 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.
+1 -1
View File
@@ -367,7 +367,7 @@ unsafe fn worker() {
}
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
+77 -25
View File
@@ -80,6 +80,7 @@ static TRACE_BASE: AtomicUsize = AtomicUsize::new(0);
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
static DESERIALIZER_EXIT_RESPONSE_VTABLE: AtomicUsize = AtomicUsize::new(0);
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
@@ -93,7 +94,7 @@ static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX);
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
struct PatchInstallerGate;
pub(crate) struct PatchInstallerGate;
impl Drop for PatchInstallerGate {
fn drop(&mut self) {
@@ -101,7 +102,7 @@ impl Drop for PatchInstallerGate {
}
}
fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
for _ in 0..200 {
if PATCH_INSTALLER_BUSY
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -114,7 +115,7 @@ fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
None
}
struct CodeInstallerPending;
pub(crate) struct CodeInstallerPending;
impl Drop for CodeInstallerPending {
fn drop(&mut self) {
@@ -138,15 +139,15 @@ enum TraceState {
DegradedHookAndProcess,
}
fn env_enabled(value: Option<&str>) -> bool {
pub(crate) fn env_enabled(value: Option<&str>) -> bool {
matches!(value, Some("1"))
}
fn target_va(base: usize, rva: usize) -> Option<usize> {
pub(crate) fn target_va(base: usize, rva: usize) -> Option<usize> {
base.checked_add(rva)
}
fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
let mut jump = [0u8; ABS_JUMP_LEN];
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
@@ -160,7 +161,7 @@ fn instruction_pointer_in_span(rip: usize, target: usize) -> bool {
.unwrap_or(true)
}
struct SuspendedPeers {
pub(crate) struct SuspendedPeers {
handles: [HANDLE; MAX_PEERS],
tids: [u32; MAX_PEERS],
count: usize,
@@ -179,7 +180,7 @@ impl SuspendedPeers {
self.tids[..self.count].contains(&tid)
}
unsafe fn resume_all(&mut self) -> bool {
pub(crate) unsafe fn resume_all(&mut self) -> bool {
let mut all_resumed = true;
for index in (0..self.count).rev() {
let handle = self.handles[index];
@@ -208,14 +209,14 @@ impl Drop for SuspendedPeers {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum QuiesceFailure {
pub(crate) enum QuiesceFailure {
Acquire,
Resume,
}
/// Stop and inspect every peer thread before touching either entry point. Any
/// incomplete enumeration/access/context operation fails the transaction closed.
unsafe fn suspend_peers(
pub(crate) unsafe fn suspend_peers(
factory: usize,
deserializer: usize,
) -> Result<SuspendedPeers, QuiesceFailure> {
@@ -329,7 +330,7 @@ unsafe fn executable_range(address: usize, length: usize) -> bool {
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
@@ -347,7 +348,7 @@ unsafe fn executable_range_in_image(base: usize, address: usize, length: usize)
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn readable_range(address: usize, length: usize) -> bool {
pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
@@ -362,20 +363,20 @@ unsafe fn readable_range(address: usize, length: usize) -> bool {
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn guarded_usize(address: usize) -> Option<usize> {
pub(crate) unsafe fn guarded_usize(address: usize) -> Option<usize> {
(address & 7 == 0 && readable_range(address, 8))
.then(|| core::ptr::read_volatile(address as *const usize))
}
unsafe fn guarded_u16(address: usize) -> Option<u16> {
pub(crate) unsafe fn guarded_u16(address: usize) -> Option<u16> {
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
}
unsafe fn guarded_u8(address: usize) -> Option<u8> {
pub(crate) unsafe fn guarded_u8(address: usize) -> Option<u8> {
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
}
unsafe fn valid_cards_image(base: usize) -> bool {
pub(crate) unsafe fn valid_cards_image(base: usize) -> bool {
let Some(control) = base.checked_add(CONTROL_RVA) else {
return false;
};
@@ -413,7 +414,7 @@ unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool {
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
}
unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
let memory = VirtualAlloc(
core::ptr::null(),
@@ -595,7 +596,10 @@ unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, e
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
original(controller, event);
if event == FUT_SBS_CATEGORIES_EVENT {
crate::sbc_hook::note_sbc_controller(controller as usize);
crate::sbc_dispatch::note_sbc_controller(
controller as usize,
TRACE_BASE.load(Ordering::Acquire),
);
}
}
@@ -630,7 +634,6 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
let original: unsafe extern "system" fn(*mut c_void) =
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
original(ctx);
crate::sbc_hook::commit_after_native_parse();
NOTIFIER_BYTE_AFTER.store(
address
.checked_add(0x88)
@@ -682,12 +685,54 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c
.and_then(|slot| guarded_u8(slot))
.map(usize::from)
.unwrap_or(usize::MAX);
let response_vtable = guarded_usize(this as usize).unwrap_or(0);
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
DESERIALIZER_EXIT_RESPONSE_VTABLE.store(response_vtable, Ordering::Relaxed);
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
result
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct DispatchEvidence {
pub(crate) base: usize,
pub(crate) factory_entries: u64,
pub(crate) factory_exits: u64,
pub(crate) factory_result: usize,
pub(crate) factory_thread: usize,
pub(crate) deserializer_entries: u64,
pub(crate) deserializer_exits: u64,
pub(crate) deserializer_this: usize,
pub(crate) deserializer_reader: usize,
pub(crate) deserializer_result: bool,
pub(crate) deserializer_thread: usize,
pub(crate) response_vtable: usize,
pub(crate) model: usize,
pub(crate) category_count: usize,
pub(crate) notifier_entries: u64,
pub(crate) notifier_exits: u64,
}
pub(crate) fn dispatch_evidence() -> DispatchEvidence {
DispatchEvidence {
base: TRACE_BASE.load(Ordering::Acquire),
factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire),
factory_exits: FACTORY_EXITS.load(Ordering::Acquire),
factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire),
factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed),
deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire),
deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire),
deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
response_vtable: DESERIALIZER_EXIT_RESPONSE_VTABLE.load(Ordering::Relaxed),
model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire),
notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
@@ -839,7 +884,7 @@ unsafe fn worker() {
let _pending = CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
@@ -926,7 +971,7 @@ unsafe fn notifier_worker() {
let _pending = CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
@@ -1029,7 +1074,7 @@ unsafe fn controller_register_worker() {
let _pending = CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
@@ -1103,10 +1148,17 @@ fn install_notifier(enabled: bool) {
}
pub(crate) fn install() {
let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
// The repair's evidence traces (parser, notifier, controller registration) are
// its decision inputs, so they follow the promoted repair, not an env var.
let dispatch_repair = crate::sbc_dispatch::REPAIR_PROMOTED;
let dispatch_trace =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
let enabled =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
let notifier_enabled =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
CODE_PATCH_PENDING.store(
enabled as usize + (notifier_enabled as usize * 2),
enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize,
Ordering::Release,
);
install_notifier(notifier_enabled);
+1 -1
View File
@@ -67,7 +67,7 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool {
/// 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)
}
+1 -1
View File
@@ -152,7 +152,7 @@ pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
let mut len: i32 = 4;
getsockopt(
s,
SOL_SOCKET as i32,
SOL_SOCKET,
SO_TYPE,
&mut ty as *mut i32 as *mut u8,
&mut len,
+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);
}
}
+342
View File
@@ -0,0 +1,342 @@
use crate::config::LauncherConfig;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
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> {
#[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,
account_funds: u32,
account_funds_cap: u32,
}
#[derive(Debug, Deserialize)]
pub struct AccountSyncResult {
pub account: AccountSummary,
}
#[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,
}
/// Select the persistent EA/FUT account before LSX and FIFA start.
///
/// This deliberately uses a tiny stdlib HTTP client so the launcher does not
/// acquire an async runtime solely for one bounded control-plane request.
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)
.to_socket_addrs()
.map_err(|error| format!("cannot resolve account server {host}:{port}: {error}"))?
.next()
.ok_or_else(|| format!("account server {host}:{port} resolved to no addresses"))?;
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
.map_err(|error| format!("cannot connect to account server {host}:{port}: {error}"))?;
stream
.set_read_timeout(Some(TIMEOUT))
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
stream
.set_write_timeout(Some(TIMEOUT))
.map_err(|error| format!("cannot set account sync timeout: {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",
payload.len()
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(&payload))
.map_err(|error| format!("cannot send account sync request: {error}"))?;
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.map_err(|error| format!("cannot read account sync response: {error}"))?;
let separator = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.ok_or_else(|| "account server returned a malformed HTTP response".to_string())?;
let headers = std::str::from_utf8(&response[..separator])
.map_err(|_| "account server returned non-UTF-8 headers".to_string())?;
let status = headers
.lines()
.next()
.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 response_body = &response[separator + 4..];
if !(200..300).contains(&status) {
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(response_body)
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
Ok(envelope.account)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::thread;
#[test]
fn sync_posts_account_and_reads_selected_profile() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = 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;
}
}
}
let request = String::from_utf8_lossy(&request);
assert!(request.starts_with("POST /openfut/account/sync HTTP/1.1"));
assert!(request.contains("\"personaId\":12345678"));
assert!(request.contains("\"personaName\":\"TEST_USER\""));
let body = r#"{"status":"OK","account":{"personaId":12345678,"personaName":"TEST_USER","level":7,"experience":200,"accountFunds":50,"coins":15000,"unopenedPacks":1}}"#;
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();
});
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(),
fut_account_level: 7,
fut_account_experience: 200,
..LauncherConfig::default()
};
let selected = sync(&config).unwrap();
assert_eq!(selected.persona_name, "TEST_USER");
assert_eq!(selected.coins, 15000);
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();
}
}
+1968 -406
View File
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
//! One-click client arming — the GUI equivalent of `client_arm.sh`, driven by
//! [`LauncherConfig`] so it repairs exactly what [`crate::preflight`] checks.
//!
//! Everything the game reaches by a routable address is redirected to the
//! OpenFUT server; two things are inherently local and are NOT touched here (they
//! are managed as child processes, see [`crate::local_services`]): the LSX/Origin
//! emulator on loopback `:4216` and `autopatch`.
//!
//! The three privileged steps run in ONE elevated batch (a single `pkexec`
//! prompt), mirroring the volatile state `client_arm.sh` set by hand:
//!
//! 1. `kernel.yama.ptrace_scope=0` — so `autopatch` can write FIFA's `/proc/PID/mem`.
//! 2. DNAT EA's hardcoded redirector IP → `server:redirector_port` (+ MASQUERADE
//! on the reply path, required for a DNAT to a remote host).
//! 3. Point each dead EA hostname at the server in `/etc/hosts`.
//!
//! All of it is idempotent: the DNAT deletes any prior copy before adding, and
//! every `/etc/hosts` line for a managed hostname is removed first — including a
//! foreign single-machine-era `127.0.0.1 easw.easports.com` shadow that
//! `client_arm.sh` could not remove, because it only deleted its own `# openfut`
//! lines and glibc returns the FIRST match.
use crate::config::LauncherConfig;
/// Accept only hostname/IP characters. These values come from config fields that
/// are ever only IPs or hostnames, so a surprising character is a bug — reject it
/// rather than try to escape it into an elevated shell command.
fn safe_host(s: &str) -> anyhow::Result<&str> {
let t = s.trim();
if t.is_empty() {
anyhow::bail!("empty host/address");
}
if t.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'-' | b'_'))
{
Ok(t)
} else {
anyhow::bail!("refusing to arm with an unexpected character in {t:?}");
}
}
/// Build the privileged arming script. Pure and unit-tested; the effectful part
/// ([`arm`]) only validates config and hands this to the elevated runner.
pub(crate) fn arming_script(
server: &str,
redirector_port: u16,
ea_ip: &str,
hostnames: &[String],
) -> anyhow::Result<String> {
let server = safe_host(server)?;
let ea_ip = safe_host(ea_ip)?;
let mut s = String::from("set -eu\n");
// 1) ptrace_scope for autopatch's /proc/PID/mem write.
s.push_str("sysctl -q kernel.yama.ptrace_scope=0\n");
// 2) DNAT EA's hardcoded redirector IP to the server; SNAT the redirected
// flow (a DNAT from OUTPUT to a remote host needs a matching MASQUERADE or
// the server's replies won't match the game's conntrack entry). Both are
// delete-then-add so re-running and IP changes stay clean.
s.push_str(&format!(
"while iptables -t nat -D OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port} 2>/dev/null; do :; done\n\
iptables -t nat -A OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port}\n\
while iptables -t nat -D POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE 2>/dev/null; do :; done\n\
iptables -t nat -A POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE\n"
));
// 3) Every dead EA hostname resolves to the server. Delete ALL existing lines
// listing the name (foreign shadow included) BEFORE writing ours, so the
// first-match-wins resolution can never land on a stale loopback line.
for host in hostnames {
let host = safe_host(host)?;
let re = host.replace('.', "\\.");
s.push_str(&format!(
"sed -ri '/[[:space:]]{re}([[:space:]]|$)/d' /etc/hosts\n\
printf '%s\\t%s\\t# openfut\\n' '{server}' '{host}' >> /etc/hosts\n"
));
}
Ok(s)
}
/// Human-readable list of what [`arm`] changed, in the order the script applies
/// it. Logged by the UI so the user sees exactly what was set — not just that
/// "something" ran under `pkexec`.
pub(crate) fn arming_summary(
server: &str,
redirector_port: u16,
ea_ip: &str,
hostnames: &[String],
) -> Vec<String> {
let mut out = vec![
"kernel.yama.ptrace_scope = 0 (autopatch can attach)".to_string(),
format!("DNAT {ea_ip} -> {server}:{redirector_port} (+ MASQUERADE reply path)"),
];
for host in hostnames {
out.push(format!("hosts: {host} -> {server}"));
}
out
}
/// Arm the client from config, under one elevated prompt. Requires the same
/// fields preflight reads; a missing one is a clear error, never a silent
/// loopback fallback. Returns the applied changes for the UI to surface.
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 Settings before arming.");
}
let ea_ip = cfg.ea_redirect_probe_ip.trim();
if ea_ip.is_empty() {
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 Settings before arming."
);
}
let redirector_port = cfg.openfut_blaze_redirector_port;
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
crate::setup::run_elevated(&script)?;
Ok(arming_summary(
server,
redirector_port,
ea_ip,
&cfg.ea_hostnames,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn script() -> String {
arming_script(
"10.10.0.120",
42127,
"159.153.51.20",
&["easw.easports.com".to_string()],
)
.unwrap()
}
#[test]
fn sets_ptrace_scope_zero() {
assert!(script().contains("sysctl -q kernel.yama.ptrace_scope=0"));
}
#[test]
fn dnats_ea_ip_to_server_and_masquerades() {
let s = script();
assert!(s.contains(
"iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 10.10.0.120:42127"
));
assert!(s.contains(
"iptables -t nat -A POSTROUTING -p tcp -d 10.10.0.120 --dport 42127 -j MASQUERADE"
));
}
#[test]
fn dnat_is_delete_then_add_for_idempotence() {
let s = script();
// The delete loop precedes the add, so re-arming never stacks duplicates.
let del = s.find("-D OUTPUT").unwrap();
let add = s.find("-A OUTPUT").unwrap();
assert!(del < add, "delete must run before add");
}
#[test]
fn removes_shadowing_hosts_line_before_writing_ours() {
let s = script();
// Deletes any existing easw.easports.com line (foreign shadow included)…
assert!(
s.contains("sed -ri '/[[:space:]]easw\\.easports\\.com([[:space:]]|$)/d' /etc/hosts")
);
// …then appends the OpenFUT-tagged mapping to the server.
assert!(s.contains(
"printf '%s\\t%s\\t# openfut\\n' '10.10.0.120' 'easw.easports.com' >> /etc/hosts"
));
let del = s.find("sed -ri").unwrap();
let add = s.find("printf").unwrap();
assert!(del < add, "shadow removal must precede our line");
}
#[test]
fn multiple_hostnames_each_get_a_mapping() {
let s = arming_script(
"10.10.0.120",
42127,
"159.153.51.20",
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
)
.unwrap();
assert!(s.contains("'easw.easports.com' >> /etc/hosts"));
assert!(s.contains("'utas.fut.ea.com' >> /etc/hosts"));
}
#[test]
fn rejects_shell_metacharacters_in_config() {
assert!(arming_script("10.0.0.1; rm -rf /", 42127, "159.153.51.20", &[]).is_err());
assert!(arming_script("10.0.0.1", 42127, "$(evil)", &[]).is_err());
assert!(
arming_script("10.0.0.1", 42127, "159.153.51.20", &["a b`c`".into()]).is_err(),
"a hostname with a backtick is rejected"
);
}
#[test]
fn arm_requires_server_ea_ip_and_hostname() {
let mut c = LauncherConfig::default();
assert!(arm(&c).unwrap_err().to_string().contains("server host"));
c.openfut_server_host = "10.10.0.120".into();
assert!(arm(&c)
.unwrap_err()
.to_string()
.contains("EA redirector IP"));
c.ea_redirect_probe_ip = "159.153.51.20".into();
assert!(arm(&c).unwrap_err().to_string().contains("EA hostname"));
}
#[test]
fn summary_lists_ptrace_dnat_and_each_host() {
let s = arming_summary(
"10.10.0.120",
42127,
"159.153.51.20",
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
);
assert!(s.iter().any(|l| l.contains("ptrace_scope = 0")));
assert!(s
.iter()
.any(|l| l.contains("DNAT 159.153.51.20 -> 10.10.0.120:42127")));
assert!(s
.iter()
.any(|l| l == "hosts: easw.easports.com -> 10.10.0.120"));
assert!(s
.iter()
.any(|l| l == "hosts: utas.fut.ea.com -> 10.10.0.120"));
}
}
+583 -18
View File
@@ -1,4 +1,101 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One `dosdevices` entry to create inside the Wine prefix before launching.
/// `link` is relative to the prefix (e.g. `dosdevices/w:`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrefixLink {
pub link: String,
pub target: String,
}
/// A DRM licence file the game refuses to start without, and the executable
/// that recreates it. A crashed launch deletes the licence, so this is a
/// per-launch precondition rather than a one-time setup step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LicenseCheck {
/// Absolute, or relative to the Wine prefix.
pub path: String,
/// Executable run through the profile's runner to regenerate it.
pub generator: String,
#[serde(default = "default_license_timeout")]
pub timeout_secs: u64,
}
/// Everything needed to start one game, as data.
///
/// This is what keeps the launcher game-independent: FIFA 17's runner, prefix,
/// executable, `w:` drive and licence id live here in the user's config, never
/// in launcher code. An unconfigured profile means "fall back to
/// `game_launch_command`", so upgrading cannot break a working setup.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameProfile {
/// Program that starts the game (e.g. `umu-run`). Empty = profile unused.
#[serde(default)]
pub runner: String,
/// Argument passed to the runner (e.g. `FIFA17.exe`).
#[serde(default)]
pub executable: String,
/// Working directory the runner is started from.
#[serde(default)]
pub game_dir: String,
/// `WINEPREFIX` for the game. Exported automatically when set.
#[serde(default)]
pub wine_prefix: String,
/// Extra environment for the runner (`GAMEID`, `PROTONPATH`, …).
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub prefix_links: Vec<PrefixLink>,
#[serde(default)]
pub license: Option<LicenseCheck>,
}
impl GameProfile {
/// Whether this profile is filled in enough to launch from.
pub fn configured(&self) -> bool {
!self.runner.trim().is_empty()
&& !self.executable.trim().is_empty()
&& !self.game_dir.trim().is_empty()
}
/// Reject a half-filled profile rather than launching something surprising.
pub fn validate(&self) -> Result<(), String> {
if self.runner.trim().is_empty() {
return Err("Game profile has no runner (e.g. umu-run).".into());
}
if self.executable.trim().is_empty() {
return Err("Game profile has no executable.".into());
}
if self.game_dir.trim().is_empty() {
return Err("Game profile has no game directory.".into());
}
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile defines prefix links but no wine_prefix.".into());
}
for l in &self.prefix_links {
if l.link.trim().is_empty() || l.target.trim().is_empty() {
return Err("Game profile has a prefix link with an empty link or target.".into());
}
if std::path::Path::new(&l.link).is_absolute() {
return Err(format!(
"Prefix link {:?} must be relative to the Wine prefix.",
l.link
));
}
}
if let Some(lic) = &self.license {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
return Err("Game profile licence needs both a path and a generator.".into());
}
}
Ok(())
}
}
fn default_license_timeout() -> u64 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherConfig {
@@ -15,8 +112,84 @@ pub struct LauncherConfig {
pub hook_dll_path: String,
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
pub fifa_game_dir: String,
/// IP the hook DLL redirects EA hostnames to (written to openfut.cfg).
pub hook_redirect_ip: String,
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
/// hostname. Empty means "not configured" — launching is blocked until set.
/// There is intentionally NO loopback default.
#[serde(default, alias = "hook_redirect_ip")]
pub openfut_server_host: String,
/// OpenFUT destination port for intercepted EA :443 (bridge HTTPS).
#[serde(default = "default_https_port")]
pub openfut_https_port: u16,
/// OpenFUT destination port for intercepted EA :10041 (Blaze redirector).
#[serde(default = "default_blaze_redirector_port")]
pub openfut_blaze_redirector_port: u16,
/// OpenFUT destination port for intercepted EA :42127 (Blaze main).
#[serde(default = "default_blaze_main_port")]
pub openfut_blaze_main_port: u16,
/// Plain HTTP UTAS/control-plane port used to select the active account.
#[serde(default = "default_account_sync_port")]
pub openfut_account_sync_port: u16,
/// EA/Origin persona selected for this local single-player profile.
#[serde(default)]
pub fut_persona_id: u64,
#[serde(default)]
pub fut_persona_name: String,
/// EASFC/POW account-bar state (separate from FUT club coins).
#[serde(default = "default_account_level")]
pub fut_account_level: u32,
#[serde(default)]
pub fut_account_experience: u32,
#[serde(default = "default_account_experience_max")]
pub fut_account_experience_max: u32,
#[serde(default)]
pub fut_account_funds: u32,
#[serde(default = "default_account_funds_cap")]
pub fut_account_funds_cap: u32,
/// Shell command the launcher runs to start the game. Run via `sh -c`, from
/// `game_launch_workdir` if set. Empty means "not configured" — the Launch
/// Game button is disabled until the user provides one. This keeps the
/// launcher agnostic to Steam vs umu-run vs a custom script.
#[serde(default)]
pub game_launch_command: String,
/// Optional working directory for `game_launch_command`. Empty = inherit.
#[serde(default)]
pub game_launch_workdir: String,
/// Native launch definition. When [`GameProfile::configured`], the launcher
/// starts the game itself and `game_launch_command` is not used; the command
/// remains as a fallback so an existing setup keeps working after upgrade.
#[serde(default)]
pub game_profile: GameProfile,
// ── Pre-launch checks (see `preflight`) ─────────────────────────────────
/// EA's hardcoded redirector IP, probed to confirm the client-side DNAT is
/// armed. Empty = the check is skipped. A game fact, so it is configuration.
#[serde(default)]
pub ea_redirect_probe_ip: String,
/// Dead EA hostnames that must resolve to `openfut_server_host`.
#[serde(default)]
pub ea_hostnames: Vec<String>,
}
fn default_https_port() -> u16 {
openfut_common::default_ports::HTTPS
}
fn default_blaze_redirector_port() -> u16 {
openfut_common::default_ports::BLAZE_REDIRECTOR
}
fn default_blaze_main_port() -> u16 {
openfut_common::default_ports::BLAZE_MAIN
}
fn default_account_sync_port() -> u16 {
8099
}
fn default_account_level() -> u32 {
1
}
fn default_account_experience_max() -> u32 {
1000
}
fn default_account_funds_cap() -> u32 {
100_000
}
impl Default for LauncherConfig {
@@ -57,7 +230,27 @@ impl Default for LauncherConfig {
.unwrap_or_default()
.to_string_lossy()
.into(),
hook_redirect_ip: "127.0.0.1".into(),
// No server configured by default — the user MUST enter one. There
// is deliberately no loopback/localhost default.
openfut_server_host: String::new(),
openfut_https_port: default_https_port(),
openfut_blaze_redirector_port: default_blaze_redirector_port(),
openfut_blaze_main_port: default_blaze_main_port(),
openfut_account_sync_port: default_account_sync_port(),
fut_persona_id: 0,
fut_persona_name: String::new(),
fut_account_level: default_account_level(),
fut_account_experience: 0,
fut_account_experience_max: default_account_experience_max(),
fut_account_funds: 0,
fut_account_funds_cap: default_account_funds_cap(),
game_launch_command: String::new(),
game_launch_workdir: String::new(),
// Empty by default, exactly like the server host: the launcher must
// never invent a path to somebody's game install.
game_profile: GameProfile::default(),
ea_redirect_probe_ip: String::new(),
ea_hostnames: Vec::new(),
}
}
}
@@ -88,22 +281,394 @@ impl LauncherConfig {
}
}
pub fn core_env(&self) -> Vec<(String, String)> {
vec![
("DATABASE_URL".into(), self.core_database_url.clone()),
("DATA_DIR".into(), self.core_data_dir.clone()),
("LISTEN_ADDR".into(), self.core_listen_addr.clone()),
("RUST_LOG".into(), "openfut_core=info,tower_http=info".into()),
]
/// The (host, port) the health monitor should poll, or None when no server
/// is configured. Uses the bridge HTTPS port — the port the FIFA client
/// actually connects to — so "reachable" means what the game will see.
pub fn health_target(&self) -> Option<(String, u16)> {
let host = self.openfut_server_host.trim();
if host.is_empty() {
None
} else {
Some((host.to_string(), self.openfut_https_port))
}
}
pub fn bridge_env(&self) -> Vec<(String, String)> {
vec![
("CORE_URL".into(), self.bridge_core_url.clone()),
("LISTEN_ADDR".into(), self.bridge_listen_addr.clone()),
("CAPTURES_DIR".into(), self.bridge_captures_dir.clone()),
("TLS_ENABLED".into(), self.bridge_tls_enabled.to_string()),
("RUST_LOG".into(), "openfut_bridge=info".into()),
]
/// 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.
pub fn server_config(&self) -> openfut_common::ServerConfig {
openfut_common::ServerConfig {
host: self.openfut_server_host.trim().to_string(),
ports: openfut_common::OpenFutPorts {
https: self.openfut_https_port,
blaze_redirector: self.openfut_blaze_redirector_port,
blaze_main: self.openfut_blaze_main_port,
},
}
}
/// Validate the configured server (syntax only, no DNS). Returns the same
/// user-facing message the task specifies when nothing is configured.
pub fn validate_server(&self) -> Result<(), String> {
if self.openfut_server_host.trim().is_empty() {
return Err("No OpenFUT server configured. Please enter the hostname \
or IP address of your OpenFUT server."
.to_string());
}
self.server_config().validate().map_err(|e| e.to_string())
}
/// 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> {
self.validate_server()?;
self.validate_account()?;
// Either launch route is acceptable, but a half-filled profile is not:
// silently falling back to the shell command would hide the mistake, so
// ANY profile that has been touched must be complete.
if self.game_profile != GameProfile::default() {
self.game_profile.validate()?;
} 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 Settings."
.into(),
);
}
Ok(())
}
pub fn validate_account(&self) -> Result<(), String> {
if self.fut_persona_id == 0 {
return Err("No account yet. Create one from the Get started tab.".into());
}
if self.fut_persona_name.trim().is_empty() {
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());
}
if self.fut_account_experience_max == 0
|| self.fut_account_experience > self.fut_account_experience_max
{
return Err("EA account XP must not exceed a nonzero XP maximum.".into());
}
if self.fut_account_funds > self.fut_account_funds_cap {
return Err("EA account funds must not exceed the funds cap.".into());
}
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).
///
/// The deployed FIFA 17 hook reads this structured format through the same
/// shared parser, so changing a destination port never requires recompiling
/// the DLL. Fixed EA source ports remain protocol signatures in the hook.
pub fn hook_cfg_contents(&self) -> Result<String, String> {
self.validate_server()?;
Ok(self.server_config().to_cfg_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_has_no_server_and_blocks_launch() {
let c = LauncherConfig::default();
assert!(c.openfut_server_host.is_empty());
let err = c.validate_server().unwrap_err();
assert!(err.contains("No OpenFUT server configured"));
assert!(c.hook_cfg_contents().is_err());
}
#[test]
fn configured_server_roundtrips_into_hook_cfg() {
let c = LauncherConfig {
openfut_server_host: "192.168.1.50".into(),
openfut_https_port: 9443,
openfut_blaze_redirector_port: 43127,
openfut_blaze_main_port: 43130,
..LauncherConfig::default()
};
let cfg = c
.hook_cfg_contents()
.expect("valid server should produce cfg");
let parsed = openfut_common::ServerConfig::parse(&cfg).unwrap();
assert_eq!(parsed.host, "192.168.1.50");
assert_eq!(parsed.ports, c.server_config().ports);
}
#[test]
fn changing_server_changes_hook_cfg_no_rebuild() {
// Models the Server A -> Server B acceptance test at the config layer:
// only the value changes; the same code path produces the new cfg.
let mut c = LauncherConfig {
openfut_server_host: "10.0.0.1".into(),
..LauncherConfig::default()
};
let a = c.hook_cfg_contents().unwrap();
c.openfut_server_host = "10.0.0.2".into();
let b = c.hook_cfg_contents().unwrap();
assert_ne!(a, b);
assert_eq!(
openfut_common::ServerConfig::parse(&b).unwrap().host,
"10.0.0.2"
);
}
#[test]
fn health_target_none_until_configured() {
let mut c = LauncherConfig::default();
assert!(c.health_target().is_none());
c.openfut_server_host = "10.10.0.120".into();
let (host, port) = c.health_target().expect("configured host yields a target");
assert_eq!(host, "10.10.0.120");
assert_eq!(port, c.openfut_https_port);
}
#[test]
fn legacy_hook_redirect_ip_field_is_read() {
// Old configs stored the address under `hook_redirect_ip`; serde alias
// must map it onto the new field so upgrades keep working.
let json = r#"{
"core_binary":"","bridge_binary":"","core_database_url":"",
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
"hook_dll_path":"","fifa_game_dir":"","hook_redirect_ip":"192.168.5.5"
}"#;
let c: LauncherConfig = serde_json::from_str(json).unwrap();
assert_eq!(c.openfut_server_host, "192.168.5.5");
}
#[test]
fn launch_config_requires_server_account_and_command() {
let mut c = LauncherConfig::default();
assert!(c.validate_launch_config().is_err());
c.openfut_server_host = "10.10.0.120".into();
c.fut_persona_id = 12345678;
c.fut_persona_name = "TEST_USER".into();
assert!(c
.validate_launch_config()
.unwrap_err()
.contains("launch command"));
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
assert!(c.validate_launch_config().is_ok());
}
/// An old config.json has no `game_profile` key at all. It must keep
/// launching exactly as before rather than failing to parse or silently
/// switching route.
#[test]
fn a_config_without_a_game_profile_still_uses_the_shell_command() {
let json = r#"{
"core_binary":"","bridge_binary":"","core_database_url":"",
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
"hook_dll_path":"","fifa_game_dir":"","openfut_server_host":"10.0.0.1",
"game_launch_command":"/home/u/launch.sh"
}"#;
let c: LauncherConfig = serde_json::from_str(json).unwrap();
assert!(!c.game_profile.configured());
assert_eq!(c.game_profile, GameProfile::default());
assert!(c.ea_hostnames.is_empty());
}
#[test]
fn a_configured_profile_satisfies_launch_without_a_shell_command() {
let mut c = LauncherConfig {
openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1,
fut_persona_name: "X".into(),
..LauncherConfig::default()
};
c.game_launch_command.clear();
assert!(
c.validate_launch_config().is_err(),
"neither route configured"
);
c.game_profile = GameProfile {
runner: "umu-run".into(),
executable: "FIFA17.exe".into(),
game_dir: "/mnt/games/FIFA 17".into(),
..GameProfile::default()
};
assert!(c.game_profile.configured());
assert!(c.validate_launch_config().is_ok());
}
/// The trap this guards: a profile filled in halfway would fail
/// `configured()` and quietly fall through to the shell command, so the user
/// edits the profile and nothing they change has any effect.
#[test]
fn a_half_filled_profile_is_an_error_not_a_silent_fallback() {
let mut c = LauncherConfig {
openfut_server_host: "10.10.0.120".into(),
fut_persona_id: 1,
fut_persona_name: "X".into(),
game_launch_command: "/home/u/launch.sh".into(),
..LauncherConfig::default()
};
c.game_profile.runner = "umu-run".into(); // and nothing else
let err = c.validate_launch_config().unwrap_err();
assert!(err.contains("executable"), "{err}");
}
/// The exact profile block deployed to the FIFA 17 machine.
///
/// `load()` swallows a parse error and returns `Default` — so a config this
/// binary cannot read would not produce an error, it would silently discard
/// the user's persona, server and ports. That makes "the shipped config
/// actually deserializes" a property worth asserting, not assuming.
#[test]
fn the_deployed_fifa17_profile_parses_exactly() {
let json = r#"{
"core_binary":"","bridge_binary":"","core_database_url":"",
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
"hook_dll_path":"","fifa_game_dir":"",
"openfut_server_host":"10.10.0.120",
"ea_hostnames":["easw.easports.com"],
"ea_redirect_probe_ip":"159.153.51.20",
"game_profile":{
"env":{"GAMEID":"fifa17","PROTONPATH":"UMU-Proton-10.0-4","STEAM_COMPAT_CONFIG":"sdlinput"},
"executable":"FIFA17.exe",
"game_dir":"/mnt/games/FIFA 17",
"license":{
"generator":"_fifa17.exe",
"path":"drive_c/ProgramData/Electronic Arts/EA Services/License/1027460.dlf",
"timeout_secs":60
},
"prefix_links":[{"link":"dosdevices/w:","target":"/mnt"}],
"runner":"umu-run",
"wine_prefix":"/home/alex/Games/umu/fifa17"
}
}"#;
let c: LauncherConfig = serde_json::from_str(json).expect("deployed config must parse");
let p = &c.game_profile;
assert!(p.configured());
assert!(p.validate().is_ok());
assert_eq!(p.runner, "umu-run");
assert_eq!(
p.env.get("STEAM_COMPAT_CONFIG").map(String::as_str),
Some("sdlinput")
);
assert_eq!(p.prefix_links.len(), 1);
let lic = p.license.as_ref().expect("licence block");
assert_eq!(lic.timeout_secs, 60);
assert!(lic.path.ends_with("1027460.dlf"));
assert_eq!(c.ea_redirect_probe_ip, "159.153.51.20");
}
/// `configured()` alone decides which launch route runs, so it is pinned
/// directly rather than only through `validate_launch_config`. All three
/// fields are required: a profile missing any of them cannot start a game.
#[test]
fn configured_requires_runner_executable_and_dir() {
let mut p = GameProfile::default();
assert!(!p.configured());
p.runner = "umu-run".into();
assert!(!p.configured(), "runner alone is not launchable");
p.executable = "G.exe".into();
assert!(!p.configured(), "no game_dir is not launchable");
p.game_dir = "/games/G".into();
assert!(p.configured());
// Whitespace is not configuration.
p.executable = " ".into();
assert!(!p.configured());
}
#[test]
fn prefix_links_must_be_relative_and_have_a_prefix() {
let mut p = GameProfile {
runner: "umu-run".into(),
executable: "G.exe".into(),
game_dir: "/games/G".into(),
prefix_links: vec![PrefixLink {
link: "dosdevices/w:".into(),
target: "/mnt".into(),
}],
..GameProfile::default()
};
assert!(
p.validate().unwrap_err().contains("wine_prefix"),
"links without a prefix have nowhere to go"
);
p.wine_prefix = "/prefix".into();
assert!(p.validate().is_ok());
// An absolute link would be created outside the prefix entirely.
p.prefix_links[0].link = "/etc/w:".into();
assert!(p.validate().unwrap_err().contains("relative"));
}
#[test]
fn launch_requires_a_valid_ea_account() {
let mut c = LauncherConfig::default();
// 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");
}
}
+224
View File
@@ -0,0 +1,224 @@
//! FIFA 17 verified patched-client capability negotiation (launcher side).
//!
//! The FIFA 17 backend suppresses its synthetic empty-My-Packs sentinel (pack id
//! 65534) only when the *current* FIFA process has positively verified the
//! CardsDLL resolver guard. autopatch proves that at runtime and advertises it on
//! its stdout; the launcher parses that line, records the capability for the live
//! FIFA process, and registers it with the backend over the same tiny stdlib-HTTP
//! transport used by [`crate::account_sync`]. See
//! `docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md`.
//!
//! Everything here is fail-closed: a line we cannot parse, or a registration POST
//! that fails, simply leaves the backend on its default active-sentinel path.
use serde::Serialize;
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
const CAPABILITY_NAME: &str = "empty_mypacks_resolver";
const CAPABILITY_PATH: &str = "/openfut/fifa17/capability";
const TIMEOUT: Duration = Duration::from_secs(3);
/// Capabilities verified for the *current* FIFA process. Starts UNKNOWN at each
/// launch and is discarded when that FIFA process ends — it is never persisted,
/// so a previous launch's capability can never leak into a later one.
#[derive(Debug, Clone, Default)]
pub struct Fifa17ClientCapabilities {
/// `Some(version)` once autopatch has verified the resolver guard for the
/// live FIFA process; `None` while unknown / unverified.
pub empty_mypacks_resolver: Option<u32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CapabilityRegistration<'a> {
capability: &'a str,
version: u32,
persona_id: u64,
fifa_pid: u64,
}
/// Pure parser for an autopatch stdout line. Returns `Some(version)` iff the raw
/// line advertises the capability — it must contain both `verified capability`
/// and `fifa17.empty_mypacks_resolver=<N>` (with `<N>` a `u32`). Non-advertising
/// lines (e.g. `guard status=UNSUPPORTED_BUILD …`) and unrelated log output
/// return `None`. Robust to a trailing ` fifa_pid=<pid>`.
pub fn parse_capability_line(line: &str) -> Option<u32> {
if !line.contains("verified capability") {
return None;
}
parse_u32_after(line, "fifa17.empty_mypacks_resolver=")
}
/// Extract the FIFA pid from a `fifa_pid=<n>` token if present.
pub fn parse_fifa_pid(line: &str) -> Option<u64> {
let digits = digits_after(line, "fifa_pid=")?;
digits.parse::<u64>().ok()
}
fn parse_u32_after(line: &str, marker: &str) -> Option<u32> {
digits_after(line, marker)?.parse::<u32>().ok()
}
fn digits_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
let start = line.find(marker)? + marker.len();
let rest = &line[start..];
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if end == 0 {
None
} else {
Some(&rest[..end])
}
}
/// Register the verified capability with the backend via `POST
/// /openfut/fifa17/capability`. Modeled exactly on [`crate::account_sync::sync`]:
/// a tiny stdlib `TcpStream` client, `Connection: close`, 3s timeouts, status
/// line parsed, and any non-2xx (or connect/IO error) returned as `Err`. The
/// caller logs the outcome; a failure is fail-closed — the backend records
/// nothing and keeps the sentinel.
pub fn register(
host: &str,
port: u16,
persona_id: u64,
fifa_pid: u64,
version: u32,
) -> Result<(), String> {
let host = host.trim();
let address = (host, port)
.to_socket_addrs()
.map_err(|error| format!("cannot resolve capability server {host}:{port}: {error}"))?
.next()
.ok_or_else(|| format!("capability server {host}:{port} resolved to no addresses"))?;
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
.map_err(|error| format!("cannot connect to capability server {host}:{port}: {error}"))?;
stream
.set_read_timeout(Some(TIMEOUT))
.map_err(|error| format!("cannot set capability timeout: {error}"))?;
stream
.set_write_timeout(Some(TIMEOUT))
.map_err(|error| format!("cannot set capability timeout: {error}"))?;
let payload = serde_json::to_vec(&CapabilityRegistration {
capability: CAPABILITY_NAME,
version,
persona_id,
fifa_pid,
})
.map_err(|error| format!("cannot encode capability request: {error}"))?;
let request = format!(
"POST {CAPABILITY_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
payload.len()
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(&payload))
.map_err(|error| format!("cannot send capability request: {error}"))?;
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.map_err(|error| format!("cannot read capability response: {error}"))?;
let separator = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.ok_or_else(|| "capability server returned a malformed HTTP response".to_string())?;
let headers = std::str::from_utf8(&response[..separator])
.map_err(|_| "capability server returned non-UTF-8 headers".to_string())?;
let status = headers
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u16>().ok())
.ok_or_else(|| "capability server returned a malformed status line".to_string())?;
if !(200..300).contains(&status) {
let detail = String::from_utf8_lossy(&response[separator + 4..]);
return Err(format!(
"capability server rejected registration (HTTP {status}): {detail}"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::thread;
#[test]
fn parses_the_verified_capability_line() {
let line =
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242";
assert_eq!(parse_capability_line(line), Some(1));
assert_eq!(parse_fifa_pid(line), Some(4242));
}
#[test]
fn non_advertising_status_line_yields_none() {
let line =
"[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)";
assert_eq!(parse_capability_line(line), None);
}
#[test]
fn unrelated_log_line_yields_none() {
let line = "[autopatch] patched /proc/4242/mem at rva 0x14858";
assert_eq!(parse_capability_line(line), None);
}
#[test]
fn version_gating_is_left_to_the_backend() {
let line = "[store-guard] verified capability fifa17.empty_mypacks_resolver=2 fifa_pid=7";
assert_eq!(parse_capability_line(line), Some(2));
}
#[test]
fn register_posts_capability_to_the_backend() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = 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;
}
}
}
let request = String::from_utf8_lossy(&request);
assert!(request.starts_with("POST /openfut/fifa17/capability HTTP/1.1"));
assert!(request.contains("\"capability\":\"empty_mypacks_resolver\""));
assert!(request.contains("\"version\":1"));
assert!(request.contains("\"personaId\":12345678"));
assert!(request.contains("\"fifaPid\":4242"));
let body = r#"{"status":"OK"}"#;
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();
});
register("127.0.0.1", port, 12345678, 4242, 1).unwrap();
server.join().unwrap();
}
}
+520
View File
@@ -0,0 +1,520 @@
//! Launch the game directly, without an external shell script.
//!
//! # Why this exists
//!
//! The launcher used to shell out to a user-written script (`game_launch_command`)
//! that set the Proton environment, prepared the Wine prefix, regenerated the
//! DRM licence and finally ran the game. That script lived on the user's Desktop
//! — and on 2026-08-11 it was moved to the Trash, after which every launch failed
//! with `sh: No such file or directory`. Three unrelated client-side faults that
//! morning each looked like "the game crashed"; none of them were.
//!
//! Everything the script did is mechanical and belongs inside the launcher, where
//! it cannot be deleted, is covered by tests, and reports failures into the same
//! log buffer as the rest of the launch.
//!
//! # What stays out of this file
//!
//! Every FIFA-17 fact — the runner, the executable, the prefix path, the `w:`
//! drive symlink, the licence file id — is [`GameProfile`] *data*, not code.
//! OpenFUT is not a FIFA 17 project; FIFA 17 is its first reference target. A
//! second game must be a different profile, never a second branch in here.
//!
//! `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;
use std::time::{Duration, Instant};
use crate::config::GameProfile;
use crate::logs::LogBuffer;
type Log = Arc<Mutex<LogBuffer>>;
fn say(log: &Log, msg: impl Into<String>) {
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. `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);
if !game_dir.is_dir() {
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
}
prepare_prefix(profile, log)?;
ensure_license(profile, log)?;
let mut cmd = Command::new(&profile.runner);
cmd.arg(&profile.executable)
.current_dir(&game_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
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);
}
say(
log,
format!(
"[launcher] launching {} {} (cwd {})",
profile.runner,
profile.executable,
game_dir.display()
),
);
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
/// 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>`:
/// an existing link is replaced, so re-running is harmless.
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
return Ok(());
}
let prefix = PathBuf::from(&profile.wine_prefix);
for link in &profile.prefix_links {
let path = prefix.join(&link.link);
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?;
std::fs::create_dir_all(parent)?;
// Replace rather than fail: `ln -sfn` semantics. Only ever remove a
// symlink — refusing on a real file avoids destroying prefix contents
// if a profile is misconfigured.
match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?,
Ok(_) => anyhow::bail!(
"refusing to replace {}: it exists and is not a symlink",
path.display()
),
Err(_) => {}
}
std::os::unix::fs::symlink(&link.target, &path)?;
say(
log,
format!(
"[launcher] prefix link {} -> {}",
path.display(),
link.target
),
);
}
Ok(())
}
/// Make sure the DRM licence file exists, running the generator if it does not.
///
/// A crashed or failed launch deletes the licence, so this runs before every
/// launch rather than only on first setup — that is the behaviour the shell
/// script proved, and it is why a crash is normally self-healing on the next try.
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let Some(lic) = &profile.license else {
return Ok(());
};
let path = resolve_under_prefix(&profile.wine_prefix, &lic.path);
if non_empty_file(&path) {
return Ok(());
}
say(
log,
format!(
"[launcher] licence missing ({}) — running {} to regenerate it",
path.display(),
lic.generator
),
);
let mut cmd = Command::new(&profile.runner);
cmd.arg(&lic.generator)
.current_dir(&profile.game_dir)
.stdout(Stdio::null())
.stderr(Stdio::null());
for (k, v) in &profile.env {
cmd.env(k, v);
}
if !profile.wine_prefix.trim().is_empty() {
cmd.env("WINEPREFIX", &profile.wine_prefix);
}
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1));
while Instant::now() < deadline {
if non_empty_file(&path) {
stop_generator(&mut child, lic, log);
say(log, "[launcher] licence regenerated.");
return Ok(());
}
std::thread::sleep(Duration::from_millis(500));
}
stop_generator(&mut child, lic, log);
anyhow::bail!(
"{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.",
lic.generator,
path.display(),
lic.timeout_secs
)
}
/// Stop the licence generator and the Windows process it started.
///
/// Killing the runner is not enough: it launches the executable through Proton,
/// so the `.exe` outlives its parent. The shell script used `pkill -f` for this
/// and it is reproduced deliberately — the pattern is a Windows executable name,
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
/// that *can* match its own caller is a real hazard; this one cannot.)
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
let _ = child.kill();
let _ = child.wait();
match Command::new("pkill").arg("-f").arg(&lic.generator).status() {
Ok(_) => {}
Err(e) => say(
log,
format!(
"[launcher] note: could not run pkill for {}: {e}",
lic.generator
),
),
}
}
/// A relative licence path is taken as relative to the Wine prefix; an absolute
/// one is used as given.
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() || prefix.trim().is_empty() {
p.to_path_buf()
} else {
Path::new(prefix).join(p)
}
}
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
/// as useless as a missing one, and treating it as valid would skip the
/// regeneration that fixes it.
fn non_empty_file(path: &Path) -> bool {
std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
}
/// 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,
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().push(line);
}
});
}
if let Some(err) = child.stderr.take() {
let buf = Arc::clone(&log);
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().push(line);
}
});
}
std::thread::spawn(move || {
let _ = child.wait();
log.lock().push(exit_msg.to_string());
on_exit();
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LicenseCheck, PrefixLink};
fn log() -> Log {
Arc::new(Mutex::new(LogBuffer::new()))
}
fn tmpdir(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_relative_licence_path_is_resolved_under_the_prefix() {
assert_eq!(
resolve_under_prefix("/p", "drive_c/lic.dlf"),
PathBuf::from("/p/drive_c/lic.dlf")
);
// Absolute wins, so a profile can point outside the prefix.
assert_eq!(
resolve_under_prefix("/p", "/elsewhere/lic.dlf"),
PathBuf::from("/elsewhere/lic.dlf")
);
}
#[test]
fn a_zero_byte_licence_does_not_count_as_present() {
let d = tmpdir("empty-lic");
let f = d.join("lic.dlf");
std::fs::write(&f, b"").unwrap();
assert!(
!non_empty_file(&f),
"an empty licence must trigger regeneration"
);
std::fs::write(&f, b"x").unwrap();
assert!(non_empty_file(&f));
}
#[test]
fn prefix_links_are_created_and_are_idempotent() {
let d = tmpdir("links");
let prefix = d.join("prefix");
let target = d.join("target");
std::fs::create_dir_all(&target).unwrap();
let profile = GameProfile {
runner: "true".into(),
executable: "x.exe".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: prefix.to_string_lossy().into(),
prefix_links: vec![PrefixLink {
link: "dosdevices/w:".into(),
target: target.to_string_lossy().into(),
}],
..GameProfile::default()
};
prepare_prefix(&profile, &log()).expect("first run creates the link");
let link = prefix.join("dosdevices/w:");
assert!(std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
// Re-running must not fail — the launcher prepares the prefix on EVERY
// launch, so a second launch would break if this were not idempotent.
prepare_prefix(&profile, &log()).expect("second run replaces the link");
assert_eq!(std::fs::read_link(&link).unwrap(), target);
}
#[test]
fn a_real_file_where_a_link_belongs_is_refused_not_deleted() {
let d = tmpdir("clobber");
let prefix = d.join("prefix");
std::fs::create_dir_all(prefix.join("dosdevices")).unwrap();
let occupied = prefix.join("dosdevices/w:");
std::fs::write(&occupied, b"important").unwrap();
let profile = GameProfile {
runner: "true".into(),
executable: "x.exe".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: prefix.to_string_lossy().into(),
prefix_links: vec![PrefixLink {
link: "dosdevices/w:".into(),
target: "/tmp".into(),
}],
..GameProfile::default()
};
assert!(prepare_prefix(&profile, &log()).is_err());
assert_eq!(
std::fs::read(&occupied).unwrap(),
b"important",
"a misconfigured profile must not destroy prefix contents"
);
}
#[test]
fn a_present_licence_skips_the_generator_entirely() {
let d = tmpdir("lic-present");
let lic = d.join("lic.dlf");
std::fs::write(&lic, b"valid").unwrap();
let profile = GameProfile {
runner: "/nonexistent/runner".into(), // would fail if it were run
executable: "x.exe".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: d.to_string_lossy().into(),
license: Some(LicenseCheck {
path: "lic.dlf".into(),
generator: "_gen.exe".into(),
timeout_secs: 1,
}),
..GameProfile::default()
};
// Proves the skip: the runner path is invalid, so reaching the generator
// would error. Ok() means it never tried.
ensure_license(&profile, &log()).expect("present licence must short-circuit");
}
/// The whole point of the licence step: a missing licence must actually run
/// the generator and wait for it. Without this, deleting `ensure_license`
/// entirely would still pass every other test in this file.
#[test]
fn a_missing_licence_runs_the_generator_and_waits_for_it() {
let d = tmpdir("lic-regen");
let lic = d.join("lic.dlf");
let gen = d.join("gen.sh");
// Sleeps first, so passing requires actually waiting rather than
// happening to observe a file that was already there. The target path
// is baked in: `generator` is passed as ONE argument, exactly as
// `umu-run "_fifa17.exe"` is.
std::fs::write(
&gen,
format!(
"#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n",
lic.display()
),
)
.unwrap();
let profile = GameProfile {
runner: "/bin/sh".into(),
executable: "unused".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: d.to_string_lossy().into(),
license: Some(LicenseCheck {
generator: gen.to_string_lossy().into(),
path: "lic.dlf".into(),
timeout_secs: 10,
}),
..GameProfile::default()
};
assert!(!non_empty_file(&lic));
ensure_license(&profile, &log()).expect("generator should produce the licence");
assert!(non_empty_file(&lic), "licence was not created");
}
#[test]
fn a_generator_that_never_delivers_times_out_with_an_actionable_error() {
let d = tmpdir("lic-timeout");
let gen = d.join("gen.sh");
std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap();
let profile = GameProfile {
runner: "/bin/sh".into(),
executable: "unused".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: d.to_string_lossy().into(),
license: Some(LicenseCheck {
generator: gen.to_string_lossy().into(), // runs, writes nothing
path: "lic.dlf".into(),
timeout_secs: 1,
}),
..GameProfile::default()
};
let err = ensure_license(&profile, &log()).unwrap_err().to_string();
assert!(err.contains("did not create"), "{err}");
assert!(
err.contains("GENERATE"),
"the error must say what to do: {err}"
);
}
#[test]
fn launch_refuses_a_missing_game_dir_before_touching_anything() {
let profile = GameProfile {
runner: "true".into(),
executable: "x.exe".into(),
game_dir: "/definitely/not/here".into(),
..GameProfile::default()
};
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");
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Read-only health monitoring of the (remote) OpenFUT server.
//!
//! The launcher no longer *controls* the servers — they run elsewhere (e.g. in
//! Docker on the server host). This module polls the configured server in a
//! background thread and exposes a snapshot the UI can render. It never starts,
//! 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,
},
thread,
time::{Duration, Instant},
};
const POLL_INTERVAL: Duration = Duration::from_secs(3);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// A snapshot of the last health probe, rendered by the dashboard.
#[derive(Clone)]
pub struct HealthState {
/// None = not yet checked / no target; Some(true/false) = reachable or not.
pub reachable: Option<bool>,
pub detail: String,
pub last_checked: Option<Instant>,
}
impl Default for HealthState {
fn default() -> Self {
Self {
reachable: None,
detail: "No server configured.".into(),
last_checked: None,
}
}
}
/// Background poller. Holds a shared target (host, port) the UI can update when
/// the user changes the server address, and a shared state the UI reads.
pub struct HealthMonitor {
pub state: Arc<Mutex<HealthState>>,
target: Arc<Mutex<Option<(String, u16)>>>,
running: Arc<AtomicBool>,
}
impl HealthMonitor {
pub fn new() -> Self {
let state = Arc::new(Mutex::new(HealthState::default()));
let target: Arc<Mutex<Option<(String, u16)>>> = 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 => {
*t_state.lock() = HealthState::default();
}
Some((host, port)) => {
let snapshot = probe(&host, port);
*t_state.lock() = snapshot;
}
}
thread::sleep(POLL_INTERVAL);
}
});
Self {
state,
target,
running,
}
}
/// 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() = target;
}
pub fn snapshot(&self) -> HealthState {
self.state.lock().clone()
}
}
impl Drop for HealthMonitor {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
}
}
/// A single reachability probe: DNS-resolve host:port and attempt a bounded TCP
/// connect. A successful connect proves the FIFA client can reach the bridge.
fn probe(host: &str, port: u16) -> HealthState {
let now = Some(Instant::now());
let addrs = match (host, port).to_socket_addrs() {
Ok(a) => a.collect::<Vec<_>>(),
Err(e) => {
return HealthState {
reachable: Some(false),
detail: format!("Cannot resolve {host}: {e}"),
last_checked: now,
};
}
};
if addrs.is_empty() {
return HealthState {
reachable: Some(false),
detail: format!("{host} resolved to no addresses"),
last_checked: now,
};
}
for addr in &addrs {
if TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() {
return HealthState {
reachable: Some(true),
detail: format!("Reachable at {addr}"),
last_checked: now,
};
}
}
HealthState {
reachable: Some(false),
detail: format!("{host}:{port} not reachable"),
last_checked: now,
}
}
+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);
}
}
+778
View File
@@ -0,0 +1,778 @@
//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch
//! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game
//! machine and are managed by the launcher as child processes, mirroring the way
//! `setup::launch_game` spawns and log-streams the game.
//!
//! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders
//! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here
//! because the game dials it on the hardcoded loopback `127.0.0.1:4216`;
//! autopatch must stay here because it writes `/proc/<FIFA17.exe>/mem`.
//!
//! Lifecycle: each service is a long-running daemon. We keep the `Child` handle
//! so the UI can show running/stopped and stop them. Both run as the launcher
//! 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, PathBuf},
process::{Child, Command, Stdio},
sync::{mpsc, Arc},
time::{Duration, Instant},
};
use std::os::unix::process::CommandExt;
use crate::fifa17_capability::{
parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities,
};
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,
args: Vec<String>,
}
/// Which companion service. The `str` values are used in log prefixes.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Service {
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
Lsx,
/// autopatch — patches FIFA17.exe process memory after host ptrace arming.
Autopatch,
}
impl Service {
pub fn label(self) -> &'static str {
match self {
Service::Lsx => "LSX",
Service::Autopatch => "autopatch",
}
}
/// 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 => "openfut-lsx",
Service::Autopatch => "openfut-autopatch",
}
}
}
/// 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: resolve_binary(service).to_string_lossy().into_owned(),
args,
}
}
fn dispatch_stop_work<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
where
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
{
let (send, receive) = mpsc::channel();
std::thread::spawn(move || {
let _ = send.send(work());
});
receive
}
fn wait_for_listener_ready(
child: &mut Child,
address: SocketAddr,
timeout: Duration,
) -> anyhow::Result<()> {
let deadline = Instant::now() + timeout;
// Let immediate startup/bind errors surface before accepting an occupied
// port as evidence that this child became ready.
std::thread::sleep(Duration::from_millis(100));
loop {
if let Some(status) = child
.try_wait()
.map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))?
{
anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use");
}
match TcpListener::bind(address) {
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()),
Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"),
Ok(listener) => drop(listener),
}
if Instant::now() >= deadline {
anyhow::bail!(
"LSX did not bind {address} within {} ms",
timeout.as_millis()
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// A managed companion service process.
#[derive(Default)]
pub struct ManagedService {
child: Option<Child>,
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
}
impl ManagedService {
/// Wrap an already-spawned child.
pub fn from_child(child: Child) -> Self {
Self {
child: Some(child),
stopping: None,
}
}
/// True while the child is spawned and has not yet exited. Reaps the exit
/// status if it has, so the UI reflects a service that died on its own.
pub fn running(&mut self, log: &Arc<Mutex<LogBuffer>>, label: &str) -> bool {
if let Some(result) = self.stopping.as_ref() {
match result.try_recv() {
Ok(Ok(())) => {
log.lock().push(format!("[launcher] {label} stopped."));
self.stopping = None;
return false;
}
Ok(Err(error)) => {
log.lock()
.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().push(format!(
"[launcher] {label} stop worker exited unexpectedly."
));
self.stopping = None;
return false;
}
}
}
match self.child.as_mut() {
None => false,
Some(c) => match c.try_wait() {
Ok(None) => true,
Ok(Some(status)) => {
log.lock()
.push(format!("[launcher] {label} exited ({status})."));
self.child = None;
false
}
Err(_) => true,
},
}
}
pub fn stopping(&self) -> bool {
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() {
return;
}
if let Some(mut child) = self.child.take() {
let label = service.label();
log.lock().push(format!("[launcher] stopping {label}"));
self.stopping = Some(dispatch_stop_work(move || {
child
.kill()
.map_err(|error| anyhow::anyhow!("kill failed: {error}"))?;
child
.wait()
.map_err(|error| anyhow::anyhow!("reap failed: {error}"))?;
Ok(())
}));
}
}
}
impl Drop for ManagedService {
fn drop(&mut self) {
if let Some(mut c) = self.child.take() {
let _ = c.kill();
}
}
}
/// Backend-registration wiring handed to the autopatch stdout reader so a
/// verified resolver-guard line can advertise the per-FIFA-process capability to
/// the backend. `Some(..)` for autopatch; `None` for LSX.
pub struct CapabilityWiring {
pub server_host: String,
pub account_sync_port: u16,
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
}
/// 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,
persona_id: u64,
persona_name: &str,
capability: Option<CapabilityWiring>,
log: Arc<Mutex<LogBuffer>>,
) -> anyhow::Result<Child> {
use std::io::{BufRead, BufReader};
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()
);
}
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)
.join("openfut-autopatch.log");
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
}
// Put each companion in its own process group for lifecycle isolation.
cmd.process_group(0);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
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.binary()))?;
if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log);
let lbl = label.to_string();
// Only autopatch carries capability wiring; LSX passes `None`.
let cap_wiring = capability;
let cap_persona = persona_id;
std::thread::spawn(move || {
// Fires the backend registration at most once per FIFA process.
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().push(format!("[{lbl}] {line}"));
let Some(wiring) = cap_wiring.as_ref() else {
continue;
};
if registered {
continue;
}
let Some(version) = parse_capability_line(&line) else {
continue;
};
registered = true;
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
wiring.sink.lock().empty_mypacks_resolver = Some(version);
{
let mut log = buf.lock();
log.push(format!(
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
));
log.push(format!(
"[fifa17] registering capability for session (persona {cap_persona})"
));
}
match register(
&wiring.server_host,
wiring.account_sync_port,
cap_persona,
fifa_pid,
version,
) {
Ok(()) => buf
.lock()
.push("[fifa17] capability registered with backend".to_string()),
Err(error) => buf
.lock()
.push(format!("[fifa17] capability registration failed: {error}")),
}
}
});
}
if let Some(err) = child.stderr.take() {
let buf = Arc::clone(&log);
let lbl = label.to_string();
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
buf.lock().push(format!("[{lbl}] {line}"));
}
});
}
if service == Service::Lsx {
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()
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
}
Ok(child)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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_its_own_binary_with_launcher_ownership() {
let parts = command_parts(Service::Autopatch);
assert_eq!(
Path::new(&parts.program).file_name().unwrap(),
"openfut-autopatch"
);
// The launcher pid is how autopatch learns to exit with its owner.
assert_eq!(
parts.args,
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"
);
}
#[test]
fn stop_work_is_dispatched_without_blocking_the_caller() {
use std::time::{Duration, Instant};
let started = Instant::now();
let done = dispatch_stop_work(|| {
std::thread::sleep(Duration::from_millis(250));
Ok(())
});
assert!(started.elapsed() < Duration::from_millis(100));
assert!(done.try_recv().is_err());
assert!(done.recv_timeout(Duration::from_secs(1)).unwrap().is_ok());
}
#[test]
fn readiness_rejects_an_lsx_child_that_exits_before_binding() {
let mut child = Command::new("sh")
.args(["-c", "exit 7"])
.spawn()
.expect("spawn short-lived child");
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
let error = wait_for_listener_ready(&mut child, address, Duration::from_secs(1))
.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())
);
}
}
+100 -3
View File
@@ -1,15 +1,27 @@
mod account_monitor;
mod account_sync;
mod app;
mod arm;
mod config;
mod fifa17_capability;
mod game_launch;
mod health;
mod launch;
mod local_services;
mod logs;
mod process;
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()
};
@@ -19,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,
}
}
+77
View File
@@ -0,0 +1,77 @@
//! "Test Connection" support: verify the configured OpenFUT server is actually
//! reachable before the user launches FIFA.
//!
//! This resolves the configured host through the SAME shared path the hook uses
//! ([`openfut_common::ServerConfig::resolve`]) and then does a bounded TCP
//! connect to the OpenFUT destination port(s). It never falls back to loopback:
//! if the server isn't configured/resolvable, it reports that plainly.
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
use openfut_common::ServerConfig;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// Outcome of a connection test, suitable for showing in the UI.
pub struct TestOutcome {
pub ok: bool,
pub message: String,
}
/// Resolve `cfg` and attempt to reach the OpenFUT server. Checks the HTTPS
/// destination port (the one EA :443 traffic is redirected to) since that is the
/// service the client relies on first. On success, also reports whether the core
/// `/health` endpoint answered (best-effort; a plain-text probe, TLS not spoken).
pub fn test_connection(cfg: &ServerConfig) -> TestOutcome {
let resolved = match cfg.resolve() {
Ok(r) => r,
Err(e) => {
return TestOutcome {
ok: false,
message: format!("Cannot resolve OpenFUT server: {e}"),
};
}
};
let addr = SocketAddr::from((resolved.redirect_ip, resolved.ports.https));
match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
Ok(mut stream) => {
// Best-effort HTTP probe of /health. The bridge front door speaks
// TLS, so a plaintext request may not get a clean 200 — a successful
// TCP connect already proves reachability, so we don't fail on this.
let health = probe_health(&mut stream);
let detail = match health {
Some(true) => " (core /health responded OK)".to_string(),
_ => String::new(),
};
TestOutcome {
ok: true,
message: format!(
"Reachable: {}:{} is accepting connections{detail}.",
resolved.redirect_ip, resolved.ports.https
),
}
}
Err(e) => TestOutcome {
ok: false,
message: format!(
"Could not reach {}:{} — {e}. Check the server is running and the \
address/port are correct.",
resolved.redirect_ip, resolved.ports.https
),
},
}
}
fn probe_health(stream: &mut TcpStream) -> Option<bool> {
let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT));
let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT));
let req = "GET /health HTTP/1.0\r\nConnection: close\r\n\r\n";
stream.write_all(req.as_bytes()).ok()?;
let mut buf = [0u8; 512];
let n = stream.read(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf[..n]);
Some(text.contains("200") || text.contains("\"status\""))
}
+484
View File
@@ -0,0 +1,484 @@
//! Pre-launch checks for the client-side state FIFA depends on.
//!
//! # Why
//!
//! On 2026-08-11 the game machine rebooted. Everything `client_arm.sh` sets —
//! `ptrace_scope=0`, the DNAT of EA's hardcoded redirector IP, the
//! `easw.easports.com` mapping — is volatile and was silently gone. The launcher
//! started, the local services started, the game started, and forty minutes later
//! the only symptom was FIFA's own dialog: *"the servers for this title have been
//! shut down"*. Nothing in the stack said anything, because nothing was looking.
//!
//! Every one of those conditions is observable **without privilege**. This module
//! looks, and reports before the user clicks Launch.
//!
//! # Deliberately not checked here
//!
//! Certificate parity across the FIFA-facing TLS services — the fault that cost
//! three redirector gates — is the single most valuable check available, but the
//! launcher has no TLS dependency (`account_sync` speaks plaintext HTTP by hand)
//! and adding one is a decision, not a detail. `scripts/check-tls-parity.sh` on
//! the server covers it in the meantime.
//!
//! # Advisory, not a gate
//!
//! Results colour the UI; they never disable Launch. A preflight that is itself
//! wrong must not be able to lock the user out of their own game.
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
use std::time::Duration;
use crate::config::LauncherConfig;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Pass,
/// Genuinely wrong, but something else in the stack covers it, so the game
/// can still work. Kept distinct from [`State::Fail`] because a checker that
/// cries "this will fail" and is then contradicted by a working game teaches
/// the user to ignore it — which is worse than not checking at all.
Warn,
Fail,
/// Not configured, so there is nothing to assert. Never reported as a pass:
/// "we did not look" and "we looked and it was fine" must not look alike.
Skipped,
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: String,
pub state: State,
pub detail: String,
}
impl Check {
fn pass(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Pass,
detail: detail.into(),
}
}
fn fail(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Fail,
detail: detail.into(),
}
}
fn warn(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Warn,
detail: detail.into(),
}
}
fn skip(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Skipped,
detail: detail.into(),
}
}
}
/// Run every applicable check. Order is the order the game exercises them.
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(),
ea_redirect(cfg),
hostname_mapping(cfg),
backend_reachable(cfg),
hook_config(cfg),
]
}
/// Checks that will stop the game working.
pub fn failures(checks: &[Check]) -> usize {
checks.iter().filter(|c| c.state == State::Fail).count()
}
/// Checks that are wrong but survivable.
pub fn warnings(checks: &[Check]) -> usize {
checks.iter().filter(|c| c.state == State::Warn).count()
}
/// 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.
///
/// 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)";
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.
Err(_) => Check::skip(NAME, "Yama not present on this kernel"),
}
}
/// The decision, split from the file read so it can be tested.
///
/// Reading `/proc` in a test would assert facts about the machine running the
/// suite rather than about this code — and left inline, "any value is fine"
/// was a mutation no test could catch.
fn ptrace_verdict(raw: &str) -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
let v = raw.trim();
if v == "0" {
Check::pass(NAME, "0 — autopatch can attach")
} else {
Check::fail(
NAME,
format!("{v} — autopatch cannot patch FIFA. Click 'Arm client'."),
)
}
}
/// FIFA dials EA's redirector by hardcoded IP. Armed, that address is DNAT'd to
/// the OpenFUT server and connects instantly; unarmed it leaves the LAN and
/// times out — which is exactly the "servers have been shut down" dialog.
///
/// This tests the *effect* rather than reading firewall rules, so it needs no
/// privilege and stays honest about what the game will actually experience.
fn ea_redirect(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA redirector IP is redirected";
let ip = cfg.ea_redirect_probe_ip.trim();
if ip.is_empty() {
return Check::skip(NAME, "no probe IP configured");
}
let Ok(addr) = ip.parse::<IpAddr>() else {
return Check::fail(NAME, format!("ea_redirect_probe_ip is not an IP: {ip:?}"));
};
let port = cfg.openfut_blaze_redirector_port;
match TcpStream::connect_timeout(&SocketAddr::new(addr, port), PROBE_TIMEOUT) {
Ok(_) => Check::pass(NAME, format!("{ip}:{port} answered — redirect is in place")),
Err(e) => Check::fail(
NAME,
format!("{ip}:{port} did not answer ({e}). Click 'Arm client'."),
),
}
}
/// The dead EA hostnames should resolve to the OpenFUT server.
///
/// Resolution is done with `getaddrinfo`, the same call the game makes, so a
/// duplicate `/etc/hosts` line that shadows the OpenFUT one is caught by its
/// effect. Parsing `/etc/hosts` would miss it: the file can contain the right
/// line and still resolve to the wrong address, because the first match wins.
///
/// # Why a warning and not a failure
///
/// Measured, not assumed. On 2026-08-11 this reported `easw.easports.com ->
/// ::1,127.0.0.1` and the game reached the FUT hub regardless. The reason is in
/// `client_arm.sh`'s own header: the responders run with `OPENFUT_ADVERTISE`
/// set, so after the first redirected contact the game is handed the server's
/// *address* for every later hop and stops using the hostname. The name is only
/// CardsDLL's built-in fallback.
///
/// So this is a real misconfiguration worth fixing and not a reason to expect
/// failure. Reporting it as fatal, and then being contradicted by a working
/// game, is how a checklist trains its user to ignore it.
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA hostnames point at OpenFUT";
if cfg.ea_hostnames.is_empty() {
return Check::skip(NAME, "no EA hostnames configured");
}
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
return Check::skip(NAME, "no OpenFUT server configured");
}
let want = match resolve(server) {
Ok(ips) if !ips.is_empty() => ips,
_ => {
return Check::fail(
NAME,
format!("cannot resolve the OpenFUT server {server:?}"),
)
}
};
let mut wrong = Vec::new();
for host in &cfg.ea_hostnames {
match resolve(host) {
Ok(got) if got.iter().any(|ip| want.contains(ip)) => {}
Ok(got) => wrong.push(format!(
"{host} -> {} (expected {})",
join(&got),
join(&want)
)),
Err(e) => wrong.push(format!("{host} -> unresolvable ({e})")),
}
}
if wrong.is_empty() {
Check::pass(
NAME,
format!("{} host(s) resolve to {server}", cfg.ea_hostnames.len()),
)
} else {
Check::warn(
NAME,
format!(
"{}. Look for an earlier /etc/hosts line shadowing it. \
Usually survivable: the server advertises its address, so the \
game stops using this name after the first hop.",
wrong.join("; ")
),
)
}
}
/// The server side of the same question: are the ports the game will use open?
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() {
return Check::skip(NAME, "no OpenFUT server configured");
}
let ports = [
("blaze redirector", cfg.openfut_blaze_redirector_port),
("account sync", cfg.openfut_account_sync_port),
];
let mut dead = Vec::new();
for (label, port) in ports {
if !connects(host, port) {
dead.push(format!("{label} :{port}"));
}
}
if dead.is_empty() {
Check::pass(NAME, format!("{host}: all {} ports answering", ports.len()))
} else {
Check::fail(NAME, format!("{host}: no answer on {}", dead.join(", ")))
}
}
/// 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()),
Err(_) => false,
}
}
fn resolve(host: &str) -> std::io::Result<Vec<IpAddr>> {
Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect())
}
fn join(ips: &[IpAddr]) -> String {
ips.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> LauncherConfig {
LauncherConfig::default()
}
#[test]
fn an_unconfigured_launcher_skips_rather_than_passes() {
// 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.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:#?}"
);
assert_eq!(failures(&checks), 0, "nothing configured is not a failure");
}
#[test]
fn only_ptrace_scope_zero_lets_autopatch_work() {
assert_eq!(ptrace_verdict("0\n").state, State::Pass);
// 1 is the default on most distributions and is exactly the state that
// let autopatch fail silently for forty minutes on 2026-08-11.
assert_eq!(ptrace_verdict("1\n").state, State::Fail);
assert_eq!(ptrace_verdict("2").state, State::Fail);
assert_eq!(ptrace_verdict("3").state, State::Fail);
assert!(ptrace_verdict("1").detail.contains("Arm client"));
}
#[test]
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
let mut c = cfg();
c.ea_redirect_probe_ip = "not-an-ip".into();
let check = ea_redirect(&c);
assert_eq!(check.state, State::Fail);
assert!(check.detail.contains("not an IP"), "{}", check.detail);
}
#[test]
fn hostname_check_is_skipped_without_a_server_but_not_passed() {
let mut c = cfg();
c.ea_hostnames = vec!["easw.easports.com".into()];
assert_eq!(hostname_mapping(&c).state, State::Skipped);
}
#[test]
fn hostname_check_detects_a_host_pointing_somewhere_else() {
// localhost and 127.0.0.1 resolve without a network; this is the
// shadowed-/etc/hosts shape without depending on the real one.
let mut c = cfg();
c.openfut_server_host = "127.0.0.2".into();
c.ea_hostnames = vec!["localhost".into()];
let check = hostname_mapping(&c);
// Warn, not Fail: observed on 2026-08-11 to be survivable, because the
// server advertises its address after the first hop.
assert_eq!(check.state, State::Warn, "{}", check.detail);
assert!(check.detail.contains("localhost -> "), "{}", check.detail);
}
/// 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()];
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]
fn hostname_check_passes_when_it_points_at_the_server() {
let mut c = cfg();
c.openfut_server_host = "127.0.0.1".into();
c.ea_hostnames = vec!["localhost".into()];
// `localhost` may resolve to ::1 as well; the check requires only that
// one resolved address matches, which mirrors what connecting does.
assert_eq!(hostname_mapping(&c).state, State::Pass);
}
#[test]
fn a_dead_backend_port_is_reported_as_a_failure() {
let mut c = cfg();
c.openfut_server_host = "127.0.0.1".into();
// Port 1 requires root to bind, so nothing is listening on it.
c.openfut_blaze_redirector_port = 1;
c.openfut_account_sync_port = 1;
let check = backend_reachable(&c);
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);
}
}
-133
View File
@@ -1,133 +0,0 @@
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
sync::{Arc, Mutex},
thread,
};
use crate::logs::LogBuffer;
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
Stopped,
Starting,
Running,
Failed(String),
}
impl ServiceStatus {
pub fn label(&self) -> &str {
match self {
ServiceStatus::Stopped => "Stopped",
ServiceStatus::Starting => "Starting…",
ServiceStatus::Running => "Running",
ServiceStatus::Failed(_) => "Failed",
}
}
pub fn color(&self) -> egui::Color32 {
match self {
ServiceStatus::Running => egui::Color32::from_rgb(80, 200, 120),
ServiceStatus::Starting => egui::Color32::from_rgb(255, 200, 0),
ServiceStatus::Failed(_) => egui::Color32::from_rgb(220, 60, 60),
ServiceStatus::Stopped => egui::Color32::from_rgb(150, 150, 150),
}
}
}
pub struct ServiceHandle {
child: Option<Child>,
pub status: Arc<Mutex<ServiceStatus>>,
}
impl ServiceHandle {
pub fn new() -> Self {
Self {
child: None,
status: Arc::new(Mutex::new(ServiceStatus::Stopped)),
}
}
pub fn start(
&mut self,
binary: &str,
env_pairs: &[(String, String)],
log_buf: Arc<Mutex<LogBuffer>>,
) -> anyhow::Result<()> {
if self.is_running() {
return Ok(());
}
*self.status.lock().unwrap() = ServiceStatus::Starting;
let mut cmd = Command::new(binary);
for (k, v) in env_pairs {
cmd.env(k, v);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().inspect_err(|e| {
*self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string());
})?;
// Drain stdout
if let Some(stdout) = child.stdout.take() {
let buf = Arc::clone(&log_buf);
let status = Arc::clone(&self.status);
thread::spawn(move || {
*status.lock().unwrap() = ServiceStatus::Running;
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
// Drain stderr
if let Some(stderr) = child.stderr.take() {
let buf = Arc::clone(&log_buf);
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
self.child = Some(child);
Ok(())
}
pub fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
*self.status.lock().unwrap() = ServiceStatus::Stopped;
}
pub fn is_running(&mut self) -> bool {
if let Some(child) = &mut self.child {
match child.try_wait() {
Ok(Some(_)) => {
// process exited
self.child = None;
*self.status.lock().unwrap() = ServiceStatus::Stopped;
false
}
Ok(None) => true,
Err(_) => false,
}
} else {
false
}
}
pub fn status(&self) -> ServiceStatus {
self.status.lock().unwrap().clone()
}
}
impl Drop for ServiceHandle {
fn drop(&mut self) {
self.stop();
}
}
+94 -38
View File
@@ -1,25 +1,7 @@
use std::{path::{Path, PathBuf}, process::Command};
// ── Port 443 capability ───────────────────────────────────────────────────────
/// Check whether the bridge binary already has cap_net_bind_service set.
pub fn bridge_has_cap443(binary: &Path) -> bool {
std::process::Command::new("getcap")
.arg(binary)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("cap_net_bind_service"))
.unwrap_or(false)
}
/// Grant cap_net_bind_service to the bridge binary so it can bind port 443
/// without running as root. Uses pkexec (or sudo as fallback).
pub fn setcap_bridge_443(binary: &Path) -> anyhow::Result<()> {
let script = format!(
"setcap cap_net_bind_service=+ep '{}'",
binary.to_string_lossy()
);
run_elevated(&script)
}
use std::{
path::{Path, PathBuf},
process::Command,
};
// ── Cert installation ─────────────────────────────────────────────────────────
@@ -67,17 +49,13 @@ fn try_wine_certutil(cert_src: &Path) -> anyhow::Result<()> {
}
}
fn run_elevated(script: &str) -> anyhow::Result<()> {
let status = Command::new("pkexec")
.args(["sh", "-c", script])
.status();
pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
let status = Command::new("pkexec").args(["sh", "-c", script]).status();
match status {
Ok(s) if s.success() => Ok(()),
_ => {
let s = Command::new("sudo")
.args(["sh", "-c", script])
.status()?;
let s = Command::new("sudo").args(["sh", "-c", script]).status()?;
if s.success() {
Ok(())
} else {
@@ -89,11 +67,17 @@ 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 redirect IP the hook will use.
/// openfut.cfg with the structured server configuration the hook reads.
/// `cfg_contents` must be the full `openfut.cfg` body (see
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
/// address itself, so a missing server can never silently become loopback.
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
/// the system copy, so Proton picks up our local one first.
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
if !dll_src.exists() {
anyhow::bail!(
"Hook DLL not found at {}. Build it first with:\n\
@@ -104,20 +88,30 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> an
}
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"), redirect_ip)?;
std::fs::write(game_dir.join(HOOK_CFG_FILE), cfg_contents)?;
Ok(())
}
/// Update only openfut.cfg without redeploying the DLL.
pub fn update_hook_config(game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
let cfg = game_dir.join("openfut.cfg");
/// 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(HOOK_CFG_FILE);
if !cfg.exists() {
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
}
std::fs::write(cfg, redirect_ip)?;
std::fs::write(cfg, cfg_contents)?;
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");
@@ -134,5 +128,67 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
/// 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.
pub const STEAM_LAUNCH_OPTIONS: &str =
"WINEDLLOVERRIDES=\"version=n,b\" %command%";
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
// ── Game launch ───────────────────────────────────────────────────────────────
/// Launch the game via the user-provided shell command. Runs `sh -c <command>`
/// (optionally from `workdir`), streaming stdout+stderr into `log_buf` on a
/// background thread. The launcher does not assume Steam vs umu-run vs a custom
/// script — whatever the user configured is what runs.
pub fn launch_game(
command: &str,
workdir: &str,
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 Settings).");
}
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command);
if !workdir.trim().is_empty() {
cmd.current_dir(workdir);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
log_buf
.lock()
.push(format!("[launcher] launching game: {command}"));
let mut child = cmd.spawn()?;
if let Some(out) = child.stdout.take() {
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().push(line);
}
});
}
if let Some(err) = child.stderr.take() {
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().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. `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()
.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);
}