Add gated FIFA17 Offline Seasons PMA repair
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<i32>,
|
||||
free_roam_class: bool,
|
||||
free_roam_state: Option<i32>,
|
||||
free_roam_ready: Option<i32>,
|
||||
secondary_listener_class: bool,
|
||||
selected_listener_class: bool,
|
||||
selected_mode: Option<i32>,
|
||||
}
|
||||
|
||||
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
|
||||
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<i32> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user