3 Commits

Author SHA1 Message Date
funman300 7724f168bc 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.
2026-08-19 18:02:10 +00:00
funman300 e4c56a225e hook(fifa17): pre-warm purchase groups so the store tab bar binds natively
Fixes the ordering instead of fighting the movie. The tab bar is bound by
FUN_18007e5e0 (six caption tests -> PANEL_ID, else hide panel), which is
slot 0 of a secondary vtable invoked by the screen framework at
screen-show. On a cold session the /store/purchasegroup groups have not
arrived by then, so all six panels hide and no tab bar is drawn. Late
publishing does not fix it: deploying the 0x278a publish at render time
fired with its gate accepting (log: tabpublish=1 state=0x418) and the bar
still did not appear, i.e. the movie ignores late tab updates.

So load the groups BEFORE the store is ever opened. The store screen
issues its own pack-list request at 0x18007f25e as
FUN_180017870(*(base+0x2de0d0)) -- a single-argument call on the
storefront global. Issue exactly that call once per process from the FUT
event dispatcher, which already runs on a game thread long before the
store screen exists. When the user then opens the store, the native
screen-show bind sees a populated group list and binds the tabs itself --
the same reason a second entry has always worked.

Fail-closed: base + CardsDLL image validated, storefront read guarded,
FUN_180017870 fingerprinted before the first call, one request per
process claimed before issuing (no re-entrant double request), and
skipped entirely once groups exist. Logs prewarm= for evidence.
fmt/clippy -D warnings clean, 32 hook tests pass.
2026-08-19 16:11:58 +00:00
funman300 8ca89bcc75 hook(fifa17): bind the store tab bar on first entry
The category clamp fixed WHICH content the first store render draws, but
the tab bar was still missing on first entry (operator-observed: first
open = bronze packs with no tab bar; re-entry = same packs WITH
Bronze/Silver/Gold tabs).

Root cause: the store screen dispatcher 0x18007d880 publishes the tab bar
on a DIFFERENT event than it renders. Event 0x278a -> FUN_18007df60
resolves the six hardcoded tab tokens against the loaded purchase groups
and publishes *_CATEGORY_ID; event 0x753f -> FUN_18007dab0 renders. On
first entry the publish runs before /store/purchasegroup has landed, so
all six tokens resolve -1, every panel hides, and no tab bar is drawn;
re-entry only works because the groups are cached by then.

Re-run the native publish once per screen from the render detour, where
the groups are provably present (the ordinal-1 lookup already proves it),
reproducing the working re-entry order (publish, then render). Safe: it
is the same call the dispatcher makes with the same single argument, it
self-gates on screen+0x2cc == 0x418 (a mismatch is a native no-op, not a
fault), it is fingerprinted before the first call, and it runs at most
once per screen instance. Also logs the gate state so a no-op publish is
diagnosable. fmt/clippy -D warnings clean, 29 hook tests pass.
2026-08-19 15:57:23 +00:00
+312 -154
View File
@@ -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,71 +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;
/// 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_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 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 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
}
unsafe fn guarded_i32(address: usize) -> Option<i32> {
crate::sbc_trace::readable_range(address, 4)
.then(|| core::ptr::read_volatile(address as *const i32))
/// 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.
unsafe fn gate_mask() -> u8 {
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 {
return 0;
}
let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else {
return 0;
};
let gate_fn: HasCategoryFn = core::mem::transmute(gate);
let mut mask = 0u8;
for (slot, index) in GATE_INDICES.iter().enumerate() {
if gate_fn(core::ptr::null_mut(), *index) != 0 {
mask |= 1 << slot;
}
}
mask
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
@@ -147,35 +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) {
let category_addr = (screen as usize).wrapping_add(CATEGORY_OFFSET);
if let Some(0) = guarded_i32(category_addr) {
if let Some(lookup) = base.checked_add(LOOKUP_RVA) {
// The lookup's first argument is dead; pass null. `1` is the first
// present ordinal. Non-NULL means the resolver will find a group,
// so writing `1` cannot reach the NULL-deref crash path.
let lookup_fn: LookupFn = core::mem::transmute(lookup);
let group = lookup_fn(core::ptr::null_mut(), FIRST_ORDINAL);
let clamped = clamp_category(0, !group.is_null());
if clamped != 0 {
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)]
@@ -188,50 +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;
};
// Fingerprint the image and BOTH functions: the one we detour and the one we
// 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, 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(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) => {
@@ -239,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,
}
@@ -266,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
}
@@ -290,49 +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={} tid={}\n",
entries,
CLAMPS_APPLIED.load(Ordering::Acquire),
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() });
}
@@ -341,32 +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 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() {
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());
}
}