Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe2e531b0c | |||
| c3d41153be | |||
| 8d5bb6202a | |||
| 9c4db41289 | |||
| 164100fc40 | |||
| 79e566883f | |||
| 7724f168bc | |||
| e4c56a225e | |||
| 8ca89bcc75 | |||
| 9aecc658ad | |||
| af7a5948a7 |
Generated
+1
@@ -2293,6 +2293,7 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"openfut-common",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
@@ -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 `<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
|
||||
@@ -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.
|
||||
|
||||
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"
|
||||
|
||||
@@ -86,6 +86,8 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
crate::sbc_trace::install();
|
||||
crate::sbc_dispatch::install();
|
||||
crate::sbc_request_trace::install();
|
||||
crate::store_entry::install();
|
||||
crate::season_trace::install();
|
||||
0
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ mod sbc_hook;
|
||||
mod sbc_request_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod season_trace;
|
||||
mod ssl_patch;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod store_entry;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
mod version_proxy;
|
||||
|
||||
@@ -443,6 +443,10 @@ unsafe extern "system" fn event_wrapper(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Piggyback the store pre-warm on this game-thread hub event: it loads the
|
||||
// purchase groups once, before the store screen is shown, so the store's native
|
||||
// screen-show tab bind sees a populated group list (see `store_entry`).
|
||||
crate::store_entry::maybe_prewarm_groups();
|
||||
let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire));
|
||||
let result = original(controller, event, payload);
|
||||
EVENT_EXITS.fetch_add(1, Ordering::Release);
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
//! Passive, behavior-preserving diagnostic traces for FIFA 17's offline-season
|
||||
//! entry flow.
|
||||
//!
|
||||
//! RE (2026-08-19, live memory) placed the "problem communicating with the FIFA
|
||||
//! Ultimate Team servers" modal in the `futOfflineSeasonEntry` ActionScript's
|
||||
//! season-load path. A first trace on the load completion `FUN_1800578e0`
|
||||
//! (`0x578e0`) armed but NEVER fired on an entry attempt — so the modal is raised
|
||||
//! before that callback runs. These traces log the actual CardsDLL season-native
|
||||
//! call sequence (which functions the entry screen reaches, and in what order) so
|
||||
//! we can see exactly where the flow stops/fails. Every trace is read-only: it
|
||||
//! logs, then calls the original through a trampoline; it never alters control
|
||||
//! flow. Targets are chosen so their copied prologues are position-independent
|
||||
//! (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 crate::sbc_trace::{
|
||||
absolute_jump, allocate_trampoline, readable_range, target_va, validate_cards_build,
|
||||
};
|
||||
use crate::write_log;
|
||||
|
||||
static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
||||
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
|
||||
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
unsafe fn rd_i32(addr: usize) -> Option<i32> {
|
||||
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
|
||||
}
|
||||
unsafe fn rd_u8(addr: usize) -> Option<u8> {
|
||||
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
|
||||
}
|
||||
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
|
||||
unsafe fn rd_cstr(addr: usize, max: usize) -> String {
|
||||
if addr == 0 || !readable_range(addr, 1) {
|
||||
return String::from("<unreadable>");
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < max && readable_range(addr + i, 1) {
|
||||
let b = core::ptr::read_volatile((addr + i) as *const u8);
|
||||
if b == 0 {
|
||||
break;
|
||||
}
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
|
||||
/// MUST be whole, position-independent instructions) with an absolute jump to
|
||||
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
|
||||
unsafe fn install_detour(
|
||||
base: usize,
|
||||
rva: usize,
|
||||
name: &str,
|
||||
copy_len: usize,
|
||||
signature: &[u8],
|
||||
wrapper: usize,
|
||||
trampoline_slot: &AtomicUsize,
|
||||
) -> bool {
|
||||
let Some(target) = target_va(base, rva) else {
|
||||
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
|
||||
return false;
|
||||
};
|
||||
if !readable_range(target, copy_len)
|
||||
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
|
||||
{
|
||||
write_log(&format!(
|
||||
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
|
||||
));
|
||||
return false;
|
||||
}
|
||||
let Some(trampoline) = allocate_trampoline(target, copy_len) else {
|
||||
write_log(&format!("SEASON_TRACE: {name}: trampoline alloc failed\n"));
|
||||
return false;
|
||||
};
|
||||
trampoline_slot.store(trampoline, Ordering::Release);
|
||||
let mut patch = [0x90u8; 24];
|
||||
let jump = absolute_jump(wrapper);
|
||||
patch[..jump.len()].copy_from_slice(&jump);
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
||||
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
|
||||
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
|
||||
let mut ignored = 0u32;
|
||||
VirtualProtect(target as _, copy_len, old, &mut ignored);
|
||||
if flushed {
|
||||
write_log(&format!(
|
||||
"SEASON_TRACE: {name}: installed at {target:#x} (tramp {trampoline:#x})\n"
|
||||
));
|
||||
true
|
||||
} else {
|
||||
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn log_call(name: &str, rcx: usize, rdx: usize, r8: usize) {
|
||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
if n < 64 {
|
||||
write_log(&format!(
|
||||
"SEASON_CALL: {name} rcx={rcx:#x} rdx={rdx:#x} r8={r8:#x}\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare a passive 4-register-arg call trace. The wrapper is entered via the
|
||||
/// abs-jump patched over the target prologue (original args in rcx/rdx/r8/r9,
|
||||
/// caller's return address on the stack), logs, then tail-calls the original via
|
||||
/// the trampoline. A 4-arg/usize-return signature safely covers these season
|
||||
/// natives (<=4 integer args, void/int returns).
|
||||
macro_rules! season_call_trace {
|
||||
($wrap:ident, $tramp:ident, $name:literal) => {
|
||||
static $tramp: AtomicUsize = AtomicUsize::new(0);
|
||||
unsafe extern "system" fn $wrap(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
||||
log_call($name, rcx, rdx, r8);
|
||||
let t = $tramp.load(Ordering::Acquire);
|
||||
if t == 0 {
|
||||
return 0;
|
||||
}
|
||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||
core::mem::transmute(t);
|
||||
original(rcx, rdx, r8, r9)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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)"
|
||||
);
|
||||
// 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)"
|
||||
);
|
||||
|
||||
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
|
||||
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
|
||||
// string ptr. Logs those, then calls the original.
|
||||
static LOAD_CURRENT_IMPL_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
||||
unsafe extern "system" fn load_current_impl_wrapper(
|
||||
param_1: usize,
|
||||
param_2: usize,
|
||||
param_3: usize,
|
||||
param_4: usize,
|
||||
) -> usize {
|
||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
if n < 64 {
|
||||
// param_3 -> C string season id (best-effort read of first bytes).
|
||||
let sid = if param_3 != 0 && readable_range(param_3, 8) {
|
||||
let p = *(param_3 as *const usize);
|
||||
if p != 0 && readable_range(p, 8) {
|
||||
*(p as *const u64)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
write_log(&format!(
|
||||
"SEASON_CALL: LoadCurrentOfflineSeason_impl mgr={param_1:#x} stateByte={param_2:#x} sidPtr={param_3:#x} sidHead={sid:#x}\n"
|
||||
));
|
||||
}
|
||||
let t = LOAD_CURRENT_IMPL_TRAMP.load(Ordering::Acquire);
|
||||
if t == 0 {
|
||||
return 0;
|
||||
}
|
||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||
core::mem::transmute(t);
|
||||
original(param_1, param_2, param_3, param_4)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
if n < 64 {
|
||||
let status = rd_i32(result + 0x1c);
|
||||
let state = rd_u8(result + 0x68);
|
||||
let season_id = rd_i32(result + 0x5c);
|
||||
write_log(&format!(
|
||||
"SEASON_LOAD_COMPLETE: ctx={ctx:#x} result={result:#x} status(+0x1c)={} state(+0x68)={} seasonId(+0x5c)={}\n",
|
||||
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
||||
state.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
||||
season_id.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
||||
));
|
||||
}
|
||||
let t = COMPLETION_TRAMP.load(Ordering::Acquire);
|
||||
if t == 0 {
|
||||
return 0;
|
||||
}
|
||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||
core::mem::transmute(t);
|
||||
original(ctx, result, r8, r9)
|
||||
}
|
||||
|
||||
// GetUsersOfflineDivision native FUN_18004eb50 (registration FUN_18004e3f0 proved
|
||||
// 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)"
|
||||
);
|
||||
|
||||
/// 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.
|
||||
unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
|
||||
const GRAN: usize = 0x10000;
|
||||
let mut step = GRAN;
|
||||
while step < 0x6000_0000 {
|
||||
for signed in [step as isize, -(step as isize)] {
|
||||
let cand = base.wrapping_add(signed as usize) & !(GRAN - 1);
|
||||
if cand == 0 {
|
||||
continue;
|
||||
}
|
||||
let p = VirtualAlloc(cand as _, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||
if !p.is_null() {
|
||||
return Some(p as usize);
|
||||
}
|
||||
}
|
||||
step += GRAN;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Passive detour for a target whose copied prologue contains a single
|
||||
/// rip-relative operand (disp32 at `disp_off`, instruction ending at `insn_end`,
|
||||
/// both within the copied bytes). The trampoline is allocated near `base` and the
|
||||
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn install_detour_reloc(
|
||||
base: usize,
|
||||
rva: usize,
|
||||
name: &str,
|
||||
copy_len: usize,
|
||||
signature: &[u8],
|
||||
disp_off: usize,
|
||||
insn_end: usize,
|
||||
wrapper: usize,
|
||||
trampoline_slot: &AtomicUsize,
|
||||
) -> bool {
|
||||
let Some(target) = target_va(base, rva) else {
|
||||
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
|
||||
return false;
|
||||
};
|
||||
if !readable_range(target, copy_len)
|
||||
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
|
||||
{
|
||||
write_log(&format!(
|
||||
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
|
||||
));
|
||||
return false;
|
||||
}
|
||||
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"
|
||||
));
|
||||
return false;
|
||||
};
|
||||
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
|
||||
// Relocate the rip-relative disp32 to keep the same absolute target.
|
||||
let orig_disp = core::ptr::read_unaligned((target + disp_off) as *const i32) as i64;
|
||||
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"
|
||||
));
|
||||
return false;
|
||||
}
|
||||
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
|
||||
let back = absolute_jump(target + copy_len);
|
||||
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"
|
||||
));
|
||||
return false;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
|
||||
trampoline_slot.store(tramp, Ordering::Release);
|
||||
let mut patch = [0x90u8; 24];
|
||||
patch[..jump.len()].copy_from_slice(&jump);
|
||||
let mut prot = 0u32;
|
||||
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut prot) == 0 {
|
||||
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
|
||||
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
|
||||
let mut ignored = 0u32;
|
||||
VirtualProtect(target as _, copy_len, prot, &mut ignored);
|
||||
if flushed {
|
||||
write_log(&format!(
|
||||
"SEASON_TRACE: {name}: installed(reloc) at {target:#x} (tramp {tramp:#x})\n"
|
||||
));
|
||||
true
|
||||
} else {
|
||||
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// FutCompetitionServiceImpl::LoadOfflineSeasons FINAL completion (FUN_1800ffe90):
|
||||
// delivers the result to the AS callback LoadSeasons_Complete via
|
||||
// FUN_18019fb30->slot0x20(vm,"_global",cbref, "SUCCESS" | errString). param_1 = the
|
||||
// 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 {
|
||||
// Read the delivered status: byte0==0 => failure with an error string at +8.
|
||||
let flag = rd_u8(result);
|
||||
let errstr = if flag == Some(0) {
|
||||
let p = if readable_range(result + 8, 8) {
|
||||
core::ptr::read_volatile((result + 8) as *const usize)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
rd_cstr(p, 96)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
if n < 64 {
|
||||
let cbref = if readable_range(ctx + 0x18, 8) {
|
||||
core::ptr::read_volatile((ctx + 0x18) as *const usize)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let kind = match flag {
|
||||
Some(0) => "ERROR",
|
||||
Some(_) => "SUCCESS",
|
||||
None => "??",
|
||||
};
|
||||
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"
|
||||
));
|
||||
}
|
||||
// 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 {
|
||||
return 0;
|
||||
}
|
||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||
core::mem::transmute(t);
|
||||
original(ctx, result, r8, r9)
|
||||
}
|
||||
|
||||
// LoadOfflineSeasons STAGE-1 async completion (FUN_180106240): fails with
|
||||
// "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 {
|
||||
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"
|
||||
};
|
||||
write_log(&format!(
|
||||
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
|
||||
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
||||
));
|
||||
}
|
||||
}
|
||||
let t = STAGE1_COMPLETION_TRAMP.load(Ordering::Acquire);
|
||||
if t == 0 {
|
||||
return 0;
|
||||
}
|
||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||
core::mem::transmute(t);
|
||||
original(param1, result, r8, r9)
|
||||
}
|
||||
|
||||
// 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
|
||||
// rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating 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);
|
||||
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<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);
|
||||
if n < 64 {
|
||||
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 {
|
||||
return 0;
|
||||
}
|
||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||
core::mem::transmute(t);
|
||||
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() {
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
if base == 0 || !validate_cards_build(base) {
|
||||
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,
|
||||
&[
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
write_log("SEASON_TRACE: all season-native traces armed\n");
|
||||
}
|
||||
|
||||
/// 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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show.
|
||||
//!
|
||||
//! # Confirmed root cause (live, 2026-08-19)
|
||||
//!
|
||||
//! `FUN_18007e5e0(ctx, panel)` is the native tab binder the screen framework
|
||||
//! invokes at store screen-show. It is an unrolled six-slot loop; each slot gates
|
||||
//! on one hard-coded category token and either publishes that group's id as
|
||||
//! `PANEL_ID` for the slot, or hides the slot:
|
||||
//!
|
||||
//! ```text
|
||||
//! if (FUN_180014df0(_, idx)) // token present?
|
||||
//! (*(panel_vtbl+0x48))(panel, slot, "PANEL_ID", FUN_180014580(_, idx));
|
||||
//! else
|
||||
//! (*(panel_vtbl+0xa0))(panel, slot); // hide slot
|
||||
//! ```
|
||||
//!
|
||||
//! slot -> token, in bind order: `mypacks, bronze, silver, gold, special, points`.
|
||||
//! The gate `FUN_180014df0` resolves the token through `FUN_180014380`, which scans
|
||||
//! the loaded purchase groups (stride `0x108`) comparing the token at `group+0x70`.
|
||||
//! So a tab appears iff a purchase group carrying that token is loaded AT BIND TIME.
|
||||
//!
|
||||
//! The bind detour below measured the ground truth on the retail client:
|
||||
//!
|
||||
//! ```text
|
||||
//! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show
|
||||
//! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after
|
||||
//! ```
|
||||
//!
|
||||
//! `mask=0x00` at screen-show confirms the container is empty when the framework
|
||||
//! binds, so all six slots hide and no tab bar is built. The store's own
|
||||
//! `GET store/purchasegroup/all` only returns *after* screen-show, so re-entry works
|
||||
//! (groups cached) but first entry does not. (`0x0e` = bronze|silver|gold; bit 0
|
||||
//! `mypacks` is clear because an empty My Packs serves no `mypacks` group.)
|
||||
//!
|
||||
//! # What did NOT work, and why this module changed
|
||||
//!
|
||||
//! A previous version re-invoked the binder at the next render, once the groups had
|
||||
//! arrived (`rebound ... mask=0x0e` above). The movie built NO tab bar from that
|
||||
//! late bind: the Scaleform movie only honours the framework's OWN bind at
|
||||
//! screen-show, not a later re-publish/commit. That approach is abandoned.
|
||||
//!
|
||||
//! # This module: make the container non-empty BEFORE the first bind
|
||||
//!
|
||||
//! The only publish the movie honours is the framework's bind at screen-show, and
|
||||
//! re-entry proves that bind builds the bar correctly when the container is already
|
||||
//! full. So the fix is to load the purchase groups BEFORE the store screen is shown.
|
||||
//!
|
||||
//! `FUN_180017870(storefront)` issues the store's own `GET store/purchasegroup/all`.
|
||||
//! Firing it from the FUT hub event pump (a real game thread, well before the store
|
||||
//! screen exists) gives the response time to arrive and populate the container, so
|
||||
//! the first screen-show bind sees a full list and binds the tabs natively — exactly
|
||||
//! the re-entry path, on first entry.
|
||||
//!
|
||||
//! The bind detour is retained purely as the SENSOR: the first-entry bind mask is
|
||||
//! the safe, definitive measurement of whether the pre-warm populated the container
|
||||
//! in time. `mask != 0` at first bind ⇒ pre-warm worked and the tabs bind natively;
|
||||
//! `mask == 0` (with `storefront_seen=1` in the pre-warm log) ⇒ a hub-time request
|
||||
//! cannot land in time and the remaining route is the extracted `StoreFront.apt`.
|
||||
//!
|
||||
//! # Fail-closed
|
||||
//!
|
||||
//! * Pre-warm fires at most once per process, claimed atomically, and only once the
|
||||
//! storefront singleton is non-null; the storefront pointer is read through a
|
||||
//! guarded load and the request function's signature is validated before the call.
|
||||
//! * The bind detour only reads (captures pointers, probes the game's own gate with
|
||||
//! a provably-dead `this`) and never mutates store state.
|
||||
//! * Image plus every function signature are verified before any write and again
|
||||
//! under thread suspension; one wrong byte aborts with no write and no call.
|
||||
//!
|
||||
//! # Promotion
|
||||
//!
|
||||
//! PROMOTED: armed by the build, never by an environment variable (see
|
||||
//! [`REPAIR_PROMOTED`]). Rollback is a `version.dll` file swap.
|
||||
|
||||
use core::ffi::c_void;
|
||||
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
|
||||
GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
};
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
/// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework
|
||||
/// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw.
|
||||
const BIND_RVA: usize = 0x7e5e0;
|
||||
/// Category gate `FUN_180014df0(dead_this, idx) -> bool`: maps `idx` to one of the
|
||||
/// six hard-coded tokens and reports whether a loaded purchase group carries it.
|
||||
const HAS_CATEGORY_RVA: usize = 0x14df0;
|
||||
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
|
||||
/// the store screen makes at entry (from `0x18007f25e`). Fired early to pre-warm.
|
||||
const REQUEST_GROUPS_RVA: usize = 0x17870;
|
||||
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
|
||||
/// request/lookup helpers (loaded at `0x18007f25e`, right before the pack-list GET).
|
||||
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
|
||||
|
||||
/// Gate indices in slot order: `mypacks, bronze, silver, gold, special, points`.
|
||||
/// Taken from the binder's unrolled call sequence, not from the index order of
|
||||
/// `FUN_180014580`'s jump table (which is deliberately different).
|
||||
const GATE_INDICES: [u32; 6] = [0, 2, 3, 4, 5, 1];
|
||||
|
||||
/// Whole-instruction prologue length relocated into the trampoline; also the number
|
||||
/// of bytes overwritten by the entry detour. 15 bytes, a clean boundary covering the
|
||||
/// 14-byte absolute jump.
|
||||
const COPY_LEN: usize = 15;
|
||||
const ABS_JUMP_LEN: usize = 14;
|
||||
|
||||
/// First 15 bytes of `FUN_18007e5e0`: `mov [rsp+8],rbx; mov [rsp+0x10],rbp;
|
||||
/// mov [rsp+0x18],rsi` = 5 + 5 + 5.
|
||||
const BIND_SIGNATURE: [u8; COPY_LEN] = [
|
||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18,
|
||||
];
|
||||
/// First 15 bytes of `FUN_180014df0`. Validated before we ever call it, so the gate
|
||||
/// probe only runs on the exact build it was reversed against.
|
||||
const HAS_CATEGORY_SIGNATURE: [u8; 15] = [
|
||||
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x33, 0xdb, 0x44, 0x8b, 0xc3, 0x85, 0xd2, 0x74, 0x35,
|
||||
];
|
||||
/// First 18 bytes of `FUN_180017870`. Validated before we ever call it, so the
|
||||
/// pre-warm only fires the genuine request on the exact build it was reversed against.
|
||||
const REQUEST_GROUPS_SIGNATURE: [u8; 18] = [
|
||||
0x40, 0x57, 0x48, 0x81, 0xec, 0x90, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff,
|
||||
0xff, 0xff,
|
||||
];
|
||||
|
||||
type BindFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> *mut c_void;
|
||||
type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8;
|
||||
type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize;
|
||||
|
||||
/// The tab-bar repair is PROMOTED: armed by the build, never by an environment
|
||||
/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves
|
||||
/// identically. Promotion does not weaken any check — the signature gate, the image
|
||||
/// validation and the thread quiesce all remain in the runtime evidence path.
|
||||
pub(crate) const REPAIR_PROMOTED: bool = true;
|
||||
|
||||
/// Compile-time contract: the repair stays build-armed. Regressing it to an env gate
|
||||
/// would silently restore the missing first-entry tab bar on a normal launch, so it
|
||||
/// must be a deliberate, visible change here rather than a missing variable.
|
||||
const _: () = assert!(REPAIR_PROMOTED);
|
||||
|
||||
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static STORE_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
/// Gate mask the framework's most recent bind observed (bit N = slot N would bind).
|
||||
static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0);
|
||||
static LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
|
||||
/// Set once the pre-warm request has been fired (or is provably unnecessary).
|
||||
static PREWARM_DONE: AtomicBool = AtomicBool::new(false);
|
||||
static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
|
||||
/// Highest storefront pointer observed at hub time (0 = never non-null yet). Logged
|
||||
/// so a failed pre-warm can be attributed to "storefront not up at hub" vs "fired
|
||||
/// but the response did not land before screen-show".
|
||||
static PREWARM_STOREFRONT_SEEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Pure pre-warm decision, isolated for host tests.
|
||||
///
|
||||
/// Fire exactly once, and only once the storefront singleton is non-null; before
|
||||
/// that, keep waiting (a null storefront early at the hub is expected).
|
||||
fn should_prewarm(already_done: bool, storefront: usize) -> bool {
|
||||
!already_done && storefront != 0
|
||||
}
|
||||
|
||||
/// Probe all six category tokens with the game's own gate and return a slot mask.
|
||||
///
|
||||
/// `FUN_180014df0` forwards its `this` to `FUN_180014380`, which discards it and
|
||||
/// fetches the group container from a singleton, so a null `this` is exactly what
|
||||
/// the native code effectively passes. Called only from the bind detour, where the
|
||||
/// store subsystem is provably live.
|
||||
unsafe fn gate_mask() -> u8 {
|
||||
let base = STORE_BASE.load(Ordering::Acquire);
|
||||
if base == 0 {
|
||||
return 0;
|
||||
}
|
||||
let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else {
|
||||
return 0;
|
||||
};
|
||||
let gate_fn: HasCategoryFn = core::mem::transmute(gate);
|
||||
let mut mask = 0u8;
|
||||
for (slot, index) in GATE_INDICES.iter().enumerate() {
|
||||
if gate_fn(core::ptr::null_mut(), *index) != 0 {
|
||||
mask |= 1 << slot;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Ask the game to load the purchase groups now, on the caller's (game) thread.
|
||||
///
|
||||
/// Called from the FUT event dispatcher so it runs on a real game thread well before
|
||||
/// the store screen is ever shown — the same thread the store screen itself would use
|
||||
/// for this call at entry. Fail-closed: base/signature/storefront all validated, at
|
||||
/// most one request per process.
|
||||
pub(crate) unsafe fn maybe_prewarm_groups() {
|
||||
if PREWARM_DONE.load(Ordering::Acquire) || !REPAIR_ENABLED.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let base = STORE_BASE.load(Ordering::Acquire);
|
||||
if base == 0 || !crate::sbc_trace::valid_cards_image(base) {
|
||||
return;
|
||||
}
|
||||
let Some(storefront) = base
|
||||
.checked_add(STOREFRONT_GLOBAL_RVA)
|
||||
.and_then(|slot| crate::sbc_trace::guarded_usize(slot))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if storefront != 0 {
|
||||
PREWARM_STOREFRONT_SEEN.store(storefront, Ordering::Release);
|
||||
}
|
||||
if !should_prewarm(false, storefront) {
|
||||
// Storefront not up yet at the hub: keep waiting, do not consume the attempt.
|
||||
return;
|
||||
}
|
||||
let Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else {
|
||||
return;
|
||||
};
|
||||
if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len())
|
||||
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
|
||||
!= REQUEST_GROUPS_SIGNATURE
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Claim the single attempt before issuing it, so a re-entrant event can never
|
||||
// fire a second request.
|
||||
PREWARM_DONE.store(true, Ordering::Release);
|
||||
PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
let request_fn: RequestGroupsFn = core::mem::transmute(request);
|
||||
request_fn(storefront as *mut c_void);
|
||||
crate::write_log(&format!(
|
||||
"STORE_TABS: pre-warmed purchase groups at hub (storefront={storefront:#x})\n"
|
||||
));
|
||||
}
|
||||
|
||||
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
|
||||
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
|
||||
let mut ignored = 0u32;
|
||||
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
|
||||
}
|
||||
|
||||
unsafe fn write_entry<const N: usize>(
|
||||
target: usize,
|
||||
destination: usize,
|
||||
original: &[u8; N],
|
||||
) -> Result<(), bool> {
|
||||
let mut patch = [0x90u8; N];
|
||||
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
||||
return Err(true);
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
|
||||
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
|
||||
let mut ignored = 0u32;
|
||||
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(restore_entry(target, original))
|
||||
}
|
||||
}
|
||||
|
||||
/// Detour target for the native tab binder. Read-only sensor: records the gate mask
|
||||
/// the framework's bind is about to act on, then runs the original unchanged. This is
|
||||
/// the definitive measurement of whether the pre-warm populated the container in time.
|
||||
unsafe extern "system" fn bind_wrapper(ctx: *mut c_void, panel: *mut c_void) -> *mut c_void {
|
||||
let mask = gate_mask();
|
||||
LAST_BIND_MASK.store(mask as u32, Ordering::Release);
|
||||
LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||
BIND_ENTRIES.fetch_add(1, Ordering::AcqRel);
|
||||
let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(ctx, panel)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum InstallOutcome {
|
||||
Installed,
|
||||
CleanFailure,
|
||||
DegradedHookActive,
|
||||
DegradedProcessState,
|
||||
DegradedHookAndProcess,
|
||||
}
|
||||
|
||||
unsafe fn install_hook(base: usize) -> InstallOutcome {
|
||||
let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let Some(request) = crate::sbc_trace::target_va(base, REQUEST_GROUPS_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
// Fingerprint the image and ALL THREE functions: the one we detour and the two we
|
||||
// call (gate probe, group request). A single mismatched byte aborts cleanly with
|
||||
// no write and no call.
|
||||
if !crate::sbc_trace::valid_cards_image(base)
|
||||
|| !crate::sbc_trace::executable_range_in_image(base, bind, BIND_SIGNATURE.len())
|
||||
|| !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_SIGNATURE.len())
|
||||
|| !crate::sbc_trace::executable_range_in_image(
|
||||
base,
|
||||
request,
|
||||
REQUEST_GROUPS_SIGNATURE.len(),
|
||||
)
|
||||
|| core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) != BIND_SIGNATURE
|
||||
|| core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len())
|
||||
!= HAS_CATEGORY_SIGNATURE
|
||||
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
|
||||
!= REQUEST_GROUPS_SIGNATURE
|
||||
{
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
let mut pinned = core::ptr::null_mut();
|
||||
if GetModuleHandleExA(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
bind as *const u8,
|
||||
&mut pinned,
|
||||
) == 0
|
||||
|| pinned as usize != base
|
||||
{
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
BIND_TRAMPOLINE.store(trampoline, Ordering::Release);
|
||||
STORE_BASE.store(base, Ordering::Release);
|
||||
|
||||
let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
BIND_TRAMPOLINE.store(0, Ordering::Release);
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) {
|
||||
Ok(peers) => peers,
|
||||
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
BIND_TRAMPOLINE.store(0, Ordering::Release);
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
|
||||
return InstallOutcome::DegradedProcessState;
|
||||
}
|
||||
};
|
||||
let final_valid = crate::sbc_trace::valid_cards_image(base)
|
||||
&& core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE;
|
||||
let transaction = if !final_valid {
|
||||
InstallOutcome::CleanFailure
|
||||
} else {
|
||||
match write_entry(bind, bind_wrapper as *const () as usize, &BIND_SIGNATURE) {
|
||||
Ok(()) => InstallOutcome::Installed,
|
||||
Err(true) => InstallOutcome::CleanFailure,
|
||||
Err(false) => InstallOutcome::DegradedHookActive,
|
||||
}
|
||||
};
|
||||
let resumed = peers.resume_all();
|
||||
let outcome = if resumed {
|
||||
transaction
|
||||
} else if matches!(
|
||||
transaction,
|
||||
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
|
||||
) {
|
||||
InstallOutcome::DegradedHookAndProcess
|
||||
} else {
|
||||
InstallOutcome::DegradedProcessState
|
||||
};
|
||||
if outcome == InstallOutcome::CleanFailure {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
BIND_TRAMPOLINE.store(0, Ordering::Release);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
unsafe fn worker() {
|
||||
let _pending = crate::sbc_trace::CodeInstallerPending;
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let outcome = if base == 0 {
|
||||
InstallOutcome::CleanFailure
|
||||
} else {
|
||||
install_hook(base)
|
||||
};
|
||||
drop(_pending);
|
||||
match outcome {
|
||||
InstallOutcome::Installed => {
|
||||
crate::write_log("STORE_TABS: bind sensor + pre-warm installed (promoted)\n")
|
||||
}
|
||||
InstallOutcome::CleanFailure => {
|
||||
crate::write_log("STORE_TABS: clean install failure; inactive\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedHookActive => {
|
||||
crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedProcessState => {
|
||||
crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedHookAndProcess => {
|
||||
crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut binds_seen = 0u64;
|
||||
let mut reports = 0u8;
|
||||
while reports < 64 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let binds = BIND_ENTRIES.load(Ordering::Acquire);
|
||||
if binds != binds_seen {
|
||||
crate::write_log(&format!(
|
||||
"STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n",
|
||||
binds,
|
||||
LAST_BIND_MASK.load(Ordering::Acquire),
|
||||
PREWARM_ATTEMPTS.load(Ordering::Acquire),
|
||||
PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire),
|
||||
LAST_THREAD.load(Ordering::Relaxed),
|
||||
));
|
||||
binds_seen = binds;
|
||||
reports += 1;
|
||||
}
|
||||
}
|
||||
crate::write_log("STORE_TABS: report cap reached; hook remains installed\n");
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gate_indices_match_the_native_slot_order() {
|
||||
// mypacks, bronze, silver, gold, special, points — the order FUN_18007e5e0
|
||||
// tests them in, which is NOT the index order of FUN_180014580's jump table.
|
||||
assert_eq!(GATE_INDICES, [0, 2, 3, 4, 5, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prewarms_once_the_storefront_is_up() {
|
||||
assert!(should_prewarm(false, 0x1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waits_while_the_storefront_is_still_null() {
|
||||
assert!(!should_prewarm(false, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_prewarms_twice() {
|
||||
assert!(!should_prewarm(true, 0x1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detour_signature_is_long_enough_for_the_absolute_jump() {
|
||||
assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN);
|
||||
assert_eq!(COPY_LEN, BIND_SIGNATURE.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_signature_covers_the_validated_prologue() {
|
||||
// 18 bytes: `push rdi; sub rsp,0x90; movq [rsp+0x20],-2`.
|
||||
assert_eq!(REQUEST_GROUPS_SIGNATURE.len(), 18);
|
||||
}
|
||||
}
|
||||
+13
-14
@@ -541,7 +541,7 @@ impl LauncherApp {
|
||||
status_text(ui, readiness_status(server), &server_label);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Client integration").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Game files").color(theme::TEXT_WEAK));
|
||||
status_text(
|
||||
ui,
|
||||
readiness_status(integration),
|
||||
@@ -555,11 +555,11 @@ impl LauncherApp {
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Local services").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Background helpers").color(theme::TEXT_WEAK));
|
||||
status_text(ui, readiness_status(services), &services_label);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Game patch").color(theme::TEXT_WEAK));
|
||||
status_text(ui, readiness_status(hook), &hook_label);
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -725,13 +725,12 @@ impl LauncherApp {
|
||||
match (ready, blocked) {
|
||||
(_, true) => (launch::Readiness::Attention, "Blocked".into()),
|
||||
(2, _) => (launch::Readiness::Ready, "Ready".into()),
|
||||
(0, _) => (
|
||||
// Not a problem: Launch starts them. Stating "Stopped" is honest
|
||||
// and does not demand an action.
|
||||
launch::Readiness::Unknown,
|
||||
"Stopped — Launch starts them".into(),
|
||||
),
|
||||
(_, _) => (launch::Readiness::Unknown, "Partly running".into()),
|
||||
// Neither stopped nor mid-start is a fault: the helpers only run
|
||||
// alongside a session and Launch brings up whatever is missing. This
|
||||
// used to read "Partly running", which sounds broken for what is the
|
||||
// normal idle state and gave the player nothing to act on. Say what
|
||||
// will happen instead.
|
||||
(_, _) => (launch::Readiness::Unknown, "Start with the game".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,14 +744,14 @@ impl LauncherApp {
|
||||
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
|
||||
{
|
||||
Some(d) if d == self.config.server_config() => {
|
||||
(launch::Readiness::Ready, format!("Deployed → {}", d.host))
|
||||
(launch::Readiness::Ready, "Installed".into())
|
||||
}
|
||||
// Launch rewrites it, so this is not something to demand action for.
|
||||
Some(d) => (
|
||||
Some(_) => (
|
||||
launch::Readiness::Unknown,
|
||||
format!("Deployed → {} · Launch updates it", d.host),
|
||||
"Installed · Launch will update it".into(),
|
||||
),
|
||||
None => (launch::Readiness::Attention, "No openfut.cfg".into()),
|
||||
None => (launch::Readiness::Attention, "Not set up".into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user