Files
openfut-launcher/openfut-hook/src/dial_notification.rs
T
funman300 7dcf610b71 openfut-hook: RE instrumentation for the Blaze dial-gate investigation
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>
2026-07-03 15:58:17 -07:00

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);
}
}