Instrument FIFA17 SBC completion dispatch
This commit is contained in:
@@ -79,12 +79,10 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
));
|
||||
dump_modules();
|
||||
write_log("fifa17: worker complete (injection healthy)\n");
|
||||
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
|
||||
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
|
||||
// Every SBC detour is deferred and inert unless its exact environment gate is `1`.
|
||||
crate::sbc_hook::install();
|
||||
// Passive transaction tracing has a separate kill switch from cache resolution.
|
||||
// It currently fails closed until safe relocating trampolines are proven.
|
||||
crate::sbc_trace::install();
|
||||
crate::sbc_dispatch::install();
|
||||
crate::sbc_request_trace::install();
|
||||
0
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ mod probe;
|
||||
#[cfg(feature = "capture_baseline")]
|
||||
mod recv_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_dispatch;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_request_trace;
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
|
||||
//!
|
||||
//! `OPENFUT_SBC_DISPATCH=1` permits one narrowly-scoped repair per native
|
||||
//! deserializer generation. The default and `OPENFUT_SBC_DISPATCH_TRACE=1` paths
|
||||
//! are behavior-preserving.
|
||||
|
||||
use core::ffi::c_void;
|
||||
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
|
||||
GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
};
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualAlloc, VirtualFree, VirtualProtect, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE,
|
||||
PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
const COMPLETION_RVA: usize = 0x0b8950;
|
||||
const EVENT_DISPATCH_RVA: usize = 0x1a4cd0;
|
||||
const CATEGORY_RESPONSE_VTABLE_RVA: usize = 0x22e5b0;
|
||||
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
||||
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
||||
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
||||
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
||||
const COMPLETION_COPY_LEN: usize = 14;
|
||||
const EVENT_COPY_LEN: usize = 16;
|
||||
const ABS_JUMP_LEN: usize = 14;
|
||||
const COMPLETION_TRAMPOLINE_LEN: usize = 12 + 2 + ABS_JUMP_LEN * 2;
|
||||
const UNKNOWN_TRANSPORT_STATUS: u32 = 999;
|
||||
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
|
||||
const FUT_SBS_CATEGORIES_READY_EVENT: u32 = 0x756d;
|
||||
const SBC_REFRESH_EVENT: u32 = 0x138c;
|
||||
|
||||
const COMPLETION_SIGNATURE: [u8; 32] = [
|
||||
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2, 0x74, 0x4e, 0x83, 0x7a,
|
||||
0x1c, 0x00, 0x75, 0x48, 0xc6, 0x81, 0x1d, 0x02, 0x00, 0x00, 0x01, 0x48, 0x8b, 0x89, 0x40, 0x01,
|
||||
];
|
||||
const EVENT_SIGNATURE: [u8; EVENT_COPY_LEN] = [
|
||||
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x40, 0xb8, 0xfe, 0xff, 0xff, 0xff,
|
||||
];
|
||||
|
||||
type CompletionFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> usize;
|
||||
type EventDispatchFn = unsafe extern "system" fn(*mut c_void, u32, *mut c_void) -> usize;
|
||||
|
||||
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static COMPLETION_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static EVENT_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static LAST_REPAIRED_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static COMPLETION_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
static COMPLETION_EXITS: AtomicU64 = AtomicU64::new(0);
|
||||
static COMPLETION_THREAD: AtomicUsize = AtomicUsize::new(0);
|
||||
static COMPLETION_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static COMPLETION_STATUS_OBJECT: AtomicUsize = AtomicUsize::new(0);
|
||||
static COMPLETION_STATUS: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static COMPLETION_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static COMPLETION_DECISION: AtomicUsize = AtomicUsize::new(Decision::NativeSuccess as usize);
|
||||
static COMPLETION_REJECTION: AtomicUsize = AtomicUsize::new(Rejection::None as usize);
|
||||
static EVENT_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
static EVENT_EXITS: AtomicU64 = AtomicU64::new(0);
|
||||
static EVENT_THREAD: AtomicUsize = AtomicUsize::new(0);
|
||||
static EVENT_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static EVENT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
static EVENT_PAYLOAD: AtomicUsize = AtomicUsize::new(0);
|
||||
static EVENT_CATEGORIES: AtomicU64 = AtomicU64::new(0);
|
||||
static EVENT_REFRESH: AtomicU64 = AtomicU64::new(0);
|
||||
static EVENT_READY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(usize)]
|
||||
enum Decision {
|
||||
NativeSuccess,
|
||||
Repair,
|
||||
}
|
||||
|
||||
const REJECTED_DECISION: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(usize)]
|
||||
enum Rejection {
|
||||
None,
|
||||
RepairDisabled,
|
||||
NullStatus,
|
||||
StatusUnreadable,
|
||||
UnsupportedStatus,
|
||||
CardsBuildMismatch,
|
||||
ParserUnbalanced,
|
||||
FactoryMismatch,
|
||||
ParserThreadMismatch,
|
||||
ReaderMissing,
|
||||
ParseFailed,
|
||||
ResponseClassMismatch,
|
||||
ModelEmpty,
|
||||
NotifierNotCurrent,
|
||||
ControllerMismatch,
|
||||
ControllerModelMismatch,
|
||||
DuplicateGeneration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DecisionInput {
|
||||
repair_enabled: bool,
|
||||
status: Option<u32>,
|
||||
status_present: bool,
|
||||
status_copyable: bool,
|
||||
cards_build_matches: bool,
|
||||
factory_entries: u64,
|
||||
factory_exits: u64,
|
||||
factory_result: usize,
|
||||
factory_thread: usize,
|
||||
deserializer_entries: u64,
|
||||
deserializer_exits: u64,
|
||||
deserializer_this: usize,
|
||||
deserializer_reader: usize,
|
||||
deserializer_result: bool,
|
||||
deserializer_thread: usize,
|
||||
response_class_matches: bool,
|
||||
model: usize,
|
||||
category_count: usize,
|
||||
notifier_entries: u64,
|
||||
notifier_exits: u64,
|
||||
controller_matches: bool,
|
||||
controller_model_matches: bool,
|
||||
last_repaired_generation: u64,
|
||||
}
|
||||
|
||||
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
|
||||
let Some(status) = input.status else {
|
||||
return Err(if input.status_present {
|
||||
Rejection::StatusUnreadable
|
||||
} else {
|
||||
Rejection::NullStatus
|
||||
});
|
||||
};
|
||||
if status == 0 {
|
||||
return Ok(Decision::NativeSuccess);
|
||||
}
|
||||
if !input.repair_enabled {
|
||||
return Err(Rejection::RepairDisabled);
|
||||
}
|
||||
if status != UNKNOWN_TRANSPORT_STATUS {
|
||||
return Err(Rejection::UnsupportedStatus);
|
||||
}
|
||||
if !input.status_copyable {
|
||||
return Err(Rejection::StatusUnreadable);
|
||||
}
|
||||
if !input.cards_build_matches {
|
||||
return Err(Rejection::CardsBuildMismatch);
|
||||
}
|
||||
let generation = input.deserializer_exits;
|
||||
if generation == 0
|
||||
|| input.factory_entries != input.factory_exits
|
||||
|| input.deserializer_entries != generation
|
||||
|| input.factory_exits != generation
|
||||
{
|
||||
return Err(Rejection::ParserUnbalanced);
|
||||
}
|
||||
if input.factory_result == 0 || input.factory_result != input.deserializer_this {
|
||||
return Err(Rejection::FactoryMismatch);
|
||||
}
|
||||
if input.factory_thread == 0 || input.factory_thread != input.deserializer_thread {
|
||||
return Err(Rejection::ParserThreadMismatch);
|
||||
}
|
||||
if input.deserializer_reader == 0 {
|
||||
return Err(Rejection::ReaderMissing);
|
||||
}
|
||||
if !input.deserializer_result {
|
||||
return Err(Rejection::ParseFailed);
|
||||
}
|
||||
if !input.response_class_matches {
|
||||
return Err(Rejection::ResponseClassMismatch);
|
||||
}
|
||||
if input.model == 0 || input.category_count == 0 || input.category_count == usize::MAX {
|
||||
return Err(Rejection::ModelEmpty);
|
||||
}
|
||||
if input.notifier_entries != generation
|
||||
|| input.notifier_entries == 0
|
||||
|| input.notifier_exits.checked_add(1) != Some(input.notifier_entries)
|
||||
{
|
||||
return Err(Rejection::NotifierNotCurrent);
|
||||
}
|
||||
if !input.controller_matches {
|
||||
return Err(Rejection::ControllerMismatch);
|
||||
}
|
||||
if !input.controller_model_matches {
|
||||
return Err(Rejection::ControllerModelMismatch);
|
||||
}
|
||||
if input.last_repaired_generation >= generation {
|
||||
return Err(Rejection::DuplicateGeneration);
|
||||
}
|
||||
Ok(Decision::Repair)
|
||||
}
|
||||
|
||||
unsafe fn guarded_u32(address: usize) -> Option<u32> {
|
||||
crate::sbc_trace::readable_range(address, 4)
|
||||
.then(|| core::ptr::read_volatile(address as *const u32))
|
||||
}
|
||||
|
||||
unsafe fn status_code(status: usize) -> Option<u32> {
|
||||
status
|
||||
.checked_add(0x1c)
|
||||
.and_then(|address| guarded_u32(address))
|
||||
}
|
||||
|
||||
unsafe fn controller_identity(base: usize, controller: usize, model: usize) -> (bool, bool) {
|
||||
if base == 0 || controller == 0 {
|
||||
return (false, false);
|
||||
}
|
||||
let main_vtable = crate::sbc_trace::guarded_usize(controller);
|
||||
let event_vtable = controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|address| crate::sbc_trace::guarded_usize(address));
|
||||
let controller_model = controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|address| crate::sbc_trace::guarded_usize(address));
|
||||
(
|
||||
main_vtable == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
&& event_vtable == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA),
|
||||
controller_model == Some(model),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn note_sbc_controller(controller: usize, base: usize) {
|
||||
let (identity_matches, _) = controller_identity(base, controller, 0);
|
||||
if identity_matches {
|
||||
SBC_CONTROLLER.store(controller, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_DISPATCH: captured category controller={controller:#x}\n"
|
||||
));
|
||||
} else {
|
||||
crate::write_log(&format!(
|
||||
"SBC_DISPATCH: rejected category controller={controller:#x} (class mismatch)\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C, align(16))]
|
||||
struct CompletionStatusShadow([u8; 0x20]);
|
||||
|
||||
unsafe extern "system" fn completion_wrapper(
|
||||
controller: *mut c_void,
|
||||
status: *mut c_void,
|
||||
) -> usize {
|
||||
COMPLETION_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||
COMPLETION_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||
COMPLETION_CONTROLLER.store(controller as usize, Ordering::Relaxed);
|
||||
COMPLETION_STATUS_OBJECT.store(status as usize, Ordering::Relaxed);
|
||||
|
||||
let evidence = crate::sbc_trace::dispatch_evidence();
|
||||
let status_address = status as usize;
|
||||
let observed_status = if status_address == 0 {
|
||||
None
|
||||
} else {
|
||||
status_code(status_address)
|
||||
};
|
||||
COMPLETION_STATUS.store(
|
||||
observed_status
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(usize::MAX),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed);
|
||||
|
||||
let response_vtable = crate::sbc_trace::guarded_usize(evidence.deserializer_this);
|
||||
let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
let (controller_matches, controller_model_matches) =
|
||||
controller_identity(evidence.base, captured_controller, evidence.model);
|
||||
let input = DecisionInput {
|
||||
repair_enabled: REPAIR_ENABLED.load(Ordering::Acquire),
|
||||
status: observed_status,
|
||||
status_present: status_address != 0,
|
||||
status_copyable: status_address != 0
|
||||
&& crate::sbc_trace::readable_range(status_address, 0x20),
|
||||
cards_build_matches: crate::sbc_trace::valid_cards_image(evidence.base),
|
||||
factory_entries: evidence.factory_entries,
|
||||
factory_exits: evidence.factory_exits,
|
||||
factory_result: evidence.factory_result,
|
||||
factory_thread: evidence.factory_thread,
|
||||
deserializer_entries: evidence.deserializer_entries,
|
||||
deserializer_exits: evidence.deserializer_exits,
|
||||
deserializer_this: evidence.deserializer_this,
|
||||
deserializer_reader: evidence.deserializer_reader,
|
||||
deserializer_result: evidence.deserializer_result,
|
||||
deserializer_thread: evidence.deserializer_thread,
|
||||
response_class_matches: response_vtable
|
||||
== evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA),
|
||||
model: evidence.model,
|
||||
category_count: evidence.category_count,
|
||||
notifier_entries: evidence.notifier_entries,
|
||||
notifier_exits: evidence.notifier_exits,
|
||||
controller_matches: controller_matches && captured_controller == controller as usize,
|
||||
controller_model_matches,
|
||||
last_repaired_generation: LAST_REPAIRED_GENERATION.load(Ordering::Acquire),
|
||||
};
|
||||
|
||||
let original: CompletionFn =
|
||||
core::mem::transmute(COMPLETION_TRAMPOLINE.load(Ordering::Acquire));
|
||||
let result = match decide(input) {
|
||||
Ok(Decision::Repair) => {
|
||||
if LAST_REPAIRED_GENERATION
|
||||
.compare_exchange(
|
||||
input.last_repaired_generation,
|
||||
evidence.deserializer_exits,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let mut shadow = CompletionStatusShadow([0; 0x20]);
|
||||
core::ptr::copy_nonoverlapping(
|
||||
status_address as *const u8,
|
||||
shadow.0.as_mut_ptr(),
|
||||
shadow.0.len(),
|
||||
);
|
||||
shadow.0[0x1c..0x20].copy_from_slice(&0u32.to_le_bytes());
|
||||
COMPLETION_DECISION.store(Decision::Repair as usize, Ordering::Relaxed);
|
||||
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
|
||||
original(controller, shadow.0.as_mut_ptr().cast())
|
||||
} else {
|
||||
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
|
||||
COMPLETION_REJECTION
|
||||
.store(Rejection::DuplicateGeneration as usize, Ordering::Relaxed);
|
||||
original(controller, status)
|
||||
}
|
||||
}
|
||||
Ok(Decision::NativeSuccess) => {
|
||||
COMPLETION_DECISION.store(Decision::NativeSuccess as usize, Ordering::Relaxed);
|
||||
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
|
||||
original(controller, status)
|
||||
}
|
||||
Err(rejection) => {
|
||||
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
|
||||
COMPLETION_REJECTION.store(rejection as usize, Ordering::Relaxed);
|
||||
original(controller, status)
|
||||
}
|
||||
};
|
||||
COMPLETION_EXITS.fetch_add(1, Ordering::Release);
|
||||
result
|
||||
}
|
||||
|
||||
unsafe extern "system" fn event_wrapper(
|
||||
controller: *mut c_void,
|
||||
event: u32,
|
||||
payload: *mut c_void,
|
||||
) -> usize {
|
||||
EVENT_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||
EVENT_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||
EVENT_CONTROLLER.store(controller as usize, Ordering::Relaxed);
|
||||
EVENT_ID.store(event as usize, Ordering::Relaxed);
|
||||
EVENT_PAYLOAD.store(payload as usize, Ordering::Relaxed);
|
||||
match event {
|
||||
FUT_SBS_CATEGORIES_EVENT => {
|
||||
EVENT_CATEGORIES.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
SBC_REFRESH_EVENT => {
|
||||
EVENT_REFRESH.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
FUT_SBS_CATEGORIES_READY_EVENT => {
|
||||
EVENT_READY.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire));
|
||||
let result = original(controller, event, payload);
|
||||
EVENT_EXITS.fetch_add(1, Ordering::Release);
|
||||
result
|
||||
}
|
||||
|
||||
unsafe fn allocate_completion_trampoline(target: usize) -> Option<usize> {
|
||||
let failure_target = target.checked_add(0x5c)?;
|
||||
let success_target = target.checked_add(COMPLETION_COPY_LEN)?;
|
||||
let memory = VirtualAlloc(
|
||||
core::ptr::null(),
|
||||
COMPLETION_TRAMPOLINE_LEN,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_READWRITE,
|
||||
) as usize;
|
||||
if memory == 0 {
|
||||
return None;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, 12);
|
||||
// The relocated branch preserves the original null-status failure edge.
|
||||
core::ptr::copy_nonoverlapping([0x75, 0x0e].as_ptr(), (memory + 12) as *mut u8, 2);
|
||||
let failure = crate::sbc_trace::absolute_jump(failure_target);
|
||||
core::ptr::copy_nonoverlapping(failure.as_ptr(), (memory + 14) as *mut u8, ABS_JUMP_LEN);
|
||||
let success = crate::sbc_trace::absolute_jump(success_target);
|
||||
core::ptr::copy_nonoverlapping(success.as_ptr(), (memory + 28) as *mut u8, ABS_JUMP_LEN);
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
memory as _,
|
||||
COMPLETION_TRAMPOLINE_LEN,
|
||||
PAGE_EXECUTE_READ,
|
||||
&mut old,
|
||||
) == 0
|
||||
|| FlushInstructionCache(GetCurrentProcess(), memory as _, COMPLETION_TRAMPOLINE_LEN) == 0
|
||||
{
|
||||
VirtualFree(memory as _, 0, MEM_RELEASE);
|
||||
return None;
|
||||
}
|
||||
Some(memory)
|
||||
}
|
||||
|
||||
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
|
||||
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
|
||||
let mut ignored = 0u32;
|
||||
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
|
||||
}
|
||||
|
||||
unsafe fn write_entry<const N: usize>(
|
||||
target: usize,
|
||||
destination: usize,
|
||||
original: &[u8; N],
|
||||
) -> Result<(), bool> {
|
||||
let mut patch = [0x90u8; N];
|
||||
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
||||
return Err(true);
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
|
||||
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
|
||||
let mut ignored = 0u32;
|
||||
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(restore_entry(target, original))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum InstallOutcome {
|
||||
Installed,
|
||||
CleanFailure,
|
||||
DegradedHookActive,
|
||||
DegradedProcessState,
|
||||
DegradedHookAndProcess,
|
||||
}
|
||||
|
||||
unsafe fn install_pair(base: usize) -> InstallOutcome {
|
||||
let Some(completion) = crate::sbc_trace::target_va(base, COMPLETION_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let Some(event) = crate::sbc_trace::target_va(base, EVENT_DISPATCH_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let completion_original: [u8; COMPLETION_COPY_LEN] = COMPLETION_SIGNATURE
|
||||
[..COMPLETION_COPY_LEN]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
if !crate::sbc_trace::valid_cards_image(base)
|
||||
|| !crate::sbc_trace::executable_range_in_image(
|
||||
base,
|
||||
completion,
|
||||
COMPLETION_SIGNATURE.len(),
|
||||
)
|
||||
|| !crate::sbc_trace::executable_range_in_image(base, event, EVENT_SIGNATURE.len())
|
||||
|| core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
|
||||
!= COMPLETION_SIGNATURE
|
||||
|| core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) != EVENT_SIGNATURE
|
||||
{
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
let mut pinned = core::ptr::null_mut();
|
||||
if GetModuleHandleExA(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
completion as *const u8,
|
||||
&mut pinned,
|
||||
) == 0
|
||||
|| pinned as usize != base
|
||||
{
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
let Some(completion_trampoline) = allocate_completion_trampoline(completion) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let Some(event_trampoline) = crate::sbc_trace::allocate_trampoline(event, EVENT_COPY_LEN)
|
||||
else {
|
||||
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
COMPLETION_TRAMPOLINE.store(completion_trampoline, Ordering::Release);
|
||||
EVENT_TRAMPOLINE.store(event_trampoline, Ordering::Release);
|
||||
|
||||
let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else {
|
||||
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
|
||||
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
|
||||
EVENT_TRAMPOLINE.store(0, Ordering::Release);
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let mut peers = match crate::sbc_trace::suspend_peers(completion, event) {
|
||||
Ok(peers) => peers,
|
||||
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
|
||||
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
|
||||
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
|
||||
EVENT_TRAMPOLINE.store(0, Ordering::Release);
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
|
||||
return InstallOutcome::DegradedProcessState;
|
||||
}
|
||||
};
|
||||
let final_valid = crate::sbc_trace::valid_cards_image(base)
|
||||
&& core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
|
||||
== COMPLETION_SIGNATURE
|
||||
&& core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len())
|
||||
== EVENT_SIGNATURE;
|
||||
let transaction = if !final_valid {
|
||||
InstallOutcome::CleanFailure
|
||||
} else {
|
||||
match write_entry(
|
||||
completion,
|
||||
completion_wrapper as *const () as usize,
|
||||
&completion_original,
|
||||
) {
|
||||
Ok(()) => {
|
||||
match write_entry(event, event_wrapper as *const () as usize, &EVENT_SIGNATURE) {
|
||||
Ok(()) => InstallOutcome::Installed,
|
||||
Err(event_clean) => {
|
||||
let completion_clean = restore_entry(completion, &completion_original);
|
||||
if event_clean && completion_clean {
|
||||
InstallOutcome::CleanFailure
|
||||
} else {
|
||||
InstallOutcome::DegradedHookActive
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(true) => InstallOutcome::CleanFailure,
|
||||
Err(false) => InstallOutcome::DegradedHookActive,
|
||||
}
|
||||
};
|
||||
let resumed = peers.resume_all();
|
||||
let outcome = if resumed {
|
||||
transaction
|
||||
} else if matches!(
|
||||
transaction,
|
||||
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
|
||||
) {
|
||||
InstallOutcome::DegradedHookAndProcess
|
||||
} else {
|
||||
InstallOutcome::DegradedProcessState
|
||||
};
|
||||
if outcome == InstallOutcome::CleanFailure {
|
||||
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
|
||||
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
|
||||
EVENT_TRAMPOLINE.store(0, Ordering::Release);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
unsafe fn worker() {
|
||||
let _pending = crate::sbc_trace::CodeInstallerPending;
|
||||
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));
|
||||
}
|
||||
let outcome = if base == 0 {
|
||||
InstallOutcome::CleanFailure
|
||||
} else {
|
||||
install_pair(base)
|
||||
};
|
||||
drop(_pending);
|
||||
match outcome {
|
||||
InstallOutcome::Installed => crate::write_log(
|
||||
"SBC_DISPATCH: completion+event hooks installed; repair remains gate-controlled\n",
|
||||
),
|
||||
InstallOutcome::CleanFailure => {
|
||||
crate::write_log("SBC_DISPATCH: clean install failure; inactive\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedHookActive => {
|
||||
crate::write_log("SBC_DISPATCH: DEGRADED hook may be active; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedProcessState => {
|
||||
crate::write_log("SBC_DISPATCH: DEGRADED thread state; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedHookAndProcess => {
|
||||
crate::write_log("SBC_DISPATCH: DEGRADED hook and thread state; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut completion_seen = 0u64;
|
||||
let mut event_seen = 0u64;
|
||||
let mut reports = 0u8;
|
||||
while reports < 64 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let completion_entries = COMPLETION_ENTRIES.load(Ordering::Acquire);
|
||||
let event_entries = EVENT_ENTRIES.load(Ordering::Acquire);
|
||||
if completion_entries != completion_seen || event_entries != event_seen {
|
||||
crate::write_log(&format!(
|
||||
"SBC_DISPATCH: completion entry={} exit={} tid={} controller={:#x} status_obj={:#x} status={} generation={} decision={} rejection={}; event entry={} exit={} tid={} controller={:#x} id={:#x} payload={:#x} categories={} refresh={} ready={}\n",
|
||||
completion_entries,
|
||||
COMPLETION_EXITS.load(Ordering::Acquire),
|
||||
COMPLETION_THREAD.load(Ordering::Relaxed),
|
||||
COMPLETION_CONTROLLER.load(Ordering::Relaxed),
|
||||
COMPLETION_STATUS_OBJECT.load(Ordering::Relaxed),
|
||||
COMPLETION_STATUS.load(Ordering::Relaxed),
|
||||
COMPLETION_GENERATION.load(Ordering::Relaxed),
|
||||
COMPLETION_DECISION.load(Ordering::Relaxed),
|
||||
COMPLETION_REJECTION.load(Ordering::Relaxed),
|
||||
event_entries,
|
||||
EVENT_EXITS.load(Ordering::Acquire),
|
||||
EVENT_THREAD.load(Ordering::Relaxed),
|
||||
EVENT_CONTROLLER.load(Ordering::Relaxed),
|
||||
EVENT_ID.load(Ordering::Relaxed),
|
||||
EVENT_PAYLOAD.load(Ordering::Relaxed),
|
||||
EVENT_CATEGORIES.load(Ordering::Relaxed),
|
||||
EVENT_REFRESH.load(Ordering::Relaxed),
|
||||
EVENT_READY.load(Ordering::Relaxed),
|
||||
));
|
||||
completion_seen = completion_entries;
|
||||
event_seen = event_entries;
|
||||
reports += 1;
|
||||
}
|
||||
}
|
||||
crate::write_log("SBC_DISPATCH: report cap reached; hooks remain installed\n");
|
||||
}
|
||||
|
||||
pub(crate) fn install() {
|
||||
let repair =
|
||||
crate::sbc_trace::env_enabled(std::env::var("OPENFUT_SBC_DISPATCH").ok().as_deref());
|
||||
let trace = repair
|
||||
|| crate::sbc_trace::env_enabled(
|
||||
std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref(),
|
||||
);
|
||||
REPAIR_ENABLED.store(repair, Ordering::Release);
|
||||
if !trace {
|
||||
crate::write_log("SBC_DISPATCH: disabled\n");
|
||||
return;
|
||||
}
|
||||
crate::write_log(if repair {
|
||||
"SBC_DISPATCH: repair ARMED; strict native evidence gate enabled\n"
|
||||
} else {
|
||||
"SBC_DISPATCH: passive trace requested; repair disabled\n"
|
||||
});
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_input(generation: u64) -> DecisionInput {
|
||||
DecisionInput {
|
||||
repair_enabled: true,
|
||||
status: Some(UNKNOWN_TRANSPORT_STATUS),
|
||||
status_present: true,
|
||||
status_copyable: true,
|
||||
cards_build_matches: true,
|
||||
factory_entries: generation,
|
||||
factory_exits: generation,
|
||||
factory_result: 0x2000,
|
||||
factory_thread: 7,
|
||||
deserializer_entries: generation,
|
||||
deserializer_exits: generation,
|
||||
deserializer_this: 0x2000,
|
||||
deserializer_reader: 0x3000,
|
||||
deserializer_result: true,
|
||||
deserializer_thread: 7,
|
||||
response_class_matches: true,
|
||||
model: 0x4000,
|
||||
category_count: 2,
|
||||
notifier_entries: generation,
|
||||
notifier_exits: generation - 1,
|
||||
controller_matches: true,
|
||||
controller_model_matches: true,
|
||||
last_repaired_generation: generation - 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_success_is_never_rewritten() {
|
||||
let mut input = valid_input(1);
|
||||
input.status = Some(0);
|
||||
assert_eq!(decide(input), Ok(Decision::NativeSuccess));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_unknown_status_and_full_evidence_allow_repair() {
|
||||
assert_eq!(decide(valid_input(1)), Ok(Decision::Repair));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_is_exactly_gated_and_fail_closed() {
|
||||
let mut input = valid_input(1);
|
||||
input.repair_enabled = false;
|
||||
assert_eq!(decide(input), Err(Rejection::RepairDisabled));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.status = Some(500);
|
||||
assert_eq!(decide(input), Err(Rejection::UnsupportedStatus));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.category_count = 0;
|
||||
assert_eq!(decide(input), Err(Rejection::ModelEmpty));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.controller_matches = false;
|
||||
assert_eq!(decide(input), Err(Rejection::ControllerMismatch));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.status = None;
|
||||
input.status_present = false;
|
||||
assert_eq!(decide(input), Err(Rejection::NullStatus));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.status = None;
|
||||
assert_eq!(decide(input), Err(Rejection::StatusUnreadable));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.notifier_exits = 1;
|
||||
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.controller_model_matches = false;
|
||||
assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() {
|
||||
let mut duplicate = valid_input(1);
|
||||
duplicate.last_repaired_generation = 1;
|
||||
assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration));
|
||||
|
||||
let next = valid_input(2);
|
||||
assert_eq!(decide(next), Ok(Decision::Repair));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocated_completion_branch_has_proven_layout() {
|
||||
assert_eq!(COMPLETION_COPY_LEN, 14);
|
||||
assert_eq!(
|
||||
&COMPLETION_SIGNATURE[..12],
|
||||
&[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2]
|
||||
);
|
||||
assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]);
|
||||
assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,9 @@
|
||||
//! (all addresses, RVA math, call order, crash risks, staged test plan):
|
||||
//! fifa17-recon/docs/sbc-hook-dll-spec.md
|
||||
//!
|
||||
//! Everything here is **inert by default** and gated by env vars, so shipping the DLL
|
||||
//! with this module compiled in changes nothing unless a var is set:
|
||||
//! Everything here is **inert by default** and gated by env vars:
|
||||
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
||||
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
||||
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
|
||||
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
||||
//!
|
||||
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
|
||||
@@ -20,14 +18,11 @@
|
||||
//! See the spec for the verified disassembly behind each one.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
|
||||
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
|
||||
PAGE_WRITECOPY,
|
||||
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||
const IMAGE_BASE: usize = 0x180000000;
|
||||
@@ -46,13 +41,6 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
|
||||
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
|
||||
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
|
||||
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
|
||||
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
||||
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
||||
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
||||
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
||||
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
|
||||
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
|
||||
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
|
||||
const B_DTOR_RVA: usize = 0x63040;
|
||||
const B_ISVALID_RVA: usize = 0x65d40;
|
||||
const B_CLEAR_RVA: usize = 0x65d20;
|
||||
@@ -87,11 +75,9 @@ mod rva {
|
||||
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||
static COMMIT: AtomicBool = AtomicBool::new(false);
|
||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -147,13 +133,6 @@ enum ValidationError {
|
||||
CollectionUnreadable,
|
||||
CollectionNotNull,
|
||||
ReadyByteNotWritable,
|
||||
ModelEmpty,
|
||||
ControllerMissing,
|
||||
ControllerVtableMismatch,
|
||||
ControllerModelMismatch,
|
||||
CompletionBranchMismatch,
|
||||
CompletionBranchProtectFailed,
|
||||
CompletionBranchFlushFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -428,12 +407,6 @@ pub fn install() {
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
COMMIT.store(
|
||||
std::env::var("OPENFUT_SBC_COMMIT")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
POPULATE.store(
|
||||
std::env::var("OPENFUT_SBC_POPULATE")
|
||||
.map(|v| v == "1")
|
||||
@@ -444,192 +417,6 @@ pub fn install() {
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
|
||||
/// The registration hook is observational; all structural checks happen again on
|
||||
/// the notifier thread before this address is trusted.
|
||||
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
let valid = base != 0
|
||||
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
&& controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
|
||||
if valid {
|
||||
SBC_CONTROLLER.store(controller, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
|
||||
));
|
||||
} else {
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn log_controller_model(native_model: usize) {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
let controller_model = controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
let main_vtable = read_ptr(controller).unwrap_or(0);
|
||||
let event_vtable = controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
|
||||
controller != 0 && controller_model == native_model,
|
||||
));
|
||||
}
|
||||
|
||||
unsafe fn validated_sbc_controller(
|
||||
base: usize,
|
||||
native_model: usize,
|
||||
) -> Result<usize, ValidationError> {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
if controller == 0 {
|
||||
return Err(ValidationError::ControllerMissing);
|
||||
}
|
||||
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
|| controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
|
||||
{
|
||||
return Err(ValidationError::ControllerVtableMismatch);
|
||||
}
|
||||
if controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= Some(native_model)
|
||||
{
|
||||
return Err(ValidationError::ControllerModelMismatch);
|
||||
}
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
/// Route the already-scheduled category completion through CardsDLL's own success
|
||||
/// branch. The original function first rejects a non-zero status with a two-byte
|
||||
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
|
||||
/// to the stale scheduler completion rather than the category HTTP transaction.
|
||||
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
|
||||
let target = base
|
||||
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
|
||||
.ok_or(ValidationError::AddressOverflow)?;
|
||||
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|
||||
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
|
||||
!= SBC_COMPLETION_STATUS_JNE
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchMismatch);
|
||||
}
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchProtectFailed);
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
|
||||
target as *mut u8,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
);
|
||||
let flushed = FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
) != 0;
|
||||
let mut ignored = 0u32;
|
||||
let protected = VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
old,
|
||||
&mut ignored,
|
||||
) != 0;
|
||||
if !flushed || !protected {
|
||||
return Err(ValidationError::CompletionBranchFlushFailed);
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
|
||||
GetCurrentThreadId(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit the already-populated native SBC model after the category success notifier.
|
||||
///
|
||||
/// This is called synchronously by the passive notifier wrapper *after* the original
|
||||
/// notifier returns. It never invokes a parser or constructs game objects. The only
|
||||
/// mutation is the established cache-ready byte, and only when the normal parser has
|
||||
/// produced at least one category and every pointer/vtable invariant still matches.
|
||||
pub(crate) unsafe fn commit_after_native_parse() {
|
||||
if !COMMIT.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
if base == 0 || !control_matches(base) {
|
||||
set_failed(ValidationError::AUnreadable);
|
||||
return;
|
||||
}
|
||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
if snapshot.m == 0
|
||||
|| read_u16(snapshot.m + M_COUNT_OFF)
|
||||
.filter(|&count| count > 0)
|
||||
.is_none()
|
||||
{
|
||||
return Err(ValidationError::ModelEmpty);
|
||||
}
|
||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
||||
return Err(ValidationError::ReadyByteNotWritable);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
|
||||
log_controller_model(snapshot.m);
|
||||
if DONE.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
|
||||
snapshot.m,
|
||||
count,
|
||||
snapshot.b + B_READY_OFF,
|
||||
));
|
||||
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
|
||||
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|
||||
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
|
||||
{
|
||||
set_failed(ValidationError::ReadyByteUnexpected);
|
||||
return;
|
||||
}
|
||||
let _controller = match validated_sbc_controller(base, snapshot.m) {
|
||||
Ok(controller) => controller,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = arm_native_completion_success(base) {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
crate::write_log(
|
||||
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
|
||||
);
|
||||
}
|
||||
|
||||
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
||||
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
||||
/// exactly once.
|
||||
|
||||
@@ -93,7 +93,7 @@ static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
|
||||
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct PatchInstallerGate;
|
||||
pub(crate) struct PatchInstallerGate;
|
||||
|
||||
impl Drop for PatchInstallerGate {
|
||||
fn drop(&mut self) {
|
||||
@@ -101,7 +101,7 @@ impl Drop for PatchInstallerGate {
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||
pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||
for _ in 0..200 {
|
||||
if PATCH_INSTALLER_BUSY
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
@@ -114,7 +114,7 @@ fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||
None
|
||||
}
|
||||
|
||||
struct CodeInstallerPending;
|
||||
pub(crate) struct CodeInstallerPending;
|
||||
|
||||
impl Drop for CodeInstallerPending {
|
||||
fn drop(&mut self) {
|
||||
@@ -138,15 +138,15 @@ enum TraceState {
|
||||
DegradedHookAndProcess,
|
||||
}
|
||||
|
||||
fn env_enabled(value: Option<&str>) -> bool {
|
||||
pub(crate) fn env_enabled(value: Option<&str>) -> bool {
|
||||
matches!(value, Some("1"))
|
||||
}
|
||||
|
||||
fn target_va(base: usize, rva: usize) -> Option<usize> {
|
||||
pub(crate) fn target_va(base: usize, rva: usize) -> Option<usize> {
|
||||
base.checked_add(rva)
|
||||
}
|
||||
|
||||
fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
||||
pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
||||
let mut jump = [0u8; ABS_JUMP_LEN];
|
||||
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
|
||||
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
|
||||
@@ -160,7 +160,7 @@ fn instruction_pointer_in_span(rip: usize, target: usize) -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
struct SuspendedPeers {
|
||||
pub(crate) struct SuspendedPeers {
|
||||
handles: [HANDLE; MAX_PEERS],
|
||||
tids: [u32; MAX_PEERS],
|
||||
count: usize,
|
||||
@@ -179,7 +179,7 @@ impl SuspendedPeers {
|
||||
self.tids[..self.count].contains(&tid)
|
||||
}
|
||||
|
||||
unsafe fn resume_all(&mut self) -> bool {
|
||||
pub(crate) unsafe fn resume_all(&mut self) -> bool {
|
||||
let mut all_resumed = true;
|
||||
for index in (0..self.count).rev() {
|
||||
let handle = self.handles[index];
|
||||
@@ -208,14 +208,14 @@ impl Drop for SuspendedPeers {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum QuiesceFailure {
|
||||
pub(crate) enum QuiesceFailure {
|
||||
Acquire,
|
||||
Resume,
|
||||
}
|
||||
|
||||
/// Stop and inspect every peer thread before touching either entry point. Any
|
||||
/// incomplete enumeration/access/context operation fails the transaction closed.
|
||||
unsafe fn suspend_peers(
|
||||
pub(crate) unsafe fn suspend_peers(
|
||||
factory: usize,
|
||||
deserializer: usize,
|
||||
) -> Result<SuspendedPeers, QuiesceFailure> {
|
||||
@@ -329,7 +329,7 @@ unsafe fn executable_range(address: usize, length: usize) -> bool {
|
||||
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
||||
pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
||||
let Some(end) = address.checked_add(length) else {
|
||||
return false;
|
||||
};
|
||||
@@ -347,7 +347,7 @@ unsafe fn executable_range_in_image(base: usize, address: usize, length: usize)
|
||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
let Some(end) = address.checked_add(length) else {
|
||||
return false;
|
||||
};
|
||||
@@ -362,20 +362,20 @@ unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
||||
pub(crate) unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
||||
(address & 7 == 0 && readable_range(address, 8))
|
||||
.then(|| core::ptr::read_volatile(address as *const usize))
|
||||
}
|
||||
|
||||
unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
||||
pub(crate) unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
||||
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
|
||||
}
|
||||
|
||||
unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||
pub(crate) unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
|
||||
}
|
||||
|
||||
unsafe fn valid_cards_image(base: usize) -> bool {
|
||||
pub(crate) unsafe fn valid_cards_image(base: usize) -> bool {
|
||||
let Some(control) = base.checked_add(CONTROL_RVA) else {
|
||||
return false;
|
||||
};
|
||||
@@ -413,7 +413,7 @@ unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool {
|
||||
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
|
||||
}
|
||||
|
||||
unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
||||
pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
||||
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
|
||||
let memory = VirtualAlloc(
|
||||
core::ptr::null(),
|
||||
@@ -595,7 +595,10 @@ unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, e
|
||||
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(controller, event);
|
||||
if event == FUT_SBS_CATEGORIES_EVENT {
|
||||
crate::sbc_hook::note_sbc_controller(controller as usize);
|
||||
crate::sbc_dispatch::note_sbc_controller(
|
||||
controller as usize,
|
||||
TRACE_BASE.load(Ordering::Acquire),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,7 +633,6 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
|
||||
let original: unsafe extern "system" fn(*mut c_void) =
|
||||
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(ctx);
|
||||
crate::sbc_hook::commit_after_native_parse();
|
||||
NOTIFIER_BYTE_AFTER.store(
|
||||
address
|
||||
.checked_add(0x88)
|
||||
@@ -688,6 +690,44 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c
|
||||
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
|
||||
result
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct DispatchEvidence {
|
||||
pub(crate) base: usize,
|
||||
pub(crate) factory_entries: u64,
|
||||
pub(crate) factory_exits: u64,
|
||||
pub(crate) factory_result: usize,
|
||||
pub(crate) factory_thread: usize,
|
||||
pub(crate) deserializer_entries: u64,
|
||||
pub(crate) deserializer_exits: u64,
|
||||
pub(crate) deserializer_this: usize,
|
||||
pub(crate) deserializer_reader: usize,
|
||||
pub(crate) deserializer_result: bool,
|
||||
pub(crate) deserializer_thread: usize,
|
||||
pub(crate) model: usize,
|
||||
pub(crate) category_count: usize,
|
||||
pub(crate) notifier_entries: u64,
|
||||
pub(crate) notifier_exits: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_evidence() -> DispatchEvidence {
|
||||
DispatchEvidence {
|
||||
base: TRACE_BASE.load(Ordering::Acquire),
|
||||
factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire),
|
||||
factory_exits: FACTORY_EXITS.load(Ordering::Acquire),
|
||||
factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire),
|
||||
factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed),
|
||||
deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire),
|
||||
deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire),
|
||||
deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
|
||||
deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
|
||||
deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
|
||||
deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
|
||||
model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
|
||||
category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
|
||||
notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire),
|
||||
notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum InstallOutcome {
|
||||
@@ -1103,10 +1143,15 @@ fn install_notifier(enabled: bool) {
|
||||
}
|
||||
|
||||
pub(crate) fn install() {
|
||||
let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
||||
let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
||||
let dispatch_repair = env_enabled(std::env::var("OPENFUT_SBC_DISPATCH").ok().as_deref());
|
||||
let dispatch_trace =
|
||||
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
|
||||
let enabled =
|
||||
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
||||
let notifier_enabled =
|
||||
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
||||
CODE_PATCH_PENDING.store(
|
||||
enabled as usize + (notifier_enabled as usize * 2),
|
||||
enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize,
|
||||
Ordering::Release,
|
||||
);
|
||||
install_notifier(notifier_enabled);
|
||||
|
||||
Reference in New Issue
Block a user