//! 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()); } }