Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16f3452990 | |||
| 966e92b304 | |||
| cf515f5584 | |||
| 6be75f5452 | |||
| 057cf92c3b |
@@ -115,6 +115,41 @@ pub struct ResolvedServer {
|
||||
pub ports: OpenFutPorts,
|
||||
}
|
||||
|
||||
/// Where one matched EA connection is rewritten to, in the exact WinSock
|
||||
/// on-the-wire representation the socket hooks need. Produced by
|
||||
/// [`ResolvedServer::redirect_for_ea_port`] so the hook and the launcher's
|
||||
/// `openfut.cfg` share one decision by construction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Redirect {
|
||||
/// Rewritten IPv4 for `sockaddr_in.sin_addr` (network byte order in memory).
|
||||
pub addr_nbo: u32,
|
||||
/// Rewritten port for `sin_port` / `sin6_port` (network byte order).
|
||||
pub port_nbo: u16,
|
||||
/// The resolved server IPv4, for callers building an IPv6 v4-mapped address.
|
||||
pub redirect_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl ResolvedServer {
|
||||
/// Decide the redirect for an outbound EA connection whose destination port
|
||||
/// is `ea_port_nbo` (network byte order, as read straight from the sockaddr).
|
||||
///
|
||||
/// Returns `None` when the port is not a recognised OpenFUT route — the hook
|
||||
/// then leaves the connection untouched. The original destination IP is
|
||||
/// intentionally ignored: matching is by the fixed EA source-port signature
|
||||
/// ([`ea_ports`]), so a hardcoded EA IP (e.g. FIFA17's `159.153.51.20`
|
||||
/// redirector) and a DNS-resolved one are treated identically and both land
|
||||
/// on the configured server — no `/etc/hosts`, DNAT, or portproxy required.
|
||||
pub fn redirect_for_ea_port(&self, ea_port_nbo: u16) -> Option<Redirect> {
|
||||
let ea_port = u16::from_be(ea_port_nbo);
|
||||
let dest_port = self.ports.map_source_port(ea_port)?;
|
||||
Some(Redirect {
|
||||
addr_nbo: sin_addr_from_ipv4(self.redirect_ip),
|
||||
port_nbo: sin_port_nbo(dest_port),
|
||||
redirect_ip: self.redirect_ip,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors loading/validating OpenFUT server configuration. Every one of these
|
||||
/// must BLOCK operation — none of them may fall back to loopback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -476,4 +511,58 @@ mod tests {
|
||||
};
|
||||
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_maps_every_fifa17_route_to_configured_server() {
|
||||
// The canonical staging cfg. Ports come from the file, not constants.
|
||||
let resolved = ServerConfig::parse(
|
||||
"host=10.10.0.120\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
|
||||
)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
let server = Ipv4Addr::new(10, 10, 0, 120);
|
||||
// (EA source port [host order], expected OpenFUT dest port)
|
||||
for (ea, dest) in [
|
||||
(443u16, 8443u16),
|
||||
(10041, 42127),
|
||||
(42230, 42127),
|
||||
(42127, 42130),
|
||||
] {
|
||||
let r = resolved
|
||||
.redirect_for_ea_port(ea.to_be())
|
||||
.unwrap_or_else(|| panic!("EA port {ea} should be a route"));
|
||||
assert_eq!(u16::from_be(r.port_nbo), dest, "EA {ea} -> dest");
|
||||
assert_eq!(r.redirect_ip, server, "EA {ea} -> server ip");
|
||||
assert_eq!(
|
||||
r.addr_nbo,
|
||||
sin_addr_from_ipv4(server),
|
||||
"EA {ea} -> sin_addr"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_leaves_unknown_ports_untouched() {
|
||||
let resolved = ServerConfig::parse("host=10.10.0.120\n")
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
assert!(resolved.redirect_for_ea_port(8080u16.to_be()).is_none());
|
||||
assert!(resolved.redirect_for_ea_port(22u16.to_be()).is_none());
|
||||
assert!(resolved.redirect_for_ea_port(443u16.to_be()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_targets_configured_remote_host_not_loopback() {
|
||||
let resolved = ServerConfig::parse("host=10.10.0.120\n")
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
// FIFA17 redirector (hardcoded EA IP 159.153.51.20:42230) must be rewritten
|
||||
// to the configured REMOTE server, never 127.0.0.1.
|
||||
let r = resolved.redirect_for_ea_port(42230u16.to_be()).unwrap();
|
||||
assert_eq!(r.redirect_ip, Ipv4Addr::new(10, 10, 0, 120));
|
||||
assert_ne!(r.redirect_ip, Ipv4Addr::LOCALHOST);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5
@@ -2,10 +2,15 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-hook"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-common",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"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]
|
||||
opt-level = "s"
|
||||
|
||||
@@ -97,7 +97,7 @@ unsafe fn restore_original(target: *mut u8) {
|
||||
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
|
||||
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
|
||||
/// ConnectEx path can share this one implementation.
|
||||
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
|
||||
unsafe fn redirect_loopback(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
|
||||
if namelen < 8 || name.is_null() {
|
||||
return None;
|
||||
}
|
||||
@@ -172,6 +172,89 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
}
|
||||
}
|
||||
|
||||
/// The armed FIFA17 redirect target, resolved once from `openfut.cfg` via
|
||||
/// `openfut-common`. When set, `redirect_if_ea` rewrites matched EA connections
|
||||
/// to this configured server; when unset, the legacy loopback path is used.
|
||||
static REDIRECT: OnceLock<openfut_common::ResolvedServer> = OnceLock::new();
|
||||
|
||||
/// Arm the config-driven redirect (FIFA17). Idempotent: the first call wins.
|
||||
pub fn set_redirect(server: openfut_common::ResolvedServer) {
|
||||
let _ = REDIRECT.set(server);
|
||||
}
|
||||
|
||||
/// Dispatch: config-driven (FIFA17, shared `openfut-common` map + configured
|
||||
/// host) when armed, else the legacy hardcoded-loopback rewrite.
|
||||
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
|
||||
if namelen < 8 || name.is_null() {
|
||||
return None;
|
||||
}
|
||||
match REDIRECT.get() {
|
||||
Some(server) => redirect_configured(server, name, namelen),
|
||||
None => redirect_loopback(name, namelen),
|
||||
}
|
||||
}
|
||||
|
||||
/// FIFA17 config-driven rewrite. Destination host+port come from `openfut.cfg`
|
||||
/// through `openfut-common`, so the hook and the launcher agree by construction.
|
||||
/// Matching is by EA source-port signature only (see `openfut_common::ea_ports`),
|
||||
/// so a hardcoded EA IP (e.g. the `159.153.51.20:42230` redirector) and a
|
||||
/// DNS-resolved one both land on the configured — possibly remote — server. An
|
||||
/// unrecognised port returns `None` (connection left untouched). Never corrupts
|
||||
/// the sockaddr: it only writes into a fresh 28-byte buffer.
|
||||
unsafe fn redirect_configured(
|
||||
server: &openfut_common::ResolvedServer,
|
||||
name: *const u8,
|
||||
namelen: i32,
|
||||
) -> Option<([u8; 28], i32)> {
|
||||
let family = *(name as *const u16);
|
||||
let mut buf = [0u8; 28];
|
||||
match family {
|
||||
AF_INET => {
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
let redir = server.redirect_for_ea_port(sa.sin_port)?;
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v4 :{} → {}:{}\n",
|
||||
u16::from_be(sa.sin_port),
|
||||
redir.redirect_ip,
|
||||
u16::from_be(redir.port_nbo)
|
||||
));
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = redir.port_nbo;
|
||||
out.sin_addr = redir.addr_nbo;
|
||||
Some((buf, 16))
|
||||
}
|
||||
AF_INET6 => {
|
||||
if namelen < 28 {
|
||||
return None;
|
||||
}
|
||||
let sa6 = &*(name as *const SockaddrIn6);
|
||||
let redir = server.redirect_for_ea_port(sa6.sin6_port)?;
|
||||
// ::ffff:<redirect_ip> — a v4-mapped v6 target so a v6 socket sends
|
||||
// real IPv4 packets to the configured server.
|
||||
let o = redir.redirect_ip.octets();
|
||||
let mut v4mapped = [0u8; 16];
|
||||
v4mapped[10] = 0xff;
|
||||
v4mapped[11] = 0xff;
|
||||
v4mapped[12..16].copy_from_slice(&o);
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v6 :{} → ::ffff:{}:{}\n",
|
||||
u16::from_be(sa6.sin6_port),
|
||||
redir.redirect_ip,
|
||||
u16::from_be(redir.port_nbo)
|
||||
));
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
|
||||
out.sin6_family = AF_INET6;
|
||||
out.sin6_port = redir.port_nbo;
|
||||
out.sin6_flowinfo = 0;
|
||||
out.sin6_addr = v4mapped;
|
||||
out.sin6_scope_id = 0;
|
||||
Some((buf, 28))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 {
|
||||
let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
|
||||
|
||||
@@ -67,6 +67,29 @@ unsafe fn dump_modules() {
|
||||
CloseHandle(snap);
|
||||
}
|
||||
|
||||
/// Read `openfut.cfg` from the game directory (next to `FIFA17.exe`) and resolve
|
||||
/// the OpenFUT server via the shared `openfut-common` parser. Returns `None`
|
||||
/// with a diagnostic when the file is absent or unusable, so the hook fails
|
||||
/// safe — no redirect installed rather than a corrupt one.
|
||||
fn load_server() -> Option<openfut_common::ResolvedServer> {
|
||||
let dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
|
||||
let path = dir.join("openfut.cfg");
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
write_log(&format!("fifa17: cannot read {}: {e}\n", path.display()));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match openfut_common::ServerConfig::parse(&contents).and_then(|c| c.resolve()) {
|
||||
Ok(server) => Some(server),
|
||||
Err(e) => {
|
||||
write_log(&format!("fifa17: openfut.cfg unusable: {e}\n"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -79,6 +102,50 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
));
|
||||
dump_modules();
|
||||
write_log("fifa17: worker complete (injection healthy)\n");
|
||||
|
||||
// ── FIFA17 in-process network redirect (Milestone A) ──────────────────────
|
||||
// Route EA endpoints to the configured OpenFUT server from openfut.cfg
|
||||
// (openfut-common is the single source of truth). No hosts/iptables/portproxy.
|
||||
match load_server() {
|
||||
Some(server) => {
|
||||
write_log(&format!(
|
||||
"fifa17: redirect armed → {} https={} redirector={} main={}\n",
|
||||
server.redirect_ip,
|
||||
server.ports.https,
|
||||
server.ports.blaze_redirector,
|
||||
server.ports.blaze_main
|
||||
));
|
||||
crate::connect_hook::set_redirect(server);
|
||||
if crate::connect_hook::install_inline_connect_hook() {
|
||||
write_log("fifa17: connect inline-hooked\n");
|
||||
} else {
|
||||
write_log("fifa17: connect hook FAILED\n");
|
||||
}
|
||||
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");
|
||||
}
|
||||
if crate::connectex_hook::install_wsaioctl_hook() {
|
||||
write_log("fifa17: ConnectEx (WSAIoctl) hooked\n");
|
||||
} else {
|
||||
write_log("fifa17: ConnectEx hook FAILED\n");
|
||||
}
|
||||
}
|
||||
None => write_log(
|
||||
"fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n",
|
||||
),
|
||||
}
|
||||
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
|
||||
// itself from the build; its safety is the runtime signature/evidence gate. The
|
||||
// remaining legacy experiment modules stay inert unless their env gate is `1`.
|
||||
|
||||
+9
-1
@@ -1804,7 +1804,15 @@ impl LauncherApp {
|
||||
|
||||
impl eframe::App for LauncherApp {
|
||||
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();
|
||||
|
||||
egui::TopBottomPanel::top("header")
|
||||
|
||||
+14
-1
@@ -25,6 +25,7 @@ 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.
|
||||
#[cfg(unix)]
|
||||
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
||||
let t = s.trim();
|
||||
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
|
||||
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn arming_script(
|
||||
server: &str,
|
||||
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
|
||||
/// it. Logged by the UI so the user sees exactly what was set — not just that
|
||||
/// "something" ran under `pkexec`.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn arming_summary(
|
||||
server: &str,
|
||||
redirector_port: u16,
|
||||
@@ -103,6 +106,16 @@ pub(crate) fn arming_summary(
|
||||
/// 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.
|
||||
/// 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>> {
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
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 {
|
||||
use super::*;
|
||||
|
||||
|
||||
+28
-18
@@ -54,13 +54,18 @@ pub struct GameProfile {
|
||||
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()
|
||||
// Windows starts the executable directly (no runner); unix needs a
|
||||
// runner such as umu-run.
|
||||
#[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.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
if self.runner.trim().is_empty() {
|
||||
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() {
|
||||
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());
|
||||
// Wine-prefix links and the DRM licence precondition only exist on the
|
||||
// unix/Proton launch path; native Windows has neither.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
||||
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
||||
}
|
||||
if std::path::Path::new(&l.link).is_absolute() {
|
||||
return Err(format!(
|
||||
"Prefix link {:?} must be relative to the Wine prefix.",
|
||||
l.link
|
||||
));
|
||||
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());
|
||||
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(())
|
||||
|
||||
+72
-2
@@ -24,11 +24,13 @@
|
||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
#[cfg(unix)]
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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
|
||||
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||
/// ends, which is how the launch state machine leaves its Running state.
|
||||
#[cfg(unix)]
|
||||
pub fn launch(
|
||||
profile: &GameProfile,
|
||||
log: &Log,
|
||||
@@ -96,6 +99,62 @@ pub fn launch(
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// 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.
|
||||
#[cfg(unix)]
|
||||
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
|
||||
#[cfg(unix)]
|
||||
const HOOK_DLL_VALUE: &str = "version";
|
||||
#[cfg(unix)]
|
||||
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
||||
|
||||
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
|
||||
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
|
||||
/// repairs a prefix a player has reset or replaced.
|
||||
#[cfg(unix)]
|
||||
fn dll_override_args() -> [&'static str; 10] {
|
||||
[
|
||||
"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
|
||||
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
|
||||
/// error, since the player cannot act on the latter.
|
||||
#[cfg(unix)]
|
||||
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
||||
if profile.wine_prefix.trim().is_empty() {
|
||||
return;
|
||||
@@ -163,7 +227,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod override_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -204,6 +268,7 @@ mod override_tests {
|
||||
///
|
||||
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
||||
/// deliberately must not be silently overruled.
|
||||
#[cfg(unix)]
|
||||
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
||||
const HOOK: &str = "version=n,b";
|
||||
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>`:
|
||||
/// an existing link is replaced, so re-running is harmless.
|
||||
#[cfg(unix)]
|
||||
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
||||
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
|
||||
/// 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.
|
||||
#[cfg(unix)]
|
||||
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
let Some(lic) = &profile.license else {
|
||||
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,
|
||||
/// 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.)
|
||||
#[cfg(unix)]
|
||||
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
|
||||
let _ = child.kill();
|
||||
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
|
||||
/// one is used as given.
|
||||
#[cfg(unix)]
|
||||
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
||||
let p = Path::new(path);
|
||||
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
|
||||
/// as useless as a missing one, and treating it as valid would skip the
|
||||
/// regeneration that fixes it.
|
||||
#[cfg(unix)]
|
||||
fn non_empty_file(path: &Path) -> bool {
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.len() > 0)
|
||||
@@ -381,7 +451,7 @@ pub fn stream(
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LicenseCheck, PrefixLink};
|
||||
|
||||
+22
-2
@@ -22,6 +22,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use crate::fifa17_capability::{
|
||||
@@ -79,12 +80,18 @@ impl Service {
|
||||
/// keeps `spawn` responsible for reporting a missing binary, with one error message
|
||||
/// instead of two.
|
||||
fn resolve_binary(service: Service) -> PathBuf {
|
||||
let name = service.binary();
|
||||
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()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(Path::to_path_buf))
|
||||
{
|
||||
let sibling = dir.join(name);
|
||||
let sibling = dir.join(&name);
|
||||
if sibling.is_file() {
|
||||
return sibling;
|
||||
}
|
||||
@@ -433,6 +440,18 @@ impl ServiceSupervisor {
|
||||
/// Start `service` only if it is not already usable. Never restarts a healthy
|
||||
/// service, and never adopts a foreign one as ours.
|
||||
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
|
||||
// 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);
|
||||
if runtime.ready() {
|
||||
self.log.lock().push(format!(
|
||||
@@ -532,6 +551,7 @@ pub fn spawn(
|
||||
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
||||
}
|
||||
// Put each companion in its own process group for lifecycle isolation.
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ fn main() -> eframe::Result<()> {
|
||||
.with_icon(app_icon())
|
||||
.with_inner_size([1040.0, 720.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()
|
||||
};
|
||||
|
||||
|
||||
+17
-1
@@ -31,6 +31,7 @@ use std::time::Duration;
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
#[cfg(unix)]
|
||||
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -86,6 +87,7 @@ impl Check {
|
||||
}
|
||||
|
||||
/// Run every applicable check. Order is the order the game exercises them.
|
||||
#[cfg(unix)]
|
||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
vec![
|
||||
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.
|
||||
pub fn failures(checks: &[Check]) -> usize {
|
||||
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
|
||||
/// launcher, so there is no configuration that could make this inapplicable —
|
||||
/// every launch runs it.
|
||||
#[cfg(unix)]
|
||||
fn ptrace_scope() -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
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
|
||||
/// suite rather than about this code — and left inline, "any value is fine"
|
||||
/// was a mutation no test could catch.
|
||||
#[cfg(unix)]
|
||||
fn ptrace_verdict(raw: &str) -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
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
|
||||
/// privilege and stays honest about what the game will actually experience.
|
||||
#[cfg(unix)]
|
||||
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA redirector IP is redirected";
|
||||
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
|
||||
/// failure. Reporting it as fatal, and then being contradicted by a working
|
||||
/// game, is how a checklist trains its user to ignore it.
|
||||
#[cfg(unix)]
|
||||
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA hostnames point at OpenFUT";
|
||||
if cfg.ea_hostnames.is_empty() {
|
||||
@@ -320,7 +336,7 @@ fn join(ips: &[IpAddr]) -> String {
|
||||
.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
+9
-1
@@ -299,5 +299,13 @@ fn install_style(ctx: &Context) {
|
||||
v.widgets.open.rounding = radius;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user