7dcf610b71
In-process, read-only probes and transport observation built while closing the online/FUT route from both the memory and network sides. - probe.rs / dial_notification.rs: menu-time ctx dump, connMgr enumerator, synthetic dial-notification + direct-call dial trigger, and the [element+0x40] container write-watchpoint. All env-gated, one-shot, VirtualQuery-guarded; none alter game state by default. - transport_watch.rs + connect/connectex/hooks/lib: M0 transport observation (grep-friendly TRANSPORT_WATCH logging on the existing getaddrinfo/connect/ WSAConnect/ConnectEx detours) and an IPv6 (v4-mapped) EA-redirect so the game's IPv6 :443 dials land on the bridge instead of the dead servers. Findings: the game never initiates a Blaze connection offline; the dial handler is registered by a self-registering, message-driven state machine whose container stays empty with no Blaze exchange. See openfut-bridge docs/closure-and-preservation.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
9.1 KiB
Rust
184 lines
9.1 KiB
Rust
//! 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);
|
|
}
|
|
}
|