79e566883f
The rebind approach was disproven live: the bind sensor measured mask=0x00 at screen-show (container empty, all six slots hidden -> no tab bar), and a rebind after the groups arrived (mask=0x0e = bronze|silver|gold) built NO tab bar. The Scaleform movie only honours the framework's OWN bind at screen-show, not a later re-publish/commit. Root cause therefore stands confirmed: the store's GET store/purchasegroup/all returns only after screen-show, so the first bind sees an empty container. Re-entry works because the groups are cached by then. Fix: load the purchase groups BEFORE the store screen is shown. FUN_180017870 (storefront) issues the store's own group request; firing it from the FUT hub event pump (a real game thread, before the store screen exists) lets the response arrive and populate the container so the first screen-show bind sees a full list and binds the tabs natively -- the re-entry path, on first entry. The bind detour is retained purely as the read-only SENSOR: the first-entry bind mask is the definitive measurement of whether the pre-warm landed in time. mask!=0 => pre-warm worked and the tabs bind natively; mask==0 with storefront_seen!=0 in the pre-warm log => a hub-time request cannot land in time and the remaining route is the extracted StoreFront.apt. Removed: render detour, maybe_rebind/should_rebind, and all rebind state. Re-added the hub-time maybe_prewarm_groups() call in sbc_dispatch::event_wrapper. Promoted (build-armed). Deployed artifact 668e9324; profile-gated fifa17 build.
857 lines
34 KiB
Rust
857 lines
34 KiB
Rust
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
|
||
//!
|
||
//! The repair is a PROMOTED feature: it is armed by the build itself, never by an
|
||
//! environment variable (see [`REPAIR_PROMOTED`]). Safety lives in the runtime
|
||
//! evidence gate, not in a flag.
|
||
|
||
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;
|
||
|
||
/// The guarded native dispatch repair is PROMOTED: armed by the build, never by an
|
||
/// environment variable. Retail Gates A–G passed on the pinned CardsDLL build, so a
|
||
/// deployed hook must repair the SBC completion on every launch path (Steam, the
|
||
/// launcher, or a bare `umu-run`) with nothing to export.
|
||
///
|
||
/// Promotion does NOT weaken any check — every guard stays in the runtime evidence
|
||
/// gate rather than in a flag. `worker` still validates the exact CardsDLL
|
||
/// signatures before installing a detour, and [`decide`] still requires the
|
||
/// transport sentinel status, the pinned category-response vtable captured while
|
||
/// the response object was provably live, balanced parser counts on the one parser
|
||
/// thread, this generation's notifier having entered AND returned, the captured
|
||
/// controller/model identity, and one repair per deserializer generation. Anything
|
||
/// unrecognised leaves native execution untouched.
|
||
///
|
||
/// Rollback is a file swap (restore the previous `version.dll`) — the documented
|
||
/// client rollback path — deliberately not an env kill-switch.
|
||
pub(crate) const REPAIR_PROMOTED: bool = true;
|
||
|
||
/// Compile-time contract: the repair stays armed by the build. Flipping this back to
|
||
/// an env gate would silently cost a normal launch (Steam or the launcher) its SBC
|
||
/// screen, which is exactly the regression promotion removed — so it must be a
|
||
/// deliberate, visible change here rather than a missing variable at runtime.
|
||
const _: () = assert!(REPAIR_PROMOTED);
|
||
|
||
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,
|
||
ModelChanged,
|
||
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,
|
||
live_category_count: 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.live_category_count != input.category_count {
|
||
return Err(Rejection::ModelChanged);
|
||
}
|
||
// The category-success notifier for this generation must have entered and
|
||
// fully returned before the SBC completion runs. On the pinned CardsDLL the
|
||
// completion fires immediately after the notifier unwinds (measured: notifier
|
||
// entries == exits == generation at completion), not nested inside it, so we
|
||
// bind both notifier counts to the current generation rather than requiring
|
||
// an in-flight notifier.
|
||
if input.notifier_entries != generation
|
||
|| input.notifier_entries == 0
|
||
|| input.notifier_exits != generation
|
||
{
|
||
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)
|
||
}
|
||
|
||
/// The parsed response is the FIFA 17 typed SBC-category response only when the
|
||
/// vtable captured at deserializer exit (object provably live) equals the pinned
|
||
/// category-response vtable for the running CardsDLL image. A zero capture means
|
||
/// the object vtable was unreadable and never qualifies.
|
||
fn response_class_matches(base: usize, response_vtable: usize) -> bool {
|
||
response_vtable != 0 && base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA) == Some(response_vtable)
|
||
}
|
||
|
||
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 captured_controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||
let live_category_count = evidence
|
||
.model
|
||
.checked_add(0x50)
|
||
.and_then(|address| crate::sbc_trace::guarded_u16(address))
|
||
.map(usize::from)
|
||
.unwrap_or(usize::MAX);
|
||
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_class_matches(evidence.base, evidence.response_vtable),
|
||
model: evidence.model,
|
||
live_category_count,
|
||
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)
|
||
}
|
||
};
|
||
crate::write_log(&format!(
|
||
"SBC_DISPATCH: decide gen={} status={} present={} copyable={} cards={} factory_e={} factory_x={} factory_r={:#x} factory_t={} deser_e={} deser_x={} deser_this={:#x} reader={:#x} deser_ok={} deser_t={} vt_obs={:#x} vt_exp={:#x} class={} model={:#x} live={} count={} notif_e={} notif_x={} ctrl_match={} ctrl_model={} captured_ctrl={:#x} arg_ctrl={:#x} last_gen={} decision={} rejection={}\n",
|
||
input.deserializer_exits,
|
||
input.status.map(i64::from).unwrap_or(-1),
|
||
input.status_present,
|
||
input.status_copyable,
|
||
input.cards_build_matches,
|
||
input.factory_entries,
|
||
input.factory_exits,
|
||
input.factory_result,
|
||
input.factory_thread,
|
||
input.deserializer_entries,
|
||
input.deserializer_exits,
|
||
input.deserializer_this,
|
||
input.deserializer_reader,
|
||
input.deserializer_result,
|
||
input.deserializer_thread,
|
||
evidence.response_vtable,
|
||
evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA).unwrap_or(0),
|
||
input.response_class_matches,
|
||
input.model,
|
||
input.live_category_count,
|
||
input.category_count,
|
||
input.notifier_entries,
|
||
input.notifier_exits,
|
||
input.controller_matches,
|
||
input.controller_model_matches,
|
||
captured_controller,
|
||
controller as usize,
|
||
input.last_repaired_generation,
|
||
COMPLETION_DECISION.load(Ordering::Relaxed),
|
||
COMPLETION_REJECTION.load(Ordering::Relaxed),
|
||
));
|
||
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);
|
||
}
|
||
_ => {}
|
||
}
|
||
// Piggyback the store pre-warm on this game-thread hub event: it loads the
|
||
// purchase groups once, before the store screen is shown, so the store's native
|
||
// screen-show tab bind sees a populated group list (see `store_entry`).
|
||
crate::store_entry::maybe_prewarm_groups();
|
||
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() {
|
||
// Promoted: armed by the build. No environment variable participates in the
|
||
// decision, so every launch path behaves identically.
|
||
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
|
||
crate::write_log(
|
||
"SBC_DISPATCH: repair ARMED (promoted); strict native evidence gate enabled\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,
|
||
live_category_count: 2,
|
||
category_count: 2,
|
||
notifier_entries: generation,
|
||
notifier_exits: generation,
|
||
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));
|
||
|
||
// Notifier still in flight for this generation (has not returned) is rejected:
|
||
// on the pinned build the completion only runs after the notifier unwinds.
|
||
let mut input = valid_input(1);
|
||
input.notifier_exits = 0;
|
||
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
|
||
|
||
// A notifier count that does not match the current generation is rejected.
|
||
let mut input = valid_input(1);
|
||
input.notifier_entries = 2;
|
||
input.notifier_exits = 2;
|
||
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));
|
||
|
||
let mut input = valid_input(1);
|
||
input.live_category_count = 0;
|
||
assert_eq!(decide(input), Err(Rejection::ModelChanged));
|
||
}
|
||
|
||
#[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 response_class_requires_exact_pinned_vtable() {
|
||
let base = 0x1_8000_0000usize;
|
||
let expected = base + CATEGORY_RESPONSE_VTABLE_RVA;
|
||
assert!(response_class_matches(base, expected));
|
||
// An unreadable capture (zero) never qualifies.
|
||
assert!(!response_class_matches(base, 0));
|
||
// Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected.
|
||
assert!(!response_class_matches(base, expected + 8));
|
||
assert!(!response_class_matches(base, base));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|