hook(fifa17): repair the store tab bar by rebinding the native binder
Replaces three disproven store-entry mechanisms (category clamp, late *_CATEGORY_ID publish, purchase-group pre-warm) with the one repair the reversing actually supports. FUN_18007e5e0(ctx, panel) is the native tab binder the screen framework invokes at 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: slot 0 mypacks, 1 bronze, 2 silver, 3 gold, 4 special, slot 5 points (extra gate: (*(store_vtbl+0x30))(store) must be false) The gate FUN_180014df0(_, idx) resolves the token through FUN_180014380, which linearly scans the loaded purchase groups (stride 0x108) comparing the token at group+0x70. So a tab exists iff a purchase group carrying that 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: 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 -- which is exactly the reported symptom. The repair re-invokes the binder once, with the framework's own (ctx, panel), at the first render after the groups arrive, reproducing the re-entry ordering on the first entry. Repeating the binder is safe: 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 rebuilds the movie's bar. Fail-closed: rebind only when the framework's bind observed an EMPTY mask and at least one token now resolves (a store that already bound tabs is never touched); one rebind per bind generation, claimed by compare-exchange; only framework-supplied pointers are ever used; image plus all three function signatures verified before any write and re-verified under thread suspension. The gate probe passes a null this, which is sound because FUN_180014df0 forwards rcx to FUN_180014380, which discards it and uses a singleton. Why the earlier attempts could not work: the clamp forced a single category (regressing Browse Packs to bronze-only), the publish targeted FUN_18007df60 which does not bind panels, and the pre-warm ran from the FUT event dispatcher -- after screen-show, so the slot decisions were already made. Promoted (build-armed, no env var). Rollback is a version.dll file swap.
This commit is contained in:
@@ -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);
|
||||
|
||||
+296
-324
@@ -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 <end>` at `0x18007dfbf`). Logged for evidence:
|
||||
/// a mismatch makes the publish a native no-op rather than a fault.
|
||||
const SCREEN_STATE_OFFSET: usize = 0x2cc;
|
||||
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
|
||||
/// request/lookup helpers (loaded at `0x18007f25e` right before the pack-list request).
|
||||
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
|
||||
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
|
||||
/// the store screen makes at entry (`0x18007f25e`).
|
||||
///
|
||||
/// Firing it EARLY is the actual fix for the missing first-entry tab bar. The tab bar
|
||||
/// is bound by `FUN_18007e5e0` (six caption tests -> `PANEL_ID`, else hide) which the
|
||||
/// screen framework invokes at screen-show; on a cold session the purchase groups have
|
||||
/// not arrived yet, so all six panels hide and no amount of later publishing rebuilds
|
||||
/// the movie's bar. Pre-warming the groups before the store is ever opened makes the
|
||||
/// native bind see a populated list, so the tabs bind natively — exactly what already
|
||||
/// happens on a second entry.
|
||||
const REQUEST_GROUPS_RVA: usize = 0x17870;
|
||||
/// Whole-instruction prologue length relocated into the render trampoline; also the
|
||||
/// number of bytes overwritten by the entry detour. `push rdi; sub rsp,0x40;
|
||||
/// movq [rsp+0x30],-2` = 2 + 4 + 9 = 15, a clean boundary that covers the 14-byte
|
||||
/// absolute jump.
|
||||
/// 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<i32> {
|
||||
crate::sbc_trace::readable_range(address, 4)
|
||||
.then(|| core::ptr::read_volatile(address as *const i32))
|
||||
mask
|
||||
}
|
||||
|
||||
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
|
||||
@@ -258,57 +232,63 @@ unsafe fn write_entry<const N: usize>(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user