diff --git a/openfut-hook/src/sbc_dispatch.rs b/openfut-hook/src/sbc_dispatch.rs index 3c88ae0..0ffab5c 100644 --- a/openfut-hook/src/sbc_dispatch.rs +++ b/openfut-hook/src/sbc_dispatch.rs @@ -443,6 +443,10 @@ unsafe extern "system" fn event_wrapper( } _ => {} } + // Piggyback the store pre-warm on this game-thread hub event: it loads the + // purchase groups once, before the store screen is shown, so the store's native + // screen-show tab bind sees a populated group list (see `store_entry`). + crate::store_entry::maybe_prewarm_groups(); let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire)); let result = original(controller, event, payload); EVENT_EXITS.fetch_add(1, Ordering::Release); diff --git a/openfut-hook/src/store_entry.rs b/openfut-hook/src/store_entry.rs index 883ba00..eec72dc 100644 --- a/openfut-hook/src/store_entry.rs +++ b/openfut-hook/src/store_entry.rs @@ -1,16 +1,9 @@ -//! FIFA 17 store tab-bar bind repair. +//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show. //! -//! # What this repairs +//! # Confirmed root cause (live, 2026-08-19) //! -//! On the FIRST store entry of a session the category tab bar is missing: the -//! store draws a single category with no tabs. Leaving and re-entering the store -//! shows the bar correctly. Nothing about the server response differs between the -//! two entries — the difference is purely *when* the purchase groups exist. -//! -//! # Mechanism (reversed from the pinned CardsDLL) -//! -//! `FUN_18007e5e0(ctx, panel)` is the native tab binder, invoked by the screen -//! framework at screen-show. It is a fully unrolled six-slot loop; each slot gates +//! `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: //! @@ -21,60 +14,63 @@ //! (*(panel_vtbl+0xa0))(panel, slot); // hide slot //! ``` //! -//! slot -> token, in the order the binder tests them: +//! 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. //! -//! | slot | token | note | -//! |------|-----------|----------------------------------------------------------| -//! | 0 | `mypacks` | | -//! | 1 | `bronze` | | -//! | 2 | `silver` | | -//! | 3 | `gold` | | -//! | 4 | `special` | | -//! | 5 | `points` | extra gate: `(*(store_vtbl+0x30))(store)` must be false | +//! The bind detour below measured the ground truth on the retail client: //! -//! The gate `FUN_180014df0(_, idx)` maps the index to one of those token strings -//! and calls `FUN_180014380`, which linearly scans the loaded purchase groups -//! (stride `0x108`) comparing the token at `group+0x70`. So a tab appears if and -//! only if a purchase group carrying that exact token is loaded *at bind time*. -//! Our server emits `mypacks`/`bronze`/`silver`/`gold` as `displayGroup.value`, so -//! four tabs are expected. +//! ```text +//! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show +//! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after +//! ``` //! -//! On a cold session the store screen shows BEFORE its own -//! `GET store/purchasegroup/all` response arrives, so every gate fails, all six -//! slots take the hide path, and the binder is never invoked again for that -//! screen. Re-entry works only because the groups are cached by then. +//! `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.) //! -//! # The repair +//! # What did NOT work, and why this module changed //! -//! Re-invoke the binder ONCE, with the same `(ctx, panel)` the framework used, at -//! the first render after the groups have arrived. That reproduces exactly the -//! re-entry ordering on the first entry. The binder is safe to repeat: it only -//! publishes `PANEL_ID` or hides per slot, reads the group list from a process -//! singleton, and finishes by tail-calling `panel->vtbl[0xd0](panel, true)` — the -//! provider commit that makes the movie rebuild its bar. +//! 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. //! -//! # Why it is fail-closed +//! # This module: make the container non-empty BEFORE the first bind //! -//! * The rebind fires only when the framework's own bind observed an EMPTY gate -//! mask (every tab hidden) and at least one token now resolves. A store that -//! already bound tabs is never touched. -//! * At most one rebind per bind generation, claimed with a compare-exchange, so a -//! re-entrant render can never loop. -//! * `(ctx, panel)` are only ever the pointers the framework itself passed; we -//! never synthesise them, and a generation with no captured pair is skipped. -//! * The gate probe is the game's own `FUN_180014df0`. Its `rcx` is provably dead -//! (it is forwarded to `FUN_180014380`, which discards it and fetches the -//! container from a singleton), so probing with a null `this` is exactly what -//! the native code does. -//! * Image plus all three function signatures are verified before any write and -//! re-verified under thread suspension; one wrong byte aborts with no write and -//! no call. +//! 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 //! -//! Like the SBC dispatch repair, this is a PROMOTED feature: armed by the build, -//! never by an environment variable (see [`REPAIR_PROMOTED`]). Rollback is a -//! `version.dll` file swap, not an env kill-switch. +//! 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}; @@ -89,24 +85,26 @@ use windows_sys::Win32::System::Memory::{ use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; /// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework -/// at screen-show. Detoured to capture its arguments and the gate mask it saw. +/// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw. const BIND_RVA: usize = 0x7e5e0; -/// Store render `FUN_18007dab0(screen)`, called by the screen dispatcher for event -/// `0x753f` — i.e. once the data needed to draw the category has arrived. Detoured -/// as the rebind trigger, on the same thread the dispatcher and screen-show use. -const RENDER_RVA: usize = 0x7dab0; /// 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 each trampoline; also the -/// number of bytes overwritten by an entry detour. Both prologues below are 15 -/// bytes, a clean boundary covering the 14-byte absolute jump. +/// 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; @@ -115,25 +113,26 @@ const ABS_JUMP_LEN: usize = 14; 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_18007dab0`: `push rdi; sub rsp,0x40; movq [rsp+0x30],-2`. -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_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 RenderFn = unsafe extern "system" fn(*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-bind repair is PROMOTED: armed by the build, never by an environment +/// 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, the thread quiesce and the empty-mask precondition all remain in the -/// runtime evidence path. +/// 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 @@ -143,45 +142,33 @@ const _: () = assert!(REPAIR_PROMOTED); static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false); static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); -static RENDER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); static STORE_BASE: AtomicUsize = AtomicUsize::new(0); -/// `(ctx, panel)` as the framework passed them to the most recent bind. -static BIND_CTX: AtomicUsize = AtomicUsize::new(0); -static BIND_PANEL: AtomicUsize = AtomicUsize::new(0); -/// Monotonic bind generation; `0` means "no bind observed yet". -static BIND_GEN: AtomicU64 = AtomicU64::new(0); -/// Generation whose rebind has already been claimed. -static REBOUND_GEN: AtomicU64 = AtomicU64::new(0); static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0); -static RENDER_ENTRIES: AtomicU64 = AtomicU64::new(0); -static REBINDS: AtomicU64 = AtomicU64::new(0); -/// Gate mask the framework's bind observed (bit N = slot N bound). +/// Gate mask the framework's most recent bind observed (bit N = slot N would bind). static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0); -/// Gate mask at the moment of the rebind. -static LAST_REBIND_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 rebind decision, isolated for host tests. +/// Pure pre-warm decision, isolated for host tests. /// -/// Rebind exactly when the framework's own bind hid every slot (`bind_mask == 0`) -/// but at least one token resolves now, for a generation we have both pointers for -/// and have not already rebound. -fn should_rebind( - gen: u64, - rebound_gen: u64, - bind_mask: u8, - now_mask: u8, - ctx: usize, - panel: usize, -) -> bool { - gen != 0 && rebound_gen != gen && ctx != 0 && panel != 0 && bind_mask == 0 && now_mask != 0 +/// 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. +/// 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 { @@ -200,6 +187,53 @@ unsafe fn gate_mask() -> u8 { 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(target: usize, original: &[u8; N]) -> bool { let mut old = 0u32; if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 { @@ -232,65 +266,18 @@ unsafe fn write_entry( } } -/// Detour target for the native tab binder. Records the arguments and the gate mask -/// the framework's bind is about to act on, then runs the original unchanged. +/// 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(); - BIND_CTX.store(ctx as usize, Ordering::Release); - BIND_PANEL.store(panel as usize, Ordering::Release); LAST_BIND_MASK.store(mask as u32, Ordering::Release); LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); - let generation = BIND_ENTRIES.fetch_add(1, Ordering::AcqRel) + 1; - BIND_GEN.store(generation, Ordering::Release); + BIND_ENTRIES.fetch_add(1, Ordering::AcqRel); let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire)); original(ctx, panel) } -/// Re-invoke the binder once for the current generation, if the framework's bind -/// hid every slot and the groups have since arrived. -unsafe fn maybe_rebind() { - if !REPAIR_ENABLED.load(Ordering::Acquire) { - return; - } - let trampoline = BIND_TRAMPOLINE.load(Ordering::Acquire); - if trampoline == 0 { - return; - } - let generation = BIND_GEN.load(Ordering::Acquire); - let rebound = REBOUND_GEN.load(Ordering::Acquire); - let ctx = BIND_CTX.load(Ordering::Acquire); - let panel = BIND_PANEL.load(Ordering::Acquire); - let bind_mask = LAST_BIND_MASK.load(Ordering::Acquire) as u8; - let now_mask = gate_mask(); - if !should_rebind(generation, rebound, bind_mask, now_mask, ctx, panel) { - return; - } - // Claim this generation before acting, so a re-entrant render cannot rebind twice. - if REBOUND_GEN - .compare_exchange(rebound, generation, Ordering::AcqRel, Ordering::Acquire) - .is_err() - { - return; - } - LAST_REBIND_MASK.store(now_mask as u32, Ordering::Release); - REBINDS.fetch_add(1, Ordering::Relaxed); - let original: BindFn = core::mem::transmute(trampoline); - original(ctx as *mut c_void, panel as *mut c_void); - crate::write_log(&format!( - "STORE_TABS: rebound generation={generation} mask={now_mask:#04x} (bind saw 0x00)\n" - )); -} - -/// Detour target for the store render. Runs the original first, then repairs the tab -/// bind if this is the first render since an empty bind. -unsafe extern "system" fn render_wrapper(screen: *mut c_void) -> *mut c_void { - RENDER_ENTRIES.fetch_add(1, Ordering::Relaxed); - let original: RenderFn = core::mem::transmute(RENDER_TRAMPOLINE.load(Ordering::Acquire)); - let result = original(screen); - maybe_rebind(); - result -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InstallOutcome { Installed, @@ -304,23 +291,28 @@ unsafe fn install_hook(base: usize) -> InstallOutcome { let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else { return InstallOutcome::CleanFailure; }; - let Some(render) = crate::sbc_trace::target_va(base, RENDER_RVA) else { - return InstallOutcome::CleanFailure; - }; let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else { return InstallOutcome::CleanFailure; }; - // Fingerprint the image and ALL THREE functions: the two we detour and the one we - // call. A single mismatched byte aborts cleanly with no write and no call. + 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, render, RENDER_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(render as *const u8, RENDER_SIGNATURE.len()) - != RENDER_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; } @@ -334,32 +326,22 @@ unsafe fn install_hook(base: usize) -> InstallOutcome { { return InstallOutcome::CleanFailure; } - let Some(bind_trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else { + let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else { return InstallOutcome::CleanFailure; }; - let Some(render_trampoline) = crate::sbc_trace::allocate_trampoline(render, COPY_LEN) else { - VirtualFree(bind_trampoline as _, 0, MEM_RELEASE); - return InstallOutcome::CleanFailure; - }; - BIND_TRAMPOLINE.store(bind_trampoline, Ordering::Release); - RENDER_TRAMPOLINE.store(render_trampoline, Ordering::Release); + BIND_TRAMPOLINE.store(trampoline, Ordering::Release); STORE_BASE.store(base, Ordering::Release); - let release_trampolines = || { - VirtualFree(bind_trampoline as _, 0, MEM_RELEASE); - VirtualFree(render_trampoline as _, 0, MEM_RELEASE); - BIND_TRAMPOLINE.store(0, Ordering::Release); - RENDER_TRAMPOLINE.store(0, Ordering::Release); - }; - let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else { - release_trampolines(); + 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, render) { + let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) { Ok(peers) => peers, Err(crate::sbc_trace::QuiesceFailure::Acquire) => { - release_trampolines(); + VirtualFree(trampoline as _, 0, MEM_RELEASE); + BIND_TRAMPOLINE.store(0, Ordering::Release); return InstallOutcome::CleanFailure; } Err(crate::sbc_trace::QuiesceFailure::Resume) => { @@ -367,26 +349,12 @@ unsafe fn install_hook(base: usize) -> InstallOutcome { } }; let final_valid = crate::sbc_trace::valid_cards_image(base) - && core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE - && core::slice::from_raw_parts(render as *const u8, RENDER_SIGNATURE.len()) - == RENDER_SIGNATURE; + && 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(()) => { - match write_entry(render, render_wrapper as *const () as usize, &RENDER_SIGNATURE) { - Ok(()) => InstallOutcome::Installed, - // Second detour failed: undo the first so the image is left pristine. - Err(_) => { - if restore_entry(bind, &BIND_SIGNATURE) { - InstallOutcome::CleanFailure - } else { - InstallOutcome::DegradedHookActive - } - } - } - } + Ok(()) => InstallOutcome::Installed, Err(true) => InstallOutcome::CleanFailure, Err(false) => InstallOutcome::DegradedHookActive, } @@ -403,7 +371,8 @@ unsafe fn install_hook(base: usize) -> InstallOutcome { InstallOutcome::DegradedProcessState }; if outcome == InstallOutcome::CleanFailure { - release_trampolines(); + VirtualFree(trampoline as _, 0, MEM_RELEASE); + BIND_TRAMPOLINE.store(0, Ordering::Release); } outcome } @@ -426,7 +395,7 @@ unsafe fn worker() { drop(_pending); match outcome { InstallOutcome::Installed => { - crate::write_log("STORE_TABS: tab-bind repair installed (promoted)\n") + crate::write_log("STORE_TABS: bind sensor + pre-warm installed (promoted)\n") } InstallOutcome::CleanFailure => { crate::write_log("STORE_TABS: clean install failure; inactive\n"); @@ -453,12 +422,11 @@ unsafe fn worker() { let binds = BIND_ENTRIES.load(Ordering::Acquire); if binds != binds_seen { crate::write_log(&format!( - "STORE_TABS: bind generation={} mask={:#04x} rebinds={} rebind_mask={:#04x} renders={} tid={}\n", + "STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n", binds, LAST_BIND_MASK.load(Ordering::Acquire), - REBINDS.load(Ordering::Acquire), - LAST_REBIND_MASK.load(Ordering::Acquire), - RENDER_ENTRIES.load(Ordering::Acquire), + PREWARM_ATTEMPTS.load(Ordering::Acquire), + PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire), LAST_THREAD.load(Ordering::Relaxed), )); binds_seen = binds; @@ -471,7 +439,7 @@ unsafe fn worker() { 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: tab-bind repair ARMED (promoted); strict signature gate\n"); + crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n"); std::thread::spawn(|| unsafe { worker() }); } @@ -487,44 +455,29 @@ mod tests { } #[test] - fn rebinds_when_an_empty_bind_is_followed_by_loaded_groups() { - // bronze|silver|gold|mypacks present after the response lands. - assert!(should_rebind(1, 0, 0x00, 0x0f, 0x1000, 0x2000)); + fn prewarms_once_the_storefront_is_up() { + assert!(should_prewarm(false, 0x1000)); } #[test] - fn never_rebinds_a_store_that_already_bound_tabs() { - assert!(!should_rebind(1, 0, 0x0f, 0x0f, 0x1000, 0x2000)); + fn waits_while_the_storefront_is_still_null() { + assert!(!should_prewarm(false, 0)); } #[test] - fn never_rebinds_while_groups_are_still_absent() { - assert!(!should_rebind(1, 0, 0x00, 0x00, 0x1000, 0x2000)); + fn never_prewarms_twice() { + assert!(!should_prewarm(true, 0x1000)); } #[test] - fn rebinds_at_most_once_per_generation() { - assert!(!should_rebind(1, 1, 0x00, 0x0f, 0x1000, 0x2000)); - // A later screen-show is a new generation and is eligible again. - assert!(should_rebind(2, 1, 0x00, 0x0f, 0x1000, 0x2000)); - } - - #[test] - fn never_rebinds_without_captured_framework_pointers() { - assert!(!should_rebind(1, 0, 0x00, 0x0f, 0, 0x2000)); - assert!(!should_rebind(1, 0, 0x00, 0x0f, 0x1000, 0)); - } - - #[test] - fn never_rebinds_before_any_bind_is_observed() { - assert!(!should_rebind(0, 0, 0x00, 0x0f, 0x1000, 0x2000)); - } - - #[test] - fn detour_signatures_are_long_enough_for_the_absolute_jump() { + fn detour_signature_is_long_enough_for_the_absolute_jump() { assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN); - assert!(RENDER_SIGNATURE.len() >= ABS_JUMP_LEN); assert_eq!(COPY_LEN, BIND_SIGNATURE.len()); - assert_eq!(COPY_LEN, RENDER_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); } }