e4c56a225e
Fixes the ordering instead of fighting the movie. The tab bar is bound by FUN_18007e5e0 (six caption tests -> PANEL_ID, else hide panel), which is slot 0 of a secondary vtable invoked by the screen framework at screen-show. On a cold session the /store/purchasegroup groups have not arrived by then, so all six panels hide and no tab bar is drawn. Late publishing does not fix it: deploying the 0x278a publish at render time fired with its gate accepting (log: tabpublish=1 state=0x418) and the bar still did not appear, i.e. the movie ignores late tab updates. So load the groups BEFORE the store is ever opened. The store screen issues its own pack-list request at 0x18007f25e as FUN_180017870(*(base+0x2de0d0)) -- a single-argument call on the storefront global. Issue exactly that call once per process from the FUT event dispatcher, which already runs on a game thread long before the store screen exists. When the user then opens the store, the native screen-show bind sees a populated group list and binds the tabs itself -- the same reason a second entry has always worked. Fail-closed: base + CardsDLL image validated, storefront read guarded, FUN_180017870 fingerprinted before the first call, one request per process claimed before issuing (no re-entrant double request), and skipped entirely once groups exist. Logs prewarm= for evidence. fmt/clippy -D warnings clean, 32 hook tests pass.
559 lines
25 KiB
Rust
559 lines
25 KiB
Rust
//! 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;
|
||
/// Tab publish `FUN_18007df60(screen)`: resolves the six hardcoded tab tokens
|
||
/// (`mypacks/points/bronze/silver/gold/special`) against the loaded purchase groups
|
||
/// and publishes `MYPACK_/POINTS_/BRONZE_/SILVER_/GOLD_/SPECIAL_CATEGORY_ID` to the
|
||
/// movie, which is what makes the tab bar appear. The store screen's message
|
||
/// dispatcher (`0x18007d880`) calls it for event `0x278a`, entirely separately from
|
||
/// the render it calls for event `0x753f`.
|
||
const TAB_PUBLISH_RVA: usize = 0x7df60;
|
||
/// `screen + SCREEN_STATE_OFFSET` is the state the tab publish self-gates on
|
||
/// (`cmpl $0x418, 0x2cc(%rbp); jne <end>` at `0x18007dfbf`). Logged for evidence:
|
||
/// a mismatch makes the publish a native no-op rather than a fault.
|
||
const SCREEN_STATE_OFFSET: usize = 0x2cc;
|
||
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
|
||
/// request/lookup helpers (loaded at `0x18007f25e` right before the pack-list request).
|
||
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
|
||
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
|
||
/// the store screen makes at entry (`0x18007f25e`).
|
||
///
|
||
/// Firing it EARLY is the actual fix for the missing first-entry tab bar. The tab bar
|
||
/// is bound by `FUN_18007e5e0` (six caption tests -> `PANEL_ID`, else hide) which the
|
||
/// screen framework invokes at screen-show; on a cold session the purchase groups have
|
||
/// not arrived yet, so all six panels hide and no amount of later publishing rebuilds
|
||
/// the movie's bar. Pre-warming the groups before the store is ever opened makes the
|
||
/// native bind see a populated list, so the tabs bind natively — exactly what already
|
||
/// happens on a second entry.
|
||
const REQUEST_GROUPS_RVA: usize = 0x17870;
|
||
/// 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,
|
||
];
|
||
/// First 15 bytes of `FUN_18007df60`. Validated before we ever call it.
|
||
const TAB_PUBLISH_SIGNATURE: [u8; 15] = [
|
||
0x48, 0x8b, 0xc4, 0x56, 0x57, 0x41, 0x54, 0x41, 0x56, 0x41, 0x57, 0x48, 0x83, 0xec, 0x60,
|
||
];
|
||
/// First 18 bytes of `FUN_180017870`. Validated before we ever call it.
|
||
const REQUEST_GROUPS_SIGNATURE: [u8; 18] = [
|
||
0x40, 0x57, 0x48, 0x81, 0xec, 0x90, 0x00, 0x00, 0x00, 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;
|
||
type TabPublishFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void;
|
||
type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize;
|
||
|
||
/// 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);
|
||
static TAB_PUBLISH_SCREEN: AtomicUsize = AtomicUsize::new(0);
|
||
static TAB_PUBLISHES: AtomicU64 = AtomicU64::new(0);
|
||
static LAST_SCREEN_STATE: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||
static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
|
||
static PREWARM_DONE: AtomicBool = AtomicBool::new(false);
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// Pure decision for the tab-bar repair, isolated for host tests.
|
||
///
|
||
/// The tab bar is published by a DIFFERENT dispatcher event (`0x278a`) than the
|
||
/// render (`0x753f`). On first store entry that publish runs before the
|
||
/// `/store/purchasegroup` groups exist, so all six tab tokens resolve to −1, every
|
||
/// panel hides, and the store draws its category with no tab bar; re-entry works
|
||
/// only because the groups are cached by then. Re-running the publish once per
|
||
/// screen at render time — when the groups are provably present — reproduces the
|
||
/// re-entry ordering (publish, then render) on the very first entry.
|
||
fn should_publish_tabs(screen: usize, last_published: usize, groups_present: bool) -> bool {
|
||
groups_present && screen != 0 && screen != last_published
|
||
}
|
||
|
||
/// Pure decision for the pre-warm, isolated for host tests.
|
||
///
|
||
/// Request the purchase groups exactly once per process, and only while they are
|
||
/// still absent — once the groups are loaded (by us or by a store visit) there is
|
||
/// nothing to warm and re-requesting would be pointless traffic.
|
||
fn should_prewarm(already_done: bool, storefront: usize, groups_present: bool) -> bool {
|
||
!already_done && storefront != 0 && !groups_present
|
||
}
|
||
|
||
/// Ask the game to load the purchase groups now, on the caller's (game) thread.
|
||
///
|
||
/// Called from the FUT event dispatcher so it runs on a real game thread well before
|
||
/// the store screen is ever shown — the same thread the store screen itself would use
|
||
/// for this call at entry. Fail-closed: base/signature/storefront all validated, at
|
||
/// most one request per process, and it is skipped entirely once groups exist.
|
||
pub(crate) unsafe fn maybe_prewarm_groups() {
|
||
if PREWARM_DONE.load(Ordering::Acquire) || !CLAMP_ENABLED.load(Ordering::Acquire) {
|
||
return;
|
||
}
|
||
let base = STORE_BASE.load(Ordering::Acquire);
|
||
if base == 0 || !crate::sbc_trace::validate_cards_build(base) {
|
||
return;
|
||
}
|
||
let Some(storefront) = base
|
||
.checked_add(STOREFRONT_GLOBAL_RVA)
|
||
.and_then(|slot| crate::sbc_trace::guarded_usize(slot))
|
||
else {
|
||
return;
|
||
};
|
||
let Some(lookup) = base.checked_add(LOOKUP_RVA) else {
|
||
return;
|
||
};
|
||
let lookup_fn: LookupFn = core::mem::transmute(lookup);
|
||
let groups_present = !lookup_fn(core::ptr::null_mut(), FIRST_ORDINAL).is_null();
|
||
if !should_prewarm(false, storefront, groups_present) {
|
||
// Groups already loaded: nothing to warm, and never ask again.
|
||
PREWARM_DONE.store(true, Ordering::Release);
|
||
return;
|
||
}
|
||
let Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else {
|
||
return;
|
||
};
|
||
if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len())
|
||
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
|
||
!= REQUEST_GROUPS_SIGNATURE
|
||
{
|
||
return;
|
||
}
|
||
// Claim the single attempt before issuing it, so a re-entrant event can never
|
||
// fire a second request.
|
||
PREWARM_DONE.store(true, Ordering::Release);
|
||
PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||
let request_fn: RequestGroupsFn = core::mem::transmute(request);
|
||
request_fn(storefront as *mut c_void);
|
||
crate::write_log("STORE_ENTRY: pre-warmed purchase groups before first store entry\n");
|
||
}
|
||
|
||
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) {
|
||
// Record the state the native tab publish self-gates on (expects 0x418),
|
||
// so a no-op publish is diagnosable from the log rather than a mystery.
|
||
if let Some(state) = guarded_i32((screen as usize).wrapping_add(SCREEN_STATE_OFFSET)) {
|
||
LAST_SCREEN_STATE.store(state as u32 as usize, Ordering::Relaxed);
|
||
}
|
||
// The lookup's first argument is dead; pass null. Non-NULL for ordinal 1 is
|
||
// both the resolver's own non-crash precondition AND proof that the
|
||
// purchase groups have finished loading.
|
||
let groups_present = match base.checked_add(LOOKUP_RVA) {
|
||
Some(lookup) => {
|
||
let lookup_fn: LookupFn = core::mem::transmute(lookup);
|
||
!lookup_fn(core::ptr::null_mut(), FIRST_ORDINAL).is_null()
|
||
}
|
||
None => false,
|
||
};
|
||
// Publish first, then set the category — the native order for a working
|
||
// re-entry is event 0x278a (tab publish) followed by 0x753f (render).
|
||
if should_publish_tabs(
|
||
screen as usize,
|
||
TAB_PUBLISH_SCREEN.load(Ordering::Relaxed),
|
||
groups_present,
|
||
) {
|
||
if let Some(publish) = base.checked_add(TAB_PUBLISH_RVA) {
|
||
let publish_fn: TabPublishFn = core::mem::transmute(publish);
|
||
publish_fn(screen);
|
||
TAB_PUBLISH_SCREEN.store(screen as usize, Ordering::Relaxed);
|
||
TAB_PUBLISHES.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
}
|
||
let category_addr = (screen as usize).wrapping_add(CATEGORY_OFFSET);
|
||
if let Some(current) = guarded_i32(category_addr) {
|
||
let clamped = clamp_category(current, groups_present);
|
||
if clamped != current {
|
||
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;
|
||
};
|
||
let Some(publish) = crate::sbc_trace::target_va(base, TAB_PUBLISH_RVA) else {
|
||
return InstallOutcome::CleanFailure;
|
||
};
|
||
// Fingerprint the image and ALL THREE functions: the one we detour, and the two
|
||
// we call (ordinal lookup, tab publish). 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())
|
||
|| !crate::sbc_trace::executable_range_in_image(base, publish, TAB_PUBLISH_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
|
||
|| core::slice::from_raw_parts(publish as *const u8, TAB_PUBLISH_SIGNATURE.len())
|
||
!= TAB_PUBLISH_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={} tabpublish={} prewarm={} state={:#x} tid={}\n",
|
||
entries,
|
||
CLAMPS_APPLIED.load(Ordering::Acquire),
|
||
TAB_PUBLISHES.load(Ordering::Acquire),
|
||
PREWARM_ATTEMPTS.load(Ordering::Acquire),
|
||
LAST_SCREEN_STATE.load(Ordering::Relaxed),
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn tabs_publish_once_per_screen_when_groups_are_loaded() {
|
||
// First render for this screen with groups present -> publish (this is the
|
||
// first-entry case where the native 0x278a publish already ran too early).
|
||
assert!(should_publish_tabs(0x1000, 0, true));
|
||
// Already published for this screen -> never again, so later renders don't
|
||
// re-publish on every frame.
|
||
assert!(!should_publish_tabs(0x1000, 0x1000, true));
|
||
// A new store screen instance publishes again.
|
||
assert!(should_publish_tabs(0x2000, 0x1000, true));
|
||
}
|
||
|
||
#[test]
|
||
fn tabs_never_publish_before_groups_load() {
|
||
// Publishing with no groups is what leaves all six tab tokens at -1 and hides
|
||
// every panel — the defect itself. Never repeat it.
|
||
assert!(!should_publish_tabs(0x1000, 0, false));
|
||
}
|
||
|
||
#[test]
|
||
fn null_screen_never_publishes() {
|
||
assert!(!should_publish_tabs(0, 0, true));
|
||
}
|
||
|
||
#[test]
|
||
fn prewarm_fires_once_on_a_cold_session() {
|
||
// Cold: storefront exists, groups absent -> warm them before any store visit.
|
||
assert!(should_prewarm(false, 0x1000, false));
|
||
// Already attempted -> never again (one request per process).
|
||
assert!(!should_prewarm(true, 0x1000, false));
|
||
}
|
||
|
||
#[test]
|
||
fn prewarm_skipped_when_groups_already_loaded() {
|
||
// Nothing to warm; the native bind will already see the groups.
|
||
assert!(!should_prewarm(false, 0x1000, true));
|
||
}
|
||
|
||
#[test]
|
||
fn prewarm_needs_a_storefront() {
|
||
assert!(!should_prewarm(false, 0, false));
|
||
}
|
||
}
|