diff --git a/Cargo.lock b/Cargo.lock index 24d9295..bc53fc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2293,6 +2293,7 @@ dependencies = [ "eframe", "egui", "openfut-common", + "parking_lot", "serde", "serde_json", "tokio", diff --git a/openfut-common/src/lib.rs b/openfut-common/src/lib.rs index 2bd5033..96724d0 100644 --- a/openfut-common/src/lib.rs +++ b/openfut-common/src/lib.rs @@ -53,6 +53,12 @@ pub mod default_ports { pub const BLAZE_REDIRECTOR: u16 = 42127; /// OpenFUT FIFA 17 Blaze main listener. 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 `/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 @@ -66,6 +72,9 @@ pub struct OpenFutPorts { pub blaze_redirector: u16, /// Destination for EA :42127 traffic (Blaze main). 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 { @@ -74,6 +83,7 @@ impl Default for OpenFutPorts { https: default_ports::HTTPS, blaze_redirector: default_ports::BLAZE_REDIRECTOR, blaze_main: default_ports::BLAZE_MAIN, + fut_content: default_ports::FUT_CONTENT, } } } @@ -209,6 +219,7 @@ impl ServerConfig { "https_port" => ports.https = parse_port(value)?, "blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?, "blaze_main_port" => ports.blaze_main = parse_port(value)?, + "fut_content_port" => ports.fut_content = parse_port(value)?, other => { return Err(ConfigError::MalformedConfig(format!( "line {}: unknown key '{other}'", @@ -225,8 +236,27 @@ impl ServerConfig { /// Serialize to the structured `openfut.cfg` format. pub fn to_cfg_string(&self) -> String { format!( - "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n", - self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main + "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\nfut_content_port={}\n", + self.host, + 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 ) } @@ -414,12 +444,36 @@ mod tests { https: 8443, blaze_redirector: 10041, blaze_main: 42127, + fut_content: 8110, }, }; let s = c.to_cfg_string(); 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] fn configured_ipv4_becomes_correct_sockaddr() { // Resolve an IPv4 literal and confirm the sin_addr value. diff --git a/openfut-hook/Cargo.lock b/openfut-hook/Cargo.lock index f02d3a6..c75ff37 100644 --- a/openfut-hook/Cargo.lock +++ b/openfut-hook/Cargo.lock @@ -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", ] diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index 6c8e75b..2d9ab23 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -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" diff --git a/openfut-hook/src/season_trace.rs b/openfut-hook/src/season_trace.rs index c92d208..a97f29f 100644 --- a/openfut-hook/src/season_trace.rs +++ b/openfut-hook/src/season_trace.rs @@ -13,17 +13,18 @@ //! (no rip-relative / rel32 in the copied bytes). 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::{ + AddVectoredExceptionHandler, EXCEPTION_POINTERS, +}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::Memory::{ VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE, }; use windows_sys::Win32::System::Threading::GetCurrentProcess; -use windows_sys::Win32::System::Diagnostics::Debug::{ - AddVectoredExceptionHandler, EXCEPTION_POINTERS, -}; use crate::sbc_trace::{ absolute_jump, allocate_trampoline, readable_range, target_va, validate_cards_build, @@ -140,15 +141,35 @@ macro_rules! season_call_trace { }; } -season_call_trace!(load_current_native_wrapper, LOAD_CURRENT_NATIVE_TRAMP, "LoadCurrentOfflineSeason_native"); -season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native"); -season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native"); +season_call_trace!( + load_current_native_wrapper, + LOAD_CURRENT_NATIVE_TRAMP, + "LoadCurrentOfflineSeason_native" +); +season_call_trace!( + start_season_native_wrapper, + START_SEASON_NATIVE_TRAMP, + "StartSeason_native" +); +season_call_trace!( + get_info_native_wrapper, + GET_INFO_NATIVE_TRAMP, + "GetOfflineSeasonInfo_native" +); // Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason // actually calls; hands the callback name to the manager's async slot 0x80. -season_call_trace!(load_offline_real_wrapper, LOAD_OFFLINE_REAL_TRAMP, "LoadOfflineSeasons_native(0x4ee10)"); +season_call_trace!( + load_offline_real_wrapper, + LOAD_OFFLINE_REAL_TRAMP, + "LoadOfflineSeasons_native(0x4ee10)" +); // Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season // count and invokes the LoadSeasons_Complete AS callback. -season_call_trace!(load_offline_async_wrapper, LOAD_OFFLINE_ASYNC_TRAMP, "LoadOfflineSeasons_asyncimpl(0x57560)"); +season_call_trace!( + load_offline_async_wrapper, + LOAD_OFFLINE_ASYNC_TRAMP, + "LoadOfflineSeasons_asyncimpl(0x57560)" +); // LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks // and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId @@ -189,7 +210,12 @@ unsafe extern "system" fn load_current_impl_wrapper( // Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm // whether it ever fires; logs the result fields it branches on. static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); -unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize { +unsafe extern "system" fn completion_wrapper( + ctx: usize, + result: usize, + r8: usize, + r9: usize, +) -> usize { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { let status = rd_i32(result + 0x1c); @@ -215,7 +241,11 @@ unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usiz // this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for // the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`, // so it needs the relocating installer below. -season_call_trace!(get_users_division_wrapper, GET_USERS_DIVISION_TRAMP, "GetUsersOfflineDivision_native(0x4eb50)"); +season_call_trace!( + get_users_division_wrapper, + GET_USERS_DIVISION_TRAMP, + "GetUsersOfflineDivision_native(0x4eb50)" +); /// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into /// CardsDLL data still fits after we relocate a copied prologue into it. @@ -269,7 +299,9 @@ unsafe fn install_detour_reloc( let jump = absolute_jump(wrapper); let tramp_len = copy_len + jump.len(); let Some(tramp) = alloc_near(base, tramp_len) else { - write_log(&format!("SEASON_TRACE: {name}: near trampoline alloc failed\n")); + write_log(&format!( + "SEASON_TRACE: {name}: near trampoline alloc failed\n" + )); return false; }; core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len); @@ -278,7 +310,9 @@ unsafe fn install_detour_reloc( let abs_target = target as i64 + insn_end as i64 + orig_disp; let new_disp = abs_target - (tramp as i64 + insn_end as i64); if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 { - write_log(&format!("SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n")); + write_log(&format!( + "SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n" + )); return false; } core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32); @@ -286,7 +320,9 @@ unsafe fn install_detour_reloc( core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len()); let mut old = 0u32; if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 { - write_log(&format!("SEASON_TRACE: {name}: trampoline protect failed\n")); + write_log(&format!( + "SEASON_TRACE: {name}: trampoline protect failed\n" + )); return false; } FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len); @@ -319,7 +355,12 @@ unsafe fn install_detour_reloc( // completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error // string ptr when byte0==0). Logs the EXACT status string delivered. Passive. static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); -unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize { +unsafe extern "system" fn final_completion_wrapper( + ctx: usize, + result: usize, + r8: usize, + r9: usize, +) -> usize { // Read the delivered status: byte0==0 => failure with an error string at +8. let flag = rd_u8(result); let errstr = if flag == Some(0) { @@ -344,7 +385,11 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8 Some(_) => "SUCCESS", None => "??", }; - let shown = if flag == Some(0) { errstr.as_str() } else { "SUCCESS" }; + let shown = if flag == Some(0) { + errstr.as_str() + } else { + "SUCCESS" + }; write_log(&format!( "SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n" )); @@ -353,7 +398,10 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8 // by the WEBFILE base-supply (the real file now downloads), so the guarded // success-forcing bypass is DISABLED — a recurring CACHE_PACKNAMES here means // the base-supply did not take effect and MUST NOT be masked. - if flag == Some(0) && errstr.contains("CACHE_PACKNAMES") && !BYPASS_DONE.swap(true, Ordering::AcqRel) { + if flag == Some(0) + && errstr.contains("CACHE_PACKNAMES") + && !BYPASS_DONE.swap(true, Ordering::AcqRel) + { write_log("SEASONS_BYPASS: DISABLED (base-supply active); CACHE_PACKNAMES not masked\n"); } let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire); @@ -369,14 +417,23 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8 // "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains // the next async stage. Logs whether the first async stage succeeded. Passive. static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); -unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize, r8: usize, r9: usize) -> usize { +unsafe extern "system" fn stage1_completion_wrapper( + param1: usize, + result: usize, + r8: usize, + r9: usize, +) -> usize { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { if result == 0 { write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n"); } else { let status = rd_i32(result + 0x1c); - let verdict = if status == Some(0) { "ok(chain next)" } else { "CACHE_PACKNAMES_FAILED" }; + let verdict = if status == Some(0) { + "ok(chain next)" + } else { + "CACHE_PACKNAMES_FAILED" + }; write_log(&format!( "SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n", status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()), @@ -397,17 +454,34 @@ unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize // rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating installer // (disp32 at copied offset 7, instruction end 11). // -// BASE-SUPPLY (staging experiment): 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 `/fut/` prefix pointing -// at the staging content server 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. -const STAGING_FUT_BASE: &str = "http://10.10.0.120:8110/fut/"; +// 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 `/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 = 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); -unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { +unsafe extern "system" fn url_capture_wrapper( + 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 @@ -415,11 +489,13 @@ unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, // client copies the url synchronously during the call — a local buffer is // sufficient and nothing is leaked. let mut full: Vec = Vec::new(); - if !orig.is_empty() && !orig.contains("://") { - full.extend_from_slice(STAGING_FUT_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; + 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); if n < 64 { @@ -533,61 +609,138 @@ unsafe fn worker() { write_log("SEASON_TRACE: crash logger (VEH) armed\n"); // (rva, name, copy_len, signature, wrapper, trampoline slot) install_detour( - base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15, - &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], - load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP, + base, + 0x4eb70, + "LoadCurrentOfflineSeason_native", + 15, + &[ + 0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, + 0xff, + ], + load_current_native_wrapper as *const () as usize, + &LOAD_CURRENT_NATIVE_TRAMP, ); install_detour( - base, 0x4f340, "StartSeason_native", 15, - &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], - start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP, + base, + 0x4f340, + "StartSeason_native", + 15, + &[ + 0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, + 0xff, + ], + start_season_native_wrapper as *const () as usize, + &START_SEASON_NATIVE_TRAMP, ); install_detour( - base, 0x4e850, "GetOfflineSeasonInfo_native", 15, - &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18], - get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP, + base, + 0x4e850, + "GetOfflineSeasonInfo_native", + 15, + &[ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, + 0x18, + ], + get_info_native_wrapper as *const () as usize, + &GET_INFO_NATIVE_TRAMP, ); install_detour( - base, 0x57230, "LoadCurrentOfflineSeason_impl", 19, - &[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff], - load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP, + base, + 0x57230, + "LoadCurrentOfflineSeason_impl", + 19, + &[ + 0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, + 0x98, 0xfe, 0xff, 0xff, 0xff, + ], + load_current_impl_wrapper as *const () as usize, + &LOAD_CURRENT_IMPL_TRAMP, ); install_detour( - base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16, - &[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff], - completion_wrapper as *const () as usize, &COMPLETION_TRAMP, + base, + 0x578e0, + "LoadCurrentOfflineSeason_completion", + 16, + &[ + 0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, + 0xff, 0xff, + ], + completion_wrapper as *const () as usize, + &COMPLETION_TRAMP, ); install_detour_reloc( - base, 0x4eb50, "GetUsersOfflineDivision_native", 14, - &[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01], - 7, 11, - get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP, + base, + 0x4eb50, + "GetUsersOfflineDivision_native", + 14, + &[ + 0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01, + ], + 7, + 11, + get_users_division_wrapper as *const () as usize, + &GET_USERS_DIVISION_TRAMP, ); install_detour( - base, 0x4ee10, "LoadOfflineSeasons_native", 15, - &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], - load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP, + base, + 0x4ee10, + "LoadOfflineSeasons_native", + 15, + &[ + 0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, + 0xff, + ], + load_offline_real_wrapper as *const () as usize, + &LOAD_OFFLINE_REAL_TRAMP, ); install_detour( - base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17, - &[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], - load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP, + base, + 0x57560, + "LoadOfflineSeasons_asyncimpl", + 17, + &[ + 0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, + 0xff, 0xff, 0xff, + ], + load_offline_async_wrapper as *const () as usize, + &LOAD_OFFLINE_ASYNC_TRAMP, ); install_detour( - base, 0xffe90, "LoadOfflineSeasons_final_completion", 16, - &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda], - final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP, + base, + 0xffe90, + "LoadOfflineSeasons_final_completion", + 16, + &[ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, + 0x8b, 0xda, + ], + final_completion_wrapper as *const () as usize, + &FINAL_COMPLETION_TRAMP, ); install_detour( - base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15, - &[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00], - stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP, + base, + 0x106240, + "LoadOfflineSeasons_stage1_completion", + 15, + &[ + 0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, + 0x00, + ], + stage1_completion_wrapper as *const () as usize, + &STAGE1_COMPLETION_TRAMP, ); install_detour_reloc( - base, 0x17ff90, "start_webfile_dl_url", 14, - &[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1], - 7, 11, - url_capture_wrapper as *const () as usize, &URL_CAPTURE_TRAMP, + base, + 0x17ff90, + "start_webfile_dl_url", + 14, + &[ + 0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1, + ], + 7, + 11, + url_capture_wrapper as *const () as usize, + &URL_CAPTURE_TRAMP, ); write_log("SEASON_TRACE: all season-native traces armed\n"); } @@ -595,6 +748,46 @@ unsafe fn worker() { /// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not /// yet loaded at DllMain time). Read-only: never changes game behavior. pub(crate) fn install() { + arm_fut_content_base(); write_log("SEASON_TRACE: requested; deferred signature validation starting\n"); 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" + )), + } +} diff --git a/openfut-hook/src/store_entry.rs b/openfut-hook/src/store_entry.rs index eec72dc..238b978 100644 --- a/openfut-hook/src/store_entry.rs +++ b/openfut-hook/src/store_entry.rs @@ -439,7 +439,9 @@ unsafe fn worker() { pub(crate) fn install() { // Promoted: armed by the build. No environment variable participates. REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release); - crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n"); + crate::write_log( + "STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n", + ); std::thread::spawn(|| unsafe { worker() }); } diff --git a/src/config.rs b/src/config.rs index 6be3c54..49c9e62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -314,6 +314,7 @@ impl LauncherConfig { https: self.openfut_https_port, blaze_redirector: self.openfut_blaze_redirector_port, blaze_main: self.openfut_blaze_main_port, + fut_content: openfut_common::default_ports::FUT_CONTENT, }, } }