diff --git a/openfut-hook/src/season_trace.rs b/openfut-hook/src/season_trace.rs index 42a6b15..c92d208 100644 --- a/openfut-hook/src/season_trace.rs +++ b/openfut-hook/src/season_trace.rs @@ -21,6 +21,9 @@ use windows_sys::Win32::System::Memory::{ 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, @@ -346,17 +349,12 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8 "SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n" )); } - // Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names - // failure to SUCCESS so the offline-season load advances to - // LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure, - // once per process; verified by the error string before touching memory. - if flag == Some(0) - && errstr.contains("CACHE_PACKNAMES") - && readable_range(result, 1) - && !BYPASS_DONE.swap(true, Ordering::AcqRel) - { - 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"); + // Base-supply experiment: the CACHE_PACKNAMES failure is expected to be fixed + // 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) { + write_log("SEASONS_BYPASS: DISABLED (base-supply active); CACHE_PACKNAMES not masked\n"); } let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire); if t == 0 { @@ -395,14 +393,44 @@ unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize } // 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. Passive capture. Its -// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating -// installer (disp32 at copied offset 7, instruction end 11). +// URL of the pack-names / cards-tournament-list web file. Its prologue has a +// 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/"; static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0); 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 + // 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 = 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; + } let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { - write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256))); + if arg_rcx != rcx { + 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); if t == 0 { @@ -410,7 +438,80 @@ unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, } let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); - original(rcx, rdx, r8, r9) + let ret = original(arg_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() { @@ -426,6 +527,10 @@ unsafe fn worker() { write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n"); 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) install_detour( base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15,