79e566883f
The rebind approach was disproven live: the bind sensor measured mask=0x00 at screen-show (container empty, all six slots hidden -> no tab bar), and a rebind after the groups arrived (mask=0x0e = bronze|silver|gold) built NO tab bar. The Scaleform movie only honours the framework's OWN bind at screen-show, not a later re-publish/commit. Root cause therefore stands confirmed: the store's GET store/purchasegroup/all returns only after screen-show, so the first bind sees an empty container. Re-entry works because the groups are cached by then. Fix: load the purchase groups BEFORE the store screen is shown. FUN_180017870 (storefront) issues the store's own group request; firing it from the FUT hub event pump (a real game thread, before the store screen exists) lets the response arrive and populate the container so the first screen-show bind sees a full list and binds the tabs natively -- the re-entry path, on first entry. The bind detour is retained purely as the read-only SENSOR: the first-entry bind mask is the definitive measurement of whether the pre-warm landed in time. mask!=0 => pre-warm worked and the tabs bind natively; mask==0 with storefront_seen!=0 in the pre-warm log => a hub-time request cannot land in time and the remaining route is the extracted StoreFront.apt. Removed: render detour, maybe_rebind/should_rebind, and all rebind state. Re-added the hub-time maybe_prewarm_groups() call in sbc_dispatch::event_wrapper. Promoted (build-armed). Deployed artifact 668e9324; profile-gated fifa17 build.
484 lines
20 KiB
Rust
484 lines
20 KiB
Rust
//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show.
|
|
//!
|
|
//! # Confirmed root cause (live, 2026-08-19)
|
|
//!
|
|
//! `FUN_18007e5e0(ctx, panel)` is the native tab binder the screen framework
|
|
//! invokes at store screen-show. It is an unrolled six-slot loop; each slot gates
|
|
//! on one hard-coded category token and either publishes that group's id as
|
|
//! `PANEL_ID` for the slot, or hides the slot:
|
|
//!
|
|
//! ```text
|
|
//! if (FUN_180014df0(_, idx)) // token present?
|
|
//! (*(panel_vtbl+0x48))(panel, slot, "PANEL_ID", FUN_180014580(_, idx));
|
|
//! else
|
|
//! (*(panel_vtbl+0xa0))(panel, slot); // hide slot
|
|
//! ```
|
|
//!
|
|
//! slot -> token, in bind order: `mypacks, bronze, silver, gold, special, points`.
|
|
//! The gate `FUN_180014df0` resolves the token through `FUN_180014380`, which scans
|
|
//! the loaded purchase groups (stride `0x108`) comparing the token at `group+0x70`.
|
|
//! So a tab appears iff a purchase group carrying that token is loaded AT BIND TIME.
|
|
//!
|
|
//! The bind detour below measured the ground truth on the retail client:
|
|
//!
|
|
//! ```text
|
|
//! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show
|
|
//! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after
|
|
//! ```
|
|
//!
|
|
//! `mask=0x00` at screen-show confirms the container is empty when the framework
|
|
//! binds, so all six slots hide and no tab bar is built. The store's own
|
|
//! `GET store/purchasegroup/all` only returns *after* screen-show, so re-entry works
|
|
//! (groups cached) but first entry does not. (`0x0e` = bronze|silver|gold; bit 0
|
|
//! `mypacks` is clear because an empty My Packs serves no `mypacks` group.)
|
|
//!
|
|
//! # What did NOT work, and why this module changed
|
|
//!
|
|
//! A previous version re-invoked the binder at the next render, once the groups had
|
|
//! arrived (`rebound ... mask=0x0e` above). The movie built NO tab bar from that
|
|
//! late bind: the Scaleform movie only honours the framework's OWN bind at
|
|
//! screen-show, not a later re-publish/commit. That approach is abandoned.
|
|
//!
|
|
//! # This module: make the container non-empty BEFORE the first bind
|
|
//!
|
|
//! The only publish the movie honours is the framework's bind at screen-show, and
|
|
//! re-entry proves that bind builds the bar correctly when the container is already
|
|
//! full. So the fix is to load the purchase groups BEFORE the store screen is shown.
|
|
//!
|
|
//! `FUN_180017870(storefront)` issues the store's own `GET store/purchasegroup/all`.
|
|
//! Firing it from the FUT hub event pump (a real game thread, well before the store
|
|
//! screen exists) gives the response time to arrive and populate the container, so
|
|
//! the first screen-show bind sees a full list and binds the tabs natively — exactly
|
|
//! the re-entry path, on first entry.
|
|
//!
|
|
//! The bind detour is retained purely as the SENSOR: the first-entry bind mask is
|
|
//! the safe, definitive measurement of whether the pre-warm populated the container
|
|
//! in time. `mask != 0` at first bind ⇒ pre-warm worked and the tabs bind natively;
|
|
//! `mask == 0` (with `storefront_seen=1` in the pre-warm log) ⇒ a hub-time request
|
|
//! cannot land in time and the remaining route is the extracted `StoreFront.apt`.
|
|
//!
|
|
//! # Fail-closed
|
|
//!
|
|
//! * Pre-warm fires at most once per process, claimed atomically, and only once the
|
|
//! storefront singleton is non-null; the storefront pointer is read through a
|
|
//! guarded load and the request function's signature is validated before the call.
|
|
//! * The bind detour only reads (captures pointers, probes the game's own gate with
|
|
//! a provably-dead `this`) and never mutates store state.
|
|
//! * Image plus every function signature are verified before any write and again
|
|
//! under thread suspension; one wrong byte aborts with no write and no call.
|
|
//!
|
|
//! # Promotion
|
|
//!
|
|
//! PROMOTED: armed by the build, never by an environment variable (see
|
|
//! [`REPAIR_PROMOTED`]). Rollback is a `version.dll` file swap.
|
|
|
|
use core::ffi::c_void;
|
|
use core::sync::atomic::{AtomicBool, AtomicU32, 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};
|
|
|
|
/// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework
|
|
/// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw.
|
|
const BIND_RVA: usize = 0x7e5e0;
|
|
/// Category gate `FUN_180014df0(dead_this, idx) -> bool`: maps `idx` to one of the
|
|
/// six hard-coded tokens and reports whether a loaded purchase group carries it.
|
|
const HAS_CATEGORY_RVA: usize = 0x14df0;
|
|
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
|
|
/// the store screen makes at entry (from `0x18007f25e`). Fired early to pre-warm.
|
|
const REQUEST_GROUPS_RVA: usize = 0x17870;
|
|
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
|
|
/// request/lookup helpers (loaded at `0x18007f25e`, right before the pack-list GET).
|
|
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
|
|
|
|
/// Gate indices in slot order: `mypacks, bronze, silver, gold, special, points`.
|
|
/// Taken from the binder's unrolled call sequence, not from the index order of
|
|
/// `FUN_180014580`'s jump table (which is deliberately different).
|
|
const GATE_INDICES: [u32; 6] = [0, 2, 3, 4, 5, 1];
|
|
|
|
/// Whole-instruction prologue length relocated into the trampoline; also the number
|
|
/// of bytes overwritten by the entry detour. 15 bytes, a clean boundary covering the
|
|
/// 14-byte absolute jump.
|
|
const COPY_LEN: usize = 15;
|
|
const ABS_JUMP_LEN: usize = 14;
|
|
|
|
/// First 15 bytes of `FUN_18007e5e0`: `mov [rsp+8],rbx; mov [rsp+0x10],rbp;
|
|
/// mov [rsp+0x18],rsi` = 5 + 5 + 5.
|
|
const BIND_SIGNATURE: [u8; COPY_LEN] = [
|
|
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18,
|
|
];
|
|
/// First 15 bytes of `FUN_180014df0`. Validated before we ever call it, so the gate
|
|
/// probe only runs on the exact build it was reversed against.
|
|
const HAS_CATEGORY_SIGNATURE: [u8; 15] = [
|
|
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x33, 0xdb, 0x44, 0x8b, 0xc3, 0x85, 0xd2, 0x74, 0x35,
|
|
];
|
|
/// First 18 bytes of `FUN_180017870`. Validated before we ever call it, so the
|
|
/// pre-warm only fires the genuine request on the exact build it was reversed against.
|
|
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 BindFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> *mut c_void;
|
|
type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8;
|
|
type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize;
|
|
|
|
/// The tab-bar repair 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 and the thread quiesce all remain in the runtime evidence path.
|
|
pub(crate) const REPAIR_PROMOTED: bool = true;
|
|
|
|
/// Compile-time contract: the repair stays build-armed. Regressing it to an env gate
|
|
/// would silently restore the missing first-entry tab bar on a normal launch, so it
|
|
/// must be a deliberate, visible change here rather than a missing variable.
|
|
const _: () = assert!(REPAIR_PROMOTED);
|
|
|
|
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
|
|
static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
static STORE_BASE: AtomicUsize = AtomicUsize::new(0);
|
|
static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
|
/// Gate mask the framework's most recent bind observed (bit N = slot N would bind).
|
|
static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0);
|
|
static LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
|
|
/// Set once the pre-warm request has been fired (or is provably unnecessary).
|
|
static PREWARM_DONE: AtomicBool = AtomicBool::new(false);
|
|
static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
|
|
/// Highest storefront pointer observed at hub time (0 = never non-null yet). Logged
|
|
/// so a failed pre-warm can be attributed to "storefront not up at hub" vs "fired
|
|
/// but the response did not land before screen-show".
|
|
static PREWARM_STOREFRONT_SEEN: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// Pure pre-warm decision, isolated for host tests.
|
|
///
|
|
/// Fire exactly once, and only once the storefront singleton is non-null; before
|
|
/// that, keep waiting (a null storefront early at the hub is expected).
|
|
fn should_prewarm(already_done: bool, storefront: usize) -> bool {
|
|
!already_done && storefront != 0
|
|
}
|
|
|
|
/// Probe all six category tokens with the game's own gate and return a slot mask.
|
|
///
|
|
/// `FUN_180014df0` forwards its `this` to `FUN_180014380`, which discards it and
|
|
/// fetches the group container from a singleton, so a null `this` is exactly what
|
|
/// the native code effectively passes. Called only from the bind detour, where the
|
|
/// store subsystem is provably live.
|
|
unsafe fn gate_mask() -> u8 {
|
|
let base = STORE_BASE.load(Ordering::Acquire);
|
|
if base == 0 {
|
|
return 0;
|
|
}
|
|
let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else {
|
|
return 0;
|
|
};
|
|
let gate_fn: HasCategoryFn = core::mem::transmute(gate);
|
|
let mut mask = 0u8;
|
|
for (slot, index) in GATE_INDICES.iter().enumerate() {
|
|
if gate_fn(core::ptr::null_mut(), *index) != 0 {
|
|
mask |= 1 << slot;
|
|
}
|
|
}
|
|
mask
|
|
}
|
|
|
|
/// 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.
|
|
pub(crate) unsafe fn maybe_prewarm_groups() {
|
|
if PREWARM_DONE.load(Ordering::Acquire) || !REPAIR_ENABLED.load(Ordering::Acquire) {
|
|
return;
|
|
}
|
|
let base = STORE_BASE.load(Ordering::Acquire);
|
|
if base == 0 || !crate::sbc_trace::valid_cards_image(base) {
|
|
return;
|
|
}
|
|
let Some(storefront) = base
|
|
.checked_add(STOREFRONT_GLOBAL_RVA)
|
|
.and_then(|slot| crate::sbc_trace::guarded_usize(slot))
|
|
else {
|
|
return;
|
|
};
|
|
if storefront != 0 {
|
|
PREWARM_STOREFRONT_SEEN.store(storefront, Ordering::Release);
|
|
}
|
|
if !should_prewarm(false, storefront) {
|
|
// Storefront not up yet at the hub: keep waiting, do not consume the attempt.
|
|
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(&format!(
|
|
"STORE_TABS: pre-warmed purchase groups at hub (storefront={storefront:#x})\n"
|
|
));
|
|
}
|
|
|
|
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 the native tab binder. Read-only sensor: records the gate mask
|
|
/// the framework's bind is about to act on, then runs the original unchanged. This is
|
|
/// the definitive measurement of whether the pre-warm populated the container in time.
|
|
unsafe extern "system" fn bind_wrapper(ctx: *mut c_void, panel: *mut c_void) -> *mut c_void {
|
|
let mask = gate_mask();
|
|
LAST_BIND_MASK.store(mask as u32, Ordering::Release);
|
|
LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
|
BIND_ENTRIES.fetch_add(1, Ordering::AcqRel);
|
|
let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire));
|
|
original(ctx, panel)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum InstallOutcome {
|
|
Installed,
|
|
CleanFailure,
|
|
DegradedHookActive,
|
|
DegradedProcessState,
|
|
DegradedHookAndProcess,
|
|
}
|
|
|
|
unsafe fn install_hook(base: usize) -> InstallOutcome {
|
|
let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else {
|
|
return InstallOutcome::CleanFailure;
|
|
};
|
|
let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else {
|
|
return InstallOutcome::CleanFailure;
|
|
};
|
|
let Some(request) = crate::sbc_trace::target_va(base, REQUEST_GROUPS_RVA) else {
|
|
return InstallOutcome::CleanFailure;
|
|
};
|
|
// Fingerprint the image and ALL THREE functions: the one we detour and the two we
|
|
// call (gate probe, group request). 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, bind, BIND_SIGNATURE.len())
|
|
|| !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_SIGNATURE.len())
|
|
|| !crate::sbc_trace::executable_range_in_image(
|
|
base,
|
|
request,
|
|
REQUEST_GROUPS_SIGNATURE.len(),
|
|
)
|
|
|| core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) != BIND_SIGNATURE
|
|
|| core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len())
|
|
!= HAS_CATEGORY_SIGNATURE
|
|
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
|
|
!= REQUEST_GROUPS_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,
|
|
bind as *const u8,
|
|
&mut pinned,
|
|
) == 0
|
|
|| pinned as usize != base
|
|
{
|
|
return InstallOutcome::CleanFailure;
|
|
}
|
|
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else {
|
|
return InstallOutcome::CleanFailure;
|
|
};
|
|
BIND_TRAMPOLINE.store(trampoline, Ordering::Release);
|
|
STORE_BASE.store(base, Ordering::Release);
|
|
|
|
let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else {
|
|
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
|
BIND_TRAMPOLINE.store(0, Ordering::Release);
|
|
return InstallOutcome::CleanFailure;
|
|
};
|
|
let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) {
|
|
Ok(peers) => peers,
|
|
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
|
|
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
|
BIND_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(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE;
|
|
let transaction = if !final_valid {
|
|
InstallOutcome::CleanFailure
|
|
} else {
|
|
match write_entry(bind, bind_wrapper as *const () as usize, &BIND_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);
|
|
BIND_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_TABS: bind sensor + pre-warm installed (promoted)\n")
|
|
}
|
|
InstallOutcome::CleanFailure => {
|
|
crate::write_log("STORE_TABS: clean install failure; inactive\n");
|
|
return;
|
|
}
|
|
InstallOutcome::DegradedHookActive => {
|
|
crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n");
|
|
return;
|
|
}
|
|
InstallOutcome::DegradedProcessState => {
|
|
crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n");
|
|
return;
|
|
}
|
|
InstallOutcome::DegradedHookAndProcess => {
|
|
crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n");
|
|
return;
|
|
}
|
|
}
|
|
|
|
let mut binds_seen = 0u64;
|
|
let mut reports = 0u8;
|
|
while reports < 64 {
|
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
let binds = BIND_ENTRIES.load(Ordering::Acquire);
|
|
if binds != binds_seen {
|
|
crate::write_log(&format!(
|
|
"STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n",
|
|
binds,
|
|
LAST_BIND_MASK.load(Ordering::Acquire),
|
|
PREWARM_ATTEMPTS.load(Ordering::Acquire),
|
|
PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire),
|
|
LAST_THREAD.load(Ordering::Relaxed),
|
|
));
|
|
binds_seen = binds;
|
|
reports += 1;
|
|
}
|
|
}
|
|
crate::write_log("STORE_TABS: report cap reached; hook remains installed\n");
|
|
}
|
|
|
|
pub(crate) fn install() {
|
|
// Promoted: armed by the build. No environment variable participates.
|
|
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
|
|
crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n");
|
|
std::thread::spawn(|| unsafe { worker() });
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn gate_indices_match_the_native_slot_order() {
|
|
// mypacks, bronze, silver, gold, special, points — the order FUN_18007e5e0
|
|
// tests them in, which is NOT the index order of FUN_180014580's jump table.
|
|
assert_eq!(GATE_INDICES, [0, 2, 3, 4, 5, 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn prewarms_once_the_storefront_is_up() {
|
|
assert!(should_prewarm(false, 0x1000));
|
|
}
|
|
|
|
#[test]
|
|
fn waits_while_the_storefront_is_still_null() {
|
|
assert!(!should_prewarm(false, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn never_prewarms_twice() {
|
|
assert!(!should_prewarm(true, 0x1000));
|
|
}
|
|
|
|
#[test]
|
|
fn detour_signature_is_long_enough_for_the_absolute_jump() {
|
|
assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN);
|
|
assert_eq!(COPY_LEN, BIND_SIGNATURE.len());
|
|
}
|
|
|
|
#[test]
|
|
fn request_signature_covers_the_validated_prologue() {
|
|
// 18 bytes: `push rdi; sub rsp,0x90; movq [rsp+0x20],-2`.
|
|
assert_eq!(REQUEST_GROUPS_SIGNATURE.len(), 18);
|
|
}
|
|
}
|