//! Generic, fail-closed byte-patch primitive shared by per-game compatibility //! patch tables (currently FIFA 17's TLS/store gates in [`crate::fifa17_tls`]). //! //! The decision logic is expressed against the [`Mem`] trait rather than raw //! process memory, so every outcome — ORIGINAL / ALREADY_PATCHED / MISMATCH and //! the write/verify path — is unit-testable on the host without a live client. //! [`WinMem`] is the in-process Windows implementation used at runtime. //! //! FAIL-CLOSED INVARIANT: a site is written only when its live bytes are *exactly* //! the known original. Already-patched is an idempotent no-op; anything else is //! reported and left untouched — an unrecognised or not-yet-unpacked build is //! never blindly overwritten. /// Longest patch payload across all tables (FIFA17 GATE1 is 6 bytes). Sizes the /// fixed stack buffers so no slicing panic is reachable from the patch logic. pub const MAX_PATCH_LEN: usize = 6; /// Byte-level access to the target's address space. pub trait Mem { /// Fill `buf` from `addr`. `false` = not readable yet (page uncommitted / /// module not mapped / not unpacked) — the caller waits, it is not an error. fn read(&self, addr: usize, buf: &mut [u8]) -> bool; /// Write `data` at `addr`. `false` = the write could not be performed. fn write(&mut self, addr: usize, data: &[u8]) -> bool; } /// Fail-closed classification of live bytes against a site's original/replacement. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PatchState { /// Live bytes are the known original — safe to patch. Original, /// Live bytes already equal the replacement — idempotent. AlreadyPatched, /// Neither — unrecognised/not-yet-ready build; must be left untouched. Mismatch, } /// Pure classification (no memory access). pub fn classify(cur: &[u8], orig: &[u8], patch: &[u8]) -> PatchState { if cur == patch { PatchState::AlreadyPatched } else if cur == orig { PatchState::Original } else { PatchState::Mismatch } } /// Outcome of a checked patch attempt at one site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApplyOutcome { /// Bytes were the original and were written and re-read as the replacement. Applied, /// Bytes already equalled the replacement; nothing written. AlreadyPatched, /// Bytes were neither original nor replacement; nothing written. Mismatch, /// Bytes could not be read yet (module/page not available) — retry later. NotReadable, /// The write itself failed (protection change or copy). WriteFailed, /// Wrote, but the re-read did not equal the replacement. VerifyFailed, } impl ApplyOutcome { /// Whether the site now holds the replacement (freshly or already). pub fn is_patched(self) -> bool { matches!(self, ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched) } } /// Read → classify → (only on ORIGINAL) write → re-read verify. Never writes on /// MISMATCH; treats ALREADY_PATCHED as success. `orig`/`patch` must be equal, /// non-empty and within [`MAX_PATCH_LEN`]. pub fn apply_checked(mem: &mut M, addr: usize, orig: &[u8], patch: &[u8]) -> ApplyOutcome { debug_assert_eq!(orig.len(), patch.len()); debug_assert!(!patch.is_empty() && patch.len() <= MAX_PATCH_LEN); let n = patch.len(); let mut cur = [0u8; MAX_PATCH_LEN]; if !mem.read(addr, &mut cur[..n]) { return ApplyOutcome::NotReadable; } match classify(&cur[..n], orig, patch) { PatchState::AlreadyPatched => ApplyOutcome::AlreadyPatched, PatchState::Mismatch => ApplyOutcome::Mismatch, PatchState::Original => { if !mem.write(addr, patch) { return ApplyOutcome::WriteFailed; } let mut after = [0u8; MAX_PATCH_LEN]; if !mem.read(addr, &mut after[..n]) || &after[..n] != patch { return ApplyOutcome::VerifyFailed; } ApplyOutcome::Applied } } } /// RVA of a static VA relative to an image's preferred base (pure). pub const fn rva(static_va: u64, preferred_base: u64) -> u64 { static_va - preferred_base } /// Read and classify a site without writing (`None` = not readable yet). Used to /// decide multi-site patches (e.g. apply a gate pair only when both are original). pub fn read_state(mem: &M, addr: usize, orig: &[u8], patch: &[u8]) -> Option { let n = patch.len(); let mut cur = [0u8; MAX_PATCH_LEN]; if !mem.read(addr, &mut cur[..n]) { return None; } Some(classify(&cur[..n], orig, patch)) } /// Live in-process address of an image-relative site given the module's runtime base. pub const fn live_addr(module_base: usize, rva: u64) -> usize { module_base + rva as usize } /// Lowercase, unseparated hex for diagnostics (matches the autopatch SKIP line). pub fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); s.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); } s } // ─── In-process Windows memory (runtime only; not exercised by host tests) ────── /// In-process implementation of [`Mem`] over this (FIFA17.exe) address space. pub struct WinMem; impl Mem for WinMem { fn read(&self, addr: usize, buf: &mut [u8]) -> bool { unsafe { guarded_read(addr, buf) } } fn write(&mut self, addr: usize, data: &[u8]) -> bool { unsafe { protected_write(addr, data) } } } /// Resolve a loaded module's runtime base by name, or `None` if not loaded. pub unsafe fn module_base(name: *const u8) -> Option { use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; let h = GetModuleHandleA(name); if h.is_null() { None } else { Some(h as usize) } } /// Read `buf.len()` bytes from `addr` only if the whole range is committed and /// readable (VirtualQuery-guarded), so a wrong base/RVA can never fault. unsafe fn guarded_read(addr: usize, buf: &mut [u8]) -> bool { 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_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, }; if addr == 0 || buf.is_empty() { return false; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let want = core::mem::size_of::(); if VirtualQuery(addr as _, &mut mbi, want) != want { return false; } if mbi.State != MEM_COMMIT { return false; } let readable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; if mbi.Protect & readable == 0 || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 { return false; } // The full range must fit inside this single committed region. let region_end = (mbi.BaseAddress as usize).wrapping_add(mbi.RegionSize); if addr.checked_add(buf.len()).is_none_or(|e| e > region_end) { return false; } core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), buf.len()); true } /// Make `[addr, addr+data.len())` writable, copy `data`, flush the instruction /// cache, then restore the original protection. `false` if protection could not /// be changed. Verification is the caller's re-read (see [`apply_checked`]). unsafe fn protected_write(addr: usize, data: &[u8]) -> bool { use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; use windows_sys::Win32::System::Threading::GetCurrentProcess; if addr == 0 || data.is_empty() { return false; } let mut old: u32 = 0; if VirtualProtect(addr as _, data.len(), PAGE_EXECUTE_READWRITE, &mut old) == 0 { return false; } core::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, data.len()); FlushInstructionCache(GetCurrentProcess(), addr as _, data.len()); // Best-effort restore of the original page protection. let mut restored: u32 = 0; VirtualProtect(addr as _, data.len(), old, &mut restored); true } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; /// Deterministic fake address space for the pure patch logic. struct FakeMem { cells: HashMap, readable: bool, writable: bool, } impl FakeMem { fn with(addr: usize, bytes: &[u8]) -> Self { let mut cells = HashMap::new(); for (i, b) in bytes.iter().enumerate() { cells.insert(addr + i, *b); } Self { cells, readable: true, writable: true, } } } impl Mem for FakeMem { fn read(&self, addr: usize, buf: &mut [u8]) -> bool { if !self.readable { return false; } for (i, slot) in buf.iter_mut().enumerate() { match self.cells.get(&(addr + i)) { Some(b) => *slot = *b, None => return false, } } true } fn write(&mut self, addr: usize, data: &[u8]) -> bool { if !self.writable { return false; } for (i, b) in data.iter().enumerate() { self.cells.insert(addr + i, *b); } true } } const ORIG: [u8; 2] = [0x75, 0x0f]; const PATCH: [u8; 2] = [0x7f, 0x0f]; #[test] fn classify_recognises_all_three_states() { assert_eq!(classify(&ORIG, &ORIG, &PATCH), PatchState::Original); assert_eq!(classify(&PATCH, &ORIG, &PATCH), PatchState::AlreadyPatched); assert_eq!(classify(&[0x12, 0x34], &ORIG, &PATCH), PatchState::Mismatch); } #[test] fn original_bytes_are_applied_and_verified() { let mut m = FakeMem::with(0x1000, &ORIG); assert_eq!( apply_checked(&mut m, 0x1000, &ORIG, &PATCH), ApplyOutcome::Applied ); // Memory now holds the replacement. let mut got = [0u8; 2]; assert!(m.read(0x1000, &mut got)); assert_eq!(got, PATCH); } #[test] fn already_patched_is_idempotent_noop() { let mut m = FakeMem::with(0x2000, &PATCH); assert_eq!( apply_checked(&mut m, 0x2000, &ORIG, &PATCH), ApplyOutcome::AlreadyPatched ); } #[test] fn mismatch_never_writes() { let junk = [0xde, 0xad]; let mut m = FakeMem::with(0x3000, &junk); assert_eq!( apply_checked(&mut m, 0x3000, &ORIG, &PATCH), ApplyOutcome::Mismatch ); // Untouched. let mut got = [0u8; 2]; assert!(m.read(0x3000, &mut got)); assert_eq!(got, junk); } #[test] fn unreadable_module_waits_without_crashing() { let mut m = FakeMem::with(0x4000, &ORIG); m.readable = false; let out = apply_checked(&mut m, 0x4000, &ORIG, &PATCH); assert_eq!(out, ApplyOutcome::NotReadable); assert!(!out.is_patched()); } #[test] fn write_failure_is_reported_not_pretended() { let mut m = FakeMem::with(0x5000, &ORIG); m.writable = false; assert_eq!( apply_checked(&mut m, 0x5000, &ORIG, &PATCH), ApplyOutcome::WriteFailed ); } #[test] fn running_twice_does_not_corrupt() { let mut m = FakeMem::with(0x6000, &ORIG); assert_eq!( apply_checked(&mut m, 0x6000, &ORIG, &PATCH), ApplyOutcome::Applied ); // Second pass sees the replacement and is a no-op. assert_eq!( apply_checked(&mut m, 0x6000, &ORIG, &PATCH), ApplyOutcome::AlreadyPatched ); let mut got = [0u8; 2]; assert!(m.read(0x6000, &mut got)); assert_eq!(got, PATCH); } #[test] fn rva_and_live_addr_relocate_across_bases() { // GATE1 example: preferred 0x140000000, VA 0x146132548. assert_eq!(rva(0x1_4613_2548, 0x1_4000_0000), 0x613_2548); // Applied at the preferred base gives the static VA back. assert_eq!(live_addr(0x1_4000_0000, 0x613_2548), 0x1_4613_2548); // Applied at a relocated (ASLR) base tracks the base exactly. assert_eq!(live_addr(0x2_0000_0000, 0x613_2548), 0x2_0613_2548); } #[test] fn hex_is_lowercase_unseparated() { assert_eq!(hex(&[0x0f, 0x85, 0xde]), "0f85de"); } }