diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index 62b1f73..8a258ba 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -160,6 +160,7 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { crate::store_entry::install(); crate::season_trace::install(); crate::season_team_compat::install(); + crate::offline_seasons_pma::install(); crate::kit_trace::install(); 0 } diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index bd10504..249d537 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -20,6 +20,8 @@ mod fifa17_tls; mod iat; #[cfg(feature = "fifa17")] mod kit_trace; +#[cfg(feature = "fifa17")] +mod offline_seasons_pma; mod patch_mem; #[cfg(feature = "fifa17")] mod sbc_dispatch; diff --git a/openfut-hook/src/offline_seasons_pma.rs b/openfut-hook/src/offline_seasons_pma.rs new file mode 100644 index 0000000..d45e668 --- /dev/null +++ b/openfut-hook/src/offline_seasons_pma.rs @@ -0,0 +1,399 @@ +//! FIFA 17 Offline Seasons PMA completion compatibility repair. +//! +//! Retail-compatible main-menu Kick Off completes the PMA instructions state by +//! broadcasting event `1` through the mode-zero child's callback dispatcher. FUT +//! Offline Seasons reaches the same PMA UI state but its completed drill scenario +//! broadcasts event `5`, which returns the UI to state `0` and leaves the drill +//! active. This default-off repair intercepts that shared callback dispatcher and +//! rewrites only the fully identified Offline Seasons `5` to `1`, then calls the +//! original dispatcher so every native subscriber observes the working completion. + +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; + +use crate::sbc_trace::{guarded_u8, guarded_usize, readable_range, validate_cards_build}; +use crate::season_trace::install_detour; +use crate::write_log; + +const ENABLE_ENV: &str = "OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX"; + +const CALLBACK_DISPATCHER_RVA: usize = 0x07ac_87b0; +const CALLBACK_DISPATCHER_COPY_LEN: usize = 15; +const CALLBACK_DISPATCHER_SIGNATURE: [u8; CALLBACK_DISPATCHER_COPY_LEN] = [ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, +]; + +const GAMEPLAY_GLOBAL_SLOT_RVA: usize = 0x04bf_b910; +const CALLBACK_DISPATCHER_VTABLE_RVA: usize = 0x03ae_9ba0; +const PMA_INSTRUCTIONS_VTABLE_RVA: usize = 0x03af_2750; +const PMA_INSTRUCTIONS_HANDLER_RVA: usize = 0x07ac_91e0; +const FREE_ROAM_VTABLE_RVA: usize = 0x03ae_df58; +const FREE_ROAM_DTOR_RVA: usize = 0x07a5_db70; + +const FUT_SECONDARY_LISTENER_VTABLE_RVA: usize = 0x20e9b8; +const FUT_SELECTED_LISTENER_VTABLE_RVA: usize = 0x20fea8; + +const EVENT_COMPLETE_ADVANCE: u32 = 1; +const EVENT_DRILL_COMPLETE: u32 = 5; +const PMA_UI_INSTRUCTIONS_STATE: usize = 4; +const FREE_ROAM_ACTIVE_PMA_STATE: i32 = 9; +const OFFLINE_SEASONS_MODE_ID: i32 = 21; + +static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false); +static DISPATCHER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static CANDIDATE_REPORTS: AtomicUsize = AtomicUsize::new(0); +static MAIN_BASE: AtomicUsize = AtomicUsize::new(0); +static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Decision { + Rewrite, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum Rejection { + None, + RepairDisabled, + DispatcherClass, + InstructionsState, + ListenerTopology, + FreeRoamClass, + FreeRoamState, + FreeRoamNotReady, + SecondaryListenerClass, + SelectedListenerClass, + OfflineSeasonsMode, +} + +#[derive(Clone, Copy, Debug)] +struct DecisionInput { + repair_enabled: bool, + dispatcher_class: bool, + instructions_state: bool, + selected_index: Option, + free_roam_class: bool, + free_roam_state: Option, + free_roam_ready: Option, + secondary_listener_class: bool, + selected_listener_class: bool, + selected_mode: Option, +} + +fn decide(input: DecisionInput) -> Result { + if !input.repair_enabled { + return Err(Rejection::RepairDisabled); + } + if !input.dispatcher_class { + return Err(Rejection::DispatcherClass); + } + if !input.instructions_state { + return Err(Rejection::InstructionsState); + } + if input.selected_index != Some(2) { + return Err(Rejection::ListenerTopology); + } + if !input.free_roam_class { + return Err(Rejection::FreeRoamClass); + } + if input.free_roam_state != Some(FREE_ROAM_ACTIVE_PMA_STATE) { + return Err(Rejection::FreeRoamState); + } + if input.free_roam_ready != Some(1) { + return Err(Rejection::FreeRoamNotReady); + } + if !input.secondary_listener_class { + return Err(Rejection::SecondaryListenerClass); + } + if !input.selected_listener_class { + return Err(Rejection::SelectedListenerClass); + } + if input.selected_mode != Some(OFFLINE_SEASONS_MODE_ID) { + return Err(Rejection::OfflineSeasonsMode); + } + Ok(Decision::Rewrite) +} + +fn enabled(value: Option<&str>) -> bool { + value == Some("1") +} + +unsafe fn read_i32(address: usize) -> Option { + readable_range(address, 4).then(|| core::ptr::read_volatile(address as *const i32)) +} + +unsafe fn expected_pointer(address: usize, expected: usize) -> bool { + guarded_usize(address) == Some(expected) +} + +unsafe fn main_image_matches(base: usize) -> bool { + expected_pointer( + base + CALLBACK_DISPATCHER_VTABLE_RVA, + base + CALLBACK_DISPATCHER_RVA, + ) && expected_pointer( + base + PMA_INSTRUCTIONS_VTABLE_RVA, + base + PMA_INSTRUCTIONS_HANDLER_RVA, + ) && expected_pointer(base + FREE_ROAM_VTABLE_RVA, base + FREE_ROAM_DTOR_RVA) +} + +unsafe fn instructions_state_active(dispatcher: usize, main_base: usize) -> bool { + let sentinel = match dispatcher.checked_add(8) { + Some(value) => value, + None => return false, + }; + let mut node = match guarded_usize(sentinel) { + Some(value) => value, + None => return false, + }; + for _ in 0..8 { + if node == sentinel { + return false; + } + let listener = match node + .checked_add(0x10) + .and_then(|address| guarded_usize(address)) + { + Some(value) if value != 0 => value, + _ => return false, + }; + if guarded_usize(listener) == Some(main_base + PMA_INSTRUCTIONS_VTABLE_RVA) { + let parent = listener + .checked_add(8) + .and_then(|address| guarded_usize(address)); + let machine = parent + .and_then(|value| value.checked_add(8)) + .and_then(|address| guarded_usize(address)); + let states = machine + .and_then(|value| value.checked_add(8)) + .and_then(|address| guarded_usize(address)); + let current = machine + .and_then(|value| value.checked_add(0x10)) + .and_then(|address| guarded_usize(address)); + let state_four = states + .and_then(|value| value.checked_add(PMA_UI_INSTRUCTIONS_STATE * 8)) + .and_then(|address| guarded_usize(address)); + return current == Some(listener) + && state_four == Some(listener) + && guarded_u8(listener + 0x18) == Some(0); + } + node = match guarded_usize(node) { + Some(value) => value, + None => return false, + }; + } + false +} + +unsafe fn snapshot(dispatcher: usize) -> DecisionInput { + let main_base = MAIN_BASE.load(Ordering::Acquire); + let cards_base = CARDS_BASE.load(Ordering::Acquire); + let dispatcher_class = + guarded_usize(dispatcher) == Some(main_base + CALLBACK_DISPATCHER_VTABLE_RVA); + + let gameplay_global = guarded_usize(main_base + GAMEPLAY_GLOBAL_SLOT_RVA); + let listener_manager = gameplay_global + .and_then(|value| value.checked_add(0x58)) + .and_then(|address| guarded_usize(address)); + let table = listener_manager.and_then(|value| guarded_usize(value)); + let selected_index = table + .and_then(|value| value.checked_add(0x20)) + .and_then(|address| read_i32(address)); + let free_roam = table.and_then(|value| guarded_usize(value)); + let secondary = table + .and_then(|value| value.checked_add(8)) + .and_then(|address| guarded_usize(address)); + let selected = match (table, selected_index) { + (Some(value), Some(index @ 0..=2)) => value + .checked_add(index as usize * 8) + .and_then(|address| guarded_usize(address)), + _ => None, + }; + + DecisionInput { + repair_enabled: REPAIR_ACTIVE.load(Ordering::Acquire), + dispatcher_class, + instructions_state: instructions_state_active(dispatcher, main_base), + selected_index, + free_roam_class: free_roam.and_then(|value| guarded_usize(value)) + == Some(main_base + FREE_ROAM_VTABLE_RVA), + free_roam_state: free_roam + .and_then(|value| value.checked_add(0x30)) + .and_then(|address| read_i32(address)), + free_roam_ready: free_roam + .and_then(|value| value.checked_add(0x124)) + .and_then(|address| read_i32(address)), + secondary_listener_class: secondary.and_then(|value| guarded_usize(value)) + == Some(cards_base + FUT_SECONDARY_LISTENER_VTABLE_RVA), + selected_listener_class: selected.and_then(|value| guarded_usize(value)) + == Some(cards_base + FUT_SELECTED_LISTENER_VTABLE_RVA), + selected_mode: selected + .and_then(|value| value.checked_add(0x18)) + .and_then(|address| read_i32(address)), + } +} + +type DispatcherFn = unsafe extern "system" fn(usize, u32, usize, usize) -> usize; + +unsafe extern "system" fn dispatcher_wrapper( + dispatcher: usize, + event: u32, + r8: usize, + r9: usize, +) -> usize { + let trampoline = DISPATCHER_TRAMPOLINE.load(Ordering::Acquire); + if trampoline == 0 { + return 0; + } + let original: DispatcherFn = core::mem::transmute(trampoline); + if event != EVENT_DRILL_COMPLETE { + return original(dispatcher, event, r8, r9); + } + + let input = snapshot(dispatcher); + let decision = decide(input); + let rewritten = matches!(decision, Ok(Decision::Rewrite)); + let forwarded_event = if rewritten { + EVENT_COMPLETE_ADVANCE + } else { + event + }; + let report = CANDIDATE_REPORTS.fetch_add(1, Ordering::Relaxed); + if report < 16 { + write_log(&format!( + "[OpenFUT][OfflineSeasons] PMA completion observed event={event} dispatcher={dispatcher:#x} pma_state4={} selected_index={} selected_mode={} free_roam_state={} ready={} action={} rejection={:?}\n", + input.instructions_state, + input.selected_index.unwrap_or(-1), + input.selected_mode.unwrap_or(-1), + input.free_roam_state.unwrap_or(-1), + input.free_roam_ready.unwrap_or(-1), + if rewritten { "rewrite-5-to-1" } else { "native" }, + decision.err().unwrap_or(Rejection::None), + )); + } + original(dispatcher, forwarded_event, r8, r9) +} + +unsafe fn worker() { + let main_base = GetModuleHandleA(core::ptr::null()) as usize; + if main_base == 0 || !main_image_matches(main_base) { + write_log("[OpenFUT][OfflineSeasons] main FIFA image mismatch; inactive\n"); + return; + } + + let mut cards_base = 0usize; + for _ in 0..600u32 { + cards_base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize; + if cards_base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if cards_base == 0 || !validate_cards_build(cards_base) { + write_log("[OpenFUT][OfflineSeasons] CardsDLL unavailable/invalid; inactive\n"); + return; + } + + MAIN_BASE.store(main_base, Ordering::Release); + CARDS_BASE.store(cards_base, Ordering::Release); + if !install_detour( + main_base, + CALLBACK_DISPATCHER_RVA, + "OfflineSeasons_PMA_callback_dispatcher", + CALLBACK_DISPATCHER_COPY_LEN, + &CALLBACK_DISPATCHER_SIGNATURE, + dispatcher_wrapper as *const () as usize, + &DISPATCHER_TRAMPOLINE, + ) { + write_log("[OpenFUT][OfflineSeasons] callback dispatcher hook failed; inactive\n"); + return; + } + REPAIR_ACTIVE.store(true, Ordering::Release); + write_log("[OpenFUT][OfflineSeasons] PMA completion repair ARMED\n"); +} + +pub(crate) fn install() { + if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) { + write_log("[OpenFUT][OfflineSeasons] PMA completion repair disabled\n"); + return; + } + write_log("[OpenFUT][OfflineSeasons] PMA completion repair requested\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_input() -> DecisionInput { + DecisionInput { + repair_enabled: true, + dispatcher_class: true, + instructions_state: true, + selected_index: Some(2), + free_roam_class: true, + free_roam_state: Some(9), + free_roam_ready: Some(1), + secondary_listener_class: true, + selected_listener_class: true, + selected_mode: Some(21), + } + } + + #[test] + fn feature_is_default_off() { + assert!(!enabled(None)); + assert!(!enabled(Some("0"))); + assert!(!enabled(Some("true"))); + assert!(enabled(Some("1"))); + } + + #[test] + fn exact_offline_seasons_evidence_rewrites() { + assert_eq!(decide(valid_input()), Ok(Decision::Rewrite)); + } + + #[test] + fn every_runtime_gate_fails_closed() { + let cases: &[(Rejection, fn(&mut DecisionInput))] = &[ + (Rejection::RepairDisabled, |input: &mut DecisionInput| { + input.repair_enabled = false + }), + (Rejection::DispatcherClass, |input: &mut DecisionInput| { + input.dispatcher_class = false + }), + (Rejection::InstructionsState, |input: &mut DecisionInput| { + input.instructions_state = false + }), + (Rejection::ListenerTopology, |input: &mut DecisionInput| { + input.selected_index = Some(1) + }), + (Rejection::FreeRoamClass, |input: &mut DecisionInput| { + input.free_roam_class = false + }), + (Rejection::FreeRoamState, |input: &mut DecisionInput| { + input.free_roam_state = Some(10) + }), + (Rejection::FreeRoamNotReady, |input: &mut DecisionInput| { + input.free_roam_ready = Some(0) + }), + ( + Rejection::SecondaryListenerClass, + |input: &mut DecisionInput| input.secondary_listener_class = false, + ), + ( + Rejection::SelectedListenerClass, + |input: &mut DecisionInput| input.selected_listener_class = false, + ), + ( + Rejection::OfflineSeasonsMode, + |input: &mut DecisionInput| input.selected_mode = Some(1), + ), + ]; + for &(expected, mutate) in cases { + let mut input = valid_input(); + mutate(&mut input); + assert_eq!(decide(input), Err(expected)); + } + } +}