diff --git a/openfut-hook/src/sbc_dispatch.rs b/openfut-hook/src/sbc_dispatch.rs index 764e2f0..3c88ae0 100644 --- a/openfut-hook/src/sbc_dispatch.rs +++ b/openfut-hook/src/sbc_dispatch.rs @@ -443,10 +443,6 @@ unsafe extern "system" fn event_wrapper( } _ => {} } - // Piggyback the store pre-warm on this game-thread event: it loads the purchase - // groups once, long 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 805639a..883ba00 100644 --- a/openfut-hook/src/store_entry.rs +++ b/openfut-hook/src/store_entry.rs @@ -1,43 +1,83 @@ -//! Guarded FIFA 17 store-entry category clamp. +//! FIFA 17 store tab-bar bind repair. //! //! # 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. +//! 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. //! -//! 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`). +//! # Mechanism (reversed from the pinned CardsDLL) //! -//! # Why it cannot crash the client +//! `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 +//! on one hard-coded category token and either publishes that group's id as +//! `PANEL_ID` for the slot, or hides the slot: //! -//! 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. +//! ```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 the order the binder tests them: +//! +//! | 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 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. +//! +//! 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. +//! +//! # The repair +//! +//! 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. +//! +//! # Why it is fail-closed +//! +//! * 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. //! //! # 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. +//! never by an environment variable (see [`REPAIR_PROMOTED`]). 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 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, @@ -48,182 +88,116 @@ use windows_sys::Win32::System::Memory::{ }; use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; -/// Store render `FUN_18007dab0`: reads `screen+0x290` and drives the resolver. +/// 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. +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; -/// 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 ` 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. +/// 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; + +/// 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. 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. +/// 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_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_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, +/// 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, ]; +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 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; +type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8; -/// The store-entry clamp is PROMOTED: armed by the build, never by an environment +/// The tab-bind 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 resolver-backed non-NULL precondition all -/// remain in the runtime evidence path. -pub(crate) const CLAMP_PROMOTED: bool = true; +/// validation, the thread quiesce and the empty-mask precondition all remain in the +/// runtime evidence path. +pub(crate) const REPAIR_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); +/// 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 CLAMP_ENABLED: AtomicBool = AtomicBool::new(false); +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 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); +static REBINDS: AtomicU64 = AtomicU64::new(0); +/// Gate mask the framework's bind observed (bit N = slot N bound). +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); -/// 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 rebind 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 } -/// Pure decision for the tab-bar repair, isolated for host tests. +/// Probe all six category tokens with the game's own gate and return a slot mask. /// -/// 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; - } +/// `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. +unsafe fn gate_mask() -> u8 { let base = STORE_BASE.load(Ordering::Acquire); - if base == 0 || !crate::sbc_trace::validate_cards_build(base) { - return; + if base == 0 { + return 0; } - let Some(storefront) = base - .checked_add(STOREFRONT_GLOBAL_RVA) - .and_then(|slot| crate::sbc_trace::guarded_usize(slot)) - else { - return; + let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else { + return 0; }; - 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 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; + } } - 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 { - crate::sbc_trace::readable_range(address, 4) - .then(|| core::ptr::read_volatile(address as *const i32)) + mask } unsafe fn restore_entry(target: usize, original: &[u8; N]) -> bool { @@ -258,57 +232,63 @@ unsafe fn write_entry( } } -/// 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); - } - } - } +/// 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. +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); + 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)); - original(screen) + let result = original(screen); + maybe_rebind(); + result } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -321,57 +301,65 @@ enum InstallOutcome { } 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(lookup) = crate::sbc_trace::target_va(base, LOOKUP_RVA) else { + let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_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. + // 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. 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, lookup, LOOKUP_SIGNATURE.len()) - || !crate::sbc_trace::executable_range_in_image(base, publish, TAB_PUBLISH_SIGNATURE.len()) + || !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_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(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 + || core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len()) + != HAS_CATEGORY_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, + bind 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 { + let Some(bind_trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else { return InstallOutcome::CleanFailure; }; - RENDER_TRAMPOLINE.store(trampoline, Ordering::Release); + 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); STORE_BASE.store(base, Ordering::Release); - let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else { - VirtualFree(trampoline as _, 0, MEM_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(); return InstallOutcome::CleanFailure; }; - let mut peers = match crate::sbc_trace::suspend_peers(render, lookup) { + let mut peers = match crate::sbc_trace::suspend_peers(bind, render) { Ok(peers) => peers, Err(crate::sbc_trace::QuiesceFailure::Acquire) => { - VirtualFree(trampoline as _, 0, MEM_RELEASE); - RENDER_TRAMPOLINE.store(0, Ordering::Release); + release_trampolines(); return InstallOutcome::CleanFailure; } Err(crate::sbc_trace::QuiesceFailure::Resume) => { @@ -379,17 +367,26 @@ 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; let transaction = if !final_valid { InstallOutcome::CleanFailure } else { - match write_entry( - render, - store_render_wrapper as *const () as usize, - &RENDER_SIGNATURE, - ) { - Ok(()) => InstallOutcome::Installed, + 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 + } + } + } + } Err(true) => InstallOutcome::CleanFailure, Err(false) => InstallOutcome::DegradedHookActive, } @@ -406,8 +403,7 @@ unsafe fn install_hook(base: usize) -> InstallOutcome { InstallOutcome::DegradedProcessState }; if outcome == InstallOutcome::CleanFailure { - VirtualFree(trampoline as _, 0, MEM_RELEASE); - RENDER_TRAMPOLINE.store(0, Ordering::Release); + release_trampolines(); } outcome } @@ -430,52 +426,52 @@ unsafe fn worker() { drop(_pending); match outcome { InstallOutcome::Installed => { - crate::write_log("STORE_ENTRY: render clamp installed (promoted)\n") + crate::write_log("STORE_TABS: tab-bind repair installed (promoted)\n") } InstallOutcome::CleanFailure => { - crate::write_log("STORE_ENTRY: clean install failure; inactive\n"); + crate::write_log("STORE_TABS: clean install failure; inactive\n"); return; } InstallOutcome::DegradedHookActive => { - crate::write_log("STORE_ENTRY: DEGRADED hook may be active; terminate game now\n"); + crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n"); return; } InstallOutcome::DegradedProcessState => { - crate::write_log("STORE_ENTRY: DEGRADED thread state; terminate game now\n"); + crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n"); return; } InstallOutcome::DegradedHookAndProcess => { - crate::write_log("STORE_ENTRY: DEGRADED hook and thread state; terminate game now\n"); + crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n"); return; } } - let mut entries_seen = 0u64; + let mut binds_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 { + let binds = BIND_ENTRIES.load(Ordering::Acquire); + if binds != binds_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), + "STORE_TABS: bind generation={} mask={:#04x} rebinds={} rebind_mask={:#04x} renders={} tid={}\n", + binds, + LAST_BIND_MASK.load(Ordering::Acquire), + REBINDS.load(Ordering::Acquire), + LAST_REBIND_MASK.load(Ordering::Acquire), + RENDER_ENTRIES.load(Ordering::Acquire), + LAST_THREAD.load(Ordering::Relaxed), )); - entries_seen = entries; + binds_seen = binds; reports += 1; } } - crate::write_log("STORE_ENTRY: report cap reached; hook remains installed\n"); + 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. - CLAMP_ENABLED.store(CLAMP_PROMOTED, Ordering::Release); - crate::write_log("STORE_ENTRY: clamp ARMED (promoted); strict native signature gate\n"); + REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release); + crate::write_log("STORE_TABS: tab-bind repair ARMED (promoted); strict signature gate\n"); std::thread::spawn(|| unsafe { worker() }); } @@ -484,75 +480,51 @@ 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" - ); + 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 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); + 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)); } #[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); + fn never_rebinds_a_store_that_already_bound_tabs() { + assert!(!should_rebind(1, 0, 0x0f, 0x0f, 0x1000, 0x2000)); } #[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); + fn never_rebinds_while_groups_are_still_absent() { + assert!(!should_rebind(1, 0, 0x00, 0x00, 0x1000, 0x2000)); } #[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)); + 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 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)); + 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 null_screen_never_publishes() { - assert!(!should_publish_tabs(0, 0, true)); + fn never_rebinds_before_any_bind_is_observed() { + assert!(!should_rebind(0, 0, 0x00, 0x0f, 0x1000, 0x2000)); } #[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)); + fn detour_signatures_are_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()); } }