Compare commits
9 Commits
1cd4f18e92
...
9aecc658ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aecc658ad | |||
| af7a5948a7 | |||
| 3d3790a83a | |||
| 94feaec63f | |||
| c3addde9b1 | |||
| 9900772690 | |||
| 35ceb084ef | |||
| 1c7111ddbf | |||
| 6cdb45e482 |
@@ -79,13 +79,14 @@ 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.
|
||||
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
|
||||
// itself from the build; its safety is the runtime signature/evidence gate. The
|
||||
// remaining legacy experiment modules stay inert unless their env 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();
|
||||
crate::store_entry::install();
|
||||
0
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,16 @@ 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;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_trace;
|
||||
mod ssl_patch;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod store_entry;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
mod version_proxy;
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
//! 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -80,6 +80,7 @@ static TRACE_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static DESERIALIZER_EXIT_RESPONSE_VTABLE: AtomicUsize = AtomicUsize::new(0);
|
||||
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -93,7 +94,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 +102,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 +115,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 +139,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 +161,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 +180,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 +209,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 +330,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 +348,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 +363,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 +414,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 +596,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 +634,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)
|
||||
@@ -682,12 +685,54 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c
|
||||
.and_then(|slot| guarded_u8(slot))
|
||||
.map(usize::from)
|
||||
.unwrap_or(usize::MAX);
|
||||
let response_vtable = guarded_usize(this as usize).unwrap_or(0);
|
||||
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
|
||||
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
|
||||
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
|
||||
DESERIALIZER_EXIT_RESPONSE_VTABLE.store(response_vtable, Ordering::Relaxed);
|
||||
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) response_vtable: 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),
|
||||
response_vtable: DESERIALIZER_EXIT_RESPONSE_VTABLE.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 +1148,17 @@ 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());
|
||||
// The repair's evidence traces (parser, notifier, controller registration) are
|
||||
// its decision inputs, so they follow the promoted repair, not an env var.
|
||||
let dispatch_repair = crate::sbc_dispatch::REPAIR_PROMOTED;
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Guarded FIFA 17 store-entry category clamp.
|
||||
//!
|
||||
//! # What this repairs
|
||||
//!
|
||||
//! Opening the FUT store shows a one-frame "Browse Packs" overview (all group
|
||||
//! tiles) before it settles on a tabbed category. The overview is the store
|
||||
//! screen's constructor default: `screen+0x290` (the CATEGORY_ID the renderer
|
||||
//! resolves) starts at `0`, and the category resolver `FUN_1800147f0` treats a
|
||||
//! `0` category as "list every group" (`FUN_180014610`, the Browse path). Only
|
||||
//! after the Scaleform movie posts a real tab ordinal does the screen re-render
|
||||
//! on a tab — hence the visible flash on first open.
|
||||
//!
|
||||
//! This hook removes that flash by making the FIRST render already land on a
|
||||
//! real tab: it detours the store render `FUN_18007dab0` and, when the incoming
|
||||
//! category is `0`, substitutes the first present group ordinal (`1`).
|
||||
//!
|
||||
//! # Why it cannot crash the client
|
||||
//!
|
||||
//! The resolver crashes (`0x180014882`, `[NULL+0x48]`) only when it resolves a
|
||||
//! POSITIVE ordinal that `FUN_180014420` (ordinal->group lookup) cannot find and
|
||||
//! returns NULL for. The existing autopatch guard (`JG` at `0x180014858`) already
|
||||
//! routes `category <= 0` to the safe Browse path, but does NOT cover a positive
|
||||
//! ordinal that misses. So this hook substitutes `1` ONLY after calling the exact
|
||||
//! same lookup the resolver uses — `FUN_180014420(_, 1)` — and confirming it
|
||||
//! returns non-NULL. That is the resolver's own non-crash precondition, so by
|
||||
//! construction the substituted category can never reach the NULL deref. When no
|
||||
//! group exists yet, the category is left untouched (`0` -> Browse, still safe).
|
||||
//! `FUN_180014420` ignores its first argument (it fetches the group list from a
|
||||
//! process singleton), which the render itself dereferences on entry, so calling
|
||||
//! it here is exactly as safe as the render's own first action.
|
||||
//!
|
||||
//! # Promotion
|
||||
//!
|
||||
//! Like the SBC dispatch repair, this is a PROMOTED feature: armed by the build,
|
||||
//! never by an environment variable (see [`CLAMP_PROMOTED`]). Safety is the
|
||||
//! runtime signature/evidence gate, not a flag. Rollback is a `version.dll` file
|
||||
//! swap, not an env kill-switch.
|
||||
|
||||
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::{
|
||||
VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
/// Store render `FUN_18007dab0`: reads `screen+0x290` and drives the resolver.
|
||||
const RENDER_RVA: usize = 0x7dab0;
|
||||
/// Ordinal->group lookup `FUN_180014420`: returns the group for a 1-based ordinal
|
||||
/// or NULL when absent. Its first argument is dead (list comes from a singleton).
|
||||
const LOOKUP_RVA: usize = 0x14420;
|
||||
/// `screen + CATEGORY_OFFSET` holds the CATEGORY_ID the renderer resolves.
|
||||
const CATEGORY_OFFSET: usize = 0x290;
|
||||
/// The first present group's ordinal. `FUN_180014420` numbers present groups from
|
||||
/// 1, so `1` is always the first present group — the natural default landing tab.
|
||||
const FIRST_ORDINAL: u32 = 1;
|
||||
/// Whole-instruction prologue length relocated into the render trampoline; also the
|
||||
/// number of bytes overwritten by the entry detour. `push rdi; sub rsp,0x40;
|
||||
/// movq [rsp+0x30],-2` = 2 + 4 + 9 = 15, a clean boundary that covers the 14-byte
|
||||
/// absolute jump.
|
||||
const COPY_LEN: usize = 15;
|
||||
const ABS_JUMP_LEN: usize = 14;
|
||||
|
||||
/// First 15 bytes of `FUN_18007dab0` on the pinned CardsDLL. Verified before the
|
||||
/// detour is written and again under thread suspension.
|
||||
const RENDER_SIGNATURE: [u8; COPY_LEN] = [
|
||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff, 0xff,
|
||||
];
|
||||
/// First 15 bytes of `FUN_180014420`. Validated before we ever call it, so the clamp
|
||||
/// only invokes the genuine lookup on the exact build it was reversed against.
|
||||
const LOOKUP_SIGNATURE: [u8; 15] = [
|
||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff,
|
||||
];
|
||||
|
||||
type RenderFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void;
|
||||
type LookupFn = unsafe extern "system" fn(*mut c_void, u32) -> *mut c_void;
|
||||
|
||||
/// The store-entry clamp is PROMOTED: armed by the build, never by an environment
|
||||
/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves
|
||||
/// identically. Promotion does not weaken any check — the signature gate, the image
|
||||
/// validation, the thread quiesce, and the resolver-backed non-NULL precondition all
|
||||
/// remain in the runtime evidence path.
|
||||
pub(crate) const CLAMP_PROMOTED: bool = true;
|
||||
|
||||
/// Compile-time contract: the clamp stays build-armed. Regressing this to an env gate
|
||||
/// would silently restore the overview flash on a normal launch, so it must be a
|
||||
/// deliberate, visible change here rather than a missing variable at runtime.
|
||||
const _: () = assert!(CLAMP_PROMOTED);
|
||||
|
||||
static CLAMP_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static RENDER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static STORE_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static RENDER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
static CLAMPS_APPLIED: AtomicU64 = AtomicU64::new(0);
|
||||
static CLAMP_LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Pure clamp decision, isolated for host tests. Returns the category the renderer
|
||||
/// should resolve: substitute the first present ordinal only for the overview
|
||||
/// default (`0`) and only when that ordinal actually resolves to a group; otherwise
|
||||
/// leave the incoming category untouched.
|
||||
fn clamp_category(current: i32, first_group_present: bool) -> i32 {
|
||||
if current == 0 && first_group_present {
|
||||
FIRST_ORDINAL as i32
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn guarded_i32(address: usize) -> Option<i32> {
|
||||
crate::sbc_trace::readable_range(address, 4)
|
||||
.then(|| core::ptr::read_volatile(address as *const i32))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Detour target for `FUN_18007dab0`. Runs on the native render thread. Before the
|
||||
/// original renders, clamp an overview-default category to the first present group so
|
||||
/// the first frame already shows a tab. Any guard miss leaves the category untouched
|
||||
/// and simply tail-calls the original.
|
||||
unsafe extern "system" fn store_render_wrapper(screen: *mut c_void) -> *mut c_void {
|
||||
RENDER_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||
if CLAMP_ENABLED.load(Ordering::Acquire) {
|
||||
let base = STORE_BASE.load(Ordering::Acquire);
|
||||
if base != 0 && !screen.is_null() && crate::sbc_trace::validate_cards_build(base) {
|
||||
let category_addr = (screen as usize).wrapping_add(CATEGORY_OFFSET);
|
||||
if let Some(0) = guarded_i32(category_addr) {
|
||||
if let Some(lookup) = base.checked_add(LOOKUP_RVA) {
|
||||
// The lookup's first argument is dead; pass null. `1` is the first
|
||||
// present ordinal. Non-NULL means the resolver will find a group,
|
||||
// so writing `1` cannot reach the NULL-deref crash path.
|
||||
let lookup_fn: LookupFn = core::mem::transmute(lookup);
|
||||
let group = lookup_fn(core::ptr::null_mut(), FIRST_ORDINAL);
|
||||
let clamped = clamp_category(0, !group.is_null());
|
||||
if clamped != 0 {
|
||||
core::ptr::write_volatile(category_addr as *mut i32, clamped);
|
||||
CLAMPS_APPLIED.fetch_add(1, Ordering::Relaxed);
|
||||
CLAMP_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let original: RenderFn = core::mem::transmute(RENDER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(screen)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum InstallOutcome {
|
||||
Installed,
|
||||
CleanFailure,
|
||||
DegradedHookActive,
|
||||
DegradedProcessState,
|
||||
DegradedHookAndProcess,
|
||||
}
|
||||
|
||||
unsafe fn install_hook(base: usize) -> InstallOutcome {
|
||||
let Some(render) = crate::sbc_trace::target_va(base, RENDER_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let Some(lookup) = crate::sbc_trace::target_va(base, LOOKUP_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
// Fingerprint the image and BOTH functions: the one we detour and the one we
|
||||
// call. A single mismatched byte aborts cleanly with no write and no call.
|
||||
if !crate::sbc_trace::valid_cards_image(base)
|
||||
|| !crate::sbc_trace::executable_range_in_image(base, render, RENDER_SIGNATURE.len())
|
||||
|| !crate::sbc_trace::executable_range_in_image(base, lookup, LOOKUP_SIGNATURE.len())
|
||||
|| core::slice::from_raw_parts(render as *const u8, RENDER_SIGNATURE.len())
|
||||
!= RENDER_SIGNATURE
|
||||
|| core::slice::from_raw_parts(lookup as *const u8, LOOKUP_SIGNATURE.len())
|
||||
!= LOOKUP_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,
|
||||
render as *const u8,
|
||||
&mut pinned,
|
||||
) == 0
|
||||
|| pinned as usize != base
|
||||
{
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(render, COPY_LEN) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
RENDER_TRAMPOLINE.store(trampoline, Ordering::Release);
|
||||
STORE_BASE.store(base, Ordering::Release);
|
||||
|
||||
let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
RENDER_TRAMPOLINE.store(0, Ordering::Release);
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
let mut peers = match crate::sbc_trace::suspend_peers(render, lookup) {
|
||||
Ok(peers) => peers,
|
||||
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
RENDER_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(render as *const u8, RENDER_SIGNATURE.len())
|
||||
== RENDER_SIGNATURE;
|
||||
let transaction = if !final_valid {
|
||||
InstallOutcome::CleanFailure
|
||||
} else {
|
||||
match write_entry(
|
||||
render,
|
||||
store_render_wrapper as *const () as usize,
|
||||
&RENDER_SIGNATURE,
|
||||
) {
|
||||
Ok(()) => InstallOutcome::Installed,
|
||||
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(trampoline as _, 0, MEM_RELEASE);
|
||||
RENDER_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_hook(base)
|
||||
};
|
||||
drop(_pending);
|
||||
match outcome {
|
||||
InstallOutcome::Installed => {
|
||||
crate::write_log("STORE_ENTRY: render clamp installed (promoted)\n")
|
||||
}
|
||||
InstallOutcome::CleanFailure => {
|
||||
crate::write_log("STORE_ENTRY: clean install failure; inactive\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedHookActive => {
|
||||
crate::write_log("STORE_ENTRY: DEGRADED hook may be active; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedProcessState => {
|
||||
crate::write_log("STORE_ENTRY: DEGRADED thread state; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
InstallOutcome::DegradedHookAndProcess => {
|
||||
crate::write_log("STORE_ENTRY: DEGRADED hook and thread state; terminate game now\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut entries_seen = 0u64;
|
||||
let mut reports = 0u8;
|
||||
while reports < 64 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let entries = RENDER_ENTRIES.load(Ordering::Acquire);
|
||||
if entries != entries_seen {
|
||||
crate::write_log(&format!(
|
||||
"STORE_ENTRY: render entries={} clamps={} tid={}\n",
|
||||
entries,
|
||||
CLAMPS_APPLIED.load(Ordering::Acquire),
|
||||
CLAMP_LAST_THREAD.load(Ordering::Relaxed),
|
||||
));
|
||||
entries_seen = entries;
|
||||
reports += 1;
|
||||
}
|
||||
}
|
||||
crate::write_log("STORE_ENTRY: report cap reached; hook remains installed\n");
|
||||
}
|
||||
|
||||
pub(crate) fn install() {
|
||||
// Promoted: armed by the build. No environment variable participates.
|
||||
CLAMP_ENABLED.store(CLAMP_PROMOTED, Ordering::Release);
|
||||
crate::write_log("STORE_ENTRY: clamp ARMED (promoted); strict native signature gate\n");
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overview_default_clamps_to_first_ordinal_when_group_present() {
|
||||
assert_eq!(
|
||||
clamp_category(0, true),
|
||||
1,
|
||||
"the ctor overview default (0) must land on the first present group"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_default_left_alone_when_no_group_exists() {
|
||||
// No group -> leaving 0 routes to the safe Browse path; substituting a
|
||||
// positive ordinal here would be the exact positive-invalid crash.
|
||||
assert_eq!(clamp_category(0, false), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_positive_category_is_never_touched() {
|
||||
// A real tab selection must pass through unchanged, group present or not.
|
||||
assert_eq!(clamp_category(3, true), 3);
|
||||
assert_eq!(clamp_category(3, false), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_category_is_left_to_the_existing_browse_guard() {
|
||||
// The absent-mypacks -1 is handled by the JG guard (routes <=0 to Browse);
|
||||
// this clamp deliberately only touches the 0 overview default.
|
||||
assert_eq!(clamp_category(-1, true), -1);
|
||||
}
|
||||
}
|
||||
+15
-19
@@ -541,7 +541,7 @@ impl LauncherApp {
|
||||
status_text(ui, readiness_status(server), &server_label);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Client integration").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Game files").color(theme::TEXT_WEAK));
|
||||
status_text(
|
||||
ui,
|
||||
readiness_status(integration),
|
||||
@@ -555,11 +555,11 @@ impl LauncherApp {
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Local services").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Background helpers").color(theme::TEXT_WEAK));
|
||||
status_text(ui, readiness_status(services), &services_label);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Game patch").color(theme::TEXT_WEAK));
|
||||
status_text(ui, readiness_status(hook), &hook_label);
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -725,13 +725,12 @@ impl LauncherApp {
|
||||
match (ready, blocked) {
|
||||
(_, true) => (launch::Readiness::Attention, "Blocked".into()),
|
||||
(2, _) => (launch::Readiness::Ready, "Ready".into()),
|
||||
(0, _) => (
|
||||
// Not a problem: Launch starts them. Stating "Stopped" is honest
|
||||
// and does not demand an action.
|
||||
launch::Readiness::Unknown,
|
||||
"Stopped — Launch starts them".into(),
|
||||
),
|
||||
(_, _) => (launch::Readiness::Unknown, "Partly running".into()),
|
||||
// Neither stopped nor mid-start is a fault: the helpers only run
|
||||
// alongside a session and Launch brings up whatever is missing. This
|
||||
// used to read "Partly running", which sounds broken for what is the
|
||||
// normal idle state and gave the player nothing to act on. Say what
|
||||
// will happen instead.
|
||||
(_, _) => (launch::Readiness::Unknown, "Start with the game".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,14 +744,14 @@ impl LauncherApp {
|
||||
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
|
||||
{
|
||||
Some(d) if d == self.config.server_config() => {
|
||||
(launch::Readiness::Ready, format!("Deployed → {}", d.host))
|
||||
(launch::Readiness::Ready, "Installed".into())
|
||||
}
|
||||
// Launch rewrites it, so this is not something to demand action for.
|
||||
Some(d) => (
|
||||
Some(_) => (
|
||||
launch::Readiness::Unknown,
|
||||
format!("Deployed → {} · Launch updates it", d.host),
|
||||
"Installed · Launch will update it".into(),
|
||||
),
|
||||
None => (launch::Readiness::Attention, "No openfut.cfg".into()),
|
||||
None => (launch::Readiness::Attention, "Not set up".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,10 +838,7 @@ impl LauncherApp {
|
||||
self.restart_queue.push(service);
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
runtime.started_by_launcher,
|
||||
egui::Button::new("Stop"),
|
||||
)
|
||||
.add_enabled(runtime.started_by_launcher, egui::Button::new("Stop"))
|
||||
.on_disabled_hover_text(
|
||||
"Started outside this launcher — stop it where it \
|
||||
was started.",
|
||||
@@ -2180,7 +2176,7 @@ fn group_thousands(digits: &str) -> String {
|
||||
let len = bytes.len();
|
||||
let mut out = String::with_capacity(len + len / 3);
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if i > 0 && (len - i) % 3 == 0 {
|
||||
if i > 0 && (len - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(*b as char);
|
||||
|
||||
@@ -58,6 +58,7 @@ pub fn launch(
|
||||
}
|
||||
|
||||
prepare_prefix(profile, log)?;
|
||||
ensure_dll_override(profile, log);
|
||||
ensure_license(profile, log)?;
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
@@ -95,6 +96,99 @@ pub fn launch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
|
||||
///
|
||||
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
|
||||
/// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for
|
||||
/// a process we spawn ourselves — it cannot help a player who presses Play in Steam,
|
||||
/// which is why the old advice was to paste launch options by hand (see
|
||||
/// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly
|
||||
/// the kind of step that makes this unusable for anyone who does not already know what
|
||||
/// a DLL override is.
|
||||
///
|
||||
/// Persisting the override in the prefix registry removes the manual step entirely: it
|
||||
/// survives restarts and applies to every launch path, including Steam. This mirrors
|
||||
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
|
||||
/// environment) and what Proton itself already does in this prefix for other titles.
|
||||
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
|
||||
const HOOK_DLL_VALUE: &str = "version";
|
||||
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
||||
|
||||
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
|
||||
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
|
||||
/// repairs a prefix a player has reset or replaced.
|
||||
fn dll_override_args() -> [&'static str; 10] {
|
||||
[
|
||||
"reg",
|
||||
"add",
|
||||
DLL_OVERRIDE_KEY,
|
||||
"/v",
|
||||
HOOK_DLL_VALUE,
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
HOOK_DLL_OVERRIDE,
|
||||
"/f",
|
||||
]
|
||||
}
|
||||
|
||||
/// Persist the hook's DLL override into the prefix, so the game loads the proxy no
|
||||
/// matter how it is started.
|
||||
///
|
||||
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
|
||||
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
|
||||
/// error, since the player cannot act on the latter.
|
||||
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
||||
if profile.wine_prefix.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.args(dll_override_args())
|
||||
.current_dir(&profile.game_dir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
match cmd.status() {
|
||||
Ok(status) if status.success() => {
|
||||
say(log, "[launcher] game files ready (mod support enabled)");
|
||||
}
|
||||
Ok(_) | Err(_) => say(
|
||||
log,
|
||||
"[launcher] could not pre-enable mod support in the game prefix; \
|
||||
launching anyway (this launch still enables it directly)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod override_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dll_override_is_persisted_native_first_and_idempotently() {
|
||||
let args = dll_override_args();
|
||||
assert_eq!(args[0], "reg");
|
||||
assert_eq!(args[1], "add");
|
||||
assert_eq!(
|
||||
args[2], r"HKCU\Software\Wine\DllOverrides",
|
||||
"Wine reads overrides from this key; a typo silently leaves the hook unloaded"
|
||||
);
|
||||
assert_eq!(args[4], "version", "the hook ships as a version.dll proxy");
|
||||
assert_eq!(
|
||||
args[8], "native,builtin",
|
||||
"native first so the proxy wins, builtin as fallback so a missing proxy \
|
||||
cannot make the game unlaunchable"
|
||||
);
|
||||
assert_eq!(
|
||||
args[9], "/f",
|
||||
"idempotent, so running it on every launch repairs a reset prefix"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `WINEDLLOVERRIDES` value the game must be started with.
|
||||
///
|
||||
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
|
||||
|
||||
@@ -80,7 +80,10 @@ impl Service {
|
||||
/// instead of two.
|
||||
fn resolve_binary(service: Service) -> PathBuf {
|
||||
let name = service.binary();
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)) {
|
||||
if let Some(dir) = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(Path::to_path_buf))
|
||||
{
|
||||
let sibling = dir.join(name);
|
||||
if sibling.is_file() {
|
||||
return sibling;
|
||||
@@ -535,14 +538,11 @@ pub fn spawn(
|
||||
log.lock().push(format!(
|
||||
"[launcher] starting {label}: {}{}",
|
||||
parts.program,
|
||||
parts
|
||||
.args
|
||||
.iter()
|
||||
.fold(String::new(), |mut acc, a| {
|
||||
acc.push(' ');
|
||||
acc.push_str(a);
|
||||
acc
|
||||
}),
|
||||
parts.args.iter().fold(String::new(), |mut acc, a| {
|
||||
acc.push(' ');
|
||||
acc.push_str(a);
|
||||
acc
|
||||
}),
|
||||
));
|
||||
|
||||
let mut child = cmd
|
||||
|
||||
+8
-2
@@ -126,8 +126,14 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
|
||||
game_dir.join("version.dll").exists()
|
||||
}
|
||||
|
||||
/// The Steam launch options the user needs to paste in to enable the override.
|
||||
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
|
||||
/// Steam launch options that enable the hook's DLL override.
|
||||
///
|
||||
/// Kept only as a fallback to show a user who runs the game outside this launcher on
|
||||
/// a prefix we have never prepared. It is NOT the normal path any more: the launcher
|
||||
/// persists the override in the prefix registry itself
|
||||
/// (`game_launch::ensure_dll_override`), which applies to every launch including
|
||||
/// Steam's own Play button. Telling a player to paste launch options is exactly the
|
||||
/// kind of manual step this launcher exists to remove.
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
|
||||
// ── Game launch ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user