diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index 7cd60e3..62b1f73 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -159,6 +159,7 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { crate::sbc_request_trace::install(); crate::store_entry::install(); crate::season_trace::install(); + crate::season_team_compat::install(); crate::kit_trace::install(); 0 } diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 434ddb4..bd10504 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -30,6 +30,8 @@ mod sbc_request_trace; #[cfg(feature = "fifa17")] mod sbc_trace; #[cfg(feature = "fifa17")] +mod season_team_compat; +#[cfg(feature = "fifa17")] mod season_trace; #[cfg(feature = "fifa17")] mod store_entry; diff --git a/openfut-hook/src/season_team_compat.rs b/openfut-hook/src/season_team_compat.rs new file mode 100644 index 0000000..95dac3a --- /dev/null +++ b/openfut-hook/src/season_team_compat.rs @@ -0,0 +1,445 @@ +//! FIFA 17 Offline Seasons game-setup team compatibility candidate. +//! +//! `FUT::SeasonsManagerOfflineHelper` first projects the authentic dynamic pair +//! `[fixture_team, user_team]`. Later, `futSelectTeam::SetupTeamsInfo()` asks +//! `CardsGameSetupAdapter.GetTeam(side)` while rebuilding its panel state. The +//! first native reads expose the correct fixture and user teams on distinct GetTeam +//! sides. A later repeated read of the user-returning side is fed into SetTeam's +//! inverse side mapping and duplicates the user's XI over the opponent. +//! +//! This default-off candidate corrects that source read, not SetTeam or the final +//! writer. It records the projector pair, requires one native observation of each +//! team on distinct sides, and permits one correction on the next repeated +//! user-team read. Missing or conflicting evidence always preserves native behavior. + +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Mutex; + +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; + +use crate::sbc_trace::{readable_range, target_va, validate_cards_build}; +use crate::season_trace::{install_detour, install_detour_reloc, rd_i32}; +use crate::write_log; + +const ENABLE_ENV: &str = "OPENFUT_FIFA17_SEASON_TEAM_COMPAT"; + +const FIXTURE_PROJECTOR_RVA: usize = 0x0fc500; +const GET_TEAM_RVA: usize = 0x0054a0; + +const FIXTURE_PROJECTOR_SIGNATURE: [u8; 19] = [ + 0x40, 0x57, 0x41, 0x54, 0x41, 0x56, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, + 0xff, 0xff, 0xff, +]; +const GET_TEAM_SIGNATURE: [u8; 17] = [ + 0x48, 0x8b, 0x05, 0xb9, 0x8a, 0x2d, 0x00, 0x4c, 0x8b, 0x80, 0x50, 0x03, 0x00, 0x00, 0x49, 0xff, + 0xe0, +]; + +const UNKNOWN_SIDE: i32 = -1; + +static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false); +static FIXTURE_PROJECTOR_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static GET_TEAM_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static TEAM_STATE: Mutex = Mutex::new(TeamState::empty()); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TeamState { + fixture_team: i32, + user_team: i32, + fixture_get_side: i32, + user_get_side: i32, + correction_used: bool, +} + +impl TeamState { + const fn empty() -> Self { + Self { + fixture_team: 0, + user_team: 0, + fixture_get_side: UNKNOWN_SIDE, + user_get_side: UNKNOWN_SIDE, + correction_used: false, + } + } + + fn capture(&mut self, fixture_team: i32, user_team: i32) -> bool { + if !valid_pair(fixture_team, user_team) { + *self = Self::empty(); + return false; + } + *self = Self { + fixture_team, + user_team, + fixture_get_side: UNKNOWN_SIDE, + user_get_side: UNKNOWN_SIDE, + correction_used: false, + }; + true + } + + fn observe_get_team(&mut self, side: i32, native_team: i32) -> GetTeamDecision { + if !valid_side(side) || !valid_pair(self.fixture_team, self.user_team) { + return GetTeamDecision::native(native_team); + } + + if native_team == self.fixture_team { + if (self.fixture_get_side != UNKNOWN_SIDE && self.fixture_get_side != side) + || self.user_get_side == side + { + return self.clear_on_conflict(native_team); + } + let first_observation = self.fixture_get_side == UNKNOWN_SIDE; + self.fixture_get_side = side; + return GetTeamDecision { + team: native_team, + event: if self.user_get_side != UNKNOWN_SIDE { + DecisionEvent::NativePairConfirmed + } else if first_observation { + DecisionEvent::FixtureObserved + } else { + DecisionEvent::None + }, + }; + } + + if native_team == self.user_team { + if self.user_get_side == UNKNOWN_SIDE { + if self.fixture_get_side == side { + return self.clear_on_conflict(native_team); + } + self.user_get_side = side; + return GetTeamDecision { + team: native_team, + event: if self.fixture_get_side != UNKNOWN_SIDE { + DecisionEvent::NativePairConfirmed + } else { + DecisionEvent::UserObserved + }, + }; + } + if self.user_get_side != side { + return self.clear_on_conflict(native_team); + } + if !self.correction_used && self.fixture_get_side != UNKNOWN_SIDE { + self.correction_used = true; + return GetTeamDecision { + team: self.fixture_team, + event: DecisionEvent::Corrected, + }; + } + } + + GetTeamDecision::native(native_team) + } + + fn clear_on_conflict(&mut self, native_team: i32) -> GetTeamDecision { + *self = Self::empty(); + GetTeamDecision { + team: native_team, + event: DecisionEvent::ConflictingNativePair, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DecisionEvent { + None, + FixtureObserved, + UserObserved, + NativePairConfirmed, + ConflictingNativePair, + Corrected, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct GetTeamDecision { + team: i32, + event: DecisionEvent, +} + +impl GetTeamDecision { + const fn native(team: i32) -> Self { + Self { + team, + event: DecisionEvent::None, + } + } +} + +const fn valid_side(side: i32) -> bool { + side == 0 || side == 1 +} + +const fn valid_pair(fixture_team: i32, user_team: i32) -> bool { + fixture_team > 0 && user_team > 0 && fixture_team != user_team +} + +fn enabled(value: Option<&str>) -> bool { + value == Some("1") +} + +fn exact_signature(current: &[u8], expected: &[u8]) -> bool { + current == expected +} + +unsafe fn target_matches(base: usize, rva: usize, signature: &[u8]) -> bool { + let Some(target) = target_va(base, rva) else { + return false; + }; + readable_range(target, signature.len()) + && exact_signature( + core::slice::from_raw_parts(target as *const u8, signature.len()), + signature, + ) +} + +type FixtureProjectorFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize; +type GetTeamFn = unsafe extern "system" fn(usize, i32) -> i32; + +unsafe extern "system" fn fixture_projector_wrapper( + context: usize, + output_pair: usize, + r8: usize, + r9: usize, +) -> usize { + let trampoline = FIXTURE_PROJECTOR_TRAMPOLINE.load(Ordering::Acquire); + if trampoline == 0 { + return 0; + } + let original: FixtureProjectorFn = core::mem::transmute(trampoline); + let result = original(context, output_pair, r8, r9); + + let pair = rd_i32(output_pair).zip(rd_i32(output_pair.saturating_add(4))); + let captured = pair.is_some_and(|(fixture_team, user_team)| { + TEAM_STATE + .lock() + .map(|mut state| state.capture(fixture_team, user_team)) + .unwrap_or(false) + }); + match pair { + Some((fixture_team, user_team)) if captured => write_log(&format!( + "SEASON_TEAM_COMPAT: fixture captured fixture={fixture_team} user={user_team}\n" + )), + Some((fixture_team, user_team)) => write_log(&format!( + "SEASON_TEAM_COMPAT: invalid fixture pair [{fixture_team},{user_team}]; inactive\n" + )), + None => { + if let Ok(mut state) = TEAM_STATE.lock() { + *state = TeamState::empty(); + } + write_log("SEASON_TEAM_COMPAT: unreadable fixture pair; inactive\n"); + } + } + result +} + +unsafe extern "system" fn get_team_wrapper(adapter: usize, side: i32) -> i32 { + let trampoline = GET_TEAM_TRAMPOLINE.load(Ordering::Acquire); + if trampoline == 0 { + return 0; + } + let original: GetTeamFn = core::mem::transmute(trampoline); + let native_team = original(adapter, side); + if !REPAIR_ACTIVE.load(Ordering::Acquire) { + return native_team; + } + + let decision = TEAM_STATE + .lock() + .map(|mut state| state.observe_get_team(side, native_team)) + .unwrap_or_else(|_| GetTeamDecision::native(native_team)); + match decision.event { + DecisionEvent::FixtureObserved => write_log(&format!( + "SEASON_TEAM_COMPAT: fixture observed side={side} team={native_team}\n" + )), + DecisionEvent::UserObserved => write_log(&format!( + "SEASON_TEAM_COMPAT: user observed side={side} team={native_team}\n" + )), + DecisionEvent::NativePairConfirmed => write_log(&format!( + "SEASON_TEAM_COMPAT: native pair confirmed side={side} team={native_team}\n" + )), + DecisionEvent::ConflictingNativePair => write_log(&format!( + "SEASON_TEAM_COMPAT: conflicting native pair at side={side}; state cleared\n" + )), + DecisionEvent::Corrected => write_log(&format!( + "SEASON_TEAM_COMPAT: corrected GetTeam side={side} native={native_team} fixture={}\n", + decision.team + )), + DecisionEvent::None => {} + } + decision.team +} + +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_TEAM_COMPAT: CardsDLL unavailable/invalid; inactive\n"); + return; + } + if !target_matches(base, FIXTURE_PROJECTOR_RVA, &FIXTURE_PROJECTOR_SIGNATURE) + || !target_matches(base, GET_TEAM_RVA, &GET_TEAM_SIGNATURE) + { + write_log("SEASON_TEAM_COMPAT: target signature mismatch; inactive\n"); + return; + } + + if !install_detour( + base, + FIXTURE_PROJECTOR_RVA, + "OfflineSeason_fixture_projector", + FIXTURE_PROJECTOR_SIGNATURE.len(), + &FIXTURE_PROJECTOR_SIGNATURE, + fixture_projector_wrapper as *const () as usize, + &FIXTURE_PROJECTOR_TRAMPOLINE, + ) { + write_log("SEASON_TEAM_COMPAT: fixture projector hook failed; inactive\n"); + return; + } + if !install_detour_reloc( + base, + GET_TEAM_RVA, + "CardsGameSetupAdapter_GetTeam", + GET_TEAM_SIGNATURE.len(), + &GET_TEAM_SIGNATURE, + 3, + 7, + get_team_wrapper as *const () as usize, + &GET_TEAM_TRAMPOLINE, + ) { + write_log("SEASON_TEAM_COMPAT: GetTeam hook failed; inactive\n"); + return; + } + + REPAIR_ACTIVE.store(true, Ordering::Release); + write_log("SEASON_TEAM_COMPAT: candidate ARMED; exact fixture evidence gate enabled\n"); +} + +pub(crate) fn install() { + if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) { + write_log("SEASON_TEAM_COMPAT: disabled\n"); + return; + } + write_log("SEASON_TEAM_COMPAT: requested; deferred signature validation starting\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feature_is_default_off() { + assert!(!enabled(None)); + assert!(!enabled(Some("0"))); + assert!(!enabled(Some("true"))); + assert!(enabled(Some("1"))); + } + + #[test] + fn signature_validation_is_exact() { + assert!(exact_signature(&GET_TEAM_SIGNATURE, &GET_TEAM_SIGNATURE)); + let mut changed = GET_TEAM_SIGNATURE; + changed[0] ^= 1; + assert!(!exact_signature(&changed, &GET_TEAM_SIGNATURE)); + } + + #[test] + fn invalid_fixture_never_arms_state() { + let mut state = TeamState::empty(); + assert!(!state.capture(0, 130000)); + assert!(!state.capture(73, 73)); + assert_eq!( + state.observe_get_team(0, 130000), + GetTeamDecision::native(130000) + ); + } + + #[test] + fn fixture_must_be_observed_natively_before_correction() { + let mut state = TeamState::empty(); + assert!(state.capture(73, 130000)); + assert_eq!( + state.observe_get_team(1, 130000), + GetTeamDecision { + team: 130000, + event: DecisionEvent::UserObserved + } + ); + assert_eq!( + state.observe_get_team(1, 130000), + GetTeamDecision::native(130000) + ); + assert_eq!(state.fixture_get_side, UNKNOWN_SIDE); + assert!(!state.correction_used); + } + + #[test] + fn correction_requires_native_pair_then_is_one_shot() { + let mut state = TeamState::empty(); + assert!(state.capture(73, 130000)); + assert_eq!( + state.observe_get_team(0, 73), + GetTeamDecision { + team: 73, + event: DecisionEvent::FixtureObserved + } + ); + assert_eq!( + state.observe_get_team(1, 130000), + GetTeamDecision { + team: 130000, + event: DecisionEvent::NativePairConfirmed + } + ); + assert_eq!( + state.observe_get_team(1, 130000), + GetTeamDecision { + team: 73, + event: DecisionEvent::Corrected + } + ); + assert_eq!( + state.observe_get_team(1, 130000), + GetTeamDecision::native(130000) + ); + } + + #[test] + fn second_fixture_replaces_all_prior_state() { + let mut state = TeamState::empty(); + assert!(state.capture(73, 130000)); + assert_eq!(state.observe_get_team(0, 73).team, 73); + assert_eq!(state.observe_get_team(1, 130000).team, 130000); + assert_eq!(state.observe_get_team(1, 130000).team, 73); + + assert!(state.capture(240, 130000)); + assert_eq!(state.fixture_get_side, UNKNOWN_SIDE); + assert_eq!(state.user_get_side, UNKNOWN_SIDE); + assert!(!state.correction_used); + assert_eq!(state.observe_get_team(0, 240).team, 240); + assert_eq!(state.observe_get_team(1, 130000).team, 130000); + assert_eq!(state.observe_get_team(1, 130000).team, 240); + } + + #[test] + fn conflicting_fixture_sides_fail_closed() { + let mut state = TeamState::empty(); + assert!(state.capture(73, 130000)); + assert_eq!( + state.observe_get_team(0, 73).event, + DecisionEvent::FixtureObserved + ); + assert_eq!( + state.observe_get_team(1, 73).event, + DecisionEvent::ConflictingNativePair + ); + assert_eq!(state, TeamState::empty()); + } +}