8ca89bcc75
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.
460 lines
20 KiB
Rust
460 lines
20 KiB
Rust
//! 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));
|
||
}
|
||
}
|