feat: add guarded FIFA 17 SBC diagnostics
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
//! FIFA 17 SBC render intervention (feature = "fifa17").
|
||||
//!
|
||||
//! Makes the FUT **SBC menu render real data** from inside the process. Full spec
|
||||
//! (all addresses, RVA math, call order, crash risks, staged test plan):
|
||||
//! fifa17-recon/docs/sbc-hook-dll-spec.md
|
||||
//!
|
||||
//! Everything here is **inert by default** and gated by env vars, so shipping the DLL
|
||||
//! with this module compiled in changes nothing unless a var is set:
|
||||
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
||||
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
||||
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
||||
//!
|
||||
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
|
||||
//! defer off the loader lock and poll for it — the same shape as
|
||||
//! `probe::install_probes_deferred` polling for anadius64.dll.
|
||||
//!
|
||||
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
|
||||
//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva.
|
||||
//! See the spec for the verified disassembly behind each one.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
};
|
||||
|
||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||
const IMAGE_BASE: usize = 0x180000000;
|
||||
|
||||
/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes).
|
||||
const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00
|
||||
const CTRL_BYTES: &[u8] = &[
|
||||
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
|
||||
];
|
||||
|
||||
const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton)
|
||||
const A_VTABLE_RVA: usize = 0x21c2a0;
|
||||
const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache)
|
||||
const B_VTABLE_RVA: usize = 0x1fae70;
|
||||
const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
|
||||
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
|
||||
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
|
||||
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
|
||||
const B_DTOR_RVA: usize = 0x63040;
|
||||
const B_ISVALID_RVA: usize = 0x65d40;
|
||||
const B_CLEAR_RVA: usize = 0x65d20;
|
||||
const B_READY_EXPECTED_BEFORE_ARM: u8 = 0;
|
||||
|
||||
#[allow(dead_code)]
|
||||
const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter)
|
||||
#[allow(dead_code)]
|
||||
const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk)
|
||||
|
||||
// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for
|
||||
// reference/wiring; not invoked while Tier-1 is blocked.
|
||||
#[allow(dead_code)]
|
||||
mod rva {
|
||||
pub const M_LAZY_GETTER: usize = 0x11b7d0;
|
||||
pub const ISVALID: usize = 0x65d40;
|
||||
pub const DESER_SBS_SETS: usize = 0x17b2b0;
|
||||
pub const SAX_CTX_INIT: usize = 0x1c63e0;
|
||||
pub const REGISTRY_GETTER: usize = 0xd7170;
|
||||
pub const MANAGER_GETTER: usize = 0x9c80;
|
||||
pub const CLEAR_M: usize = 0x15f3a0;
|
||||
pub const CAT_CTOR: usize = 0x159da0;
|
||||
pub const CAT_DESER: usize = 0x17ab80;
|
||||
pub const CAT_FINALIZE: usize = 0x160e50;
|
||||
pub const APPEND: usize = 0x15a770;
|
||||
pub const CAT_DTOR: usize = 0x1105d0;
|
||||
pub const IDX_REBUILD_1: usize = 0x160e00;
|
||||
pub const IDX_REBUILD_2: usize = 0x160f30;
|
||||
pub const IDX_REBUILD_3: usize = 0x161020;
|
||||
pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574
|
||||
}
|
||||
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(usize)]
|
||||
enum RuntimeState {
|
||||
Disabled,
|
||||
Resolved,
|
||||
Intercepted,
|
||||
Parsed,
|
||||
Validated,
|
||||
Committed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool {
|
||||
matches!(
|
||||
(from, to),
|
||||
(RuntimeState::Disabled, RuntimeState::Resolved)
|
||||
| (RuntimeState::Resolved, RuntimeState::Intercepted)
|
||||
| (RuntimeState::Intercepted, RuntimeState::Parsed)
|
||||
| (RuntimeState::Parsed, RuntimeState::Validated)
|
||||
// Resolve-only/Tier-0 validates without installing an interceptor.
|
||||
| (RuntimeState::Resolved, RuntimeState::Validated)
|
||||
| (RuntimeState::Validated, RuntimeState::Committed)
|
||||
| (_, RuntimeState::Failed)
|
||||
)
|
||||
}
|
||||
|
||||
fn transition(from: RuntimeState, to: RuntimeState) -> bool {
|
||||
valid_transition(from, to)
|
||||
&& STATE
|
||||
.compare_exchange(
|
||||
from as usize,
|
||||
to as usize,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ValidationError {
|
||||
AddressOverflow,
|
||||
AUnreadable,
|
||||
AVtableMismatch,
|
||||
AGetterMismatch,
|
||||
BVtableMismatch,
|
||||
BDtorMismatch,
|
||||
BIsValidMismatch,
|
||||
BClearMismatch,
|
||||
MSlotUnreadable,
|
||||
ReadyByteUnexpected,
|
||||
CollectionUnreadable,
|
||||
CollectionNotNull,
|
||||
ReadyByteNotWritable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct RuntimeSnapshot {
|
||||
a: usize,
|
||||
a_vtable: usize,
|
||||
a_b_getter: usize,
|
||||
b: usize,
|
||||
b_vtable: usize,
|
||||
b_dtor: usize,
|
||||
b_isvalid: usize,
|
||||
b_clear: usize,
|
||||
b_ready: u8,
|
||||
b_coll: usize,
|
||||
m: usize,
|
||||
}
|
||||
|
||||
fn expected_va(base: usize, rva: usize) -> Result<usize, ValidationError> {
|
||||
base.checked_add(rva)
|
||||
.ok_or(ValidationError::AddressOverflow)
|
||||
}
|
||||
|
||||
fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> {
|
||||
if s.a == 0
|
||||
|| s.b
|
||||
!= s.a
|
||||
.checked_add(B_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?
|
||||
{
|
||||
return Err(ValidationError::AUnreadable);
|
||||
}
|
||||
if s.a_vtable != expected_va(base, A_VTABLE_RVA)? {
|
||||
return Err(ValidationError::AVtableMismatch);
|
||||
}
|
||||
if s.a_b_getter != expected_va(base, 0x11c1f0)? {
|
||||
return Err(ValidationError::AGetterMismatch);
|
||||
}
|
||||
if s.b_vtable != expected_va(base, B_VTABLE_RVA)? {
|
||||
return Err(ValidationError::BVtableMismatch);
|
||||
}
|
||||
if s.b_dtor != expected_va(base, B_DTOR_RVA)? {
|
||||
return Err(ValidationError::BDtorMismatch);
|
||||
}
|
||||
if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? {
|
||||
return Err(ValidationError::BIsValidMismatch);
|
||||
}
|
||||
if s.b_clear != expected_va(base, B_CLEAR_RVA)? {
|
||||
return Err(ValidationError::BClearMismatch);
|
||||
}
|
||||
if s.b_ready != B_READY_EXPECTED_BEFORE_ARM {
|
||||
return Err(ValidationError::ReadyByteUnexpected);
|
||||
}
|
||||
if s.b_coll != 0 {
|
||||
return Err(ValidationError::CollectionNotNull);
|
||||
}
|
||||
let _ = s.m; // The guarded snapshot read proves the M slot itself is readable.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
|
||||
/// in a committed, readable page and the full 8 bytes fit inside the region.
|
||||
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
|
||||
if ptr < 0x10000 || ptr & 7 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT {
|
||||
return None;
|
||||
}
|
||||
if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return None;
|
||||
}
|
||||
if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize {
|
||||
return None;
|
||||
}
|
||||
Some(core::ptr::read_volatile(ptr as *const usize))
|
||||
}
|
||||
|
||||
/// Guarded byte read.
|
||||
unsafe fn read_u8(ptr: usize) -> Option<u8> {
|
||||
if ptr < 0x10000 {
|
||||
return None;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return None;
|
||||
}
|
||||
if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize {
|
||||
return None;
|
||||
}
|
||||
Some(core::ptr::read_volatile(ptr as *const u8))
|
||||
}
|
||||
|
||||
/// A Tier-0 write is allowed only when the complete byte lies in a committed,
|
||||
/// non-guarded region whose current protection explicitly permits writes.
|
||||
unsafe fn writable_u8(ptr: usize) -> bool {
|
||||
if ptr < 0x10000 {
|
||||
return false;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return false;
|
||||
}
|
||||
let protection = mbi.Protect & 0xff;
|
||||
let writable = matches!(
|
||||
protection,
|
||||
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
);
|
||||
writable
|
||||
&& ptr
|
||||
.checked_add(1)
|
||||
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
|
||||
}
|
||||
|
||||
/// Guarded 16-bit read (M category count is a WORD).
|
||||
unsafe fn read_u16(ptr: usize) -> Option<u16> {
|
||||
let lo = read_u8(ptr)? as u16;
|
||||
let hi = read_u8(ptr + 1)? as u16;
|
||||
Some(lo | (hi << 8))
|
||||
}
|
||||
|
||||
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
|
||||
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
|
||||
unsafe fn resolve_cards_base() -> usize {
|
||||
let h = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr());
|
||||
if !h.is_null() {
|
||||
return h as usize;
|
||||
}
|
||||
// Also try the short form some tooling reports.
|
||||
let h2 = GetModuleHandleA(b"CardsDLL.dll\0".as_ptr());
|
||||
if !h2.is_null() {
|
||||
return h2 as usize;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn va(base: usize, rva: usize) -> usize {
|
||||
base + rva
|
||||
}
|
||||
|
||||
/// Prove the module didn't move: the FNV control prologue must match the on-disk PE.
|
||||
unsafe fn control_matches(base: usize) -> bool {
|
||||
let p = va(base, CTRL_RVA);
|
||||
for (i, &want) in CTRL_BYTES.iter().enumerate() {
|
||||
match read_u8(p + i) {
|
||||
Some(got) if got == want => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Take one guarded identity snapshot. A failure to read any identity-bearing field is
|
||||
/// distinct from a value mismatch and aborts before mutation.
|
||||
unsafe fn runtime_snapshot(base: usize) -> Result<RuntimeSnapshot, ValidationError> {
|
||||
let a_slot = expected_va(base, A_SLOT_RVA)?;
|
||||
let a = read_ptr(a_slot)
|
||||
.filter(|&value| value != 0)
|
||||
.ok_or(ValidationError::AUnreadable)?;
|
||||
let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?;
|
||||
let a_b_getter = read_ptr(
|
||||
a_vtable
|
||||
.checked_add(AVT_B_GETTER)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::AGetterMismatch)?;
|
||||
let b = a
|
||||
.checked_add(B_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?;
|
||||
let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?;
|
||||
let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?;
|
||||
let b_isvalid = read_ptr(
|
||||
b_vtable
|
||||
.checked_add(8)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::BIsValidMismatch)?;
|
||||
let b_clear = read_ptr(
|
||||
b_vtable
|
||||
.checked_add(16)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::BClearMismatch)?;
|
||||
let b_ready = read_u8(
|
||||
b.checked_add(B_READY_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::ReadyByteUnexpected)?;
|
||||
let b_coll = read_ptr(
|
||||
b.checked_add(B_COLL_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::CollectionUnreadable)?;
|
||||
let m = read_ptr(
|
||||
a.checked_add(M_CACHE_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::MSlotUnreadable)?;
|
||||
Ok(RuntimeSnapshot {
|
||||
a,
|
||||
a_vtable,
|
||||
a_b_getter,
|
||||
b,
|
||||
b_vtable,
|
||||
b_dtor,
|
||||
b_isvalid,
|
||||
b_clear,
|
||||
b_ready,
|
||||
b_coll,
|
||||
m,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_failed(error: ValidationError) {
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n"
|
||||
));
|
||||
}
|
||||
|
||||
/// Public entry: called from `fifa17::install`. Spawns the deferred worker if
|
||||
/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert).
|
||||
pub fn install() {
|
||||
let armed = std::env::var("OPENFUT_SBC_HOOK")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
ARMED.store(armed, Ordering::Relaxed);
|
||||
if !armed {
|
||||
STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed);
|
||||
crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n");
|
||||
return;
|
||||
}
|
||||
ARM_ONLY.store(
|
||||
std::env::var("OPENFUT_SBC_ARM_ONLY")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
POPULATE.store(
|
||||
std::env::var("OPENFUT_SBC_POPULATE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n");
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
||||
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
||||
/// exactly once.
|
||||
unsafe fn worker() {
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = resolve_cards_base();
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
if base == 0 {
|
||||
crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n");
|
||||
return;
|
||||
}
|
||||
CARDS_BASE.store(base, Ordering::Relaxed);
|
||||
let slide = base.wrapping_sub(IMAGE_BASE);
|
||||
let ctrl_ok = control_matches(base);
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n",
|
||||
if ctrl_ok { "OK" } else { "MISMATCH-ABORT" }
|
||||
));
|
||||
if !ctrl_ok {
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
return; // module map moved -> offsets untrustworthy (spec §1)
|
||||
}
|
||||
|
||||
if !transition(RuntimeState::Disabled, RuntimeState::Resolved) {
|
||||
crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n");
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve and validate A -> B, M. The vtable method checks make it substantially
|
||||
// harder for a coincidental heap pointer to pass after a binary/layout mismatch.
|
||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !transition(RuntimeState::Resolved, RuntimeState::Validated) {
|
||||
crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n");
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
let a = snapshot.a;
|
||||
let b = snapshot.b;
|
||||
let m = snapshot.m;
|
||||
let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten();
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n",
|
||||
Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count,
|
||||
));
|
||||
|
||||
// Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so
|
||||
// isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) —
|
||||
// this is the baseline, NOT the fix. One-shot.
|
||||
if ARM_ONLY.load(Ordering::Relaxed) {
|
||||
if DONE.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use
|
||||
// window. In particular, the exact patch byte must still be 0 and B+0x08 null.
|
||||
let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
||||
return Err(ValidationError::ReadyByteNotWritable);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n",
|
||||
write_snapshot.b + B_READY_OFF
|
||||
));
|
||||
core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8);
|
||||
match read_u8(write_snapshot.b + B_READY_OFF) {
|
||||
Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {}
|
||||
_ => {
|
||||
set_failed(ValidationError::ReadyByteUnexpected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
crate::write_log(
|
||||
"SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA
|
||||
// already owns a real response and SAX reader for /sbs/sets. The next milestone is
|
||||
// passive tracing of the native response-to-deserializer dispatch, not construction
|
||||
// of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault.
|
||||
if POPULATE.load(Ordering::Relaxed) {
|
||||
crate::write_log(
|
||||
"SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \
|
||||
and reader at the native dispatch boundary first (see client-hook plan M3/M4). \
|
||||
No deser call made; fabricated readers can clear M or crash.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_hex(o: Option<usize>) -> String {
|
||||
match o {
|
||||
Some(v) => format!("{v:#x}"),
|
||||
None => "<unreadable>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended
|
||||
/// implementation is now a guarded synchronous dispatch repair that borrows the genuine
|
||||
/// response and reader from the real HTTP transaction on its native thread.
|
||||
///
|
||||
/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists:
|
||||
/// let base = CARDS_BASE.load(Relaxed);
|
||||
/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool =
|
||||
/// transmute(va(base, rva::DESER_SBS_SETS));
|
||||
/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M
|
||||
/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0
|
||||
/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50]
|
||||
#[allow(dead_code)]
|
||||
unsafe fn populate_m(_reader: *mut u8) {
|
||||
// Intentionally unimplemented: the passive trace must prove the response/reader
|
||||
// ownership and exact virtual-dispatch boundary before any parser call is enabled.
|
||||
unreachable!(
|
||||
"populate_m requires a proven native dispatch contract; see client-hook plan M3/M4"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_snapshot(base: usize) -> RuntimeSnapshot {
|
||||
let a = 0x1000_0000usize;
|
||||
RuntimeSnapshot {
|
||||
a,
|
||||
a_vtable: base + A_VTABLE_RVA,
|
||||
a_b_getter: base + 0x11c1f0,
|
||||
b: a + B_OFF,
|
||||
b_vtable: base + B_VTABLE_RVA,
|
||||
b_dtor: base + B_DTOR_RVA,
|
||||
b_isvalid: base + B_ISVALID_RVA,
|
||||
b_clear: base + B_CLEAR_RVA,
|
||||
b_ready: B_READY_EXPECTED_BEFORE_ARM,
|
||||
b_coll: 0,
|
||||
m: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_exact_runtime_identity_with_null_uninitialized_m() {
|
||||
let base = 0x7fff_0000_0000usize;
|
||||
assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_a_or_b_class_identity() {
|
||||
let base = 0x7fff_0000_0000usize;
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.a_vtable += 8;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::AVtableMismatch)
|
||||
);
|
||||
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.b_vtable += 8;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::BVtableMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_changed_patch_byte_or_live_collection() {
|
||||
let base = 0x7fff_0000_0000usize;
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.b_ready = 1;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::ReadyByteUnexpected)
|
||||
);
|
||||
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.b_coll = 0x1234_0000;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::CollectionNotNull)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_is_forward_only_and_fail_closed() {
|
||||
assert!(valid_transition(
|
||||
RuntimeState::Disabled,
|
||||
RuntimeState::Resolved
|
||||
));
|
||||
assert!(valid_transition(
|
||||
RuntimeState::Resolved,
|
||||
RuntimeState::Validated
|
||||
));
|
||||
assert!(valid_transition(
|
||||
RuntimeState::Validated,
|
||||
RuntimeState::Committed
|
||||
));
|
||||
assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed));
|
||||
assert!(!valid_transition(
|
||||
RuntimeState::Validated,
|
||||
RuntimeState::Resolved
|
||||
));
|
||||
assert!(!valid_transition(
|
||||
RuntimeState::Failed,
|
||||
RuntimeState::Resolved
|
||||
));
|
||||
assert!(!valid_transition(
|
||||
RuntimeState::Resolved,
|
||||
RuntimeState::Committed
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user