hook(fifa17): pre-warm store purchase groups before screen-show; drop disproven rebind

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.
This commit is contained in:
funman300
2026-08-19 18:48:54 +00:00
parent 7724f168bc
commit 79e566883f
2 changed files with 182 additions and 225 deletions
+4
View File
@@ -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);
+178 -225
View File
@@ -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<const N: usize>(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<const N: usize>(
}
}
/// 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);
}
}