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.
This commit is contained in:
+103
-16
@@ -58,6 +58,17 @@ 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;
|
||||
/// 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
|
||||
@@ -75,9 +86,14 @@ const RENDER_SIGNATURE: [u8; COPY_LEN] = [
|
||||
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,
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
/// The store-entry clamp is PROMOTED: armed by the build, never by an environment
|
||||
/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves
|
||||
@@ -97,6 +113,9 @@ static STORE_BASE: AtomicUsize = AtomicUsize::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);
|
||||
|
||||
/// Pure clamp decision, isolated for host tests. Returns the category the renderer
|
||||
/// should resolve: substitute the first present ordinal only for the overview
|
||||
@@ -110,6 +129,19 @@ fn clamp_category(current: i32, first_group_present: bool) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure decision for the tab-bar repair, isolated for host tests.
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
|
||||
unsafe fn guarded_i32(address: usize) -> Option<i32> {
|
||||
crate::sbc_trace::readable_range(address, 4)
|
||||
.then(|| core::ptr::read_volatile(address as *const i32))
|
||||
@@ -156,20 +188,42 @@ unsafe extern "system" fn store_render_wrapper(screen: *mut c_void) -> *mut c_vo
|
||||
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.
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
!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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,15 +248,22 @@ unsafe fn install_hook(base: usize) -> InstallOutcome {
|
||||
let Some(lookup) = crate::sbc_trace::target_va(base, LOOKUP_RVA) else {
|
||||
return InstallOutcome::CleanFailure;
|
||||
};
|
||||
// Fingerprint the image and BOTH functions: the one we detour and the one we
|
||||
// call. A single mismatched byte aborts cleanly with no write and no call.
|
||||
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.
|
||||
if !crate::sbc_trace::valid_cards_image(base)
|
||||
|| !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())
|
||||
|| 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
|
||||
{
|
||||
return InstallOutcome::CleanFailure;
|
||||
}
|
||||
@@ -317,9 +378,11 @@ unsafe fn worker() {
|
||||
let entries = RENDER_ENTRIES.load(Ordering::Acquire);
|
||||
if entries != entries_seen {
|
||||
crate::write_log(&format!(
|
||||
"STORE_ENTRY: render entries={} clamps={} tid={}\n",
|
||||
"STORE_ENTRY: render entries={} clamps={} tabpublish={} state={:#x} tid={}\n",
|
||||
entries,
|
||||
CLAMPS_APPLIED.load(Ordering::Acquire),
|
||||
TAB_PUBLISHES.load(Ordering::Acquire),
|
||||
LAST_SCREEN_STATE.load(Ordering::Relaxed),
|
||||
CLAMP_LAST_THREAD.load(Ordering::Relaxed),
|
||||
));
|
||||
entries_seen = entries;
|
||||
@@ -369,4 +432,28 @@ mod tests {
|
||||
// this clamp deliberately only touches the 0 overview default.
|
||||
assert_eq!(clamp_category(-1, true), -1);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_screen_never_publishes() {
|
||||
assert!(!should_publish_tabs(0, 0, true));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user