Add gated FIFA17 Offline Seasons PMA repair

This commit is contained in:
funman300
2026-08-27 22:39:55 +00:00
parent 021a044859
commit bee97055db
3 changed files with 402 additions and 0 deletions
+1
View File
@@ -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
}
+2
View File
@@ -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;
+399
View File
@@ -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));
}
}
}