Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bee97055db | |||
| 021a044859 | |||
| d7641175be | |||
| 89cf5df71f |
@@ -159,6 +159,8 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
|||||||
crate::sbc_request_trace::install();
|
crate::sbc_request_trace::install();
|
||||||
crate::store_entry::install();
|
crate::store_entry::install();
|
||||||
crate::season_trace::install();
|
crate::season_trace::install();
|
||||||
|
crate::season_team_compat::install();
|
||||||
|
crate::offline_seasons_pma::install();
|
||||||
crate::kit_trace::install();
|
crate::kit_trace::install();
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,115 +156,6 @@ unsafe extern "system" fn kit_db_clone_wrapper(
|
|||||||
original(rcx, rdx, r8, r9)
|
original(rcx, rdx, r8, r9)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The ENGINE-PROVIDER path (2026-08-24) ────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The four traces above were written against the 2026-08-20 model, in which the
|
|
||||||
// selector was fed by DP command 0x7576 -> FutSquadServiceImpl::setAvailableKits.
|
|
||||||
// A live run refuted that: `KIT_SET` never fires, `KIT_GET` reports
|
|
||||||
// `KITS_AVAILABLE=0`, and `FUN_1801c3480` is entered 810 times without ever
|
|
||||||
// seeing a kit. The selector is actually fed by a provider CardsDLL registers
|
|
||||||
// INTO the FIFA engine — singleton FUN_1800338f0, vtable 0x1801f1d68, slots
|
|
||||||
// +0x08 enumerate and +0x10 describe — and nothing instrumented it.
|
|
||||||
//
|
|
||||||
// These three close that gap. Still strictly passive: log, then tail-call.
|
|
||||||
|
|
||||||
// FUN_180033770 (0x33770) — enumerate kits for a team. It answers only for the
|
|
||||||
// FUT custom club (130000) and otherwise forwards to the engine default, so the
|
|
||||||
// teamId it is ASKED about is the first thing worth knowing: if the pre-match
|
|
||||||
// screen asks about a real team id, our club items were never in scope.
|
|
||||||
static KIT_ENUM_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn kit_enum_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
let logged = budget();
|
|
||||||
if logged {
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_ENUM: provider.enumerate this={rcx:#x} teamId={rdx} (130000 = FUT club) \
|
|
||||||
out={r8:#x} arg4={r9:#x}\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t = KIT_ENUM_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
let r = original(rcx, rdx, r8, r9);
|
|
||||||
if logged {
|
|
||||||
write_log(&format!("KIT_ENUM: -> returned {r:#x}\n"));
|
|
||||||
}
|
|
||||||
r
|
|
||||||
}
|
|
||||||
|
|
||||||
// sub_180033430 (0x33430) — describe one kit. Ghidra never functionised this
|
|
||||||
// address. It decodes a packed id into (teamid, year, slot) and writes NAME/TYPE
|
|
||||||
// only when that triple equals the club's active home or away triple; a
|
|
||||||
// non-match leaves the descriptor completely untouched, which is what makes the
|
|
||||||
// engine fall back to its own catalogue kit.
|
|
||||||
static KIT_DESC_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn kit_desc_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
if budget() {
|
|
||||||
// rdx carries the packed kit id: teamid<<14 | year_field<<5 | slot.
|
|
||||||
let packed = rdx as u32;
|
|
||||||
let teamid = packed >> 14;
|
|
||||||
let year_field = (packed >> 5) & 0x1ff;
|
|
||||||
let year = if year_field != 0 {
|
|
||||||
year_field + 1800
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let slot = packed & 0x1f;
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_DESC: provider.describe this={rcx:#x} packed={packed:#x} \
|
|
||||||
-> teamid={teamid} year={year} slot={slot}\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t = KIT_DESC_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
original(rcx, rdx, r8, r9)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FUN_1800d73d0 (0xd73d0) — the club scan behind getActiveKit. Its prologue
|
|
||||||
// compares EDX against 2, so rdx is the home/away selector (2 home, 3 away),
|
|
||||||
// NOT the cardtype the earlier note assumed. This is the function that must find
|
|
||||||
// a `cardtype 7 + cardsubtypeid 9 + itemState 101/102` item; if it returns
|
|
||||||
// nothing, the active triple stays zero and every kit is unmatchable.
|
|
||||||
static KIT_SCAN_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn kit_scan_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
let logged = budget();
|
|
||||||
let t = KIT_SCAN_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
let r = original(rcx, rdx, r8, r9);
|
|
||||||
if logged {
|
|
||||||
// A hit returns the matching item; report the fields the caller reads so
|
|
||||||
// a wrong-shaped kit is distinguishable from an absent one.
|
|
||||||
let detail = if r != 0 {
|
|
||||||
format!(
|
|
||||||
" HIT item={r:#x} type[+0x4c]={:?} subtype[+0x50]={:?} itemState[+0x5c]={:?} \
|
|
||||||
teamid[+0x94]={:?} category[+0xb8]={:?} year[+0xba]={:?}",
|
|
||||||
rd_i32(r + 0x4c),
|
|
||||||
rd_i32(r + 0x50),
|
|
||||||
rd_i32(r + 0x5c),
|
|
||||||
rd_i32(r + 0x94),
|
|
||||||
rd_i32(r + 0xb8),
|
|
||||||
rd_i32(r + 0xba),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
" MISS (no kit item matched -> active triple stays zero)".to_string()
|
|
||||||
};
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_SCAN: clubScan mgr={rcx:#x} sel={rdx} (2=home 3=away) a3={r8} a4={r9}{detail}\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
r
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn worker() {
|
unsafe fn worker() {
|
||||||
let mut base = 0usize;
|
let mut base = 0usize;
|
||||||
for _ in 0..600u32 {
|
for _ in 0..600u32 {
|
||||||
@@ -333,51 +224,6 @@ unsafe fn worker() {
|
|||||||
kit_db_clone_wrapper as *const () as usize,
|
kit_db_clone_wrapper as *const () as usize,
|
||||||
&KIT_DB_CLONE_TRAMP,
|
&KIT_DB_CLONE_TRAMP,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── ENGINE-PROVIDER path. Prologues dumped from the analysed Ghidra project
|
|
||||||
// (cardsdll.dll, base 0x180000000) on 2026-08-24; every copy length below
|
|
||||||
// is instruction-aligned and none of these prologues is rip-relative, so
|
|
||||||
// the plain installer is correct for all three.
|
|
||||||
|
|
||||||
// FUN_180033770: 48 8b c4 57 41 56 41 57 48 83 ec 60 48 c7 40 c0 fe ff ff ff (20).
|
|
||||||
install_detour(
|
|
||||||
base,
|
|
||||||
0x33770,
|
|
||||||
"kitProviderEnumerate(0x33770)",
|
|
||||||
20,
|
|
||||||
&[
|
|
||||||
0x48, 0x8b, 0xc4, 0x57, 0x41, 0x56, 0x41, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7,
|
|
||||||
0x40, 0xc0, 0xfe, 0xff, 0xff, 0xff,
|
|
||||||
],
|
|
||||||
kit_enum_wrapper as *const () as usize,
|
|
||||||
&KIT_ENUM_TRAMP,
|
|
||||||
);
|
|
||||||
// sub_180033430 (Ghidra leaves this one undefined):
|
|
||||||
// 48 8b c4 55 41 54 41 55 41 56 41 57 48 8b ec (15).
|
|
||||||
install_detour(
|
|
||||||
base,
|
|
||||||
0x33430,
|
|
||||||
"kitProviderDescribe(0x33430)",
|
|
||||||
15,
|
|
||||||
&[
|
|
||||||
0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8b,
|
|
||||||
0xec,
|
|
||||||
],
|
|
||||||
kit_desc_wrapper as *const () as usize,
|
|
||||||
&KIT_DESC_TRAMP,
|
|
||||||
);
|
|
||||||
// FUN_1800d73d0: 48 89 5c 24 18 56 45 8b d9 41 8b f0 8b da (14).
|
|
||||||
install_detour(
|
|
||||||
base,
|
|
||||||
0xd73d0,
|
|
||||||
"clubScanForKit(0xd73d0)",
|
|
||||||
14,
|
|
||||||
&[
|
|
||||||
0x48, 0x89, 0x5c, 0x24, 0x18, 0x56, 0x45, 0x8b, 0xd9, 0x41, 0x8b, 0xf0, 0x8b, 0xda,
|
|
||||||
],
|
|
||||||
kit_scan_wrapper as *const () as usize,
|
|
||||||
&KIT_SCAN_TRAMP,
|
|
||||||
);
|
|
||||||
write_log("KIT_TRACE: all kit-selector traces armed\n");
|
write_log("KIT_TRACE: all kit-selector traces armed\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ mod fifa17_tls;
|
|||||||
mod iat;
|
mod iat;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod kit_trace;
|
mod kit_trace;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod offline_seasons_pma;
|
||||||
mod patch_mem;
|
mod patch_mem;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_dispatch;
|
mod sbc_dispatch;
|
||||||
@@ -30,6 +32,8 @@ mod sbc_request_trace;
|
|||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_trace;
|
mod sbc_trace;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod season_team_compat;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
mod season_trace;
|
mod season_trace;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod store_entry;
|
mod store_entry;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TeamState> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user