9 Commits

Author SHA1 Message Date
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
35 changed files with 5550 additions and 1580 deletions
-3
View File
@@ -4,6 +4,3 @@ target/
openfut.db
openfut.db-shm
openfut.db-wal
# hook cross-build test output
target-test/
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.
+6 -1
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"
@@ -21,7 +27,6 @@ probe = []
fifa17 = []
[dependencies]
openfut-common = { path = "../openfut-common" }
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_LibraryLoader",
+15 -37
View File
@@ -1,16 +1,20 @@
//! Load the configured OpenFUT host and destination ports from `openfut.cfg`.
//! Missing or invalid configuration is a hard error; there is no loopback
//! fallback. Both structured config and the legacy bare-host line are accepted
//! by `openfut-common`.
use openfut_common::{ConfigError, ServerConfig};
/// Reads openfut.cfg from the same directory as this DLL.
///
/// The file contains a single line: the IP the hook should redirect EA
/// hostnames to, e.g. "192.168.1.10" or "127.0.0.1".
/// Falls back to 127.0.0.1 if the file is missing or unreadable.
use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameA;
pub fn load_config(
module: windows_sys::Win32::Foundation::HMODULE,
) -> Result<ServerConfig, ConfigError> {
let cfg_path = config_path(module).ok_or(ConfigError::ConfigMissing)?;
let content = std::fs::read_to_string(&cfg_path).map_err(|_| ConfigError::ConfigMissing)?;
ServerConfig::parse(&content)
pub fn read_redirect_ip(module: windows_sys::Win32::Foundation::HMODULE) -> String {
if let Some(cfg_path) = config_path(module) {
if let Ok(content) = std::fs::read_to_string(&cfg_path) {
let ip = content.trim().to_string();
if !ip.is_empty() {
return ip;
}
}
}
"127.0.0.1".to_string()
}
fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::path::PathBuf> {
@@ -26,29 +30,3 @@ fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::p
let dll_path = std::path::Path::new(path);
Some(dll_path.parent()?.join("openfut.cfg"))
}
/// Read a raw feature-flag value (`key=value`) from `openfut.cfg` beside the DLL.
///
/// Returns the trimmed value, or `None` if the file or key is absent. This reads the
/// SAME config file as [`load_config`] but does NOT go through the strict
/// [`ServerConfig`] parser (which owns host/port validation and hard-errors on bad
/// input) — optional client feature flags must never be able to break server config.
pub fn feature_value(
module: windows_sys::Win32::Foundation::HMODULE,
key: &str,
) -> Option<String> {
let cfg_path = config_path(module)?;
let content = std::fs::read_to_string(&cfg_path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
return Some(v.trim().to_string());
}
}
}
None
}
+28 -93
View File
@@ -1,13 +1,13 @@
/// Hooks ws2_32!connect via inline detour (no iptables needed).
/// Uses unhook/rehook pattern: restores original bytes, calls real function, re-installs hook.
/// This avoids trampoline RIP-relocation issues entirely.
use std::sync::atomic::{AtomicU16, AtomicU32, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
const AF_INET: u16 = 2;
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
const PORT_FIFA17_BLAZE_REDIRECTOR_NBO: u16 = 0xF6A4; // 42230 big-endian
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
// EA App LSX. anadius handles :3216 in-process before it reaches the host TCP
// stack (keyed on port 3216 specifically), so redirecting FIFA's LSX connect to a
@@ -18,47 +18,7 @@ const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX)
#[allow(dead_code)]
const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target)
/// Redirect target for rewritten EA connects, stored in **network byte order**
/// (same layout as `sockaddr_in.sin_addr`). Zero means unconfigured and causes
/// redirect_if_ea to leave traffic untouched; there is no loopback fallback.
static TARGET_ADDR_NBO: AtomicU32 = AtomicU32::new(0);
static TARGET_HTTPS_PORT_NBO: AtomicU16 = AtomicU16::new(0);
static TARGET_BLAZE_REDIRECTOR_PORT_NBO: AtomicU16 = AtomicU16::new(0);
static TARGET_BLAZE_MAIN_PORT_NBO: AtomicU16 = AtomicU16::new(0);
/// Install the single resolved destination shared by every socket path.
pub fn set_server(server: openfut_common::ResolvedServer) {
TARGET_ADDR_NBO.store(
openfut_common::sin_addr_from_ipv4(server.redirect_ip),
Ordering::Relaxed,
);
TARGET_HTTPS_PORT_NBO.store(
openfut_common::sin_port_nbo(server.ports.https),
Ordering::Relaxed,
);
TARGET_BLAZE_REDIRECTOR_PORT_NBO.store(
openfut_common::sin_port_nbo(server.ports.blaze_redirector),
Ordering::Relaxed,
);
TARGET_BLAZE_MAIN_PORT_NBO.store(
openfut_common::sin_port_nbo(server.ports.blaze_main),
Ordering::Relaxed,
);
}
/// Current redirect target in network byte order.
fn target_addr_nbo() -> u32 {
TARGET_ADDR_NBO.load(Ordering::Relaxed)
}
/// Build the 16-byte IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) for the current
/// target, so an AF_INET6 socket reaches the same host as the AF_INET path.
fn target_v4mapped() -> [u8; 16] {
let o = target_addr_nbo().to_ne_bytes(); // a.b.c.d in memory order
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, o[0], o[1], o[2], o[3],
]
}
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
#[repr(C)]
struct SockaddrIn {
@@ -81,8 +41,12 @@ struct SockaddrIn6 {
sin6_scope_id: u32,
}
/// IPv4-mapped IPv6 loopback is no longer hardcoded — the v4-mapped target is
/// derived from the configurable `TARGET_ADDR_NBO` via `target_v4mapped()`.
/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to
/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's
/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own
/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not
/// `IPV6_V6ONLY` and will accept this target.
const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1];
// Address of ws2_32!connect (set at hook installation)
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
@@ -146,40 +110,30 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
let sa = &*(name as *const SockaddrIn);
let new_port_nbo = match sa.sin_port {
PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed),
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
#[cfg(not(feature = "capture_baseline"))]
PORT_LSX_NBO => PORT_LSX_TARGET_NBO,
PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => {
TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed)
}
PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed),
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
if new_port_nbo == 0 || target_addr_nbo() == 0 {
return None;
}
// sin_addr is network order; to_le_bytes gives memory order = the dotted
// quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed).
let o = sa.sin_addr.to_le_bytes();
let t = target_addr_nbo().to_ne_bytes();
crate::write_log(&format!(
"connect_hook: v4 {}.{}.{}.{}:{}{}.{}.{}.{}:{}\n",
"connect_hook: v4 {}.{}.{}.{}:{}127.0.0.1:{}\n",
o[0],
o[1],
o[2],
o[3],
u16::from_be(sa.sin_port),
t[0],
t[1],
t[2],
t[3],
u16::from_be(new_port_nbo)
));
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = target_addr_nbo();
out.sin_addr = ADDR_LOOPBACK_NBO;
Some((buf, 16))
}
AF_INET6 => {
@@ -190,16 +144,11 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
let sa6 = &*(name as *const SockaddrIn6);
// LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here.
let new_port_nbo = match sa6.sin6_port {
PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed),
PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => {
TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed)
}
PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed),
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
if new_port_nbo == 0 || target_addr_nbo() == 0 {
return None;
}
let a = sa6.sin6_addr;
crate::write_log(&format!(
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
@@ -215,7 +164,7 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
out.sin6_family = AF_INET6;
out.sin6_port = new_port_nbo;
out.sin6_flowinfo = 0;
out.sin6_addr = target_v4mapped();
out.sin6_addr = V4MAPPED_LOOPBACK;
out.sin6_scope_id = 0;
Some((buf, 28))
}
@@ -242,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,
@@ -266,17 +215,7 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
core::mem::transmute(addr);
f(s, buf.as_ptr(), len)
};
let wsa_error = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else {
0
};
write_hook(addr, hooked_connect as *const () as u64);
if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
WSASetLastError(wsa_error);
}
return r;
} else {
(name, namelen)
@@ -287,23 +226,19 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
f(s, call_name, call_len)
};
let wsa_error = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else {
0
};
write_hook(addr, hooked_connect as *const () as u64);
if namelen >= 8 {
let sa = &*(call_name as *const SockaddrIn);
if sa.sin_family == AF_INET {
crate::write_log(&format!("connect_hook: result={r} wsa_err={wsa_error}\n"));
let err = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else {
0
};
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
}
}
if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
WSASetLastError(wsa_error);
}
r
}
@@ -330,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,
};
+9 -100
View File
@@ -2,19 +2,13 @@
//!
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
//! of the FIFA-23 memory-layout-specific logic here (origin_spy, LSX dial, event
//! deserializer probes) — that would at best no-op and at worst crash.
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
//! no-op and at worst crash. For now this proves the version.dll hijack actually
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
//!
//! What it DOES do:
//! 1. Prove the version.dll hijack loads us into FIFA17.exe (module dump).
//! 2. Install the *generic*, memory-layout-independent network redirect:
//! ws2_32 `getaddrinfo` (EA host → configured server) and an inline
//! `connect` / `WSAConnect` detour (EA ports → bridge, dest → configured
//! server IP). These key on hostnames/ports only, not on FIFA-23 offsets,
//! so they are safe to reuse on FIFA 17.
//!
//! Not yet done (next milestone): DirtySDK/ProtoSSL cert-verify patch for the
//! secure Blaze handshake. The module dump locates the DLL that needs it.
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
use crate::write_log;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
@@ -76,7 +70,7 @@ unsafe fn dump_modules() {
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
/// other loader-touching calls are unsafe under the loader lock, so we defer them
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
unsafe extern "system" fn worker(param: *mut core::ffi::c_void) -> u32 {
unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
write_log("=== fifa17 hook: worker thread start ===\n");
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
let img = size_of_image(main_base);
@@ -84,28 +78,6 @@ unsafe extern "system" fn worker(param: *mut core::ffi::c_void) -> u32 {
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
));
dump_modules();
// Install the generic network redirect. `param` carries our own DLL's
// HMODULE so config::load_config can find openfut.cfg beside the DLL.
let dll_module = param as windows_sys::Win32::Foundation::HMODULE;
let server = match crate::config::load_config(dll_module).and_then(|c| c.resolve()) {
Ok(server) => server,
Err(e) => {
write_log(&format!(
"fifa17: invalid/missing openfut.cfg ({e}); network redirect DISABLED\n"
));
return 0;
}
};
write_log(&format!(
"fifa17: OpenFUT server={} https={} blaze_redir={} blaze_main={}\n",
server.redirect_ip,
server.ports.https,
server.ports.blaze_redirector,
server.ports.blaze_main
));
install_network_redirect(server);
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.
@@ -114,82 +86,19 @@ unsafe extern "system" fn worker(param: *mut core::ffi::c_void) -> u32 {
// It currently fails closed until safe relocating trampolines are proven.
crate::sbc_trace::install();
crate::sbc_request_trace::install();
// Empty-My-Packs Store fix (inert unless store_mypacks_fix=1 in openfut.cfg).
crate::store_hook::install(dll_module);
0
}
/// Install the generic network redirect (getaddrinfo + connect + WSAConnect).
///
/// `server` is the resolved host + configured destination ports from openfut.cfg.
/// two independent mechanisms, both keyed only on EA hostnames/ports (no
/// FIFA-version-specific memory layout):
/// - getaddrinfo: EA hostnames resolve to `redirect_ip`.
/// - connect/WSAConnect: EA source ports are remapped to the bridge ports and
/// the destination address is rewritten to `redirect_ip`.
///
/// If `redirect_ip` parses as an IPv4 literal, the connect detour rewrites the
/// destination directly (no DNS). When it is a hostname, getaddrinfo already
/// resolves it, and the connect detour falls back to leaving the resolved
/// address in place (only remapping the port).
unsafe fn install_network_redirect(server: openfut_common::ResolvedServer) {
let redirect_ip = server.redirect_ip.to_string();
// Resolver redirect: EA hostnames → configured server. Uses INLINE detours at
// the ws2_32 export addresses (getaddrinfo / GetAddrInfoW / gethostbyname),
// not IAT patching — the IAT approach patched 0 slots on FIFA 17 because the
// game doesn't import the resolver through its import table.
crate::hooks::set_redirect_ip(redirect_ip.clone());
let (ok, total) = crate::resolver_hook::install_resolver_hooks();
write_log(&format!(
"fifa17: resolver detours {ok}/{total} installed\n"
));
crate::connect_hook::set_server(server);
write_log(&format!(
"fifa17: connect target set to {} (https={} blaze_redir={} blaze_main={})\n",
server.redirect_ip,
server.ports.https,
server.ports.blaze_redirector,
server.ports.blaze_main
));
// Inline connect detour (port remap + destination rewrite).
if crate::connect_hook::install_inline_connect_hook() {
write_log("fifa17: connect inline-hooked\n");
} else {
write_log("fifa17: connect hook FAILED\n");
}
// WSAConnect IAT fallback (some EA paths use WSAConnect instead of connect).
let wp = crate::iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
if !wp.is_null() {
let f: unsafe extern "system" fn(
usize,
*const u8,
i32,
*const (),
*const (),
*const (),
*const (),
) -> i32 = core::mem::transmute(wp);
crate::connect_hook::set_real_wsa_connect(f);
crate::iat::patch_iat(wp, crate::connect_hook::hooked_wsa_connect as *const ());
write_log("fifa17: WSAConnect IAT patched\n");
}
}
/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker
/// thread and return immediately, so we never touch the loader lock from here.
/// `module` is our own DLL's HMODULE, passed to the worker so it can locate
/// openfut.cfg beside the DLL.
pub unsafe fn install(module: windows_sys::Win32::Foundation::HMODULE) {
pub unsafe fn install() {
use windows_sys::Win32::System::Threading::CreateThread;
write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n");
let h = CreateThread(
core::ptr::null(),
0,
Some(worker),
module as *const core::ffi::c_void,
core::ptr::null(),
0,
core::ptr::null_mut(),
);
+7 -21
View File
@@ -13,7 +13,6 @@ type GetaddrinfoFn =
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
static REDIRECT_IP_STR: OnceLock<String> = OnceLock::new();
// Flipped to true the first time we successfully apply the runtime cert patch.
// The patch is deferred to here (rather than DllMain) because EAWebKit.dll may
@@ -25,21 +24,9 @@ pub fn set_real(f: GetaddrinfoFn) {
}
pub fn set_redirect_ip(ip: String) {
let mut bytes = ip.clone().into_bytes();
let mut bytes = ip.into_bytes();
bytes.push(0);
let _ = REDIRECT_IP.set(bytes);
let _ = REDIRECT_IP_STR.set(ip);
}
/// The redirect IP as a NUL-terminated C string pointer, or None if unset.
/// Used by the resolver detours to rewrite an EA query's node name.
pub fn redirect_ip_cstr() -> Option<*const u8> {
REDIRECT_IP.get().map(|v| v.as_ptr())
}
/// The redirect IP as a Rust &str, or None if unset (for the wide/UTF-16 path).
pub fn redirect_ip_str() -> Option<&'static str> {
REDIRECT_IP_STR.get().map(|s| s.as_str())
}
/// Returns true if `host` is an EA / EA-Sports domain that should be redirected
@@ -81,13 +68,12 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
}
}
if let Some(redirect) = REDIRECT_IP.get() {
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
return real(redirect.as_ptr(), service_name, hints, result);
}
crate::write_log(
"openfut_hook: EA hostname seen without configured server; not redirecting\n",
);
let redirect = REDIRECT_IP
.get()
.map(|v| v.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);
}
}
}
+20 -16
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;
@@ -11,15 +20,12 @@ mod origin_spy;
mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
mod resolver_hook;
#[cfg(feature = "fifa17")]
mod sbc_hook;
#[cfg(feature = "fifa17")]
mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod store_hook;
mod ssl_patch;
mod tls_bypass;
mod transport_watch;
@@ -57,6 +63,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 {
@@ -74,8 +87,8 @@ unsafe fn install_hooks(module: HMODULE) {
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
#[cfg(feature = "fifa17")]
{
fifa17::install(module);
return;
let _ = module;
fifa17::install();
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
@@ -87,17 +100,8 @@ unsafe fn install_hooks_fifa23(module: HMODULE) {
// Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so
// the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity.
transport_watch::arm_from_env();
match config::load_config(module).and_then(|c| c.resolve()) {
Ok(server) => {
hooks::set_redirect_ip(server.redirect_ip.to_string());
connect_hook::set_server(server);
}
Err(e) => {
write_log(&format!(
"openfut_hook: invalid/missing openfut.cfg ({e}); redirection DISABLED\n"
));
}
}
let ip = config::read_redirect_ip(module);
hooks::set_redirect_ip(ip);
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
if !ga.is_null() {
-248
View File
@@ -1,248 +0,0 @@
//! DNS-resolver inline detours (getaddrinfo / GetAddrInfoW / gethostbyname).
//!
//! WHY THIS EXISTS (FIFA 17): the IAT approach in `hooks.rs` patched **0** slots on
//! FIFA 17 (`getaddrinfo IAT patched 0+0`) because the game does not import the
//! resolver through its import table — it resolves EA hostnames via a path the IAT
//! scan never covers (dynamic `GetProcAddress`, a statically-linked DirtySDK
//! resolver, or the legacy `gethostbyname`). An IAT patch can only rewrite callers
//! that go through the table, so it missed every real resolution.
//!
//! FIX: detour the resolver **at its export address** in ws2_32.dll, exactly like
//! `connect_hook` does for `connect`. An inline JMP at the function entry catches
//! *every* caller regardless of how it found the function. We use the same
//! unhook → call real → rehook pattern (no trampoline, no RIP relocation).
//!
//! We cover three resolvers:
//! - `getaddrinfo` (ANSI, modern)
//! - `GetAddrInfoW` (wide, modern) — EAWebKit/WinHTTP often use the W variant
//! - `gethostbyname` (legacy, DirtySDK-era) — returns a `hostent`
//!
//! On an EA hostname we rewrite the query node to the configured redirect IP so the
//! real resolver returns the bridge's address. The redirect IP string is owned by
//! `hooks` (set once via `hooks::set_redirect_ip`); we read it back through
//! `hooks::redirect_ip_cstr()`.
use std::ffi::CStr;
use std::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::Networking::WinSock::ADDRINFOA;
// ── EA host classifier (shared logic mirrors hooks::is_ea_host) ────────────────
fn is_ea_host(host: &str) -> bool {
let h = host.to_ascii_lowercase();
h.ends_with(".ea.com")
|| h == "ea.com"
|| h.ends_with(".easports.com")
|| h == "easports.com"
|| h.ends_with(".ugc.footapi.com")
|| h.ends_with(".footapi.com")
|| h.ends_with(".dice.se")
}
// ── getaddrinfo (ANSI) ────────────────────────────────────────────────────────
type GetaddrinfoFn =
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
static GAI_ADDR: AtomicUsize = AtomicUsize::new(0);
static mut GAI_ORIG: [u8; 14] = [0u8; 14];
// ── GetAddrInfoW (wide) ───────────────────────────────────────────────────────
type GetAddrInfoWFn = unsafe extern "system" fn(
*const u16,
*const u16,
*const core::ffi::c_void,
*mut *mut core::ffi::c_void,
) -> i32;
static GAIW_ADDR: AtomicUsize = AtomicUsize::new(0);
static mut GAIW_ORIG: [u8; 14] = [0u8; 14];
// ── gethostbyname (legacy) ────────────────────────────────────────────────────
type GethostbynameFn = unsafe extern "system" fn(*const u8) -> *mut core::ffi::c_void;
static GHBN_ADDR: AtomicUsize = AtomicUsize::new(0);
static mut GHBN_ORIG: [u8; 14] = [0u8; 14];
// ── inline-hook primitives (identical pattern to connect_hook) ────────────────
unsafe fn write_hook(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
// FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0u32);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
}
unsafe fn restore(target: *mut u8, orig: *const u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping(orig, target, 14);
VirtualProtect(target as _, 14, old, &mut old);
}
/// Save the first 14 bytes at `addr` into `orig`, store `addr`, and write the JMP.
unsafe fn install_one(addr: *mut u8, orig: *mut u8, slot: &AtomicUsize, hook: *const ()) -> bool {
if addr.is_null() {
return false;
}
core::ptr::copy_nonoverlapping(addr, orig, 14);
slot.store(addr as usize, Ordering::Relaxed);
write_hook(addr, hook as u64);
true
}
// ── hooked entry points ───────────────────────────────────────────────────────
pub unsafe extern "system" fn hooked_getaddrinfo(
node: *const u8,
service: *const u8,
hints: *const ADDRINFOA,
result: *mut *mut ADDRINFOA,
) -> i32 {
let addr = GAI_ADDR.load(Ordering::Relaxed) as *mut u8;
let mut redirected = node;
let redirect_cstr = crate::hooks::redirect_ip_cstr();
if !node.is_null() {
if let Ok(host) = CStr::from_ptr(node as *const i8).to_str() {
crate::write_log(&format!("resolver: getaddrinfo({host})\n"));
if is_ea_host(host) {
if let Some(ip) = redirect_cstr {
redirected = ip;
crate::write_log(&format!("resolver: getaddrinfo {host} → redirect\n"));
}
}
}
}
restore(addr, core::ptr::addr_of!(GAI_ORIG) as *const u8);
let r = {
let f: GetaddrinfoFn = core::mem::transmute(addr);
f(redirected, service, hints, result)
};
write_hook(addr, hooked_getaddrinfo as *const () as u64);
r
}
pub unsafe extern "system" fn hooked_getaddrinfo_w(
node: *const u16,
service: *const u16,
hints: *const core::ffi::c_void,
result: *mut *mut core::ffi::c_void,
) -> i32 {
let addr = GAIW_ADDR.load(Ordering::Relaxed) as *mut u8;
// Decode the wide hostname for classification/logging.
let mut redirected_buf: Vec<u16> = Vec::new();
let mut redirected = node;
if !node.is_null() {
let mut len = 0usize;
while *node.add(len) != 0 {
len += 1;
}
let host = String::from_utf16_lossy(core::slice::from_raw_parts(node, len));
crate::write_log(&format!("resolver: GetAddrInfoW({host})\n"));
if is_ea_host(&host) {
if let Some(ip) = crate::hooks::redirect_ip_str() {
redirected_buf = ip.encode_utf16().chain(core::iter::once(0)).collect();
redirected = redirected_buf.as_ptr();
crate::write_log(&format!("resolver: GetAddrInfoW {host} → redirect\n"));
}
}
}
restore(addr, core::ptr::addr_of!(GAIW_ORIG) as *const u8);
let r = {
let f: GetAddrInfoWFn = core::mem::transmute(addr);
f(redirected, service, hints, result)
};
write_hook(addr, hooked_getaddrinfo_w as *const () as u64);
// keep redirected_buf alive until after the call
drop(redirected_buf);
r
}
pub unsafe extern "system" fn hooked_gethostbyname(name: *const u8) -> *mut core::ffi::c_void {
let addr = GHBN_ADDR.load(Ordering::Relaxed) as *mut u8;
let mut redirected = name;
let redirect_cstr = crate::hooks::redirect_ip_cstr();
if !name.is_null() {
if let Ok(host) = CStr::from_ptr(name as *const i8).to_str() {
crate::write_log(&format!("resolver: gethostbyname({host})\n"));
if is_ea_host(host) {
if let Some(ip) = redirect_cstr {
redirected = ip;
crate::write_log(&format!("resolver: gethostbyname {host} → redirect\n"));
}
}
}
}
restore(addr, core::ptr::addr_of!(GHBN_ORIG) as *const u8);
let r = {
let f: GethostbynameFn = core::mem::transmute(addr);
f(redirected)
};
write_hook(addr, hooked_gethostbyname as *const () as u64);
r
}
// ── installer ─────────────────────────────────────────────────────────────────
/// Install inline detours on all three resolvers. Returns a (ok, total) count for
/// logging. Safe to call once from the fifa17 worker after ws2_32 is loaded.
pub unsafe fn install_resolver_hooks() -> (u32, u32) {
let mut ok = 0u32;
let total = 3u32;
let gai = crate::iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0") as *mut u8;
if install_one(
gai,
core::ptr::addr_of_mut!(GAI_ORIG) as *mut u8,
&GAI_ADDR,
hooked_getaddrinfo as *const (),
) {
ok += 1;
crate::write_log("resolver: getaddrinfo inline-hooked\n");
} else {
crate::write_log("resolver: getaddrinfo resolve FAILED\n");
}
let gaiw = crate::iat::resolve(b"ws2_32.dll\0", b"GetAddrInfoW\0") as *mut u8;
if install_one(
gaiw,
core::ptr::addr_of_mut!(GAIW_ORIG) as *mut u8,
&GAIW_ADDR,
hooked_getaddrinfo_w as *const (),
) {
ok += 1;
crate::write_log("resolver: GetAddrInfoW inline-hooked\n");
} else {
crate::write_log("resolver: GetAddrInfoW resolve FAILED\n");
}
let ghbn = crate::iat::resolve(b"ws2_32.dll\0", b"gethostbyname\0") as *mut u8;
if install_one(
ghbn,
core::ptr::addr_of_mut!(GHBN_ORIG) as *mut u8,
&GHBN_ADDR,
hooked_gethostbyname as *const (),
) {
ok += 1;
crate::write_log("resolver: gethostbyname inline-hooked\n");
} else {
crate::write_log("resolver: gethostbyname resolve FAILED\n");
}
(ok, total)
}
+2 -2
View File
@@ -313,12 +313,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;
}
+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;
}
+3 -3
View File
@@ -839,7 +839,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 +926,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 +1029,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;
}
+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)
}
-445
View File
@@ -1,445 +0,0 @@
//! FIFA 17 empty-"My Packs" client fix (config flag `store_mypacks_fix=1`).
//!
//! ## What this does
//! When the account owns **zero unopened packs**, FIFA 17's Store still selects the
//! "My Packs" category on open. CardsDLL's category resolver (`FUN_1800147f0` →
//! `FUN_180014420`) then looks up the My-Packs group ordinal and, if no such group
//! exists, dereferences a NULL group pointer → crash (`0x180014882`, read of `0x48`).
//! The backend currently avoids this with an active placeholder pack (sentinel 65534)
//! that leaves a fake empty tile.
//!
//! This hook removes the need for that sentinel *for a validated build*: it detours the
//! Store render entry `FUN_18007dab0` and, **only when the requested category is My Packs
//! AND the client's unopened-pack count is 0**, rewrites the requested category id at
//! `screen+0x290` to `0` (list-all = "Browse Packs"). The Store then opens on Browse
//! Packs, never resolves the absent My-Packs group, and neither crashes nor shows a fake
//! tile. With a real unopened pack (count > 0) nothing is changed and My Packs works
//! normally.
//!
//! ## Safety model
//! - **Inert unless enabled**: reads `store_mypacks_fix` from `openfut.cfg`; default OFF.
//! - **Validated build only**: refuses to install unless CardsDLL matches the known FIFA
//! 17 build (PE timestamp + SizeOfImage + a slide-proof control prologue + the target
//! function's own prologue signature). An unknown build → no patch, log, and the
//! backend sentinel remains the fallback.
//! - **Deferred**: CardsDLL loads lazily on entering Ultimate Team, so we poll off the
//! loader lock, exactly like `sbc_hook`.
//! - **Fail-safe count**: if the unopened-pack count cannot be read, we DO NOT redirect
//! (leave the category unchanged and call the original) — never a forced Browse.
//! - **Inline detour**: same proven `unhook → call real → rehook` primitive as
//! `resolver_hook`/`connect_hook` (no trampoline, no RIP relocation).
//!
//! Addresses are RVAs (static VA image base `0x180000000`); see
//! `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART II for the disassembly evidence.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::Foundation::HMODULE;
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_READONLY,
PAGE_READWRITE, PAGE_WRITECOPY,
};
// ── Build identity (verified against CardsDLL_Win64_retail.dll 4706a881…) ────────
const IMAGE_BASE: usize = 0x1_8000_0000;
const PE_TIMESTAMP: u32 = 1_497_050_156; // 2017-06-09T23:15:56Z
const SIZE_OF_IMAGE: u32 = 0x31d000;
/// Slide-proof FNV-hasher control prologue at VA 0x180180d00 (same control sbc_hook uses).
const CTRL_RVA: usize = 0x180d00;
const CTRL_BYTES: [u8; 12] = [
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
];
// ── Target + helper RVAs ─────────────────────────────────────────────────────────
/// FUN_18007dab0 — Store render entry (Flash message 0x753f). arg0 = store screen (RCX).
const RENDER_RVA: usize = 0x7dab0;
/// First 14 bytes of FUN_18007dab0 (PUSH RDI; SUB RSP,0x40; MOV [RSP+0x30],-2 …).
/// Doubles as the target-site signature and the bytes we save/restore for the detour.
const RENDER_PROLOGUE: [u8; 14] = [
0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff,
];
/// FUN_180014580(store, tab) → category id (1-based group ordinal, or -1 if absent).
const TABMAP_RVA: usize = 0x14580;
/// FUN_1800d7170() → registry (no args).
const REGISTRY_GETTER_RVA: usize = 0xd7170;
/// FUN_180009c80(out, registry, 0, 0) → writes the data-manager singleton into *out.
const MANAGER_GETTER_RVA: usize = 0x9c80;
/// manager->vtbl[+0x4d8]() → unopened-pack count (i32).
const UNOPENED_COUNT_VSLOT: usize = 0x4d8;
/// manager->vtbl[+0x08]() → release.
const RELEASE_VSLOT: usize = 0x08;
/// screen+0x290 = requested CATEGORY_ID (movie-written; the resolver's input).
const SCREEN_CATEGORY_OFF: usize = 0x290;
/// FUN_180014580 tab index for "mypacks".
const MYPACKS_TAB: u32 = 0;
/// Category 0 = list-all group tiles = "Browse Packs".
const CAT_BROWSE: i32 = 0;
// ── State ────────────────────────────────────────────────────────────────────────
static ENABLED: AtomicBool = AtomicBool::new(false);
static INSTALLED: AtomicBool = AtomicBool::new(false);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static RENDER_ADDR: AtomicUsize = AtomicUsize::new(0);
static mut RENDER_ORIG: [u8; 14] = [0u8; 14];
// ── Internal CardsDLL function types (MS x64 ABI) ─────────────────────────────────
type RegistryGetterFn = unsafe extern "system" fn() -> usize;
type ManagerGetterFn = unsafe extern "system" fn(*mut usize, usize, usize, usize) -> *mut usize;
type TabMapFn = unsafe extern "system" fn(usize, u32) -> u32;
type CountGetterFn = unsafe extern "system" fn(usize) -> i32;
type ReleaseFn = unsafe extern "system" fn(usize);
type RenderFn = unsafe extern "system" fn(usize) -> usize;
// ── Pure decision (host-testable; the correctness core) ───────────────────────────
/// Redirect the Store to Browse Packs iff the feature is enabled, the requested
/// category is exactly the My-Packs category, and the client owns zero unopened packs.
/// A `None` count (read failed) is treated as "do not redirect".
fn should_redirect(enabled: bool, count: Option<i32>, requested: i32, mypacks: i32) -> bool {
enabled && requested == mypacks && count == Some(0)
}
// ── Guarded memory access (no blind dereferences) ─────────────────────────────────
unsafe fn readable(ptr: usize, len: usize) -> bool {
if ptr < 0x1_0000 || len == 0 {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT {
return false;
}
let prot = mbi.Protect;
if prot & PAGE_GUARD != 0 || prot == PAGE_NOACCESS {
return false;
}
const READABLE: u32 = PAGE_READONLY
| PAGE_READWRITE
| PAGE_WRITECOPY
| PAGE_EXECUTE_READ
| PAGE_EXECUTE_READWRITE
| PAGE_EXECUTE_WRITECOPY;
if prot & READABLE == 0 {
return false;
}
let region_end = mbi.BaseAddress as usize + mbi.RegionSize;
ptr.checked_add(len).is_some_and(|end| end <= region_end)
}
unsafe fn writable(ptr: usize, len: usize) -> bool {
if ptr < 0x1_0000 || len == 0 {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT {
return false;
}
let prot = mbi.Protect;
if prot & PAGE_GUARD != 0 {
return false;
}
const WRITABLE: u32 =
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
if prot & WRITABLE == 0 {
return false;
}
let region_end = mbi.BaseAddress as usize + mbi.RegionSize;
ptr.checked_add(len).is_some_and(|end| end <= region_end)
}
unsafe fn executable(ptr: usize) -> bool {
if ptr < 0x1_0000 {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT {
return false;
}
const EXEC: u32 = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
mbi.Protect & PAGE_GUARD == 0 && mbi.Protect & EXEC != 0
}
unsafe fn read_u8(ptr: usize) -> Option<u8> {
readable(ptr, 1).then(|| *(ptr as *const u8))
}
unsafe fn read_u32(ptr: usize) -> Option<u32> {
(ptr & 3 == 0 && readable(ptr, 4)).then(|| *(ptr as *const u32))
}
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
(ptr & 7 == 0 && readable(ptr, 8)).then(|| *(ptr as *const usize))
}
unsafe fn bytes_match(addr: usize, want: &[u8]) -> bool {
want.iter()
.enumerate()
.all(|(i, &b)| read_u8(addr + i) == Some(b))
}
// ── Inline-hook primitive (identical to resolver_hook/connect_hook) ───────────────
unsafe fn write_hook(target: *mut u8, dest: u64) {
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
// FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0u32);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
}
unsafe fn restore(target: *mut u8, orig: *const u8) {
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping(orig, target, 14);
VirtualProtect(target as _, 14, old, &mut old);
}
// ── Runtime helpers ────────────────────────────────────────────────────────────
unsafe fn resolve_cards_base() -> usize {
let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr() as *const u8);
if !h.is_null() {
return h as usize;
}
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr() as *const u8);
if !h2.is_null() {
return h2 as usize;
}
0
}
/// Read the client's unopened-pack count via the data-manager singleton
/// (`registry → manager → vtbl[0x4d8]`), releasing the manager afterwards. Returns
/// `None` on any unreadable pointer/vtable so the caller never redirects on a bad read.
unsafe fn read_unopened_count(base: usize) -> Option<i32> {
if !executable(base + REGISTRY_GETTER_RVA) || !executable(base + MANAGER_GETTER_RVA) {
return None;
}
let registry_getter: RegistryGetterFn = core::mem::transmute(base + REGISTRY_GETTER_RVA);
let registry = registry_getter();
if registry == 0 {
return None;
}
let manager_getter: ManagerGetterFn = core::mem::transmute(base + MANAGER_GETTER_RVA);
let mut out: usize = 0;
manager_getter(&mut out, registry, 0, 0);
let manager = out;
if manager == 0 {
return None;
}
let vtbl = read_ptr(manager)?;
let count_fn = read_ptr(vtbl + UNOPENED_COUNT_VSLOT)?;
let release_fn = read_ptr(vtbl + RELEASE_VSLOT)?;
if !executable(count_fn) || !executable(release_fn) {
return None;
}
let getter: CountGetterFn = core::mem::transmute(count_fn);
let count = getter(manager);
let release: ReleaseFn = core::mem::transmute(release_fn);
release(manager);
Some(count)
}
/// The redirect decision + write, executed before the original render runs.
unsafe fn maybe_redirect(store: usize) {
if store == 0 {
return;
}
let base = CARDS_BASE.load(Ordering::Relaxed);
if base == 0 {
return;
}
let cat_ptr = store + SCREEN_CATEGORY_OFF;
if !readable(cat_ptr, 4) {
return;
}
let requested = *(cat_ptr as *const i32);
if !executable(base + TABMAP_RVA) {
return;
}
let tabmap: TabMapFn = core::mem::transmute(base + TABMAP_RVA);
let mypacks_id = tabmap(store, MYPACKS_TAB) as i32;
// Only pay for the count read when the requested category is actually My Packs.
if requested != mypacks_id {
return;
}
let count = read_unopened_count(base);
if should_redirect(
ENABLED.load(Ordering::Relaxed),
count,
requested,
mypacks_id,
) {
if writable(cat_ptr, 4) {
*(cat_ptr as *mut i32) = CAT_BROWSE;
crate::write_log("[store-hook] zero unopened packs: My Packs -> Browse Packs\n");
} else {
crate::write_log("[store-hook] category slot not writable; left unchanged\n");
}
}
// requested == mypacks with count > 0 or unknown: leave My Packs unchanged.
}
pub unsafe extern "system" fn hooked_render(store: usize) -> usize {
let addr = RENDER_ADDR.load(Ordering::Relaxed) as *mut u8;
if addr.is_null() {
return 0;
}
maybe_redirect(store);
restore(addr, core::ptr::addr_of!(RENDER_ORIG) as *const u8);
let r = {
let f: RenderFn = core::mem::transmute(addr as *const ());
f(store)
};
write_hook(addr, hooked_render as *const () as u64);
r
}
// ── Build guard + install ─────────────────────────────────────────────────────
unsafe fn build_supported(base: usize) -> bool {
let fail = |why: &str| {
crate::write_log(&format!(
"[store-hook] CardsDLL build UNSUPPORTED ({why}); not installing (backend sentinel remains)\n"
));
false
};
let Some(e_lfanew) = read_u32(base + 0x3c) else {
return fail("PE header unreadable");
};
let pe = base + e_lfanew as usize;
if read_u32(pe) != Some(0x0000_4550) {
return fail("PE signature");
}
if read_u32(pe + 8) != Some(PE_TIMESTAMP) {
return fail("PE timestamp");
}
if read_u32(pe + 24 + 0x38) != Some(SIZE_OF_IMAGE) {
return fail("SizeOfImage");
}
if !bytes_match(base + CTRL_RVA, &CTRL_BYTES) {
return fail("control prologue");
}
if !bytes_match(base + RENDER_RVA, &RENDER_PROLOGUE) {
return fail("FUN_18007dab0 prologue");
}
true
}
/// Deferred worker: CardsDLL loads only on entering Ultimate Team, so poll for it
/// (≤5 min) off the loader lock, then validate the build and install the detour once.
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = resolve_cards_base();
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 {
crate::write_log("[store-hook] CardsDLL never loaded; hook not installed\n");
return;
}
let slide = base.wrapping_sub(IMAGE_BASE);
crate::write_log(&format!(
"[store-hook] CardsDLL base={base:#x} slide={slide:#x}; validating build\n"
));
if !build_supported(base) {
return;
}
CARDS_BASE.store(base, Ordering::Relaxed);
let render = base + RENDER_RVA;
core::ptr::copy_nonoverlapping(
render as *const u8,
core::ptr::addr_of_mut!(RENDER_ORIG) as *mut u8,
14,
);
RENDER_ADDR.store(render, Ordering::Relaxed);
write_hook(render as *mut u8, hooked_render as *const () as u64);
INSTALLED.store(true, Ordering::Relaxed);
crate::write_log(&format!(
"[store-hook] build supported; installed at CardsDLL+{RENDER_RVA:#x} (VA {render:#x})\n"
));
}
/// Public entry, called from `fifa17::worker`. Reads `store_mypacks_fix` from
/// `openfut.cfg`; if enabled, spawns the deferred CardsDLL-load worker. Fully inert
/// otherwise (no thread, no patch).
pub fn install(module: HMODULE) {
let enabled = match crate::config::feature_value(module, "store_mypacks_fix").as_deref() {
Some("1") => true,
Some("0") | None => false,
Some(other) => {
crate::write_log(&format!(
"[store-hook] invalid store_mypacks_fix={other:?}; feature disabled\n"
));
false
}
};
ENABLED.store(enabled, Ordering::Relaxed);
if !enabled {
crate::write_log("[store-hook] disabled (set store_mypacks_fix=1 in openfut.cfg)\n");
return;
}
crate::write_log("[store-hook] enabled; deferring until CardsDLL loads\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::should_redirect;
#[test]
fn disabled_never_redirects() {
assert!(!should_redirect(false, Some(0), 3, 3));
}
#[test]
fn enabled_zero_mypacks_redirects() {
assert!(should_redirect(true, Some(0), 3, 3));
}
#[test]
fn enabled_one_pack_keeps_mypacks() {
assert!(!should_redirect(true, Some(1), 3, 3));
}
#[test]
fn enabled_zero_browse_untouched() {
// requested Browse (0) != mypacks ordinal (3)
assert!(!should_redirect(true, Some(0), 0, 3));
}
#[test]
fn enabled_zero_other_tab_untouched() {
// e.g. bronze ordinal 4 != mypacks 3
assert!(!should_redirect(true, Some(0), 4, 3));
}
#[test]
fn enabled_unknown_count_does_not_redirect() {
assert!(!should_redirect(true, None, 3, 3));
}
#[test]
fn enabled_zero_absent_mypacks_group_redirects() {
// With no sentinel, both the requested id and mypacks id are -1 (group absent).
assert!(should_redirect(true, Some(0), -1, -1));
}
}
+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();
}
}
+1787 -414
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"));
}
}
+657 -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,104 @@ 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>,
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
// box and cannot move to the server: the LSX Origin emulator (the game dials
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
// in the server container; these two stay here.
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
/// Empty means the local-services feature is unconfigured and its controls
/// stay disabled.
#[serde(default)]
pub fifa17_tools_dir: String,
/// Python interpreter used to run the local companion services.
#[serde(default = "default_python")]
pub fifa17_python: String,
}
fn default_python() -> String {
"python3".to_string()
}
fn default_https_port() -> u16 {
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 +250,32 @@ 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(),
fifa17_tools_dir: base
.join("fifa17-recon/tools")
.to_string_lossy()
.into(),
fifa17_python: default_python(),
}
}
}
@@ -88,22 +306,443 @@ 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 the client-local FIFA 17 service configuration. Filesystem
/// existence is checked by the process launcher immediately before spawn;
/// this ensures required user configuration is never silently invented.
pub fn validate_local_services(&self) -> Result<(), String> {
if self.fifa17_tools_dir.trim().is_empty() {
return Err("No FIFA 17 tools dir configured. Set it in Settings.".into());
}
if self.fifa17_python.trim().is_empty() {
return Err("No Python interpreter configured. Set it in Settings.".into());
}
Ok(())
}
/// Validate every configuration value required by the one-button FIFA 17
/// launch path. Runtime state such as hook deployment is checked by the UI.
pub fn validate_launch_config(&self) -> Result<(), String> {
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(),
);
}
self.validate_local_services()
}
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 local_services_require_tools_dir_and_python() {
let mut c = LauncherConfig::default();
c.fifa17_tools_dir.clear();
assert!(c
.validate_local_services()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
c.fifa17_python.clear();
assert!(c.validate_local_services().unwrap_err().contains("Python"));
}
#[test]
fn local_services_accept_explicit_configuration() {
let c = LauncherConfig {
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default()
};
assert!(c.validate_local_services().is_ok());
}
#[test]
fn launch_config_requires_server_local_services_and_command() {
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();
c.fifa17_tools_dir.clear();
assert!(c
.validate_launch_config()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
c.fifa17_python = "/usr/bin/python3".into();
assert!(c.validate_launch_config().is_ok());
}
/// 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(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".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(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".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();
}
}
+450
View File
@@ -0,0 +1,450 @@
//! 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::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.
pub fn launch(profile: &GameProfile, log: &Log) -> 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);
}
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.");
Ok(())
}
/// 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) {
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());
});
}
#[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}");
}
}
+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,
}
}
+421
View File
@@ -0,0 +1,421 @@
//! 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,
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;
#[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, 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 responder script filename inside the tools dir.
fn script(self) -> &'static str {
match self {
Service::Lsx => "lsx_responder_v2.py",
Service::Autopatch => "autopatch.py",
}
}
}
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
let mut args = vec![tools_dir
.join(service.script())
.to_string_lossy()
.into_owned()];
if service == Service::Autopatch {
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
}
CommandParts {
program: python.to_string(),
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()
}
/// 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>>,
}
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
/// Returns an error (without spawning) if the tools dir or script is missing.
///
/// `capability` is the backend-registration wiring + shared per-FIFA-process
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
/// verified resolver guard) and `None` for LSX.
pub fn spawn(
service: Service,
python: &str,
tools_dir: &str,
persona_id: u64,
persona_name: &str,
capability: Option<CapabilityWiring>,
log: Arc<Mutex<LogBuffer>>,
) -> anyhow::Result<Child> {
use std::io::{BufRead, BufReader};
let dir = Path::new(tools_dir);
if !dir.is_dir() {
anyhow::bail!(
"FIFA 17 tools dir not found: {} (set it in Settings)",
dir.display()
);
}
let script_path = dir.join(service.script());
if !script_path.exists() {
anyhow::bail!(
"{} not found in tools dir: {}",
service.script(),
script_path.display()
);
}
let label = service.label();
// Both services use the configured interpreter and absolute script path;
// neither invents a Python installation path. Autopatch receives launcher
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
// block startup.
let parts = command_parts(service, python, dir);
let mut cmd = Command::new(&parts.program);
cmd.args(&parts.args);
if service == Service::Lsx {
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
.env("FUT_PERSONA_NAME", persona_name);
} else if service == Service::Autopatch {
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.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
log.lock().push(format!(
"[launcher] starting {label}: {} {}",
python,
script_path.display(),
));
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
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 = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
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_python_directly() {
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
assert_eq!(parts.program, "/usr/bin/python3");
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
}
#[test]
fn autopatch_runs_python_directly_with_launcher_ownership() {
let parts = command_parts(
Service::Autopatch,
"/usr/bin/python3",
Path::new("/tmp/tools"),
);
assert_eq!(parts.program, "/usr/bin/python3");
assert_eq!(
parts.args,
vec![
"/tmp/tools/autopatch.py",
"--launcher-pid",
&std::process::id().to_string(),
]
);
}
#[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"));
}
}
+99 -3
View File
@@ -1,15 +1,26 @@
mod account_monitor;
mod account_sync;
mod app;
mod arm;
mod config;
mod fifa17_capability;
mod game_launch;
mod health;
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 +30,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\""))
}
+498
View File
@@ -0,0 +1,498 @@
//! 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(cfg),
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.
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
// does not mean the tools are installed. Key off the directory actually
// existing: that is what decides whether autopatch will run at all, and it
// keeps this from failing on a machine that never uses local services.
let tools = cfg.fifa17_tools_dir.trim();
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
return Check::skip(NAME, "no local services installed");
}
match std::fs::read_to_string(PTRACE_SCOPE) {
Ok(v) => ptrace_verdict(&v),
// Not every kernel has Yama. Absent means unenforced, which is what we want.
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?
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".
let mut c = cfg();
// `default()` points these at conventional paths whose existence varies
// by machine. Pin them so the assertion is about the code, not this box.
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
let checks = run(&c);
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 ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
// Regression: the gate used to be "is the field non-empty", and the
// field has a default — so this check ran (and failed) on machines that
// never use autopatch at all.
let mut c = cfg();
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
let mut c = cfg();
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 ptrace_check_is_skipped_when_local_services_are_not_configured() {
let mut c = cfg();
c.fifa17_tools_dir.clear();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_dead_backend_port_is_reported_as_a_failure() {
let mut c = cfg();
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();
}
}
+90 -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,63 @@ 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>>,
) -> 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.
std::thread::spawn(move || {
let _ = child.wait();
log_buf
.lock()
.push("[launcher] game process exited.".to_string());
});
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);
}