From e4c56a225e39e91a1289741c40f358a99ffd71cb Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 19 Aug 2026 16:11:58 +0000 Subject: [PATCH] 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. --- openfut-hook/src/sbc_dispatch.rs | 4 ++ openfut-hook/src/store_entry.rs | 101 ++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) 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)); + } }