4 Commits

Author SHA1 Message Date
funman300 966e92b304 fix(launcher): run LSX locally on Windows (only autopatch is in-process)
The prior Windows branch treated BOTH companions as in-process and started
neither. That is wrong for LSX: FIFA dials the Origin/LSX emulator on
127.0.0.1:4216 and it must run locally on the client (the STEAMPUNKS
stp-origin_emu.dll is the crack's activation emu, not OpenFUT's LSX). Only
autopatch is genuinely in-process on Windows (its ProtoSSL cert patch is done by
the version.dll hook), so skip just that one and spawn LSX through the normal
path. Also resolve the companion as openfut-lsx.exe on Windows.
2026-08-20 19:57:16 +00:00
funman300 cf515f5584 fix(launcher): continuous vsync-paced present for stable VRR
The 60fps cap still left 16ms gaps with no present; on windowed G-Sync/FreeSync
DWM keeps moving the window in and out of the VRR path across those gaps and the
refresh rate swings, which the panel shows as flicker. Render continuously
(request_repaint every frame) with vsync on so the window stays continuously in
VRR at the display's own variable refresh.
2026-08-20 19:38:42 +00:00
funman300 6be75f5452 fix(launcher): steady 60fps cadence to stop VRR/G-Sync flicker
The idle repaint was 500ms (~2fps), below the G-Sync/FreeSync VRR floor, so the
panel ran low-framerate compensation and every hover/animation spiked then
dropped the rate — the swinging refresh rate makes VRR displays flicker. Present
at a constant ~60fps (16ms) instead so VRR locks to one rate. Cheap for a UI
this small; vsync keeps present times regular.
2026-08-20 19:35:17 +00:00
funman300 057cf92c3b feat(launcher): native Windows support
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:

- game_launch: cfg(windows) launch spawns the game executable directly with its
  working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
  Wine prefix, or licence regen). Requires the launcher to run elevated so the
  child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
  step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
  version.dll hook), so ensure_running reports ready without spawning. Gated the
  unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.

theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.

Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
2026-08-20 19:21:01 +00:00
14 changed files with 239 additions and 456 deletions
Generated
-1
View File
@@ -2293,7 +2293,6 @@ dependencies = [
"eframe", "eframe",
"egui", "egui",
"openfut-common", "openfut-common",
"parking_lot",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
+2 -56
View File
@@ -53,12 +53,6 @@ pub mod default_ports {
pub const BLAZE_REDIRECTOR: u16 = 42127; pub const BLAZE_REDIRECTOR: u16 = 42127;
/// OpenFUT FIFA 17 Blaze main listener. /// OpenFUT FIFA 17 Blaze main listener.
pub const BLAZE_MAIN: u16 = 42130; pub const BLAZE_MAIN: u16 = 42130;
/// OpenFUT FUT web-file (CDN) content server. Unlike the others this is not
/// an EA redirect target: the client never dials it directly, because its
/// `RS4::ServerSettings` CDN base arrives EMPTY in the emulator. The hook
/// supplies the missing `<base>/fut/` prefix, and the base is built from the
/// configured server host plus this port.
pub const FUT_CONTENT: u16 = 8110;
} }
/// OpenFUT destination ports. Each field is where an intercepted EA source port /// OpenFUT destination ports. Each field is where an intercepted EA source port
@@ -72,9 +66,6 @@ pub struct OpenFutPorts {
pub blaze_redirector: u16, pub blaze_redirector: u16,
/// Destination for EA :42127 traffic (Blaze main). /// Destination for EA :42127 traffic (Blaze main).
pub blaze_main: u16, pub blaze_main: u16,
/// FUT web-file content server. Not a redirect destination — see
/// [`default_ports::FUT_CONTENT`].
pub fut_content: u16,
} }
impl Default for OpenFutPorts { impl Default for OpenFutPorts {
@@ -83,7 +74,6 @@ impl Default for OpenFutPorts {
https: default_ports::HTTPS, https: default_ports::HTTPS,
blaze_redirector: default_ports::BLAZE_REDIRECTOR, blaze_redirector: default_ports::BLAZE_REDIRECTOR,
blaze_main: default_ports::BLAZE_MAIN, blaze_main: default_ports::BLAZE_MAIN,
fut_content: default_ports::FUT_CONTENT,
} }
} }
} }
@@ -219,7 +209,6 @@ impl ServerConfig {
"https_port" => ports.https = parse_port(value)?, "https_port" => ports.https = parse_port(value)?,
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?, "blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
"blaze_main_port" => ports.blaze_main = parse_port(value)?, "blaze_main_port" => ports.blaze_main = parse_port(value)?,
"fut_content_port" => ports.fut_content = parse_port(value)?,
other => { other => {
return Err(ConfigError::MalformedConfig(format!( return Err(ConfigError::MalformedConfig(format!(
"line {}: unknown key '{other}'", "line {}: unknown key '{other}'",
@@ -236,27 +225,8 @@ impl ServerConfig {
/// Serialize to the structured `openfut.cfg` format. /// Serialize to the structured `openfut.cfg` format.
pub fn to_cfg_string(&self) -> String { pub fn to_cfg_string(&self) -> String {
format!( format!(
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\nfut_content_port={}\n", "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
self.host, self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
self.ports.https,
self.ports.blaze_redirector,
self.ports.blaze_main,
self.ports.fut_content
)
}
/// Base URL the FUT web-file (CDN) prefix is built from, e.g.
/// `http://10.10.0.120:8110/fut/`.
///
/// The client's `RS4::ServerSettings` CDN base arrives EMPTY in the emulator,
/// so FUT web-file urls reach the download entry point as bare relative paths
/// and fail. The hook supplies this prefix. Built from the SAME configured
/// host as every other redirect, so a lab address is never compiled in.
pub fn fut_content_base(&self) -> String {
format!(
"http://{}:{}/fut/",
self.host.trim(),
self.ports.fut_content
) )
} }
@@ -444,36 +414,12 @@ mod tests {
https: 8443, https: 8443,
blaze_redirector: 10041, blaze_redirector: 10041,
blaze_main: 42127, blaze_main: 42127,
fut_content: 8110,
}, },
}; };
let s = c.to_cfg_string(); let s = c.to_cfg_string();
assert_eq!(ServerConfig::parse(&s).unwrap(), c); assert_eq!(ServerConfig::parse(&s).unwrap(), c);
} }
/// The FUT web-file prefix follows the CONFIGURED server, so no lab address
/// is ever compiled into the hook.
#[test]
fn fut_content_base_follows_the_configured_host() {
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
assert_eq!(c.fut_content_base(), "http://192.168.1.50:8110/fut/");
let c = ServerConfig::parse("host=fut.mylan.home\nfut_content_port=9110\n").unwrap();
assert_eq!(c.fut_content_base(), "http://fut.mylan.home:9110/fut/");
}
/// A cfg written before `fut_content_port` existed must still parse, taking
/// the default rather than failing the whole config (which would disarm the
/// network redirect too).
#[test]
fn cfg_without_content_port_takes_the_default() {
let c = ServerConfig::parse(
"host=10.0.0.5\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
)
.unwrap();
assert_eq!(c.ports.fut_content, default_ports::FUT_CONTENT);
}
#[test] #[test]
fn configured_ipv4_becomes_correct_sockaddr() { fn configured_ipv4_becomes_correct_sockaddr() {
// Resolve an IPv4 literal and confirm the sin_addr value. // Resolve an IPv4 literal and confirm the sin_addr value.
-5
View File
@@ -2,15 +2,10 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "openfut-common"
version = "0.1.0"
[[package]] [[package]]
name = "openfut-hook" name = "openfut-hook"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"openfut-common",
"windows-sys", "windows-sys",
] ]
-4
View File
@@ -39,10 +39,6 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_Debug",
"Win32_System_Kernel", "Win32_System_Kernel",
] } ] }
# Single source of truth for the OpenFUT redirect config (openfut.cfg schema,
# EA-port -> OpenFUT-port map, WinSock byte-order helpers). Shared with the
# launcher so the hook and openfut.cfg agree by construction.
openfut-common = { path = "../openfut-common" }
[profile.release] [profile.release]
opt-level = "s" opt-level = "s"
+62 -360
View File
@@ -13,12 +13,8 @@
//! (no rip-relative / rel32 in the copied bytes). //! (no rip-relative / rel32 in the copied bytes).
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::OnceLock;
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::Diagnostics::Debug::{
AddVectoredExceptionHandler, EXCEPTION_POINTERS,
};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{ use windows_sys::Win32::System::Memory::{
VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ, VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ,
@@ -141,35 +137,15 @@ macro_rules! season_call_trace {
}; };
} }
season_call_trace!( season_call_trace!(load_current_native_wrapper, LOAD_CURRENT_NATIVE_TRAMP, "LoadCurrentOfflineSeason_native");
load_current_native_wrapper, season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native");
LOAD_CURRENT_NATIVE_TRAMP, season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native");
"LoadCurrentOfflineSeason_native"
);
season_call_trace!(
start_season_native_wrapper,
START_SEASON_NATIVE_TRAMP,
"StartSeason_native"
);
season_call_trace!(
get_info_native_wrapper,
GET_INFO_NATIVE_TRAMP,
"GetOfflineSeasonInfo_native"
);
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason // Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
// actually calls; hands the callback name to the manager's async slot 0x80. // actually calls; hands the callback name to the manager's async slot 0x80.
season_call_trace!( season_call_trace!(load_offline_real_wrapper, LOAD_OFFLINE_REAL_TRAMP, "LoadOfflineSeasons_native(0x4ee10)");
load_offline_real_wrapper,
LOAD_OFFLINE_REAL_TRAMP,
"LoadOfflineSeasons_native(0x4ee10)"
);
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season // Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
// count and invokes the LoadSeasons_Complete AS callback. // count and invokes the LoadSeasons_Complete AS callback.
season_call_trace!( season_call_trace!(load_offline_async_wrapper, LOAD_OFFLINE_ASYNC_TRAMP, "LoadOfflineSeasons_asyncimpl(0x57560)");
load_offline_async_wrapper,
LOAD_OFFLINE_ASYNC_TRAMP,
"LoadOfflineSeasons_asyncimpl(0x57560)"
);
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks // LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId // and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
@@ -210,12 +186,7 @@ unsafe extern "system" fn load_current_impl_wrapper(
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm // Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
// whether it ever fires; logs the result fields it branches on. // whether it ever fires; logs the result fields it branches on.
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn completion_wrapper( unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed); let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 { if n < 64 {
let status = rd_i32(result + 0x1c); let status = rd_i32(result + 0x1c);
@@ -241,11 +212,7 @@ unsafe extern "system" fn completion_wrapper(
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for // this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`, // the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
// so it needs the relocating installer below. // so it needs the relocating installer below.
season_call_trace!( season_call_trace!(get_users_division_wrapper, GET_USERS_DIVISION_TRAMP, "GetUsersOfflineDivision_native(0x4eb50)");
get_users_division_wrapper,
GET_USERS_DIVISION_TRAMP,
"GetUsersOfflineDivision_native(0x4eb50)"
);
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into /// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
/// CardsDLL data still fits after we relocate a copied prologue into it. /// CardsDLL data still fits after we relocate a copied prologue into it.
@@ -299,9 +266,7 @@ unsafe fn install_detour_reloc(
let jump = absolute_jump(wrapper); let jump = absolute_jump(wrapper);
let tramp_len = copy_len + jump.len(); let tramp_len = copy_len + jump.len();
let Some(tramp) = alloc_near(base, tramp_len) else { let Some(tramp) = alloc_near(base, tramp_len) else {
write_log(&format!( write_log(&format!("SEASON_TRACE: {name}: near trampoline alloc failed\n"));
"SEASON_TRACE: {name}: near trampoline alloc failed\n"
));
return false; return false;
}; };
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len); core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
@@ -310,9 +275,7 @@ unsafe fn install_detour_reloc(
let abs_target = target as i64 + insn_end as i64 + orig_disp; let abs_target = target as i64 + insn_end as i64 + orig_disp;
let new_disp = abs_target - (tramp as i64 + insn_end as i64); let new_disp = abs_target - (tramp as i64 + insn_end as i64);
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 { if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
write_log(&format!( write_log(&format!("SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"));
"SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"
));
return false; return false;
} }
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32); core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
@@ -320,9 +283,7 @@ unsafe fn install_detour_reloc(
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len()); core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
let mut old = 0u32; let mut old = 0u32;
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 { if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
write_log(&format!( write_log(&format!("SEASON_TRACE: {name}: trampoline protect failed\n"));
"SEASON_TRACE: {name}: trampoline protect failed\n"
));
return false; return false;
} }
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len); FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
@@ -355,12 +316,7 @@ unsafe fn install_detour_reloc(
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error // completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive. // string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn final_completion_wrapper( unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
// Read the delivered status: byte0==0 => failure with an error string at +8. // Read the delivered status: byte0==0 => failure with an error string at +8.
let flag = rd_u8(result); let flag = rd_u8(result);
let errstr = if flag == Some(0) { let errstr = if flag == Some(0) {
@@ -385,24 +341,22 @@ unsafe extern "system" fn final_completion_wrapper(
Some(_) => "SUCCESS", Some(_) => "SUCCESS",
None => "??", None => "??",
}; };
let shown = if flag == Some(0) { let shown = if flag == Some(0) { errstr.as_str() } else { "SUCCESS" };
errstr.as_str()
} else {
"SUCCESS"
};
write_log(&format!( write_log(&format!(
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n" "SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
)); ));
} }
// Base-supply experiment: the CACHE_PACKNAMES failure is expected to be fixed // Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
// by the WEBFILE base-supply (the real file now downloads), so the guarded // failure to SUCCESS so the offline-season load advances to
// success-forcing bypass is DISABLED — a recurring CACHE_PACKNAMES here means // LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
// the base-supply did not take effect and MUST NOT be masked. // once per process; verified by the error string before touching memory.
if flag == Some(0) if flag == Some(0)
&& errstr.contains("CACHE_PACKNAMES") && errstr.contains("CACHE_PACKNAMES")
&& readable_range(result, 1)
&& !BYPASS_DONE.swap(true, Ordering::AcqRel) && !BYPASS_DONE.swap(true, Ordering::AcqRel)
{ {
write_log("SEASONS_BYPASS: DISABLED (base-supply active); CACHE_PACKNAMES not masked\n"); core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n");
} }
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire); let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 { if t == 0 {
@@ -417,23 +371,14 @@ unsafe extern "system" fn final_completion_wrapper(
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains // "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
// the next async stage. Logs whether the first async stage succeeded. Passive. // the next async stage. Logs whether the first async stage succeeded. Passive.
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn stage1_completion_wrapper( unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize, r8: usize, r9: usize) -> usize {
param1: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed); let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 { if n < 64 {
if result == 0 { if result == 0 {
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n"); write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
} else { } else {
let status = rd_i32(result + 0x1c); let status = rd_i32(result + 0x1c);
let verdict = if status == Some(0) { let verdict = if status == Some(0) { "ok(chain next)" } else { "CACHE_PACKNAMES_FAILED" };
"ok(chain next)"
} else {
"CACHE_PACKNAMES_FAILED"
};
write_log(&format!( write_log(&format!(
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n", "SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()), status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
@@ -450,63 +395,14 @@ unsafe extern "system" fn stage1_completion_wrapper(
} }
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string // WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
// URL of the pack-names / cards-tournament-list web file. Its prologue has a // URL of the pack-names/cards-tournament-list web file. Passive capture. Its
// rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating installer // prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
// (disp32 at copied offset 7, instruction end 11). // installer (disp32 at copied offset 7, instruction end 11).
//
// BASE-SUPPLY: the client's RS4::ServerSettings CDN base (DAT_1802e6408+0x30) is
// EMPTY in the emulator — FUN_180124270 only sets it when the OSDK getter
// slot0x3f8 is non-empty, and it has no default (unlike the API base). So every
// FUT WEBFILE url arrives here as a BARE relative path and 999s (client
// sentinel). We supply the missing intended `<CDN>/fut/` prefix so the REAL file
// downloads and parses. This is a data-supply, NOT a success-forcing bypass;
// absolute urls (containing "://", e.g. the "http://sbc/..." tile route) pass
// through untouched.
//
// The prefix comes from `openfut.cfg` via `openfut-common`, the same single
// source of truth as every redirect target, so no lab address is compiled in.
// Unset (config missing/unusable) means NO rewrite: a url is left exactly as the
// client built it rather than pointed at a guessed host.
static FUT_CONTENT_BASE: OnceLock<String> = OnceLock::new();
/// Arm the FUT web-file prefix from the resolved configuration. Idempotent: the
/// first call wins.
pub(crate) fn set_fut_content_base(base: String) {
let _ = FUT_CONTENT_BASE.set(base);
}
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0); static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn url_capture_wrapper( unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
let orig = rd_cstr(rcx, 256);
let mut arg_rcx = rcx;
// Owned buffer that stays alive across the original() call below. The caller
// frees its own url buffer immediately after FUN_18017ff90 returns, so the
// client copies the url synchronously during the call — a local buffer is
// sufficient and nothing is leaked.
let mut full: Vec<u8> = Vec::new();
if let Some(base) = FUT_CONTENT_BASE.get() {
if !orig.is_empty() && !orig.contains("://") {
full.extend_from_slice(base.as_bytes());
full.extend_from_slice(orig.trim_start_matches('/').as_bytes());
full.push(0); // NUL terminator for the C-string
arg_rcx = full.as_ptr() as usize;
}
}
let n = REPORTS.fetch_add(1, Ordering::Relaxed); let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 { if n < 64 {
if arg_rcx != rcx { write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256)));
write_log(&format!(
"SEASONS_WEBFILE_URL: orig={orig:?} rewritten={:?}\n",
rd_cstr(arg_rcx, 256)
));
} else {
write_log(&format!("SEASONS_WEBFILE_URL: url={orig:?} (unchanged)\n"));
}
} }
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire); let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
if t == 0 { if t == 0 {
@@ -514,80 +410,7 @@ unsafe extern "system" fn url_capture_wrapper(
} }
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t); core::mem::transmute(t);
let ret = original(arg_rcx, rdx, r8, r9); original(rcx, rdx, r8, r9)
drop(full); // ensure the url buffer outlives the download-start call
ret
}
// ───────────────────────── crash locator (VEH) ──────────────────────────────
// A vectored exception handler that logs the faulting code/address/module for
// fatal exceptions, then lets the crash proceed (EXCEPTION_CONTINUE_SEARCH). It
// pinpoints the StartSeason crash: whether it is a CardsDLL season-data
// null-deref (fixable by supplying matches/opponents) or an engine/other fault.
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static CARDS_SIZE: AtomicUsize = AtomicUsize::new(0);
static CRASH_LOGS: AtomicUsize = AtomicUsize::new(0);
const EXCEPTION_CONTINUE_SEARCH: i32 = 0;
/// OptionalHeader.SizeOfImage from the module's PE headers (fallback 64 MiB).
unsafe fn cards_image_size(base: usize) -> usize {
if !readable_range(base + 0x3c, 4) {
return 0x0400_0000;
}
let e_lfanew = core::ptr::read_volatile((base + 0x3c) as *const u32) as usize;
let so_off = base + e_lfanew + 0x50; // NT header + OptionalHeader.SizeOfImage
if !readable_range(so_off, 4) {
return 0x0400_0000;
}
core::ptr::read_volatile(so_off as *const u32) as usize
}
unsafe extern "system" fn crash_logger(info: *mut EXCEPTION_POINTERS) -> i32 {
if info.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let rec = (*info).ExceptionRecord;
if rec.is_null() {
return EXCEPTION_CONTINUE_SEARCH;
}
let code = (*rec).ExceptionCode as u32;
// Only fatal codes; skip the many benign first-chance SEH exceptions.
let interesting = matches!(
code,
0xC000_0005 // access violation
| 0xC000_001D // illegal instruction
| 0xC000_0094 // integer divide by zero
| 0xC000_00FD // stack overflow
| 0xC000_0025 // noncontinuable exception
);
if !interesting || CRASH_LOGS.fetch_add(1, Ordering::Relaxed) >= 8 {
return EXCEPTION_CONTINUE_SEARCH;
}
let addr = (*rec).ExceptionAddress as usize;
let base = CARDS_BASE.load(Ordering::Acquire);
let size = CARDS_SIZE.load(Ordering::Acquire);
let module = if base != 0 && addr >= base && addr < base + size {
format!("CardsDLL+{:#x}", addr - base)
} else {
"other".to_string()
};
let (kind, fault) = if code == 0xC000_0005 && (*rec).NumberParameters >= 2 {
let op = (*rec).ExceptionInformation[0];
let fa = (*rec).ExceptionInformation[1];
let k = match op {
0 => "read",
1 => "write",
8 => "exec",
_ => "?",
};
(k, fa)
} else {
("", 0usize)
};
write_log(&format!(
"SEASON_CRASH: code={code:#010x} at={addr:#x} module={module} access={kind} fault_addr={fault:#x}\n"
));
EXCEPTION_CONTINUE_SEARCH
} }
unsafe fn worker() { unsafe fn worker() {
@@ -603,144 +426,63 @@ unsafe fn worker() {
write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n"); write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n");
return; return;
} }
CARDS_BASE.store(base, Ordering::Release);
CARDS_SIZE.store(cards_image_size(base), Ordering::Release);
AddVectoredExceptionHandler(1, Some(crash_logger));
write_log("SEASON_TRACE: crash logger (VEH) armed\n");
// (rva, name, copy_len, signature, wrapper, trampoline slot) // (rva, name, copy_len, signature, wrapper, trampoline slot)
install_detour( install_detour(
base, base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15,
0x4eb70, &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
"LoadCurrentOfflineSeason_native", load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP,
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_current_native_wrapper as *const () as usize,
&LOAD_CURRENT_NATIVE_TRAMP,
); );
install_detour( install_detour(
base, base, 0x4f340, "StartSeason_native", 15,
0x4f340, &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
"StartSeason_native", start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP,
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
start_season_native_wrapper as *const () as usize,
&START_SEASON_NATIVE_TRAMP,
); );
install_detour( install_detour(
base, base, 0x4e850, "GetOfflineSeasonInfo_native", 15,
0x4e850, &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18],
"GetOfflineSeasonInfo_native", get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP,
15,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24,
0x18,
],
get_info_native_wrapper as *const () as usize,
&GET_INFO_NATIVE_TRAMP,
); );
install_detour( install_detour(
base, base, 0x57230, "LoadCurrentOfflineSeason_impl", 19,
0x57230, &[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff],
"LoadCurrentOfflineSeason_impl", load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP,
19,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40,
0x98, 0xfe, 0xff, 0xff, 0xff,
],
load_current_impl_wrapper as *const () as usize,
&LOAD_CURRENT_IMPL_TRAMP,
); );
install_detour( install_detour(
base, base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16,
0x578e0, &[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff],
"LoadCurrentOfflineSeason_completion", completion_wrapper as *const () as usize, &COMPLETION_TRAMP,
16,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff,
0xff, 0xff,
],
completion_wrapper as *const () as usize,
&COMPLETION_TRAMP,
); );
install_detour_reloc( install_detour_reloc(
base, base, 0x4eb50, "GetUsersOfflineDivision_native", 14,
0x4eb50, &[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01],
"GetUsersOfflineDivision_native", 7, 11,
14, get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP,
&[
0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01,
],
7,
11,
get_users_division_wrapper as *const () as usize,
&GET_USERS_DIVISION_TRAMP,
); );
install_detour( install_detour(
base, base, 0x4ee10, "LoadOfflineSeasons_native", 15,
0x4ee10, &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
"LoadOfflineSeasons_native", load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP,
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_offline_real_wrapper as *const () as usize,
&LOAD_OFFLINE_REAL_TRAMP,
); );
install_detour( install_detour(
base, base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17,
0x57560, &[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
"LoadOfflineSeasons_asyncimpl", load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP,
17,
&[
0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
0xff, 0xff, 0xff,
],
load_offline_async_wrapper as *const () as usize,
&LOAD_OFFLINE_ASYNC_TRAMP,
); );
install_detour( install_detour(
base, base, 0xffe90, "LoadOfflineSeasons_final_completion", 16,
0xffe90, &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda],
"LoadOfflineSeasons_final_completion", final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP,
16,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48,
0x8b, 0xda,
],
final_completion_wrapper as *const () as usize,
&FINAL_COMPLETION_TRAMP,
); );
install_detour( install_detour(
base, base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15,
0x106240, &[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00],
"LoadOfflineSeasons_stage1_completion", stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP,
15,
&[
0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00,
0x00,
],
stage1_completion_wrapper as *const () as usize,
&STAGE1_COMPLETION_TRAMP,
); );
install_detour_reloc( install_detour_reloc(
base, base, 0x17ff90, "start_webfile_dl_url", 14,
0x17ff90, &[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1],
"start_webfile_dl_url", 7, 11,
14, url_capture_wrapper as *const () as usize, &URL_CAPTURE_TRAMP,
&[
0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1,
],
7,
11,
url_capture_wrapper as *const () as usize,
&URL_CAPTURE_TRAMP,
); );
write_log("SEASON_TRACE: all season-native traces armed\n"); write_log("SEASON_TRACE: all season-native traces armed\n");
} }
@@ -748,46 +490,6 @@ unsafe fn worker() {
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not /// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior. /// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() { pub(crate) fn install() {
arm_fut_content_base();
write_log("SEASON_TRACE: requested; deferred signature validation starting\n"); write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() }); std::thread::spawn(|| unsafe { worker() });
} }
/// Resolve the FUT web-file prefix from `openfut.cfg` next to the game exe, via
/// the shared `openfut-common` parser — the same single source of truth the
/// network redirect uses, so the lab address is never compiled in.
///
/// Fails SAFE: an absent or unusable config arms nothing, and the url rewriter
/// then leaves every url exactly as the client built it.
fn arm_fut_content_base() {
let path = match std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("openfut.cfg")))
{
Some(p) => p,
None => {
write_log("SEASONS_WEBFILE_BASE: cannot locate openfut.cfg — no url rewrite\n");
return;
}
};
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
write_log(&format!(
"SEASONS_WEBFILE_BASE: {} unreadable ({e}) — no url rewrite\n",
path.display()
));
return;
}
};
match openfut_common::ServerConfig::parse(&contents) {
Ok(cfg) => {
let base = cfg.fut_content_base();
write_log(&format!("SEASONS_WEBFILE_BASE: armed {base}\n"));
set_fut_content_base(base);
}
Err(e) => write_log(&format!(
"SEASONS_WEBFILE_BASE: openfut.cfg unusable ({e}) — no url rewrite\n"
)),
}
}
+1 -3
View File
@@ -439,9 +439,7 @@ unsafe fn worker() {
pub(crate) fn install() { pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates. // Promoted: armed by the build. No environment variable participates.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release); REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log( crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n");
"STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n",
);
std::thread::spawn(|| unsafe { worker() }); std::thread::spawn(|| unsafe { worker() });
} }
+9 -1
View File
@@ -1804,7 +1804,15 @@ impl LauncherApp {
impl eframe::App for LauncherApp { impl eframe::App for LauncherApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.request_repaint_after(std::time::Duration::from_millis(500)); // Render continuously (present every vsync) instead of reactively. egui
// normally idles at a low, bursty repaint rate; on a G-Sync / FreeSync
// (VRR) display a windowed app that presents in bursts with idle gaps
// makes DWM keep moving the window in and out of the VRR path and the
// refresh rate swing — which the panel shows as flicker. Presenting on
// every frame keeps the window continuously in VRR at the display's own
// (variable) refresh, which is stable. vsync (on by default) paces this to
// the monitor rather than spinning uncapped.
ctx.request_repaint();
self.drive_restart_queue(); self.drive_restart_queue();
egui::TopBottomPanel::top("header") egui::TopBottomPanel::top("header")
+14 -1
View File
@@ -25,6 +25,7 @@ use crate::config::LauncherConfig;
/// Accept only hostname/IP characters. These values come from config fields that /// 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 /// 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. /// rather than try to escape it into an elevated shell command.
#[cfg(unix)]
fn safe_host(s: &str) -> anyhow::Result<&str> { fn safe_host(s: &str) -> anyhow::Result<&str> {
let t = s.trim(); let t = s.trim();
if t.is_empty() { if t.is_empty() {
@@ -41,6 +42,7 @@ fn safe_host(s: &str) -> anyhow::Result<&str> {
/// Build the privileged arming script. Pure and unit-tested; the effectful part /// Build the privileged arming script. Pure and unit-tested; the effectful part
/// ([`arm`]) only validates config and hands this to the elevated runner. /// ([`arm`]) only validates config and hands this to the elevated runner.
#[cfg(unix)]
pub(crate) fn arming_script( pub(crate) fn arming_script(
server: &str, server: &str,
redirector_port: u16, redirector_port: u16,
@@ -84,6 +86,7 @@ pub(crate) fn arming_script(
/// Human-readable list of what [`arm`] changed, in the order the script applies /// 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 /// it. Logged by the UI so the user sees exactly what was set — not just that
/// "something" ran under `pkexec`. /// "something" ran under `pkexec`.
#[cfg(unix)]
pub(crate) fn arming_summary( pub(crate) fn arming_summary(
server: &str, server: &str,
redirector_port: u16, redirector_port: u16,
@@ -103,6 +106,16 @@ pub(crate) fn arming_summary(
/// Arm the client from config, under one elevated prompt. Requires the same /// 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 /// fields preflight reads; a missing one is a clear error, never a silent
/// loopback fallback. Returns the applied changes for the UI to surface. /// loopback fallback. Returns the applied changes for the UI to surface.
/// On native Windows there is nothing to arm: routing is the `openfut.cfg` the
/// client-files step writes into the game directory (read by the version.dll
/// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns
/// no changes so the launch sequence treats client preparation as satisfied.
#[cfg(windows)]
pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
#[cfg(unix)]
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> { pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim(); let server = cfg.openfut_server_host.trim();
if server.is_empty() { if server.is_empty() {
@@ -128,7 +141,7 @@ pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
)) ))
} }
#[cfg(test)] #[cfg(all(test, unix))]
mod tests { mod tests {
use super::*; use super::*;
+28 -19
View File
@@ -54,13 +54,18 @@ pub struct GameProfile {
impl GameProfile { impl GameProfile {
/// Whether this profile is filled in enough to launch from. /// Whether this profile is filled in enough to launch from.
pub fn configured(&self) -> bool { pub fn configured(&self) -> bool {
!self.runner.trim().is_empty() // Windows starts the executable directly (no runner); unix needs a
&& !self.executable.trim().is_empty() // runner such as umu-run.
&& !self.game_dir.trim().is_empty() #[cfg(windows)]
let runner_ok = true;
#[cfg(unix)]
let runner_ok = !self.runner.trim().is_empty();
runner_ok && !self.executable.trim().is_empty() && !self.game_dir.trim().is_empty()
} }
/// Reject a half-filled profile rather than launching something surprising. /// Reject a half-filled profile rather than launching something surprising.
pub fn validate(&self) -> Result<(), String> { pub fn validate(&self) -> Result<(), String> {
#[cfg(unix)]
if self.runner.trim().is_empty() { if self.runner.trim().is_empty() {
return Err("Game profile has no runner (e.g. umu-run).".into()); return Err("Game profile has no runner (e.g. umu-run).".into());
} }
@@ -70,23 +75,28 @@ impl GameProfile {
if self.game_dir.trim().is_empty() { if self.game_dir.trim().is_empty() {
return Err("Game profile has no game directory.".into()); return Err("Game profile has no game directory.".into());
} }
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() { // Wine-prefix links and the DRM licence precondition only exist on the
return Err("Game profile defines prefix links but no wine_prefix.".into()); // unix/Proton launch path; native Windows has neither.
} #[cfg(unix)]
for l in &self.prefix_links { {
if l.link.trim().is_empty() || l.target.trim().is_empty() { if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile has a prefix link with an empty link or target.".into()); return Err("Game profile defines prefix links but no wine_prefix.".into());
} }
if std::path::Path::new(&l.link).is_absolute() { for l in &self.prefix_links {
return Err(format!( if l.link.trim().is_empty() || l.target.trim().is_empty() {
"Prefix link {:?} must be relative to the Wine prefix.", return Err("Game profile has a prefix link with an empty link or target.".into());
l.link }
)); 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 let Some(lic) = &self.license { if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() { return Err("Game profile licence needs both a path and a generator.".into());
return Err("Game profile licence needs both a path and a generator.".into()); }
} }
} }
Ok(()) Ok(())
@@ -314,7 +324,6 @@ impl LauncherConfig {
https: self.openfut_https_port, https: self.openfut_https_port,
blaze_redirector: self.openfut_blaze_redirector_port, blaze_redirector: self.openfut_blaze_redirector_port,
blaze_main: self.openfut_blaze_main_port, blaze_main: self.openfut_blaze_main_port,
fut_content: openfut_common::default_ports::FUT_CONTENT,
}, },
} }
} }
+72 -2
View File
@@ -24,11 +24,13 @@
//! falls back to it, so an existing working setup cannot be broken by upgrading. //! falls back to it, so an existing working setup cannot be broken by upgrading.
use parking_lot::Mutex; use parking_lot::Mutex;
#[cfg(unix)]
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
#[cfg(unix)]
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::config::GameProfile; use crate::config::GameProfile;
@@ -45,6 +47,7 @@ fn say(log: &Log, msg: impl Into<String>) {
/// Returns once the game process has been spawned; its output continues to /// Returns once the game process has been spawned; its output continues to
/// stream into `log` on background threads. `on_exit` fires when the process /// stream into `log` on background threads. `on_exit` fires when the process
/// ends, which is how the launch state machine leaves its Running state. /// ends, which is how the launch state machine leaves its Running state.
#[cfg(unix)]
pub fn launch( pub fn launch(
profile: &GameProfile, profile: &GameProfile,
log: &Log, log: &Log,
@@ -96,6 +99,62 @@ pub fn launch(
Ok(()) Ok(())
} }
/// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads
/// the `version.dll` hook from its own directory through the normal search
/// order), and no licence regeneration (the native loader handles DRM).
/// Routing is the `openfut.cfg` that the client-files step already wrote into
/// the game directory.
///
/// The launcher must itself be running elevated (its shortcut carries the
/// RunAsAdmin bit): the loader requires administrator rights, and a child
/// started with `CreateProcess` inherits the launcher's token instead of
/// raising its own UAC prompt.
#[cfg(windows)]
pub fn launch(
profile: &GameProfile,
log: &Log,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> {
profile.validate().map_err(anyhow::Error::msg)?;
let game_dir = PathBuf::from(&profile.game_dir);
if !game_dir.is_dir() {
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
}
let exe = game_dir.join(&profile.executable);
if !exe.is_file() {
anyhow::bail!("game executable not found: {}", exe.display());
}
let mut cmd = Command::new(&exe);
cmd.current_dir(&game_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &profile.env {
cmd.env(k, v);
}
say(
log,
format!(
"[launcher] launching {} (cwd {})",
exe.display(),
game_dir.display()
),
);
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?;
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
/// The registry key Wine reads DLL overrides from, and the one value the hook needs. /// The registry key Wine reads DLL overrides from, and the one value the hook needs.
/// ///
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the /// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
@@ -110,13 +169,17 @@ pub fn launch(
/// survives restarts and applies to every launch path, including Steam. This mirrors /// survives restarts and applies to every launch path, including Steam. This mirrors
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the /// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
/// environment) and what Proton itself already does in this prefix for other titles. /// environment) and what Proton itself already does in this prefix for other titles.
#[cfg(unix)]
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides"; const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
#[cfg(unix)]
const HOOK_DLL_VALUE: &str = "version"; const HOOK_DLL_VALUE: &str = "version";
#[cfg(unix)]
const HOOK_DLL_OVERRIDE: &str = "native,builtin"; const HOOK_DLL_OVERRIDE: &str = "native,builtin";
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin /// `reg add` argv that persists the hook's DLL override, native-first with a builtin
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and /// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
/// repairs a prefix a player has reset or replaced. /// repairs a prefix a player has reset or replaced.
#[cfg(unix)]
fn dll_override_args() -> [&'static str; 10] { fn dll_override_args() -> [&'static str; 10] {
[ [
"reg", "reg",
@@ -138,6 +201,7 @@ fn dll_override_args() -> [&'static str; 10] {
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also /// Best-effort by design: a failure here is not fatal, because a launch we spawn also
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine /// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
/// error, since the player cannot act on the latter. /// error, since the player cannot act on the latter.
#[cfg(unix)]
fn ensure_dll_override(profile: &GameProfile, log: &Log) { fn ensure_dll_override(profile: &GameProfile, log: &Log) {
if profile.wine_prefix.trim().is_empty() { if profile.wine_prefix.trim().is_empty() {
return; return;
@@ -163,7 +227,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) {
} }
} }
#[cfg(test)] #[cfg(all(test, unix))]
mod override_tests { mod override_tests {
use super::*; use super::*;
@@ -204,6 +268,7 @@ mod override_tests {
/// ///
/// A profile that already pins `version=` wins: an operator overriding the hijack /// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled. /// deliberately must not be silently overruled.
#[cfg(unix)]
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String { fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
const HOOK: &str = "version=n,b"; const HOOK: &str = "version=n,b";
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) { match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
@@ -217,6 +282,7 @@ fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
/// ///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`: /// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
/// an existing link is replaced, so re-running is harmless. /// an existing link is replaced, so re-running is harmless.
#[cfg(unix)]
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() { if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
return Ok(()); return Ok(());
@@ -257,6 +323,7 @@ fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// A crashed or failed launch deletes the licence, so this runs before every /// 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 /// 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. /// script proved, and it is why a crash is normally self-healing on the next try.
#[cfg(unix)]
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let Some(lic) = &profile.license else { let Some(lic) = &profile.license else {
return Ok(()); return Ok(());
@@ -316,6 +383,7 @@ fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// and it is reproduced deliberately — the pattern is a Windows executable name, /// 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 /// 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.) /// that *can* match its own caller is a real hazard; this one cannot.)
#[cfg(unix)]
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) { fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
@@ -333,6 +401,7 @@ fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Lo
/// A relative licence path is taken as relative to the Wine prefix; an absolute /// A relative licence path is taken as relative to the Wine prefix; an absolute
/// one is used as given. /// one is used as given.
#[cfg(unix)]
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf { fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
let p = Path::new(path); let p = Path::new(path);
if p.is_absolute() || prefix.trim().is_empty() { if p.is_absolute() || prefix.trim().is_empty() {
@@ -345,6 +414,7 @@ fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is /// 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 /// as useless as a missing one, and treating it as valid would skip the
/// regeneration that fixes it. /// regeneration that fixes it.
#[cfg(unix)]
fn non_empty_file(path: &Path) -> bool { fn non_empty_file(path: &Path) -> bool {
std::fs::metadata(path) std::fs::metadata(path)
.map(|m| m.len() > 0) .map(|m| m.len() > 0)
@@ -381,7 +451,7 @@ pub fn stream(
}); });
} }
#[cfg(test)] #[cfg(all(test, unix))]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{LicenseCheck, PrefixLink}; use crate::config::{LicenseCheck, PrefixLink};
+22 -2
View File
@@ -22,6 +22,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
#[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use crate::fifa17_capability::{ use crate::fifa17_capability::{
@@ -79,12 +80,18 @@ impl Service {
/// keeps `spawn` responsible for reporting a missing binary, with one error message /// keeps `spawn` responsible for reporting a missing binary, with one error message
/// instead of two. /// instead of two.
fn resolve_binary(service: Service) -> PathBuf { fn resolve_binary(service: Service) -> PathBuf {
let name = service.binary(); let base = service.binary();
// On Windows the built companion is `openfut-lsx.exe`; a bare name without the
// extension matches neither the sibling file nor CreateProcess resolution.
#[cfg(windows)]
let name = format!("{base}.exe");
#[cfg(unix)]
let name = base.to_string();
if let Some(dir) = std::env::current_exe() if let Some(dir) = std::env::current_exe()
.ok() .ok()
.and_then(|p| p.parent().map(Path::to_path_buf)) .and_then(|p| p.parent().map(Path::to_path_buf))
{ {
let sibling = dir.join(name); let sibling = dir.join(&name);
if sibling.is_file() { if sibling.is_file() {
return sibling; return sibling;
} }
@@ -433,6 +440,18 @@ impl ServiceSupervisor {
/// Start `service` only if it is not already usable. Never restarts a healthy /// Start `service` only if it is not already usable. Never restarts a healthy
/// service, and never adopts a foreign one as ours. /// service, and never adopts a foreign one as ours.
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> { pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
// On Windows the ProtoSSL cert-verify patch (autopatch's job on unix, via
// /proc/PID/mem) is performed in-process by the version.dll hook, so there
// is no autopatch process to run. LSX is different: the game dials it on
// 127.0.0.1:4216, so it MUST run locally here exactly as on unix.
#[cfg(windows)]
if service == Service::Autopatch {
self.log.lock().push(
"[launcher] autopatch runs in-process on Windows (version.dll hook) — nothing to start."
.to_string(),
);
return Ok(Ensured::Reused);
}
let runtime = self.observe(service); let runtime = self.observe(service);
if runtime.ready() { if runtime.ready() {
self.log.lock().push(format!( self.log.lock().push(format!(
@@ -532,6 +551,7 @@ pub fn spawn(
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path); cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
} }
// Put each companion in its own process group for lifecycle isolation. // Put each companion in its own process group for lifecycle isolation.
#[cfg(unix)]
cmd.process_group(0); cmd.process_group(0);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
+3
View File
@@ -22,6 +22,9 @@ fn main() -> eframe::Result<()> {
.with_icon(app_icon()) .with_icon(app_icon())
.with_inner_size([1040.0, 720.0]) .with_inner_size([1040.0, 720.0])
.with_min_inner_size([880.0, 600.0]), .with_min_inner_size([880.0, 600.0]),
// Pair vsync with the display's VRR (G-Sync + Vsync is the recommended
// combination): frames present on the monitor's own variable refresh.
vsync: true,
..Default::default() ..Default::default()
}; };
+17 -1
View File
@@ -31,6 +31,7 @@ use std::time::Duration;
use crate::config::LauncherConfig; use crate::config::LauncherConfig;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2); const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(unix)]
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope"; const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -86,6 +87,7 @@ impl Check {
} }
/// Run every applicable check. Order is the order the game exercises them. /// Run every applicable check. Order is the order the game exercises them.
#[cfg(unix)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> { pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![ vec![
ptrace_scope(), ptrace_scope(),
@@ -96,6 +98,16 @@ pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
] ]
} }
/// On native Windows the client-preparation checks (ptrace_scope, the EA
/// redirector DNAT, `/etc/hosts`) do not apply: there is no host to arm and
/// routing is entirely the `openfut.cfg` the hook reads. Only the two the game
/// truly depends on remain: the backend is reachable and the deployed hook
/// config agrees with the launcher's settings.
#[cfg(windows)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![backend_reachable(cfg), hook_config(cfg)]
}
/// Checks that will stop the game working. /// Checks that will stop the game working.
pub fn failures(checks: &[Check]) -> usize { pub fn failures(checks: &[Check]) -> usize {
checks.iter().filter(|c| c.state == State::Fail).count() checks.iter().filter(|c| c.state == State::Fail).count()
@@ -113,6 +125,7 @@ pub fn warnings(checks: &[Check]) -> usize {
/// Unconditional. autopatch is a workspace binary that ships alongside the /// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable — /// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it. /// every launch runs it.
#[cfg(unix)]
fn ptrace_scope() -> Check { fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)"; const NAME: &str = "ptrace_scope (autopatch)";
match std::fs::read_to_string(PTRACE_SCOPE) { match std::fs::read_to_string(PTRACE_SCOPE) {
@@ -127,6 +140,7 @@ fn ptrace_scope() -> Check {
/// Reading `/proc` in a test would assert facts about the machine running the /// 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" /// suite rather than about this code — and left inline, "any value is fine"
/// was a mutation no test could catch. /// was a mutation no test could catch.
#[cfg(unix)]
fn ptrace_verdict(raw: &str) -> Check { fn ptrace_verdict(raw: &str) -> Check {
const NAME: &str = "ptrace_scope (autopatch)"; const NAME: &str = "ptrace_scope (autopatch)";
let v = raw.trim(); let v = raw.trim();
@@ -146,6 +160,7 @@ fn ptrace_verdict(raw: &str) -> Check {
/// ///
/// This tests the *effect* rather than reading firewall rules, so it needs no /// This tests the *effect* rather than reading firewall rules, so it needs no
/// privilege and stays honest about what the game will actually experience. /// privilege and stays honest about what the game will actually experience.
#[cfg(unix)]
fn ea_redirect(cfg: &LauncherConfig) -> Check { fn ea_redirect(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA redirector IP is redirected"; const NAME: &str = "EA redirector IP is redirected";
let ip = cfg.ea_redirect_probe_ip.trim(); let ip = cfg.ea_redirect_probe_ip.trim();
@@ -184,6 +199,7 @@ fn ea_redirect(cfg: &LauncherConfig) -> Check {
/// So this is a real misconfiguration worth fixing and not a reason to expect /// 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 /// failure. Reporting it as fatal, and then being contradicted by a working
/// game, is how a checklist trains its user to ignore it. /// game, is how a checklist trains its user to ignore it.
#[cfg(unix)]
fn hostname_mapping(cfg: &LauncherConfig) -> Check { fn hostname_mapping(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA hostnames point at OpenFUT"; const NAME: &str = "EA hostnames point at OpenFUT";
if cfg.ea_hostnames.is_empty() { if cfg.ea_hostnames.is_empty() {
@@ -320,7 +336,7 @@ fn join(ips: &[IpAddr]) -> String {
.join(",") .join(",")
} }
#[cfg(test)] #[cfg(all(test, unix))]
mod tests { mod tests {
use super::*; use super::*;
+9 -1
View File
@@ -299,5 +299,13 @@ fn install_style(ctx: &Context) {
v.widgets.open.rounding = radius; v.widgets.open.rounding = radius;
style.visuals = v; style.visuals = v;
ctx.set_style(style); // egui 0.29 keeps a separate `Style` per theme (dark/light) and renders with
// whichever the theme preference resolves to. `set_style` touches only the
// currently-active theme, so a later switch to the other one would drop our
// named text styles ("Hero", "Subheading", …) and panic in `TextStyle::resolve`.
// Install the full style into BOTH themes and pin the preference to Dark so
// the branded look is stable regardless of the host's system theme.
ctx.set_style_of(egui::Theme::Dark, style.clone());
ctx.set_style_of(egui::Theme::Light, style);
ctx.set_theme(egui::ThemePreference::Dark);
} }