//! Synthetic "notification" struct for the direct-call dial trigger. //! //! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler //! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing //! completion callback. It does NOT call the game, does NOT install any detour, //! and is NOT wired into the hook yet. The invocation phase (later) consumes //! `build_notification()` + `completion_stub`. //! //! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590): //! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1 //! [+0x80] qword : completion delegate fn pointer. => &completion_stub //! [+0x88] qword : delegate capture #1. => 0 //! [+0x90] qword : delegate capture #2. => 0 //! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0 //! everything else in [0x00..0x100] : 0 //! The RE confirmed no other offset in this range is read on the success path. //! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance //! against a read we might have missed. Any offset here is TODO/CONFIRM against the //! RE report; if the game contradicts it at runtime, stop and re-verify. // This module is deliberately unused for now (the invocation phase will call into // it). Silence "never used" warnings until then rather than sprinkle #[allow] on // each item. Remove this once the trigger wires the API up. #![allow(dead_code)] use core::sync::atomic::{AtomicU32, Ordering}; /// Size of the notification struct, in bytes. 0x100 = 256. const NOTIFICATION_SIZE: usize = 0x100; // --- field offsets (named so the code reads like the RE contract) ------------- const OFF_GATE: usize = 0x00; // byte, must be non-zero const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0) const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0) const OFF_KEY: usize = 0xa0; // dword, job key/priority (0) /// Counts how many times `completion_stub` has been entered. /// /// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to /// touch and, worse, gives *undefined behaviour* if two threads write it at once /// (a data race). The completion callback may be invoked from an arbitrary game /// thread, so a plain counter would race. `AtomicU32` makes increment a single /// lock-free hardware instruction with well-defined concurrent semantics, and it /// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the /// count value, not about ordering it against other memory. static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0); /// The completion callback the game may invoke when the RpcJob finishes. /// /// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft /// x64 calling convention — exactly how the game invokes the pointer (`call r10`, /// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the /// convention is what makes it safe for the game to call us. /// /// We declare four pointer-sized params and ignore them. The RE showed the delegate /// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the /// success-path completion may pass different values. Because Win64 is caller-clean /// and puts the first four integer args in registers, declaring four ignored args is /// safe no matter what the caller actually passes — we simply never read them. /// /// The body does the absolute minimum: bump the atomic counter and return 0. NO /// logging, NO allocation, NO calls — a completion callback can fire from any game /// context, and even a log write there could be unsafe. Observe from outside via /// `completion_stub_call_count()` instead. /// /// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks /// at the return value. (Returning void would be equally fine; 0 is a safe default.) pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize { // `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no // syscall, no allocation. Safe to call from any thread/context. COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed); 0 } /// Read how many times `completion_stub` has fired. For an outside observer thread — /// keeps all I/O out of the stub itself. pub fn completion_stub_call_count() -> u32 { COMPLETION_STUB_CALLS.load(Ordering::Relaxed) } /// Write a little-endian u64 into `buf` starting at `offset`. /// /// Endianness matters because we're hand-laying a memory image the game will read /// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant /// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in /// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the /// original `value`. Using the native byte order by hand (or `transmute`) would be /// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly. /// /// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the /// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is /// the standard, safe way to poke a fixed-width integer into a `[u8]`.) fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) { buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); } /// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as /// `write_u64_le`, 4 bytes wide.) fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) { buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } /// Build the fully-populated notification struct, ready to be passed by pointer to /// the dial handler as its `rdx` argument. /// /// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct: /// the layout is a precise *offset* contract recovered by RE, with meaningful data /// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every /// offset literally visible and immune to any field-ordering/padding surprise. A /// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier /// to get a padding byte wrong than to index a flat array. (For future reference: /// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]` /// zero-copy — worth knowing, but overkill here and an extra dependency.) pub fn build_notification() -> [u8; NOTIFICATION_SIZE] { // Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/ // +0x90, the key at +0xa0, and all padding); we only need to set the non-zero // fields below. let mut buf = [0u8; NOTIFICATION_SIZE]; // [+0x00] entry gate: must be non-zero to reach the dial path. buf[OFF_GATE] = 1; // [+0x80] completion delegate function pointer = &completion_stub. // // `completion_stub as *const ()`: a *function item* in Rust is a zero-sized, // unique type, not a value. Casting it to a raw pointer coerces it to a function // pointer and then to an untyped code pointer `*const ()` — i.e. the address of // the function's machine code. The intermediate `*const ()` before `as u64` is // the idiomatic form: it says "treat this as an address" and also avoids the // `clippy`/rustc "direct cast of function item into an integer" lint you'd get // from `completion_stub as u64`. let stub_addr = completion_stub as *const () as u64; write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr); // [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write // them explicitly so the layout intent is visible at a glance. write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0); write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0); // [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path). write_u32_le(&mut buf, OFF_KEY, 0); buf } #[cfg(test)] mod tests { use super::*; #[test] fn notification_layout() { let n = build_notification(); // Total size is exactly 0x100. assert_eq!(n.len(), NOTIFICATION_SIZE); // [+0x00] gate byte == 1. assert_eq!(n[0x00], 1); // [+0xa0..0xa4] as u32 == 0. // `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only // fail if the slice weren't length 4, which it is), and `from_le_bytes` // reads it back the same little-endian way we wrote it. let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap()); assert_eq!(key, 0); // [+0x80..0x88] as u64 == address of completion_stub. let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap()); assert_eq!(stub, completion_stub as *const () as u64); // [+0x88..0x90] and [+0x90..0x98] captures == 0. assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0); assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0); } #[test] fn stub_counter_increments() { let before = completion_stub_call_count(); let _ = completion_stub(0, 0, 0, 0); assert_eq!(completion_stub_call_count(), before + 1); } }