diff --git a/openfut-hook/src/sbc_dispatch.rs b/openfut-hook/src/sbc_dispatch.rs index 3c88ae0..764e2f0 100644 --- a/openfut-hook/src/sbc_dispatch.rs +++ b/openfut-hook/src/sbc_dispatch.rs @@ -443,6 +443,10 @@ unsafe extern "system" fn event_wrapper( } _ => {} } + // Piggyback the store pre-warm on this game-thread event: it loads the purchase + // groups once, long before the store screen is shown, so the store's native + // screen-show tab bind sees a populated group list (see `store_entry`). + crate::store_entry::maybe_prewarm_groups(); let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire)); let result = original(controller, event, payload); EVENT_EXITS.fetch_add(1, Ordering::Release); diff --git a/openfut-hook/src/store_entry.rs b/openfut-hook/src/store_entry.rs index 1c18779..805639a 100644 --- a/openfut-hook/src/store_entry.rs +++ b/openfut-hook/src/store_entry.rs @@ -69,6 +69,20 @@ const TAB_PUBLISH_RVA: usize = 0x7df60; /// (`cmpl $0x418, 0x2cc(%rbp); jne ` at `0x18007dfbf`). Logged for evidence: /// a mismatch makes the publish a native no-op rather than a fault. const SCREEN_STATE_OFFSET: usize = 0x2cc; +/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its +/// request/lookup helpers (loaded at `0x18007f25e` right before the pack-list request). +const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0; +/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call +/// the store screen makes at entry (`0x18007f25e`). +/// +/// Firing it EARLY is the actual fix for the missing first-entry tab bar. The tab bar +/// is bound by `FUN_18007e5e0` (six caption tests -> `PANEL_ID`, else hide) which the +/// screen framework invokes at screen-show; on a cold session the purchase groups have +/// not arrived yet, so all six panels hide and no amount of later publishing rebuilds +/// the movie's bar. Pre-warming the groups before the store is ever opened makes the +/// native bind see a populated list, so the tabs bind natively — exactly what already +/// happens on a second entry. +const REQUEST_GROUPS_RVA: usize = 0x17870; /// Whole-instruction prologue length relocated into the render trampoline; also the /// number of bytes overwritten by the entry detour. `push rdi; sub rsp,0x40; /// movq [rsp+0x30],-2` = 2 + 4 + 9 = 15, a clean boundary that covers the 14-byte @@ -90,10 +104,16 @@ const LOOKUP_SIGNATURE: [u8; 15] = [ 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, +]; 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; /// 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 @@ -116,6 +136,8 @@ 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); /// Pure clamp decision, isolated for host tests. Returns the category the renderer /// should resolve: substitute the first present ordinal only for the overview @@ -142,6 +164,63 @@ fn should_publish_tabs(screen: usize, last_published: usize, groups_present: boo 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; + } + let base = STORE_BASE.load(Ordering::Acquire); + if base == 0 || !crate::sbc_trace::validate_cards_build(base) { + return; + } + let Some(storefront) = base + .checked_add(STOREFRONT_GLOBAL_RVA) + .and_then(|slot| crate::sbc_trace::guarded_usize(slot)) + else { + return; + }; + 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 Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else { + return; + }; + if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len()) + || core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len()) + != REQUEST_GROUPS_SIGNATURE + { + return; + } + // Claim the single attempt before issuing it, so a re-entrant event can never + // fire a second request. + PREWARM_DONE.store(true, Ordering::Release); + PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed); + let request_fn: RequestGroupsFn = core::mem::transmute(request); + request_fn(storefront as *mut c_void); + crate::write_log("STORE_ENTRY: pre-warmed purchase groups before first store entry\n"); +} + unsafe fn guarded_i32(address: usize) -> Option { crate::sbc_trace::readable_range(address, 4) .then(|| core::ptr::read_volatile(address as *const i32)) @@ -378,10 +457,11 @@ unsafe fn worker() { let entries = RENDER_ENTRIES.load(Ordering::Acquire); if entries != entries_seen { crate::write_log(&format!( - "STORE_ENTRY: render entries={} clamps={} tabpublish={} state={:#x} tid={}\n", + "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), )); @@ -456,4 +536,23 @@ mod tests { fn null_screen_never_publishes() { assert!(!should_publish_tabs(0, 0, true)); } + + #[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)); + } }