Files
openfut-launcher/openfut-hook/src/sbc_hook.rs
T
funman300 00ad631034 refactor(launcher): retire FIFA 23; keep the hook game-generic by feature
FIFA 23 is not in development and was never a valid template for FIFA 17
(different game, different in-memory layout). Remove it as a build target and
as scaffolding, while preserving the per-game feature architecture so future
games plug in as new modules — never by copying retired reverse-engineering.

Hook (openfut-hook):
- Delete install_hooks_fifa23 and every FIFA23-only module: config, hooks,
  transport_watch, ssl_patch, origin_spy, tls_bypass, dial_notification, probe
  (+ probe feature), recv_hook (+ capture_baseline feature), plus the orphan
  FIFA23 LSX/Origin files lsx.rs and ea_stub.rs. ~3.6k lines; git + Vault retain
  the research.
- lib.rs is now game-generic: a per-game feature selects that game's module and
  install_hooks dispatches to it. No game feature => compile_error!("select a
  game, e.g. --features fifa17"). --features fifa17 remains the build invariant.
- Drop the crate-wide  blanket (it existed only
  to hide the compiled-but-unused FIFA23 modules). Replace with narrow, justified
  #[allow(dead_code)] on the three FIFA17 SBC RE-scaffolding items it was masking,
  so the candidate stays behavior-identical.
- connect_hook: the redirect is now always the config-driven path (openfut-common
  target from openfut.cfg); the hardcoded-loopback rewrite and its dead consts are
  gone. Removed the FIFA23-era transport_watch diagnostics from the shared
  connect/WSAConnect/ConnectEx detours. Deleted unused iat::patch_iat_in.

Launcher:
- fifa_game_dir no longer defaults to a hardcoded '.../FIFA 23' Steam path; it is
  empty by default, matching the launcher's own rule that it never invents a path
  to somebody's game install (like openfut_server_host and game_profile).
- Generalise the remaining 'FIFA 23' doc literals in config.rs / setup.rs.

Proof: fifa17 clippy -D warnings clean; no-game build fails with the documented
compile_error; launcher 75 tests pass unchanged; launcher + hook cross-build
x86_64-pc-windows-gnu; cargo fmt --check clean; zero FIFA23 symbols/literals
remain. FIFA17 armed-module set unchanged (redirect + SBC/store/season).
2026-08-20 20:56:52 +00:00

656 lines
23 KiB
Rust

//! 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:
//! 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 in a background thread.
//!
//! ── 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_READ, 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);
// Full SBC state model. The live repair jumps Resolved -> Validated -> Committed;
// Intercepted/Parsed document the intermediate states but are never entered.
#[allow(dead_code)]
#[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: 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))
}
// Fault-safe executable-range check retained with the address model; not currently wired.
#[allow(dead_code)]
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
let Some(end) = ptr.checked_add(len) else {
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;
matches!(
protection,
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
) && 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(c"CardsDLL_Win64_retail.dll".as_ptr().cast());
if !h.is_null() {
return h as usize;
}
// Also try the short form some tooling reports.
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr().cast());
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
));
}
}