Files
openfut-launcher/openfut-hook/src/store_entry.rs
T
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

460 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Guarded FIFA 17 store-entry category clamp.
//!
//! # 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.
//!
//! 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`).
//!
//! # Why it cannot crash the client
//!
//! 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.
//!
//! # 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.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, 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,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
/// Store render `FUN_18007dab0`: reads `screen+0x290` and drives the resolver.
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;
/// 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
/// 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.
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_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
/// 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;
/// 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);
static CLAMP_ENABLED: AtomicBool = AtomicBool::new(false);
static RENDER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
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
/// 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 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))
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
}
unsafe fn write_entry<const N: usize>(
target: usize,
destination: usize,
original: &[u8; N],
) -> Result<(), bool> {
let mut patch = [0x90u8; N];
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return Err(true);
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
Ok(())
} else {
Err(restore_entry(target, original))
}
}
/// 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) {
// 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);
!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);
}
}
}
}
let original: RenderFn = core::mem::transmute(RENDER_TRAMPOLINE.load(Ordering::Acquire));
original(screen)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_hook(base: usize) -> InstallOutcome {
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 {
return InstallOutcome::CleanFailure;
};
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;
}
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,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(render, COPY_LEN) else {
return InstallOutcome::CleanFailure;
};
RENDER_TRAMPOLINE.store(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);
RENDER_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(render, lookup) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
RENDER_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
}
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
return InstallOutcome::DegradedProcessState;
}
};
let final_valid = crate::sbc_trace::valid_cards_image(base)
&& 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,
Err(true) => InstallOutcome::CleanFailure,
Err(false) => InstallOutcome::DegradedHookActive,
}
};
let resumed = peers.resume_all();
let outcome = if resumed {
transaction
} else if matches!(
transaction,
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
) {
InstallOutcome::DegradedHookAndProcess
} else {
InstallOutcome::DegradedProcessState
};
if outcome == InstallOutcome::CleanFailure {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
RENDER_TRAMPOLINE.store(0, Ordering::Release);
}
outcome
}
unsafe fn worker() {
let _pending = crate::sbc_trace::CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let outcome = if base == 0 {
InstallOutcome::CleanFailure
} else {
install_hook(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => {
crate::write_log("STORE_ENTRY: render clamp installed (promoted)\n")
}
InstallOutcome::CleanFailure => {
crate::write_log("STORE_ENTRY: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("STORE_ENTRY: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("STORE_ENTRY: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("STORE_ENTRY: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut entries_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 {
crate::write_log(&format!(
"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;
reports += 1;
}
}
crate::write_log("STORE_ENTRY: 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");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
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"
);
}
#[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);
}
#[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);
}
#[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);
}
#[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));
}
}