diff --git a/openfut-hook/src/store_entry.rs b/openfut-hook/src/store_entry.rs index 85cd5cf..1c18779 100644 --- a/openfut-hook/src/store_entry.rs +++ b/openfut-hook/src/store_entry.rs @@ -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 ` 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 { 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)); + } }