00ad631034
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).
656 lines
23 KiB
Rust
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
|
|
));
|
|
}
|
|
}
|