9 Commits

Author SHA1 Message Date
funman300 44ebc4b23c fix(hook): preserve WinSock connect errors 2026-08-21 00:16:49 +00:00
funman300 b098617573 feat(fifa17-hook): patch FIFA17 TLS gates in-process
Milestone B: move FIFA17 ProtoSSL certificate compatibility into version.dll so
the client-local contract is openfut.cfg + LSX + version.dll with no external
/proc-writing patcher. The proven external openfut-autopatch remains the oracle
and is NOT removed; this reaches behavioral parity for the fail-closed patches.

Patch set (ASLR-relocated at runtime; fail-closed byte-verified; one-shot):
- FIFA17.exe ProtoSSL cert gates (REQUIRED_FOR_TLS), preferred base 0x140000000:
    GATE1 rva 0x6132548  0f85 76010000 (JNZ) -> 90*6 (NOP)
    GATE2 rva 0x61361b0  48 89 5c (prologue) -> 31 c0 c3 (xor eax,eax; ret)
  Applied as a pair only when BOTH read their known original, exactly like the
  external patcher's cert_pass; polled until the STEAMPUNKS packer unpacks them.
- CardsDLL empty-My-Packs store crash-guard (REQUIRED_FOR_STORE_TLS, bug 6c),
  preferred base 0x180000000: rva 0x14858  75 0f (JNZ) -> 7f 0f (JG). Applied once
  CardsDLL maps (module-late).

Deliberately NOT ported: the external patcher's 8 unconditional STORE_PATCHES.
They carry no recovered original bytes (cannot be fail-closed) and are re-applied
every tick (would require the constant-rewrite loop this milestone forbids); the
external source records no rationale for them. Documented in the Vault ADR.

Architecture:
- patch_mem.rs: generic fail-closed primitive over a Mem trait — classify
  (ORIGINAL/ALREADY_PATCHED/MISMATCH), apply_checked (read->classify->write only on
  ORIGINAL->reread verify), VirtualQuery-guarded read + VirtualProtect/Flush write
  (WinMem). Trait abstraction makes every outcome host-testable without FIFA.
- fifa17_tls.rs: FIFA17-specific patch table + bounded poll worker (250ms, 15min
  cap, no busy-spin) started from fifa17::install() after the network redirect.
  Never patches an absolute address; never blind-writes on mismatch; a write/verify
  failure is reported, never pretended.

Phase 11: removed the season_trace CACHE_PACKNAMES_FAILED->SUCCESS force-success
bypass (a staging-only behavior-changer that was armed unconditionally in the
candidate); season_trace is now genuinely read-only passive tracing. sbc_dispatch
and store_entry remain the intended REPAIR_PROMOTED fixes.

Tests: 39 hook tests (26 baseline + 13 new: classify states, apply/idempotence,
no-blind-write on mismatch, unreadable-module wait, write-failure reporting, RVA/
live-addr relocation across bases, cert-gate pairing, patch-table integrity).
clippy --features fifa17 -D warnings clean; fmt clean; x86_64-pc-windows-gnu
cross-build. No network-config authority added (routing stays Milestone A).

Runtime validation (x64dbg site check + Windows/Linux retail) still outstanding.
2026-08-20 21:15:27 +00:00
funman300 00ad631034 refactor(launcher): retire FIFA 23; keep the hook game-generic by feature
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).
2026-08-20 20:56:52 +00:00
funman300 55ffbd8c7e style: rustfmt pre-existing wrap debt in season_trace/store_entry
Behavior-preserving cargo fmt of two FIFA17 diagnostic modules that carried
long unwrapped macro-invocation / argument lines predating this work. Split
out of the FIFA23-retirement commit to keep that focused. No logic change.
2026-08-20 20:56:37 +00:00
funman300 16f3452990 feat(fifa17-hook): redirect FIFA17 sockets from shared config
The fifa17 hook path installed no connection redirect (only a module-map dump),
so FIFA17 relied entirely on Linux iptables DNAT / hosts (and had no Windows
equivalent). Add an in-process, config-driven redirect for the fifa17 build:

- openfut-common gains ResolvedServer::redirect_for_ea_port(): the single shared
  decision (EA source-port signature -> configured OpenFUT host+port, network
  byte order), reused by the hook so it and openfut.cfg agree by construction.
  Covers 443->https, 10041/42230->blaze_redirector, 42127->blaze_main.
- connect_hook: redirect_if_ea now dispatches to a config-driven rewrite when
  armed (rewrites to the CONFIGURED, possibly remote, server -- not hardcoded
  127.0.0.1), matching by EA source port so a hardcoded EA IP (159.153.51.20
  redirector) and a DNS-resolved one both land on the server. Legacy loopback
  path retained only for the not-yet-retired FIFA23 build.
- fifa17::install() reads openfut.cfg next to FIFA17.exe via openfut-common and
  installs connect + WSAConnect (IAT) + ConnectEx (shared redirect). Fail-safe:
  missing/invalid config installs NO redirect (traffic untouched), never a
  corrupt sockaddr.

Tests: openfut-common redirect map/sockaddr/endian/remote-host/unknown-port.
Cross-builds x86_64-pc-windows-gnu --features fifa17; clippy -D warnings clean.
No Linux fallback removed (migration gate).
2026-08-20 20:38:10 +00:00
funman300 966e92b304 fix(launcher): run LSX locally on Windows (only autopatch is in-process)
The prior Windows branch treated BOTH companions as in-process and started
neither. That is wrong for LSX: FIFA dials the Origin/LSX emulator on
127.0.0.1:4216 and it must run locally on the client (the STEAMPUNKS
stp-origin_emu.dll is the crack's activation emu, not OpenFUT's LSX). Only
autopatch is genuinely in-process on Windows (its ProtoSSL cert patch is done by
the version.dll hook), so skip just that one and spawn LSX through the normal
path. Also resolve the companion as openfut-lsx.exe on Windows.
2026-08-20 19:57:16 +00:00
funman300 cf515f5584 fix(launcher): continuous vsync-paced present for stable VRR
The 60fps cap still left 16ms gaps with no present; on windowed G-Sync/FreeSync
DWM keeps moving the window in and out of the VRR path across those gaps and the
refresh rate swings, which the panel shows as flicker. Render continuously
(request_repaint every frame) with vsync on so the window stays continuously in
VRR at the display's own variable refresh.
2026-08-20 19:38:42 +00:00
funman300 6be75f5452 fix(launcher): steady 60fps cadence to stop VRR/G-Sync flicker
The idle repaint was 500ms (~2fps), below the G-Sync/FreeSync VRR floor, so the
panel ran low-framerate compensation and every hover/animation spiked then
dropped the rate — the swinging refresh rate makes VRR displays flicker. Present
at a constant ~60fps (16ms) instead so VRR locks to one rate. Cheap for a UI
this small; vsync keeps present times regular.
2026-08-20 19:35:17 +00:00
funman300 057cf92c3b feat(launcher): native Windows support
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:

- game_launch: cfg(windows) launch spawns the game executable directly with its
  working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
  Wine prefix, or licence regen). Requires the launcher to run elevated so the
  child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
  step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
  version.dll hook), so ensure_running reports ready without spawning. Gated the
  unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.

theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.

Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
2026-08-20 19:21:01 +00:00
36 changed files with 1395 additions and 4013 deletions
+89
View File
@@ -115,6 +115,41 @@ pub struct ResolvedServer {
pub ports: OpenFutPorts,
}
/// Where one matched EA connection is rewritten to, in the exact WinSock
/// on-the-wire representation the socket hooks need. Produced by
/// [`ResolvedServer::redirect_for_ea_port`] so the hook and the launcher's
/// `openfut.cfg` share one decision by construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Redirect {
/// Rewritten IPv4 for `sockaddr_in.sin_addr` (network byte order in memory).
pub addr_nbo: u32,
/// Rewritten port for `sin_port` / `sin6_port` (network byte order).
pub port_nbo: u16,
/// The resolved server IPv4, for callers building an IPv6 v4-mapped address.
pub redirect_ip: Ipv4Addr,
}
impl ResolvedServer {
/// Decide the redirect for an outbound EA connection whose destination port
/// is `ea_port_nbo` (network byte order, as read straight from the sockaddr).
///
/// Returns `None` when the port is not a recognised OpenFUT route — the hook
/// then leaves the connection untouched. The original destination IP is
/// intentionally ignored: matching is by the fixed EA source-port signature
/// ([`ea_ports`]), so a hardcoded EA IP (e.g. FIFA17's `159.153.51.20`
/// redirector) and a DNS-resolved one are treated identically and both land
/// on the configured server — no `/etc/hosts`, DNAT, or portproxy required.
pub fn redirect_for_ea_port(&self, ea_port_nbo: u16) -> Option<Redirect> {
let ea_port = u16::from_be(ea_port_nbo);
let dest_port = self.ports.map_source_port(ea_port)?;
Some(Redirect {
addr_nbo: sin_addr_from_ipv4(self.redirect_ip),
port_nbo: sin_port_nbo(dest_port),
redirect_ip: self.redirect_ip,
})
}
}
/// Errors loading/validating OpenFUT server configuration. Every one of these
/// must BLOCK operation — none of them may fall back to loopback.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -476,4 +511,58 @@ mod tests {
};
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
}
#[test]
fn redirect_maps_every_fifa17_route_to_configured_server() {
// The canonical staging cfg. Ports come from the file, not constants.
let resolved = ServerConfig::parse(
"host=10.10.0.120\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
)
.unwrap()
.resolve()
.unwrap();
let server = Ipv4Addr::new(10, 10, 0, 120);
// (EA source port [host order], expected OpenFUT dest port)
for (ea, dest) in [
(443u16, 8443u16),
(10041, 42127),
(42230, 42127),
(42127, 42130),
] {
let r = resolved
.redirect_for_ea_port(ea.to_be())
.unwrap_or_else(|| panic!("EA port {ea} should be a route"));
assert_eq!(u16::from_be(r.port_nbo), dest, "EA {ea} -> dest");
assert_eq!(r.redirect_ip, server, "EA {ea} -> server ip");
assert_eq!(
r.addr_nbo,
sin_addr_from_ipv4(server),
"EA {ea} -> sin_addr"
);
}
}
#[test]
fn redirect_leaves_unknown_ports_untouched() {
let resolved = ServerConfig::parse("host=10.10.0.120\n")
.unwrap()
.resolve()
.unwrap();
assert!(resolved.redirect_for_ea_port(8080u16.to_be()).is_none());
assert!(resolved.redirect_for_ea_port(22u16.to_be()).is_none());
assert!(resolved.redirect_for_ea_port(443u16.to_be()).is_some());
}
#[test]
fn redirect_targets_configured_remote_host_not_loopback() {
let resolved = ServerConfig::parse("host=10.10.0.120\n")
.unwrap()
.resolve()
.unwrap();
// FIFA17 redirector (hardcoded EA IP 159.153.51.20:42230) must be rewritten
// to the configured REMOTE server, never 127.0.0.1.
let r = resolved.redirect_for_ea_port(42230u16.to_be()).unwrap();
assert_eq!(r.redirect_ip, Ipv4Addr::new(10, 10, 0, 120));
assert_ne!(r.redirect_ip, Ipv4Addr::LOCALHOST);
}
}
+5
View File
@@ -2,10 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "openfut-common"
version = "0.1.0"
[[package]]
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"openfut-common",
"windows-sys",
]
+8 -11
View File
@@ -13,17 +13,10 @@ edition = "2021"
crate-type = ["cdylib"]
[features]
# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect,
# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real
# responses). Default build keeps the redirect (LSX → our bridge).
capture_baseline = []
# Build with `--features probe` to install passive logging detours on FIFA's
# in-process online-flow functions (GoOnline, GetInternetConnectedState, event
# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs.
probe = []
# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY
# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map,
# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking.
# Per-game selection: each supported game is a feature enabling its module. Exactly
# one MUST be set (the crate emits a compile_error otherwise). Build the deployed
# artifact with `--features fifa17`. Add a future game as a new feature here plus a
# `mod <game>;` + dispatch arm in lib.rs — never by copying a retired game's code.
fifa17 = []
[dependencies]
@@ -39,6 +32,10 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] }
# Single source of truth for the OpenFUT redirect config (openfut.cfg schema,
# EA-port -> OpenFUT-port map, WinSock byte-order helpers). Shared with the
# launcher so the hook and openfut.cfg agree by construction.
openfut-common = { path = "../openfut-common" }
[profile.release]
opt-level = "s"
+1 -1
View File
@@ -12,6 +12,6 @@ fn main() {
{
let definition =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
println!("cargo:rustc-link-arg={}", definition.display());
println!("cargo:rustc-cdylib-link-arg={}", definition.display());
}
}
-32
View File
@@ -1,32 +0,0 @@
/// Reads openfut.cfg from the same directory as this DLL.
///
/// The file contains a single line: the IP the hook should redirect EA
/// hostnames to, e.g. "192.168.1.10" or "127.0.0.1".
/// Falls back to 127.0.0.1 if the file is missing or unreadable.
use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameA;
pub fn read_redirect_ip(module: windows_sys::Win32::Foundation::HMODULE) -> String {
if let Some(cfg_path) = config_path(module) {
if let Ok(content) = std::fs::read_to_string(&cfg_path) {
let ip = content.trim().to_string();
if !ip.is_empty() {
return ip;
}
}
}
"127.0.0.1".to_string()
}
fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::path::PathBuf> {
let mut buf = vec![0u8; 512];
let len = unsafe { GetModuleFileNameA(module, buf.as_mut_ptr(), buf.len() as u32) };
if len == 0 {
return None;
}
let path = std::ffi::CStr::from_bytes_until_nul(&buf[..len as usize + 1])
.ok()?
.to_str()
.ok()?;
let dll_path = std::path::Path::new(path);
Some(dll_path.parent()?.join("openfut.cfg"))
}
+102 -82
View File
@@ -5,20 +5,6 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
const AF_INET: u16 = 2;
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
// EA App LSX. anadius handles :3216 in-process before it reaches the host TCP
// stack (keyed on port 3216 specifically), so redirecting FIFA's LSX connect to a
// *different* host port (:3217) slips past that interception and lands on the
// native openfut-bridge LSX server. This is the load-bearing redirect that routes
// LSX to our bridge; without it FIFA uses anadius's in-process emu instead.
#[allow(dead_code)] // unused when built with the `capture_baseline` feature
const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX)
#[allow(dead_code)]
const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target)
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
#[repr(C)]
struct SockaddrIn {
@@ -41,19 +27,37 @@ struct SockaddrIn6 {
sin6_scope_id: u32,
}
/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to
/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's
/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own
/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not
/// `IPV6_V6ONLY` and will accept this target.
const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1];
// Address of ws2_32!connect (set at hook installation)
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
// Original 14 bytes saved before we overwrite them
static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
/// Restores the real WinSock call's thread-local last error after detour repair,
/// logging, and other instrumentation have run. Callers inspect this value after
/// `SOCKET_ERROR`; leaking a logger/VirtualProtect error changes connect semantics.
struct WsaLastErrorGuard(i32);
impl WsaLastErrorGuard {
unsafe fn capture() -> Self {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
Self(WSAGetLastError())
}
fn value(&self) -> i32 {
self.0
}
}
impl Drop for WsaLastErrorGuard {
fn drop(&mut self) {
unsafe {
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
WSASetLastError(self.0);
}
}
}
// For WSAConnect IAT fallback
type WsaConnectFn = unsafe extern "system" fn(
s: usize,
@@ -89,82 +93,82 @@ unsafe fn restore_original(target: *mut u8) {
VirtualProtect(target as _, 14, old, &mut old);
}
/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the
/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old
/// IPv4-only path let those slip straight past us to the real (dead) servers.
///
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
/// ConnectEx path can share this one implementation.
/// The armed redirect target, resolved once from `openfut.cfg` via `openfut-common`.
/// When set, `redirect_if_ea` rewrites matched EA connections to this configured
/// server; when unset, matched connections are left untouched (no redirect).
static REDIRECT: OnceLock<openfut_common::ResolvedServer> = OnceLock::new();
/// Arm the config-driven redirect (FIFA17). Idempotent: the first call wins.
pub fn set_redirect(server: openfut_common::ResolvedServer) {
let _ = REDIRECT.set(server);
}
/// If `name` is a matched EA connect target, return a rewritten sockaddr pointing
/// at the configured OpenFUT server (plus its meaningful byte length: 16 for v4,
/// 28 for v6). The target is armed once from `openfut.cfg` via `set_redirect`;
/// when unset — or when the port is not a known EA route — the connection is left
/// untouched. Shared by the connect / WSAConnect / ConnectEx detours.
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
if namelen < 8 || name.is_null() {
return None;
}
// The first u16 of any sockaddr is the address family.
redirect_configured(REDIRECT.get()?, name, namelen)
}
/// FIFA17 config-driven rewrite. Destination host+port come from `openfut.cfg`
/// through `openfut-common`, so the hook and the launcher agree by construction.
/// Matching is by EA source-port signature only (see `openfut_common::ea_ports`),
/// so a hardcoded EA IP (e.g. the `159.153.51.20:42230` redirector) and a
/// DNS-resolved one both land on the configured — possibly remote — server. An
/// unrecognised port returns `None` (connection left untouched). Never corrupts
/// the sockaddr: it only writes into a fresh 28-byte buffer.
unsafe fn redirect_configured(
server: &openfut_common::ResolvedServer,
name: *const u8,
namelen: i32,
) -> Option<([u8; 28], i32)> {
let family = *(name as *const u16);
let mut buf = [0u8; 28];
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
let sa = &*(name as *const SockaddrIn);
let new_port_nbo = match sa.sin_port {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
#[cfg(not(feature = "capture_baseline"))]
PORT_LSX_NBO => PORT_LSX_TARGET_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
// sin_addr is network order; to_le_bytes gives memory order = the dotted
// quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed).
let o = sa.sin_addr.to_le_bytes();
let redir = server.redirect_for_ea_port(sa.sin_port)?;
crate::write_log(&format!(
"connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
o[0],
o[1],
o[2],
o[3],
"connect_hook: v4 :{}{}:{}\n",
u16::from_be(sa.sin_port),
u16::from_be(new_port_nbo)
redir.redirect_ip,
u16::from_be(redir.port_nbo)
));
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = ADDR_LOOPBACK_NBO;
out.sin_port = redir.port_nbo;
out.sin_addr = redir.addr_nbo;
Some((buf, 16))
}
AF_INET6 => {
if namelen < 28 {
return None;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa6 = &*(name as *const SockaddrIn6);
// LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here.
let new_port_nbo = match sa6.sin6_port {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None,
};
let a = sa6.sin6_addr;
let redir = server.redirect_for_ea_port(sa6.sin6_port)?;
// ::ffff:<redirect_ip> — a v4-mapped v6 target so a v6 socket sends
// real IPv4 packets to the configured server.
let o = redir.redirect_ip.octets();
let mut v4mapped = [0u8; 16];
v4mapped[10] = 0xff;
v4mapped[11] = 0xff;
v4mapped[12..16].copy_from_slice(&o);
crate::write_log(&format!(
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
a[0],
a[1],
a[14],
a[15],
"connect_hook: v6 :{} → ::ffff:{}:{}\n",
u16::from_be(sa6.sin6_port),
u16::from_be(new_port_nbo)
redir.redirect_ip,
u16::from_be(redir.port_nbo)
));
// SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6).
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
out.sin6_family = AF_INET6;
out.sin6_port = new_port_nbo;
out.sin6_port = redir.port_nbo;
out.sin6_flowinfo = 0;
out.sin6_addr = V4MAPPED_LOOPBACK;
out.sin6_addr = v4mapped;
out.sin6_scope_id = 0;
Some((buf, 28))
}
@@ -175,9 +179,6 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 {
let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8;
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("connect", name, namelen, s);
// Log every call so we can confirm the hook fires at all
if namelen >= 8 {
let sa = &*(name as *const SockaddrIn);
@@ -215,6 +216,8 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
core::mem::transmute(addr);
f(s, buf.as_ptr(), len)
};
// Named binding held until `return r`: its Drop restores the WSA error after `write_hook`.
let _last_error = WsaLastErrorGuard::capture();
write_hook(addr, hooked_connect as *const () as u64);
return r;
} else {
@@ -226,17 +229,15 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
f(s, call_name, call_len)
};
let last_error = WsaLastErrorGuard::capture();
write_hook(addr, hooked_connect as *const () as u64);
if namelen >= 8 {
let sa = &*(call_name as *const SockaddrIn);
if sa.sin_family == AF_INET {
let err = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else {
0
};
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
let logged_error = if r != 0 { last_error.value() } else { 0 };
crate::write_log(&format!(
"connect_hook: result={r} wsa_err={logged_error}\n"
));
}
}
r
@@ -251,8 +252,6 @@ pub unsafe extern "system" fn hooked_wsa_connect(
sqos: *const (),
gqos: *const (),
) -> i32 {
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("WSAConnect", name, namelen, s);
let real = REAL_WSA.get().copied().unwrap();
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
@@ -286,3 +285,24 @@ pub unsafe fn install_inline_connect_hook() -> bool {
write_hook(connect_fn, hooked_connect as *const () as u64);
true
}
#[cfg(test)]
mod tests {
use super::WsaLastErrorGuard;
use windows_sys::Win32::Networking::WinSock::{
WSAGetLastError, WSASetLastError, WSAEWOULDBLOCK,
};
#[test]
fn restores_winsock_last_error_after_instrumentation() {
unsafe {
WSASetLastError(WSAEWOULDBLOCK);
{
let guard = WsaLastErrorGuard::capture();
assert_eq!(guard.value(), WSAEWOULDBLOCK);
WSASetLastError(0);
}
assert_eq!(WSAGetLastError(), WSAEWOULDBLOCK);
}
}
}
-4
View File
@@ -77,10 +77,6 @@ unsafe extern "system" fn hooked_connectex(
overlapped: *mut c_void,
) -> i32 {
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
// dials get the same IPv6 handling as plain connect().
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
-183
View File
@@ -1,183 +0,0 @@
//! 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);
}
}
-179
View File
@@ -1,179 +0,0 @@
/// In-process LSX server (port 3216 / EA App Local Services Exchange).
///
/// Runs in a background thread inside FIFA's process so Wine's wineserver
/// routes FIFA's connect() directly here without needing any external process.
///
/// Protocol: server speaks first (sends XML greeting with challenge key),
/// then both sides do an AES-128-ECB challenge/response handshake, then
/// all subsequent messages are AES-128-ECB encrypted.
use windows_sys::Win32::Networking::WinSock::{
WSAStartup, WSACleanup, socket, bind, listen, accept, recv, send,
closesocket, setsockopt,
WSADATA, SOCKADDR, SOCKET, SOCKET_ERROR, INVALID_SOCKET,
AF_INET, SOCK_STREAM, IPPROTO_TCP, SOMAXCONN,
SO_REUSEADDR, SOL_SOCKET,
};
const PORT: u16 = 3216;
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
fn server_loop() {
unsafe {
let mut wsa = core::mem::zeroed::<WSADATA>();
if WSAStartup(0x0202, &mut wsa) != 0 {
crate::write_log("ea_stub: WSAStartup failed\n");
return;
}
let srv = socket(AF_INET as i32, SOCK_STREAM, IPPROTO_TCP as i32);
if srv == INVALID_SOCKET {
crate::write_log("ea_stub: socket() failed\n");
WSACleanup();
return;
}
let yes: i32 = 1;
setsockopt(srv, SOL_SOCKET as i32, SO_REUSEADDR, &yes as *const i32 as *const u8, 4);
// sockaddr_in: sin_family(u16-LE) + sin_port(u16-BE) + sin_addr(u32) + padding
let mut addr = [0u8; 16];
let family = AF_INET as u16;
addr[0] = (family & 0xFF) as u8;
addr[1] = (family >> 8) as u8;
addr[2] = (PORT >> 8) as u8;
addr[3] = (PORT & 0xFF) as u8;
if bind(srv, addr.as_ptr() as *const SOCKADDR, addr.len() as i32) == SOCKET_ERROR {
crate::write_log("ea_stub: bind() failed — port 3216 in use\n");
closesocket(srv);
WSACleanup();
return;
}
listen(srv, SOMAXCONN as i32);
crate::write_log("ea_stub: listening on port 3216\n");
loop {
crate::write_log("ea_stub: calling accept...\n");
let client = accept(srv, core::ptr::null_mut(), core::ptr::null_mut());
if client == INVALID_SOCKET {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
let e = WSAGetLastError();
crate::write_log(&format!("ea_stub: accept FAILED wsa_err={e}\n"));
break;
}
crate::write_log("ea_stub: connection accepted\n");
handle_lsx(client);
}
closesocket(srv);
WSACleanup();
}
}
unsafe fn lsx_send(sock: SOCKET, msg: &str) -> bool {
// LSX messages are null-terminated
let mut buf = msg.as_bytes().to_vec();
buf.push(0);
let n = send(sock, buf.as_ptr(), buf.len() as i32, 0);
if n == SOCKET_ERROR {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
let e = WSAGetLastError();
crate::write_log(&format!("ea_stub: send FAILED wsa_err={e}\n"));
false
} else {
crate::write_log(&format!("ea_stub: sent {n} bytes\n"));
true
}
}
unsafe fn lsx_recv(sock: SOCKET) -> Option<String> {
let mut buf = vec![0u8; 8192];
let n = recv(sock, buf.as_mut_ptr(), buf.len() as i32, 0);
if n <= 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
let e = WSAGetLastError();
crate::write_log(&format!("ea_stub: recv returned {n} wsa_err={e}\n"));
return None;
}
let text = String::from_utf8_lossy(&buf[..n as usize])
.trim_matches('\0')
.to_string();
crate::write_log(&format!("ea_stub: recv {n} bytes: {}\n", &text[..text.len().min(300)]));
Some(text)
}
unsafe fn handle_lsx(sock: SOCKET) {
// ── 1. Send greeting (server speaks first) ────────────────────────────
let greeting = format!(
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>"
);
crate::write_log("ea_stub: sending LSX greeting\n");
if !lsx_send(sock, &greeting) {
closesocket(sock);
return;
}
// ── 2. Receive FIFA's ChallengeResponse ───────────────────────────────
let challenge_xml = match lsx_recv(sock) {
Some(s) => s,
None => { closesocket(sock); return; }
};
// Parse: split on '"' — EAappEmulater style
// <Request id="N" ...><ChallengeResponse ... response="HEX" key="HEX">
let parts: Vec<&str> = challenge_xml.split('"').collect();
let id = parts.get(3).copied().unwrap_or("1");
let key = parts.get(7).copied().unwrap_or("");
crate::write_log(&format!("ea_stub: challenge id={id} key={key}\n"));
let our_response = crate::lsx::make_challenge_response(key);
let seed = compute_seed(&our_response);
crate::write_log(&format!("ea_stub: our_response={our_response} seed={seed}\n"));
// ── 3. Send ChallengeAccepted ─────────────────────────────────────────
let accepted = format!(
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>"
);
crate::write_log("ea_stub: sending ChallengeAccepted\n");
if !lsx_send(sock, &accepted) {
closesocket(sock);
return;
}
// ── 4. Session loop ───────────────────────────────────────────────────
loop {
let encrypted = match lsx_recv(sock) {
Some(s) => s,
None => break,
};
if encrypted.trim().is_empty() { continue; }
let request = crate::lsx::lsx_decrypt(&encrypted, seed);
crate::write_log(&format!("ea_stub: request: {}\n", &request[..request.len().min(300)]));
if request.trim().is_empty() {
crate::write_log("ea_stub: empty decrypted request — skipping\n");
continue;
}
let response_xml = crate::lsx::dispatch(request.trim());
crate::write_log(&format!("ea_stub: response: {}\n", &response_xml[..response_xml.len().min(300)]));
let encrypted_resp = crate::lsx::lsx_encrypt(&response_xml, seed);
if !lsx_send(sock, &encrypted_resp) { break; }
}
closesocket(sock);
crate::write_log("ea_stub: client disconnected\n");
}
fn compute_seed(hex: &str) -> u16 {
let b0 = u8::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0);
let b1 = u8::from_str_radix(&hex[2..4.min(hex.len())], 16).unwrap_or(0);
((b0 as u16) << 8) | (b1 as u16)
}
pub fn start() {
std::thread::spawn(server_loop);
}
+80 -9
View File
@@ -1,14 +1,12 @@
//! FIFA 17 injection path (feature = "fifa17").
//!
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
//! no-op and at worst crash. For now this proves the version.dll hijack actually
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
//!
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
//! This is the game module selected by the `fifa17` feature: `install()` spawns a
//! worker (off the loader lock) that dumps the module map, arms the config-driven
//! network redirect (connect / WSAConnect / ConnectEx, target from `openfut.cfg`
//! via `openfut-common`), and installs the FIFA-17 SBC dispatch repair plus the
//! store/season hooks. Structures and RVAs here are specific to FIFA17.exe /
//! CardsDLL_Win64_retail.dll; a future game gets its own module, never a copy of
//! this one.
use crate::write_log;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
@@ -67,6 +65,29 @@ unsafe fn dump_modules() {
CloseHandle(snap);
}
/// Read `openfut.cfg` from the game directory (next to `FIFA17.exe`) and resolve
/// the OpenFUT server via the shared `openfut-common` parser. Returns `None`
/// with a diagnostic when the file is absent or unusable, so the hook fails
/// safe — no redirect installed rather than a corrupt one.
fn load_server() -> Option<openfut_common::ResolvedServer> {
let dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
let path = dir.join("openfut.cfg");
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
write_log(&format!("fifa17: cannot read {}: {e}\n", path.display()));
return None;
}
};
match openfut_common::ServerConfig::parse(&contents).and_then(|c| c.resolve()) {
Ok(server) => Some(server),
Err(e) => {
write_log(&format!("fifa17: openfut.cfg unusable: {e}\n"));
None
}
}
}
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
/// other loader-touching calls are unsafe under the loader lock, so we defer them
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
@@ -79,6 +100,56 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
));
dump_modules();
write_log("fifa17: worker complete (injection healthy)\n");
// ── FIFA17 in-process network redirect (Milestone A) ──────────────────────
// Route EA endpoints to the configured OpenFUT server from openfut.cfg
// (openfut-common is the single source of truth). No hosts/iptables/portproxy.
match load_server() {
Some(server) => {
write_log(&format!(
"fifa17: redirect armed → {} https={} redirector={} main={}\n",
server.redirect_ip,
server.ports.https,
server.ports.blaze_redirector,
server.ports.blaze_main
));
crate::connect_hook::set_redirect(server);
if crate::connect_hook::install_inline_connect_hook() {
write_log("fifa17: connect inline-hooked\n");
} else {
write_log("fifa17: connect hook FAILED\n");
}
let wp = crate::iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
if !wp.is_null() {
let f: unsafe extern "system" fn(
usize,
*const u8,
i32,
*const (),
*const (),
*const (),
*const (),
) -> i32 = core::mem::transmute(wp);
crate::connect_hook::set_real_wsa_connect(f);
crate::iat::patch_iat(wp, crate::connect_hook::hooked_wsa_connect as *const ());
write_log("fifa17: WSAConnect IAT patched\n");
}
if crate::connectex_hook::install_wsaioctl_hook() {
write_log("fifa17: ConnectEx (WSAIoctl) hooked\n");
} else {
write_log("fifa17: ConnectEx hook FAILED\n");
}
}
None => write_log(
"fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n",
),
}
// FIFA17 TLS/certificate + store crash-guard compatibility (Milestone B).
// Spawns its own bounded polling worker: patches the FIFA17.exe ProtoSSL cert
// gates once the packer unpacks them, then the CardsDLL store guard once UT
// loads it. Fail-closed and one-shot; replaces the external openfut-autopatch.
crate::fifa17_tls::install();
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
// itself from the build; its safety is the runtime signature/evidence gate. The
// remaining legacy experiment modules stay inert unless their env gate is `1`.
+334
View File
@@ -0,0 +1,334 @@
//! FIFA 17 in-process TLS/certificate + store crash-guard compatibility.
//!
//! Ports the *proven* subset of the external `openfut-autopatch` patch set into
//! `version.dll`, so the client-local contract no longer needs an external
//! `/proc`-writing patcher. Two concerns, both fail-closed and one-shot:
//!
//! 1. ProtoSSL certificate gates in FIFA17.exe (REQUIRED_FOR_TLS) — let the
//! TLS handshake against the OpenFUT bridge cert succeed. Present only after
//! the STEAMPUNKS packer maps/decrypts the real code, so they are polled for.
//! 2. The empty-"My Packs" store resolver crash-guard in CardsDLL
//! (REQUIRED_FOR_STORE_TLS, bug 6c) — CardsDLL loads lazily on entering UT,
//! so it is applied once the module appears.
//!
//! Deliberately NOT ported: the eight unconditional `STORE_PATCHES` from the
//! external patcher. They carry no recovered original bytes (cannot be
//! fail-closed) and are re-applied every tick (would require the very
//! constant-rewrite loop this milestone forbids); the external patcher's own
//! source records no rationale for them. See the Vault ADR.
//!
//! Every address is ASLR-relocated from its preferred image base at runtime
//! (`live = module_base + (static_va - preferred_base)`); nothing patches an
//! absolute address. Every write goes through [`crate::patch_mem`]'s fail-closed
//! primitive: original → write+verify, already-patched → no-op, anything else →
//! logged and skipped.
use crate::patch_mem::{self, ApplyOutcome, Mem, PatchState, WinMem};
use crate::write_log;
use std::time::{Duration, Instant};
/// FIFA17.exe preferred image base (confirmed: futmem reports the client mapped
/// flat at this base; Wine honours it, native Windows ASLR may not — hence the
/// runtime-base + RVA model below).
const FIFA17_PREFERRED_BASE: u64 = 0x1_4000_0000;
/// CardsDLL_Win64_retail.dll preferred image base.
const CARDS_PREFERRED_BASE: u64 = 0x1_8000_0000;
/// Which module a site lives in.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Module {
Fifa17Exe,
CardsDll,
}
impl Module {
const fn preferred_base(self) -> u64 {
match self {
Module::Fifa17Exe => FIFA17_PREFERRED_BASE,
Module::CardsDll => CARDS_PREFERRED_BASE,
}
}
/// Runtime base of the loaded module, or `None` if not mapped yet. FIFA17.exe
/// is the main image (null name); CardsDLL is resolved by its retail name.
unsafe fn runtime_base(self) -> Option<usize> {
match self {
Module::Fifa17Exe => patch_mem::module_base(core::ptr::null()),
Module::CardsDll => {
patch_mem::module_base(c"CardsDLL_Win64_retail.dll".as_ptr().cast())
.or_else(|| patch_mem::module_base(c"CardsDLL.dll".as_ptr().cast()))
}
}
}
}
/// One fail-closed byte patch, expressed as a static VA in its module's preferred
/// image so the derivation `RVA = VA - preferred_base` is auditable.
struct Site {
module: Module,
static_va: u64,
orig: &'static [u8],
patch: &'static [u8],
label: &'static str,
}
impl Site {
const fn rva(&self) -> u64 {
patch_mem::rva(self.static_va, self.module.preferred_base())
}
fn live_addr(&self, base: usize) -> usize {
patch_mem::live_addr(base, self.rva())
}
}
// ── ProtoSSL certificate gates (FIFA17.exe) — REQUIRED_FOR_TLS ──────────────────
// GATE1: JNZ rel32 -> 6×NOP (fall through the cert-verify failure branch).
// GATE2: function prologue -> `xor eax,eax; ret` (cert-verify returns 0/false).
// Applied as a pair, exactly like the external patcher: written only when BOTH
// read their known original, treated as done when BOTH already hold the patch.
const GATE1: Site = Site {
module: Module::Fifa17Exe,
static_va: 0x1_4613_2548,
orig: &[0x0f, 0x85, 0x76, 0x01, 0x00, 0x00],
patch: &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90],
label: "GATE1",
};
const GATE2: Site = Site {
module: Module::Fifa17Exe,
static_va: 0x1_4613_61b0,
orig: &[0x48, 0x89, 0x5c],
patch: &[0x31, 0xc0, 0xc3],
label: "GATE2",
};
// ── Empty "My Packs" store resolver crash-guard (CardsDLL) — REQUIRED_FOR_STORE_TLS
// JNZ 0x14869 (75 0f) -> JG 0x14869 (7f 0f): routes zero/negative store category
// ids through the Browse path instead of a NULL deref. Fail-closed one-shot.
const STORE_GUARD: Site = Site {
module: Module::CardsDll,
static_va: 0x1_8001_4858,
orig: &[0x75, 0x0f],
patch: &[0x7f, 0x0f],
label: "empty-mypacks-store-guard",
};
/// Poll cadence while waiting for the packer to unpack / CardsDLL to load. Low
/// frequency: the thread sleeps between ticks, so idle CPU is negligible.
const POLL: Duration = Duration::from_millis(250);
/// Upper bound on the whole worker's lifetime so it can never spin forever if the
/// user never enters Ultimate Team (CardsDLL never loads).
const MAX_WAIT: Duration = Duration::from_secs(15 * 60);
/// Decision for the FIFA17.exe cert-gate pair.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CertAction {
/// Not both readable yet, or a mixed/unrecognised state — keep polling.
Wait,
/// Both gates hold their known original — safe to apply the pair.
Apply,
/// Both gates already hold the patch — nothing to do.
Done,
}
/// Pure pairing rule (unit-tested): only act when both gates agree.
fn cert_action(g1: Option<PatchState>, g2: Option<PatchState>) -> CertAction {
match (g1, g2) {
(Some(PatchState::AlreadyPatched), Some(PatchState::AlreadyPatched)) => CertAction::Done,
(Some(PatchState::Original), Some(PatchState::Original)) => CertAction::Apply,
_ => CertAction::Wait,
}
}
/// Arm the FIFA17 TLS/store compatibility patcher: spawns a bounded background
/// worker so it never touches the loader lock and never blocks `install()`.
pub fn install() {
std::thread::spawn(|| unsafe { worker() });
}
unsafe fn worker() {
write_log("fifa17_tls: patch worker start\n");
let mut mem = WinMem;
let start = Instant::now();
let mut cert_done = false;
let mut guard_done = false;
// Throttle the "still waiting" diagnostics to one line each.
let mut logged_cert_wait = false;
let mut logged_guard_wait = false;
loop {
if !cert_done {
cert_done = try_cert_gates(&mut mem, &mut logged_cert_wait);
}
if !guard_done {
match Module::CardsDll.runtime_base() {
Some(cbase) => guard_done = try_store_guard(&mut mem, cbase),
None => {
if !logged_guard_wait {
write_log("fifa17_tls: waiting for CardsDLL (enter Ultimate Team)\n");
logged_guard_wait = true;
}
}
}
}
if cert_done && guard_done {
write_log("fifa17_tls: TLS patch set complete\n");
return;
}
if start.elapsed() >= MAX_WAIT {
write_log(&format!(
"fifa17_tls: worker stop (timeout {MAX_WAIT:?}); cert_gates_done={cert_done} store_guard_done={guard_done}\n"
));
return;
}
std::thread::sleep(POLL);
}
}
/// Apply the FIFA17.exe cert-gate pair. Returns `true` once the pair is settled
/// (applied or already patched); `false` while still unpacking / not both ready.
unsafe fn try_cert_gates(mem: &mut WinMem, logged_wait: &mut bool) -> bool {
let base = match Module::Fifa17Exe.runtime_base() {
Some(b) => b,
None => return false,
};
let g1_addr = GATE1.live_addr(base);
let g2_addr = GATE2.live_addr(base);
let g1 = patch_mem::read_state(mem, g1_addr, GATE1.orig, GATE1.patch);
let g2 = patch_mem::read_state(mem, g2_addr, GATE2.orig, GATE2.patch);
match cert_action(g1, g2) {
CertAction::Done => {
write_log("fifa17_tls: cert gates already patched\n");
true
}
CertAction::Apply => {
let o1 = patch_mem::apply_checked(mem, g1_addr, GATE1.orig, GATE1.patch);
let o2 = patch_mem::apply_checked(mem, g2_addr, GATE2.orig, GATE2.patch);
if o1.is_patched() && o2.is_patched() {
write_log(&format!(
"fifa17_tls: PATCHED cert gates ({} @ {g1_addr:#x} {o1:?}; {} @ {g2_addr:#x} {o2:?})\n",
GATE1.label, GATE2.label
));
true
} else {
write_log(&format!(
"fifa17_tls: cert gate write FAILED ({} {o1:?}; {} {o2:?}) — TLS NOT installed\n",
GATE1.label, GATE2.label
));
// Terminal: a write/verify failure will not fix itself by retrying.
true
}
}
CertAction::Wait => {
if !*logged_wait {
write_log(&format!(
"fifa17_tls: cert gates not ready (still unpacking?) {}={g1:?} {}={g2:?}\n",
GATE1.label, GATE2.label
));
*logged_wait = true;
}
false
}
}
}
/// Apply the CardsDLL store crash-guard once CardsDLL is mapped. Returns `true`
/// once the site is settled (its bytes are final the moment CardsDLL is loaded,
/// so any read outcome is a terminal decision — no further polling).
unsafe fn try_store_guard(mem: &mut WinMem, cbase: usize) -> bool {
let addr = STORE_GUARD.live_addr(cbase);
let outcome = patch_mem::apply_checked(mem, addr, STORE_GUARD.orig, STORE_GUARD.patch);
match outcome {
ApplyOutcome::NotReadable => false, // CardsDLL mapped but this page not yet — retry
ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched => {
write_log(&format!(
"fifa17_tls: store guard {} @ {addr:#x} {outcome:?} (VERIFIED empty-My-Packs)\n",
STORE_GUARD.label
));
true
}
ApplyOutcome::Mismatch => {
let mut cur = [0u8; patch_mem::MAX_PATCH_LEN];
let n = STORE_GUARD.patch.len();
let seen = if mem.read(addr, &mut cur[..n]) {
patch_mem::hex(&cur[..n])
} else {
"unreadable".into()
};
write_log(&format!(
"fifa17_tls: SKIP store guard @ {addr:#x}: unexpected {seen} (build mismatch)\n"
));
true
}
ApplyOutcome::WriteFailed | ApplyOutcome::VerifyFailed => {
write_log(&format!(
"fifa17_tls: store guard @ {addr:#x} {outcome:?}\n"
));
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_site_is_well_formed() {
for s in [&GATE1, &GATE2, &STORE_GUARD] {
assert_eq!(
s.orig.len(),
s.patch.len(),
"{}: orig/patch length",
s.label
);
assert!(!s.orig.is_empty(), "{}: empty", s.label);
assert!(
s.patch.len() <= patch_mem::MAX_PATCH_LEN,
"{}: exceeds MAX_PATCH_LEN",
s.label
);
assert_ne!(s.orig, s.patch, "{}: orig == patch", s.label);
}
}
#[test]
fn rvas_match_the_recovered_derivation() {
assert_eq!(GATE1.rva(), 0x613_2548);
assert_eq!(GATE2.rva(), 0x613_61b0);
assert_eq!(STORE_GUARD.rva(), 0x1_4858);
}
#[test]
fn live_addresses_track_the_runtime_base() {
// At the preferred base the live address is the recorded static VA.
assert_eq!(GATE1.live_addr(0x1_4000_0000), 0x1_4613_2548);
assert_eq!(STORE_GUARD.live_addr(0x1_8000_0000), 0x1_8001_4858);
// Relocated bases shift every site by the same delta.
assert_eq!(GATE1.live_addr(0x3_0000_0000), 0x3_0613_2548);
}
#[test]
fn cert_pair_only_acts_when_both_gates_agree() {
use PatchState::*;
assert_eq!(
cert_action(Some(Original), Some(Original)),
CertAction::Apply
);
assert_eq!(
cert_action(Some(AlreadyPatched), Some(AlreadyPatched)),
CertAction::Done
);
// Not yet unpacked / partial / mismatched => never a blind half-write.
assert_eq!(cert_action(None, None), CertAction::Wait);
assert_eq!(cert_action(Some(Original), None), CertAction::Wait);
assert_eq!(
cert_action(Some(Original), Some(AlreadyPatched)),
CertAction::Wait
);
assert_eq!(
cert_action(Some(Mismatch), Some(Mismatch)),
CertAction::Wait
);
}
}
-82
View File
@@ -1,82 +0,0 @@
use std::{
ffi::CStr,
sync::{
atomic::{AtomicBool, Ordering},
OnceLock,
},
};
use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA};
type GetaddrinfoFn =
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
// Flipped to true the first time we successfully apply the runtime cert patch.
// The patch is deferred to here (rather than DllMain) because EAWebKit.dll may
// not be loaded yet when the hook DLL is injected.
static CERT_PATCHED: AtomicBool = AtomicBool::new(false);
pub fn set_real(f: GetaddrinfoFn) {
let _ = REAL.set(f);
}
pub fn set_redirect_ip(ip: String) {
let mut bytes = ip.into_bytes();
bytes.push(0);
let _ = REDIRECT_IP.set(bytes);
}
/// Returns true if `host` is an EA / EA-Sports domain that should be redirected
/// to the local OpenFUT bridge.
fn is_ea_host(host: &str) -> bool {
let h = host.to_ascii_lowercase();
h.ends_with(".ea.com")
|| h == "ea.com"
|| h.ends_with(".easports.com")
|| h == "easports.com"
|| h.ends_with(".ugc.footapi.com")
|| h.ends_with(".footapi.com")
}
pub unsafe extern "system" fn hooked_getaddrinfo(
node_name: *const u8,
service_name: *const u8,
hints: *const ADDRINFOA,
result: *mut *mut ADDRINFOA,
) -> i32 {
if !node_name.is_null() {
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n"));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_getaddrinfo(host);
if is_ea_host(host) {
// Apply the ProtoSSL cert-verify bypass the first time we see an EA
// hostname — EAWebKit.dll must be loaded by now because it's calling us.
if !CERT_PATCHED.load(Ordering::Relaxed) {
if crate::ssl_patch::patch_eawebkit_cert_verify() {
CERT_PATCHED.store(true, Ordering::Relaxed);
crate::write_log(
"openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n",
);
} else {
crate::write_log(
"openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n",
);
}
}
let redirect = REDIRECT_IP
.get()
.map(|v| v.as_ptr())
.unwrap_or(c"127.0.0.1".as_ptr().cast());
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
return real(redirect, service_name, hints, result);
}
}
}
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
real(node_name, service_name, hints, result)
}
-13
View File
@@ -71,19 +71,6 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize {
patch_module(module, original_fn, hook_fn)
}
/// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0").
pub unsafe fn patch_iat_in(
module_name: &[u8],
original_fn: *const (),
hook_fn: *const (),
) -> usize {
let module = GetModuleHandleA(module_name.as_ptr());
if module.is_null() {
return 0;
}
patch_module(module, original_fn, hook_fn)
}
unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
if module.is_null() {
return 0;
+19 -207
View File
@@ -1,25 +1,24 @@
// The `fifa17` feature compiles this shared crate but activates only the FIFA-17
// injection path (fifa17.rs + sbc_*): install_hooks() routes to fifa17::install()
// and the FIFA-23 hook modules are reached solely via install_hooks_fifa23(), which
// is itself `#[cfg(not(feature = "fifa17"))]`. Those modules are therefore compiled
// but unused under `fifa17` (the linker strips them from the cdylib). Scope the
// resulting dead-code/unused-import lints to that feature so both builds stay
// `-D warnings` clean without dropping code the default (FIFA-23) build needs.
#![cfg_attr(feature = "fifa17", allow(dead_code, unused_imports))]
// openfut-hook: the version.dll proxy that injects OpenFUT's client-side
// compatibility hooks into an EA FUT client.
//
// GAME-GENERIC BY FEATURE: each supported game is its own module, selected by a
// per-game Cargo feature (currently only `fifa17`). `install_hooks` dispatches to
// the selected game's `install()`. Generic infrastructure — the version proxy,
// the connect/WSAConnect/ConnectEx redirect, IAT primitives, and the shared
// `openfut-common` config — stays game-neutral. Add a future game with its own
// `mod <game>;` behind a feature plus a dispatch arm; never by copying a retired
// game's reverse-engineering.
#[cfg(not(any(feature = "fifa17")))]
compile_error!("select a game, e.g. --features fifa17");
mod config;
mod connect_hook;
mod connectex_hook;
mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
mod hooks;
#[cfg(feature = "fifa17")]
mod fifa17_tls;
mod iat;
mod origin_spy;
#[cfg(feature = "probe")]
mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
mod patch_mem;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
#[cfg(feature = "fifa17")]
@@ -30,16 +29,12 @@ mod sbc_request_trace;
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod season_trace;
mod ssl_patch;
#[cfg(feature = "fifa17")]
mod store_entry;
mod tls_bypass;
mod transport_watch;
mod version_proxy;
use windows_sys::Win32::{
Foundation::{BOOL, HMODULE, TRUE},
Networking::WinSock::ADDRINFOA,
System::SystemServices::DLL_PROCESS_ATTACH,
};
@@ -54,21 +49,6 @@ pub(crate) fn write_log(msg: &str) {
}
}
/// Force the log to stable storage. `write_log` already opens+closes the file per line,
/// so nothing is buffered *inside our process* (a process crash can't lose a written
/// line). `sync_all` additionally flushes the OS cache to disk, for durability even
/// across a full system crash. We call this right before the dial trigger's call so the
/// pre-call log line is guaranteed on disk if the call faults.
#[allow(dead_code)]
pub(crate) fn flush_log() {
if let Ok(f) = std::fs::OpenOptions::new()
.append(true)
.open(r"C:\openfut_hook.log")
{
let _ = f.sync_all();
}
}
/// # Safety
///
/// This is the DLL entry point invoked by the Windows loader; it MUST NOT be
@@ -88,177 +68,9 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ())
TRUE
}
unsafe fn install_hooks(module: HMODULE) {
// FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
/// Dispatch to the selected game's install path. Exactly one game feature must be
/// enabled (enforced by the crate-level `compile_error!` above).
unsafe fn install_hooks(_module: HMODULE) {
#[cfg(feature = "fifa17")]
{
let _ = module;
fifa17::install();
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
}
#[cfg(not(feature = "fifa17"))]
unsafe fn install_hooks_fifa23(module: HMODULE) {
write_log("openfut_hook: DllMain fired\n");
// Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so
// the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity.
transport_watch::arm_from_env();
let ip = config::read_redirect_ip(module);
hooks::set_redirect_ip(ip);
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
if !ga.is_null() {
let f: unsafe extern "system" fn(
*const u8,
*const u8,
*const ADDRINFOA,
*mut *mut ADDRINFOA,
) -> i32 = std::mem::transmute(ga);
hooks::set_real(f);
let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ());
let m = iat::patch_iat_in(
b"EAWebKit.dll\0",
ga,
hooks::hooked_getaddrinfo as *const (),
);
write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n"));
}
if ssl_patch::patch_main_exe_cert_verify() {
write_log("ssl: main exe cert-verify patched\n");
} else {
write_log("ssl: main exe cert-verify NOT FOUND\n");
}
if ssl_patch::patch_eawebkit_cert_verify() {
write_log("ssl: EAWebKit cert-verify patched\n");
} else {
write_log("ssl: EAWebKit cert-verify deferred\n");
}
if connect_hook::install_inline_connect_hook() {
write_log("connect: inline-hooked\n");
} else {
write_log("connect: hook FAILED\n");
}
let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
if !wp.is_null() {
let f: unsafe extern "system" fn(
usize,
*const u8,
i32,
*const (),
*const (),
*const (),
*const (),
) -> i32 = std::mem::transmute(wp);
connect_hook::set_real_wsa_connect(f);
iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ());
write_log("connect: WSAConnect IAT patched\n");
}
if connectex_hook::install_wsaioctl_hook() {
write_log("connectex: WSAIoctl inline-hooked\n");
} else {
write_log("connectex: WSAIoctl hook FAILED\n");
}
// RE instrumentation: passive logging detours on FIFA's in-process online-flow
// functions (GoOnline, GetInternetConnectedState, event deserializers) to see
// where FIFA stalls after our pushed LSX events. Deferred until anadius loads.
#[cfg(feature = "probe")]
{
probe::install_probes_deferred();
write_log("probe: deferred install scheduled\n");
}
// recv/send hooks removed — LSX is now handled by the native openfut-bridge
// LSX server (port 3216), so in-process interception is no longer needed.
//
// Except in the `capture_baseline` build: with the LSX redirect off, FIFA talks
// to anadius directly, and these hooks log anadius's real LSX request/response
// frames (pass-through, no emulation) so we can diff them against our bridge.
#[cfg(feature = "capture_baseline")]
{
if recv_hook::install_recv_hook() {
write_log("CAP: recv inline-hooked\n");
} else {
write_log("CAP: recv hook FAILED\n");
}
if recv_hook::install_send_hook() {
write_log("CAP: send inline-hooked\n");
} else {
write_log("CAP: send hook FAILED\n");
}
}
macro_rules! hook_iat {
($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{
let ptr = iat::resolve($dll, $sym);
if !ptr.is_null() {
let f: $ty = std::mem::transmute(ptr);
origin_spy::$setter(f);
iat::patch_iat(ptr, $handler as *const ());
"ok"
} else {
"miss"
}
}};
}
let ra = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExA\0",
set_real_reg_a,
origin_spy::hooked_reg_query_a,
unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
);
let rw = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExW\0",
set_real_reg_w,
origin_spy::hooked_reg_query_w,
unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
);
let ma = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexA\0",
set_real_mutex_a,
origin_spy::hooked_open_mutex_a,
unsafe extern "system" fn(u32, i32, *const u8) -> isize
);
let mw = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexW\0",
set_real_mutex_w,
origin_spy::hooked_open_mutex_w,
unsafe extern "system" fn(u32, i32, *const u16) -> isize
);
write_log(&format!(
"origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"
));
let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0");
if !cv.is_null() {
let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL =
std::mem::transmute(cv);
tls_bypass::set_real(f);
iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(
b"EAWebKit.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"winhttp.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"wininet.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
}
fifa17::install();
}
-548
View File
@@ -1,548 +0,0 @@
/// EA App LSX protocol emulator (port 3216).
///
/// FIFA 23 opens two concurrent connections to port 3216 (one for EbisuSDK,
/// one for the login service). We track up to 4 sockets in LSX_POOL with
/// independent state per connection.
use core::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
// ─── per-connection slot ─────────────────────────────────────────────────────
struct LsxSlot {
socket: AtomicUsize, // usize::MAX = empty
state: AtomicUsize,
seed: AtomicUsize,
pending: Mutex<Option<Vec<u8>>>,
}
const MAX_LSX: usize = 4;
macro_rules! empty_slot {
() => { LsxSlot {
socket: AtomicUsize::new(usize::MAX),
state: AtomicUsize::new(0),
seed: AtomicUsize::new(0),
pending: Mutex::new(None),
}};
}
static POOL: [LsxSlot; MAX_LSX] = [
empty_slot!(), empty_slot!(), empty_slot!(), empty_slot!(),
];
fn find_slot(s: usize) -> Option<&'static LsxSlot> {
POOL.iter().find(|sl| sl.socket.load(Ordering::Relaxed) == s)
}
// ─── public API ──────────────────────────────────────────────────────────────
pub fn set_lsx_socket(s: usize) {
// Try to reuse an existing slot for this socket first
if find_slot(s).is_some() { return; }
// Find a free slot
for sl in &POOL {
if sl.socket.load(Ordering::Relaxed) == usize::MAX {
sl.state.store(0, Ordering::Relaxed);
sl.seed.store(0, Ordering::Relaxed);
if let Ok(mut g) = sl.pending.lock() { *g = None; }
sl.socket.store(s, Ordering::Relaxed);
crate::write_log(&format!("lsx: socket registered s={s}\n"));
return;
}
}
// All slots full — evict the first one
let sl = &POOL[0];
sl.state.store(0, Ordering::Relaxed);
sl.seed.store(0, Ordering::Relaxed);
if let Ok(mut g) = sl.pending.lock() { *g = None; }
sl.socket.store(s, Ordering::Relaxed);
crate::write_log(&format!("lsx: socket registered s={s} (evicted old slot)\n"));
}
pub fn is_lsx(s: usize) -> bool {
find_slot(s).is_some()
}
pub fn current_socket() -> usize {
// Return any active LSX socket (used by select hook if needed)
POOL.iter()
.map(|sl| sl.socket.load(Ordering::Relaxed))
.find(|&s| s != usize::MAX)
.unwrap_or(usize::MAX)
}
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
const AES_KEY: [u8; 16] = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15];
pub unsafe fn on_recv(s: usize, buf: *mut u8, len: i32) -> i32 {
let sl = match find_slot(s) { Some(x) => x, None => return -1 };
let state = sl.state.load(Ordering::Relaxed);
crate::write_log(&format!("lsx: recv s={s} state={state}\n"));
let payload: Vec<u8> = match state {
0 => {
let xml = format!(
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>\0"
);
sl.state.store(1, Ordering::Relaxed);
xml.into_bytes()
}
_ => {
let mut guard = sl.pending.lock().unwrap_or_else(|e| e.into_inner());
match guard.take() {
Some(pb) => pb,
None => {
// No pending data — return 0.
// For the state-1 probe recv (FIFA checking if there is more
// greeting data), 0 is the correct "no more data" signal and
// FIFA proceeds to send the ChallengeResponse.
return 0;
}
}
}
};
let n = payload.len().min(len as usize);
core::ptr::copy_nonoverlapping(payload.as_ptr(), buf, n);
crate::write_log(&format!("lsx: recv -> {n} bytes\n"));
n as i32
}
pub unsafe fn on_send(s: usize, buf: *const u8, len: i32) -> i32 {
let sl = match find_slot(s) { Some(x) => x, None => return len };
let state = sl.state.load(Ordering::Relaxed);
let data = core::slice::from_raw_parts(buf, len as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!("lsx: send s={s} state={state} len={len} data={}\n",
&text[..text.len().min(300)]));
let response = match state {
1 => handle_challenge(sl, data),
st => handle_request(sl, data, st),
};
if let Some(payload) = response {
let mut guard = sl.pending.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(payload);
}
sl.state.fetch_add(1, Ordering::Relaxed);
len
}
// ─── handshake ───────────────────────────────────────────────────────────────
fn handle_challenge(sl: &LsxSlot, raw: &[u8]) -> Option<Vec<u8>> {
let text = core::str::from_utf8(raw).unwrap_or("").trim_end_matches('\0');
let parts: Vec<&str> = text.split('"').collect();
let id = parts.get(3).copied().unwrap_or("1");
let key = parts.get(7).copied().unwrap_or("");
crate::write_log(&format!("lsx: challenge id={id} key={key}\n"));
let our_response = make_challenge_response(key);
let seed = compute_seed(&our_response);
sl.seed.store(seed as usize, Ordering::Relaxed);
crate::write_log(&format!("lsx: response={our_response} seed={seed}\n"));
let xml = format!(
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>\0"
);
Some(xml.into_bytes())
}
fn compute_seed(hex: &str) -> u16 {
let b0 = u8::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0);
let b1 = u8::from_str_radix(&hex[2..4.min(hex.len())], 16).unwrap_or(0);
((b0 as u16) << 8) | (b1 as u16)
}
fn handle_request(sl: &LsxSlot, raw: &[u8], _state: usize) -> Option<Vec<u8>> {
let seed = sl.seed.load(Ordering::Relaxed) as u16;
let text = core::str::from_utf8(raw).unwrap_or("").trim_end_matches('\0');
let decrypted = lsx_decrypt(text, seed);
crate::write_log(&format!("lsx: request decrypted={}\n", &decrypted[..decrypted.len().min(300)]));
let response_xml = dispatch_request(decrypted.trim());
crate::write_log(&format!("lsx: response={}\n", &response_xml[..response_xml.len().min(300)]));
let encrypted = lsx_encrypt(&response_xml, seed);
let payload = format!("{encrypted}\0");
Some(payload.into_bytes())
}
// ─── session dispatcher ───────────────────────────────────────────────────────
pub fn dispatch(xml: &str) -> String { dispatch_request(xml) }
fn dispatch_request(xml: &str) -> String {
let parts: Vec<&str> = xml.split('"').collect();
let id = parts.get(3).copied().unwrap_or("1");
let req_type = parts.get(4).copied().unwrap_or("");
crate::write_log(&format!("lsx: dispatch id={id} type={req_type}\n"));
match req_type {
"><GetConfig version=" => get_config(id),
"><GetAuthCode ClientId=" | "><GetAuthCode UserId=" => get_auth_code(id),
"><GetInternetConnectedState version=" => get_internet_state(id),
"><GetProfile index=" => get_profile(id),
"><GetSetting SettingId=" => {
let setting = parts.get(5).copied().unwrap_or("");
get_setting(id, setting)
}
"><QueryEntitlements UserId=" => query_entitlements(id),
"><RequestLicense UserId=" => request_license(id),
"><QueryContent UserId=" => query_content(id),
"><GetBlockList version=" => get_block_list(id),
"><QueryFriends UserId=" => query_friends(id),
"><QueryPresence UserId=" => query_presence(id),
"><SetPresence UserId=" => set_presence(id),
"><GetPresenceVisibility UserId=" => get_presence_visibility(id),
"><GetWalletBalance UserId=" => get_wallet_balance(id),
"><GetAllGameInfo version=" => get_all_game_info(id),
_ => {
crate::write_log(&format!("lsx: UNKNOWN type: {req_type}\n"));
format!("<LSX><Response id=\"{id}\" sender=\"EbisuSDK\"><Ok /></Response></LSX>\0")
}
}
}
// ─── LSX response templates ───────────────────────────────────────────────────
fn get_config(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<GetConfigResponse>
<Service Facility="SDK" Name="EbisuSDK" />
<Service Facility="PROFILE" Name="EbisuSDK" />
<Service Facility="PRESENCE" Name="XMPP" />
<Service Facility="FRIENDS" Name="XMPP" />
<Service Facility="COMMERCE" Name="Commerce" />
<Service Facility="RECENTPLAYER" Name="EbisuSDK" />
<Service Facility="IGO" Name="EbisuSDK" />
<Service Facility="MISC" Name="EbisuSDK" />
<Service Facility="LOGIN" Name="EALS" />
<Service Facility="UTILITY" Name="Utility" />
<Service Facility="XMPP" Name="XMPP" />
<Service Facility="CHAT" Name="XMPP" />
<Service Facility="IGO_EVENT" Name="EbisuSDK" />
<Service Facility="EALS_EVENTS" Name="EALS" />
<Service Facility="LOGIN_EVENT" Name="EbisuSDK" />
<Service Facility="INVITE_EVENT" Name="XMPP" />
<Service Facility="PROFILE_EVENT" Name="EbisuSDK" />
<Service Facility="PRESENCE_EVENT" Name="XMPP" />
<Service Facility="FRIENDS_EVENT" Name="XMPP" />
<Service Facility="COMMERCE_EVENT" Name="Commerce" />
<Service Facility="CHAT_EVENT" Name="XMPP" />
<Service Facility="DOWNLOAD_EVENT" Name="EbisuSDK" />
<Service Facility="PERMISSION" Name="EbisuSDK" />
<Service Facility="RESOURCES" Name="EbisuSDK" />
<Service Facility="BLOCKED_USERS" Name="EbisuSDK" />
<Service Facility="BLOCKED_USER_EVENT" Name="EbisuSDK" />
<Service Facility="GET_USERID" Name="EbisuSDK" />
<Service Facility="ONLINE_STATUS_EVENT" Name="EbisuSDK" />
<Service Facility="ACHIEVEMENT" Name="EbisuSDK" />
<Service Facility="ACHIEVEMENT_EVENT" Name="EbisuSDK" />
<Service Facility="BROADCAST_EVENT" Name="EbisuSDK" />
<Service Facility="PROGRESSIVE_INSTALLATION" Name="PI" />
<Service Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" />
<Service Facility="CONTENT" Name="EbisuSDK" />
</GetConfigResponse>
</Response>
</LSX>"#)
}
fn get_auth_code(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="Utility">
<AuthCode value="OpenFUT_fake_auth_code_v1" />
</Response>
</LSX>"#)
}
fn get_internet_state(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="Utility">
<InternetConnectedState connected="1" />
</Response>
</LSX>"#)
}
fn get_profile(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<GetProfileResponse PersonaId="1000000000001" Persona="OpenFUT_Player" Country="US" GeoCountry="US"
UserIndex="0" IsTrialSubscriber="false" AvatarId="1"
IsUnderAge="false" IsSubscriber="false" IsSteamSubscriber="false" SubscriberLevel="2"
CommerceCurrency="USD" UserId="2000000000001" CommerceCountry="US" />
</Response>
</LSX>"#)
}
fn get_setting(id: &str, setting: &str) -> String {
let value = match setting { "ENVIRONMENT" => "production", _ => "false" };
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<GetSettingResponse Setting="{value}" />
</Response>
</LSX>"#)
}
fn query_entitlements(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="Commerce">
<QueryEntitlementsResponse>
<Entitlements ItemId="Origin.OFR.50.0004658" Type="ONLINE_ACCESS"
EntitlementId="1021747550001" EntitlementTag="ONLINE_ACCESS"
Group="FIFA23PC" ResourceId="" UseCount="0"
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
<Entitlements ItemId="Origin.OFR.50.0004658" Type="DEFAULT"
EntitlementId="1021747550002" EntitlementTag="ONLINE_ACCESS"
Group="FIFA23PC" ResourceId="" UseCount="0"
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
</QueryEntitlementsResponse>
</Response>
</LSX>"#)
}
fn request_license(id: &str) -> String {
format!(r#"<LSX>
<Response sender="EbisuSDK" id="{id}">
<RequestLicenseResponse License="OpenFUT_fake_license_v1" />
</Response>
</LSX>"#)
}
fn query_content(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<QueryContentResponse>
<Content Gamestate="READY_TO_PLAY" progressValue="0"
contentID="Origin.OFR.50.0004658"
installedVersion="1.0.0.0" availableVersion="1.0.0.0"
displayName="FIFA 23" />
</QueryContentResponse>
</Response>
</LSX>"#)
}
fn get_block_list(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetBlockListResponse /></Response></LSX>"#)
}
fn query_friends(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryFriendsResponse /></Response></LSX>"#)
}
fn query_presence(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryPresenceResponse UserId="2000000000001" PersonaId="1000000000001" /></Response></LSX>"#)
}
fn set_presence(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="XMPP"><SetPresenceResponse /></Response></LSX>"#)
}
fn get_presence_visibility(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetPresenceVisibilityResponse Visibility="FRIENDS" /></Response></LSX>"#)
}
fn get_wallet_balance(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="Commerce"><GetWalletBalanceResponse Balance="0" Currency="USD" /></Response></LSX>"#)
}
fn get_all_game_info(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetAllGameInfoResponse /></Response></LSX>"#)
}
// ─── AES-128-ECB (pure Rust) ──────────────────────────────────────────────────
#[rustfmt::skip]
const SBOX: [u8; 256] = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
];
fn xtime(a: u8) -> u8 { if a & 0x80 != 0 { (a << 1) ^ 0x1b } else { a << 1 } }
fn mul(mut a: u8, mut b: u8) -> u8 {
let mut r = 0u8;
while b > 0 { if b & 1 != 0 { r ^= a; } a = xtime(a); b >>= 1; }
r
}
fn sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = SBOX[*b as usize]; } }
fn shift_rows(s: &mut [u8; 16]) {
let t = s[1]; s[1]=s[5]; s[5]=s[9]; s[9]=s[13]; s[13]=t;
s.swap(2,10); s.swap(6,14);
let t = s[15]; s[15]=s[11]; s[11]=s[7]; s[7]=s[3]; s[3]=t;
}
fn mix_col(s: &mut [u8; 16], c: usize) {
let (a,b,c2,d) = (s[c],s[c+4],s[c+8],s[c+12]);
s[c] = mul(2,a)^mul(3,b)^c2^d;
s[c+4] = a^mul(2,b)^mul(3,c2)^d;
s[c+8] = a^b^mul(2,c2)^mul(3,d);
s[c+12] = mul(3,a)^b^c2^mul(2,d);
}
fn mix_columns(s: &mut [u8; 16]) { for c in 0..4 { mix_col(s,c); } }
fn add_round_key(s: &mut [u8; 16], rk: &[u8; 16]) { for i in 0..16 { s[i] ^= rk[i]; } }
fn expand_key(key: &[u8; 16]) -> [[u8; 16]; 11] {
let rcon: [u8; 10] = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36];
let mut w = [[0u8; 4]; 44];
for i in 0..4 { w[i] = [key[4*i],key[4*i+1],key[4*i+2],key[4*i+3]]; }
for i in 4..44 {
let mut t = w[i-1];
if i % 4 == 0 {
t.rotate_left(1);
for b in &mut t { *b = SBOX[*b as usize]; }
t[0] ^= rcon[i/4-1];
}
w[i] = [w[i-4][0]^t[0], w[i-4][1]^t[1], w[i-4][2]^t[2], w[i-4][3]^t[3]];
}
let mut rk = [[0u8; 16]; 11];
for r in 0..11 { for c in 0..4 { rk[r][4*c..4*c+4].copy_from_slice(&w[r*4+c]); } }
rk
}
fn aes_block_encrypt(block: &[u8; 16], rk: &[[u8; 16]; 11]) -> [u8; 16] {
let mut s = *block;
add_round_key(&mut s, &rk[0]);
for r in 1..10 { sub_bytes(&mut s); shift_rows(&mut s); mix_columns(&mut s); add_round_key(&mut s, &rk[r]); }
sub_bytes(&mut s); shift_rows(&mut s); add_round_key(&mut s, &rk[10]);
s
}
fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec<u8> {
let rk = expand_key(key);
let pad = 16 - (plaintext.len() % 16);
let mut padded = plaintext.to_vec();
padded.resize(plaintext.len() + pad, pad as u8);
let mut out = Vec::with_capacity(padded.len());
for chunk in padded.chunks(16) {
let mut b = [0u8; 16]; b.copy_from_slice(chunk);
out.extend_from_slice(&aes_block_encrypt(&b, &rk));
}
out
}
#[rustfmt::skip]
const INV_SBOX: [u8; 256] = [
0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb,
0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb,
0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e,
0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25,
0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92,
0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84,
0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06,
0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b,
0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73,
0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e,
0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b,
0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4,
0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f,
0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef,
0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61,
0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d,
];
fn inv_sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = INV_SBOX[*b as usize]; } }
fn inv_shift_rows(s: &mut [u8; 16]) {
let t = s[13]; s[13]=s[9]; s[9]=s[5]; s[5]=s[1]; s[1]=t;
s.swap(2,10); s.swap(6,14);
let t = s[3]; s[3]=s[7]; s[7]=s[11]; s[11]=s[15]; s[15]=t;
}
fn inv_mix_col(s: &mut [u8; 16], c: usize) {
let (a,b,c2,d) = (s[c],s[c+4],s[c+8],s[c+12]);
s[c] = mul(0x0e,a)^mul(0x0b,b)^mul(0x0d,c2)^mul(0x09,d);
s[c+4] = mul(0x09,a)^mul(0x0e,b)^mul(0x0b,c2)^mul(0x0d,d);
s[c+8] = mul(0x0d,a)^mul(0x09,b)^mul(0x0e,c2)^mul(0x0b,d);
s[c+12] = mul(0x0b,a)^mul(0x0d,b)^mul(0x09,c2)^mul(0x0e,d);
}
fn inv_mix_columns(s: &mut [u8; 16]) { for c in 0..4 { inv_mix_col(s,c); } }
fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
let rk = expand_key(key);
let mut out = Vec::with_capacity(data.len());
for chunk in data.chunks(16) {
if chunk.len() < 16 { break; }
let mut b = [0u8; 16]; b.copy_from_slice(chunk);
add_round_key(&mut b, &rk[10]);
inv_shift_rows(&mut b); inv_sub_bytes(&mut b);
for r in (1..10).rev() {
add_round_key(&mut b, &rk[r]);
inv_mix_columns(&mut b); inv_shift_rows(&mut b); inv_sub_bytes(&mut b);
}
add_round_key(&mut b, &rk[0]);
out.extend_from_slice(&b);
}
if let Some(&pad) = out.last() {
let pad = pad as usize;
if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); }
}
out
}
// ─── CRandom ─────────────────────────────────────────────────────────────────
struct CRandom { seed: u32 }
impl CRandom {
fn new() -> Self { Self { seed: 0 } }
fn seed_with(&mut self, s: u32) { self.seed = s; }
fn rand(&mut self) -> u32 {
self.seed = self.seed.wrapping_mul(214013).wrapping_add(2531011);
(self.seed >> 16) & 0xFFFF
}
}
fn get_lsx_key(seed: u16) -> [u8; 16] {
let mut rng = CRandom::new();
rng.seed_with(7);
let next = rng.rand();
rng.seed_with(next.wrapping_add(seed as u32));
let mut k = [0u8; 16];
for b in &mut k { *b = rng.rand() as u8; }
k
}
// ─── session encrypt/decrypt ─────────────────────────────────────────────────
fn hex_to_bytes(s: &str) -> Vec<u8> {
let s: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if s.len() % 2 != 0 { return Vec::new(); }
(0..s.len()/2).filter_map(|i| u8::from_str_radix(&s[2*i..2*i+2], 16).ok()).collect()
}
fn bytes_to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn lsx_decrypt(hex_data: &str, seed: u16) -> String {
let key = get_lsx_key(seed);
let ct = hex_to_bytes(hex_data);
if ct.is_empty() { return String::new(); }
let plain = aes_ecb_decrypt_nopad(&key, &ct);
String::from_utf8_lossy(&plain).trim_matches('\0').to_string()
}
pub fn lsx_encrypt(text: &str, seed: u16) -> String {
let key = get_lsx_key(seed);
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes()))
}
pub fn make_challenge_response(key: &str) -> String {
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes()))
}
-137
View File
@@ -1,137 +0,0 @@
/// Hooks RegQueryValueExA/W and OpenMutexA/W to log what the Origin SDK is checking.
use std::sync::OnceLock;
type RegQueryValueExAFn = unsafe extern "system" fn(
hkey: isize,
lpvaluename: *const u8,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32;
type RegQueryValueExWFn = unsafe extern "system" fn(
hkey: isize,
lpvaluename: *const u16,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32;
type OpenMutexAFn = unsafe extern "system" fn(u32, i32, *const u8) -> isize;
type OpenMutexWFn = unsafe extern "system" fn(u32, i32, *const u16) -> isize;
static REAL_REG_A: OnceLock<RegQueryValueExAFn> = OnceLock::new();
static REAL_REG_W: OnceLock<RegQueryValueExWFn> = OnceLock::new();
static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new();
static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new();
pub fn set_real_reg_a(f: RegQueryValueExAFn) {
let _ = REAL_REG_A.set(f);
}
pub fn set_real_reg_w(f: RegQueryValueExWFn) {
let _ = REAL_REG_W.set(f);
}
pub fn set_real_mutex_a(f: OpenMutexAFn) {
let _ = REAL_MUTEX_A.set(f);
}
pub fn set_real_mutex_w(f: OpenMutexWFn) {
let _ = REAL_MUTEX_W.set(f);
}
fn narrow_to_string(p: *const u8) -> String {
if p.is_null() {
return "(null)".into();
}
let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) };
bytes.to_string_lossy().into_owned()
}
fn wide_to_string(p: *const u16) -> String {
if p.is_null() {
return "(null)".into();
}
let mut len = 0usize;
unsafe {
while *p.add(len) != 0 {
len += 1;
}
}
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) })
}
fn is_interesting(name: &str) -> bool {
name.contains("LSX")
|| name.contains("Origin")
|| name.contains("EAL")
|| name.contains("Client")
|| name.contains("lsx")
|| name.contains("Port")
|| name.contains("EA")
|| name.contains("Connection")
}
pub unsafe extern "system" fn hooked_reg_query_a(
hkey: isize,
lpvaluename: *const u8,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32 {
let name = narrow_to_string(lpvaluename);
let real = REAL_REG_A.get().copied().unwrap();
let ret = real(hkey, lpvaluename, lpreserved, lptype, lpdata, lpcbdata);
if is_interesting(&name) {
crate::write_log(&format!("origin_spy: RegQueryValueExA({name}) → {ret}\n"));
}
ret
}
pub unsafe extern "system" fn hooked_reg_query_w(
hkey: isize,
lpvaluename: *const u16,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32 {
let name = wide_to_string(lpvaluename);
let real = REAL_REG_W.get().copied().unwrap();
let ret = real(hkey, lpvaluename, lpreserved, lptype, lpdata, lpcbdata);
if is_interesting(&name) {
crate::write_log(&format!("origin_spy: RegQueryValueExW({name}) → {ret}\n"));
}
ret
}
pub unsafe extern "system" fn hooked_open_mutex_a(
dwdesiredaccess: u32,
binherithandle: i32,
lpmutexname: *const u8,
) -> isize {
let name = narrow_to_string(lpmutexname);
let real = REAL_MUTEX_A.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!(
"origin_spy: OpenMutexA({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle
}
pub unsafe extern "system" fn hooked_open_mutex_w(
dwdesiredaccess: u32,
binherithandle: i32,
lpmutexname: *const u16,
) -> isize {
let name = wide_to_string(lpmutexname);
let real = REAL_MUTEX_W.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!(
"origin_spy: OpenMutexW({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle
}
+358
View File
@@ -0,0 +1,358 @@
//! 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<M: Mem>(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<M: Mem>(mem: &M, addr: usize, orig: &[u8], patch: &[u8]) -> Option<PatchState> {
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<usize> {
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::<MEMORY_BASIC_INFORMATION>();
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<usize, u8>,
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");
}
}
File diff suppressed because it is too large Load Diff
-321
View File
@@ -1,321 +0,0 @@
/// Inline hooks on ws2_32!recv and ws2_32!send only.
///
/// WSARecv/WSASend are NOT hooked — their prologues contain RIP-relative
/// (short conditional jump) instructions that would break trampolines.
/// FIFA's LSX client uses plain recv/send, which is confirmed by prior logs.
///
/// Trampolines allow multiple threads to call the original function
/// concurrently without locks or unhook/rehook races.
use core::sync::atomic::{AtomicUsize, Ordering};
unsafe fn write_jmp(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
}
unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
use windows_sys::Win32::System::Memory::{
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
};
// Read enough prologue to walk instruction boundaries.
let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i));
let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect();
crate::write_log(&format!("recv_hook: {name} prologue {hex}\n"));
// Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP
// patch), so the trampoline never splits an instruction. Copying a fixed 14
// bytes lands mid-instruction on these prologues and crashes on execution.
let mut copy_len = 0usize;
while copy_len < 14 {
let (len, branch) = decode_instr_len(&probe[copy_len..]);
if len == 0 || branch {
crate::write_log(&format!(
"recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n"
));
return None;
}
copy_len += len;
}
let mem = VirtualAlloc(
core::ptr::null_mut(),
64,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
);
if mem.is_null() {
crate::write_log("recv_hook: VirtualAlloc failed\n");
return None;
}
let t = mem as *mut u8;
core::ptr::copy_nonoverlapping(orig, t, copy_len);
// JMP [RIP+0] → orig+copy_len (resume at the next whole instruction)
let cont = (orig as u64) + copy_len as u64;
t.add(copy_len).write(0xFF);
t.add(copy_len + 1).write(0x25);
(t.add(copy_len + 2) as *mut u32).write(0);
(t.add(copy_len + 6) as *mut u64).write(cont);
crate::write_log(&format!(
"recv_hook: {name} trampoline copy_len={copy_len}\n"
));
Some(t as usize)
}
/// Walk x86-64 instruction boundaries and return true if any relative branch
/// (JE/JNE/JCC rel8, JMP rel8, JMP/CALL rel32, Jcc rel32) is encountered.
/// Correctly skips over immediate operands so `sub rsp, 0x70` doesn't trigger.
fn has_rip_relative_branch(bytes: &[u8]) -> bool {
let mut pos = 0;
while pos < bytes.len() {
let (len, branch) = decode_instr_len(&bytes[pos..]);
if branch {
return true;
}
if len == 0 {
break;
} // unknown/truncated — stop safely
pos += len;
}
false
}
fn modrm_extra(modrm: u8) -> usize {
let md = (modrm >> 6) & 3;
let rm = modrm & 7;
match md {
0 => {
if rm == 5 {
4
} else if rm == 4 {
1
} else {
0
}
}
1 => {
if rm == 4 {
2
} else {
1
}
}
2 => {
if rm == 4 {
5
} else {
4
}
}
_ => 0,
}
}
/// Returns (instruction_length_in_bytes, is_rip_relative_branch).
/// Returns (0, false) for unknown/truncated.
fn decode_instr_len(b: &[u8]) -> (usize, bool) {
if b.is_empty() {
return (0, false);
}
let mut i = 0;
// Legacy prefixes
while let Some(&p) = b.get(i) {
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) {
i += 1;
} else {
break;
}
}
// REX prefix (404F)
if b.get(i)
.copied()
.map(|x| (0x40..=0x4F).contains(&x))
.unwrap_or(false)
{
i += 1;
}
let op = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
match op {
// push/pop reg (50-5F): no extra bytes
0x50..=0x5F => (i, false),
// nop
0x90 => (i, false),
// Short Jcc (70-7F): 1 byte operand, IS a relative branch
x if (0x70..=0x7F).contains(&x) => (i + 1, true),
// JMP rel8, JMP rel32, CALL rel32
0xEB => (i + 1, true),
0xE9 | 0xE8 => (i + 4, true),
// 0F prefix
0x0F => {
let op2 = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
if (0x80..=0x8F).contains(&op2) {
return (i + 4, true);
} // Jcc rel32
// Most 0F XX: ModRM
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// Instructions with ModRM only (no immediate)
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03
| 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B
| 0xD3 | 0xFF | 0xF7 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// ModRM + imm8
0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 1, false)
}
// ModRM + imm32
0x69 | 0x81 | 0xC7 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 4, false)
}
// MOV reg, imm8/imm32
0xB0..=0xB7 => (i + 1, false),
0xB8..=0xBF => (i + 4, false),
// PUSH imm
0x6A => (i + 1, false),
0x68 => (i + 4, false),
// RET
0xC2 => (i + 2, false),
0xC3 => (i, false),
_ => (0, false), // unknown — stop
}
}
unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let h = GetModuleHandleA(dll.as_ptr());
if h.is_null() {
return None;
}
GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8)
}
// ─── recv ──────────────────────────────────────────────────────────────────────
static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216).
/// Used in capture mode to tap only the LSX conversation.
unsafe fn peer_is_lsx(s: usize) -> bool {
use windows_sys::Win32::Networking::WinSock::getpeername;
let mut sa = [0u8; 16];
let mut sl: i32 = 16;
if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 {
return false;
}
// sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order).
u16::from_be_bytes([sa[2], sa[3]]) == 3216
}
// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have
// instructions that straddle the 14-byte patch boundary, so an inline trampoline
// corrupts them and crashes. IAT hooking only swaps import-table pointers and
// never touches the function body). The real fns are resolved in lib.rs and set
// here; our hooks call them directly.
static REAL_RECV: AtomicUsize = AtomicUsize::new(0);
static REAL_SEND: AtomicUsize = AtomicUsize::new(0);
pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) {
REAL_RECV.store(f as usize, Ordering::Relaxed);
}
pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) {
REAL_SEND.store(f as usize, Ordering::Relaxed);
}
/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our
/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks
/// catch calls from every module and dynamically-resolved calls, unlike IAT.
pub unsafe fn install_recv_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "recv") {
Some(t) => REAL_RECV.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_recv as u64);
true
}
pub unsafe fn install_send_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "send") {
Some(t) => REAL_SEND.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_send as u64);
true
}
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
let t = REAL_RECV.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
// Pass through to anadius's real socket, then log what it sent back
// (anadius's LSX response — the ground truth we want to diff against).
let n = f(s, buf, len, flags);
if n > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, n as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP recv<-anadius s={s} n={n}: {}\n",
&text[..text.len().min(2400)]
));
}
n
}
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if len > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, len as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP send->anadius s={s} len={len}: {}\n",
&text[..text.len().min(2400)]
));
}
let t = REAL_SEND.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
}
+7 -3
View File
@@ -10,8 +10,7 @@
//! 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 — the same shape as
//! `probe::install_probes_deferred` polling for anadius64.dll.
//! 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.
@@ -80,6 +79,9 @@ 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 {
@@ -192,7 +194,7 @@ fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationE
Ok(())
}
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
/// 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 {
@@ -262,6 +264,8 @@ unsafe fn writable_u8(ptr: usize) -> bool {
.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;
+1
View File
@@ -29,6 +29,7 @@ pub(crate) const CATEGORY_FACTORY_RVA: usize = 0x17aa10;
pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0;
const COPY_LEN: usize = 19;
const ABS_JUMP_LEN: usize = 14;
#[allow(dead_code)] // documents the relocated-prologue trampoline size (COPY_LEN + jump)
const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN;
const NOTIFIER_RVA: usize = 0x17aa80;
const NOTIFIER_COPY_LEN: usize = 15;
+190 -66
View File
@@ -12,7 +12,7 @@
//! flow. Targets are chosen so their copied prologues are position-independent
//! (no rip-relative / rel32 in the copied bytes).
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use core::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
@@ -28,8 +28,6 @@ use crate::sbc_trace::{
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
@@ -137,15 +135,35 @@ macro_rules! season_call_trace {
};
}
season_call_trace!(load_current_native_wrapper, LOAD_CURRENT_NATIVE_TRAMP, "LoadCurrentOfflineSeason_native");
season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native");
season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native");
season_call_trace!(
load_current_native_wrapper,
LOAD_CURRENT_NATIVE_TRAMP,
"LoadCurrentOfflineSeason_native"
);
season_call_trace!(
start_season_native_wrapper,
START_SEASON_NATIVE_TRAMP,
"StartSeason_native"
);
season_call_trace!(
get_info_native_wrapper,
GET_INFO_NATIVE_TRAMP,
"GetOfflineSeasonInfo_native"
);
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
// actually calls; hands the callback name to the manager's async slot 0x80.
season_call_trace!(load_offline_real_wrapper, LOAD_OFFLINE_REAL_TRAMP, "LoadOfflineSeasons_native(0x4ee10)");
season_call_trace!(
load_offline_real_wrapper,
LOAD_OFFLINE_REAL_TRAMP,
"LoadOfflineSeasons_native(0x4ee10)"
);
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
// count and invokes the LoadSeasons_Complete AS callback.
season_call_trace!(load_offline_async_wrapper, LOAD_OFFLINE_ASYNC_TRAMP, "LoadOfflineSeasons_asyncimpl(0x57560)");
season_call_trace!(
load_offline_async_wrapper,
LOAD_OFFLINE_ASYNC_TRAMP,
"LoadOfflineSeasons_asyncimpl(0x57560)"
);
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
@@ -186,7 +204,12 @@ unsafe extern "system" fn load_current_impl_wrapper(
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
// whether it ever fires; logs the result fields it branches on.
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
unsafe extern "system" fn completion_wrapper(
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let status = rd_i32(result + 0x1c);
@@ -212,7 +235,11 @@ unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usiz
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
// so it needs the relocating installer below.
season_call_trace!(get_users_division_wrapper, GET_USERS_DIVISION_TRAMP, "GetUsersOfflineDivision_native(0x4eb50)");
season_call_trace!(
get_users_division_wrapper,
GET_USERS_DIVISION_TRAMP,
"GetUsersOfflineDivision_native(0x4eb50)"
);
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
/// CardsDLL data still fits after we relocate a copied prologue into it.
@@ -266,7 +293,9 @@ unsafe fn install_detour_reloc(
let jump = absolute_jump(wrapper);
let tramp_len = copy_len + jump.len();
let Some(tramp) = alloc_near(base, tramp_len) else {
write_log(&format!("SEASON_TRACE: {name}: near trampoline alloc failed\n"));
write_log(&format!(
"SEASON_TRACE: {name}: near trampoline alloc failed\n"
));
return false;
};
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
@@ -275,7 +304,9 @@ unsafe fn install_detour_reloc(
let abs_target = target as i64 + insn_end as i64 + orig_disp;
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
write_log(&format!("SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"));
write_log(&format!(
"SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"
));
return false;
}
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
@@ -283,7 +314,9 @@ unsafe fn install_detour_reloc(
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
let mut old = 0u32;
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
write_log(&format!("SEASON_TRACE: {name}: trampoline protect failed\n"));
write_log(&format!(
"SEASON_TRACE: {name}: trampoline protect failed\n"
));
return false;
}
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
@@ -316,7 +349,12 @@ unsafe fn install_detour_reloc(
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
unsafe extern "system" fn final_completion_wrapper(
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
// Read the delivered status: byte0==0 => failure with an error string at +8.
let flag = rd_u8(result);
let errstr = if flag == Some(0) {
@@ -341,23 +379,15 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8
Some(_) => "SUCCESS",
None => "??",
};
let shown = if flag == Some(0) { errstr.as_str() } else { "SUCCESS" };
let shown = if flag == Some(0) {
errstr.as_str()
} else {
"SUCCESS"
};
write_log(&format!(
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
));
}
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
// failure to SUCCESS so the offline-season load advances to
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
// once per process; verified by the error string before touching memory.
if flag == Some(0)
&& errstr.contains("CACHE_PACKNAMES")
&& readable_range(result, 1)
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
{
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n");
}
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
@@ -371,14 +401,23 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
// the next async stage. Logs whether the first async stage succeeded. Passive.
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize, r8: usize, r9: usize) -> usize {
unsafe extern "system" fn stage1_completion_wrapper(
param1: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
if result == 0 {
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
} else {
let status = rd_i32(result + 0x1c);
let verdict = if status == Some(0) { "ok(chain next)" } else { "CACHE_PACKNAMES_FAILED" };
let verdict = if status == Some(0) {
"ok(chain next)"
} else {
"CACHE_PACKNAMES_FAILED"
};
write_log(&format!(
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
@@ -399,10 +438,18 @@ unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
// installer (disp32 at copied offset 7, instruction end 11).
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
unsafe extern "system" fn url_capture_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256)));
write_log(&format!(
"SEASONS_WEBFILE_URL: url={:?}\n",
rd_cstr(rcx, 256)
));
}
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
if t == 0 {
@@ -428,61 +475,138 @@ unsafe fn worker() {
}
// (rva, name, copy_len, signature, wrapper, trampoline slot)
install_detour(
base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15,
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP,
base,
0x4eb70,
"LoadCurrentOfflineSeason_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_current_native_wrapper as *const () as usize,
&LOAD_CURRENT_NATIVE_TRAMP,
);
install_detour(
base, 0x4f340, "StartSeason_native", 15,
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP,
base,
0x4f340,
"StartSeason_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
start_season_native_wrapper as *const () as usize,
&START_SEASON_NATIVE_TRAMP,
);
install_detour(
base, 0x4e850, "GetOfflineSeasonInfo_native", 15,
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18],
get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP,
base,
0x4e850,
"GetOfflineSeasonInfo_native",
15,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24,
0x18,
],
get_info_native_wrapper as *const () as usize,
&GET_INFO_NATIVE_TRAMP,
);
install_detour(
base, 0x57230, "LoadCurrentOfflineSeason_impl", 19,
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff],
load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP,
base,
0x57230,
"LoadCurrentOfflineSeason_impl",
19,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40,
0x98, 0xfe, 0xff, 0xff, 0xff,
],
load_current_impl_wrapper as *const () as usize,
&LOAD_CURRENT_IMPL_TRAMP,
);
install_detour(
base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16,
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff],
completion_wrapper as *const () as usize, &COMPLETION_TRAMP,
base,
0x578e0,
"LoadCurrentOfflineSeason_completion",
16,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff,
0xff, 0xff,
],
completion_wrapper as *const () as usize,
&COMPLETION_TRAMP,
);
install_detour_reloc(
base, 0x4eb50, "GetUsersOfflineDivision_native", 14,
&[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01],
7, 11,
get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP,
base,
0x4eb50,
"GetUsersOfflineDivision_native",
14,
&[
0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01,
],
7,
11,
get_users_division_wrapper as *const () as usize,
&GET_USERS_DIVISION_TRAMP,
);
install_detour(
base, 0x4ee10, "LoadOfflineSeasons_native", 15,
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP,
base,
0x4ee10,
"LoadOfflineSeasons_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_offline_real_wrapper as *const () as usize,
&LOAD_OFFLINE_REAL_TRAMP,
);
install_detour(
base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17,
&[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP,
base,
0x57560,
"LoadOfflineSeasons_asyncimpl",
17,
&[
0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
0xff, 0xff, 0xff,
],
load_offline_async_wrapper as *const () as usize,
&LOAD_OFFLINE_ASYNC_TRAMP,
);
install_detour(
base, 0xffe90, "LoadOfflineSeasons_final_completion", 16,
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda],
final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP,
base,
0xffe90,
"LoadOfflineSeasons_final_completion",
16,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48,
0x8b, 0xda,
],
final_completion_wrapper as *const () as usize,
&FINAL_COMPLETION_TRAMP,
);
install_detour(
base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15,
&[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00],
stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP,
base,
0x106240,
"LoadOfflineSeasons_stage1_completion",
15,
&[
0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00,
0x00,
],
stage1_completion_wrapper as *const () as usize,
&STAGE1_COMPLETION_TRAMP,
);
install_detour_reloc(
base, 0x17ff90, "start_webfile_dl_url", 14,
&[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1],
7, 11,
url_capture_wrapper as *const () as usize, &URL_CAPTURE_TRAMP,
base,
0x17ff90,
"start_webfile_dl_url",
14,
&[
0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1,
],
7,
11,
url_capture_wrapper as *const () as usize,
&URL_CAPTURE_TRAMP,
);
write_log("SEASON_TRACE: all season-native traces armed\n");
}
-81
View File
@@ -1,81 +0,0 @@
// Runtime in-memory patch for ProtoSSL's certificate verification function inside
// EAWebKit.dll. Rather than patching the DLL on disk (offset-dependent, fragile),
// we scan the loaded module for the function's unique byte prologue and overwrite the
// first six bytes with `mov eax, 1; ret` — making every cert-chain validation call
// immediately return success.
//
// Why this is safe: the patched function (`ProtoSSL_VerifyCert` at VA 0x180a85570 in
// the shipped binary) is only used by ProtoSSL's TLS state machine to validate the
// server's certificate chain. Always returning 1 is equivalent to trusting all certs,
// which is the behaviour we want for the local self-signed bridge certificate.
use windows_sys::Win32::System::{
LibraryLoader::GetModuleHandleA,
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
};
// Unique 22-byte prologue of ProtoSSL's cert-verify function.
// Confirmed present in the EA-shipped EAWebKit.dll (June 2023 build).
const PROLOGUE: &[u8] = &[
0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d
0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx
0x56, // push rsi
0x57, // push rdi
0x41, 0x55, // push r13
0x41, 0x56, // push r14
0x41, 0x57, // push r15
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
];
// Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error.
// The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success.
const PATCH: &[u8] = &[
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
0xc3, // ret
0x90, 0x90, 0x90, // nop padding
];
fn patch_module(module: isize, scan_bytes: usize) -> bool {
if module == 0 {
return false;
}
let base = module as usize;
let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) };
let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) {
Some(o) => o,
None => return false,
};
let target = (base + offset) as *mut u8;
let mut old_prot: u32 = 0;
unsafe {
VirtualProtect(
target as *const core::ffi::c_void,
PATCH.len(),
PAGE_EXECUTE_READWRITE,
&mut old_prot,
);
core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len());
VirtualProtect(
target as *const core::ffi::c_void,
PATCH.len(),
old_prot,
&mut old_prot,
);
}
true
}
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
let module = GetModuleHandleA(c"EAWebKit.dll".as_ptr().cast()) as isize;
// EAWebKit.dll is ~22 MB
patch_module(module, 24 * 1024 * 1024)
}
/// Patch ProtoSSL cert-verify compiled into FIFA23.exe itself (DirtySDK's copy).
/// The main exe is ~100 MB; confirmed present at file offset 0xf0c850.
pub unsafe fn patch_main_exe_cert_verify() -> bool {
let module = GetModuleHandleA(core::ptr::null()) as isize;
// Scan first 110 MB — the function is near offset 0xf0c850 (~15 MB in)
patch_module(module, 110 * 1024 * 1024)
}
+3 -1
View File
@@ -439,7 +439,9 @@ unsafe fn worker() {
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n");
crate::write_log(
"STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n",
);
std::thread::spawn(|| unsafe { worker() });
}
-40
View File
@@ -1,40 +0,0 @@
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::BOOL;
// CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success.
// We use raw pointers to avoid pulling in the full Cryptography struct tree.
type CertVerifyChainPolicyFn = unsafe extern "system" fn(
*const u8, // pszPolicyOID
*const (), // pChainContext
*const (), // pPolicyPara
*mut u32, // &mut pPolicyStatus.dwError (first field)
) -> BOOL;
static REAL: OnceLock<CertVerifyChainPolicyFn> = OnceLock::new();
pub fn set_real(f: CertVerifyChainPolicyFn) {
let _ = REAL.set(f);
}
/// Hooked CertVerifyCertificateChainPolicy — always reports success.
/// This allows the bridge's self-signed TLS cert to be accepted by the game.
pub unsafe extern "system" fn hooked_cert_verify_chain_policy(
psz_policy_oid: *const u8,
p_chain_context: *const (),
p_policy_para: *const (),
p_policy_status: *mut u32,
) -> BOOL {
if let Some(real) = REAL.get().copied() {
real(
psz_policy_oid,
p_chain_context,
p_policy_para,
p_policy_status,
);
}
// Clear the error field of CERT_CHAIN_POLICY_STATUS regardless
if !p_policy_status.is_null() {
*p_policy_status = 0;
}
1 // TRUE = verified OK
}
-227
View File
@@ -1,227 +0,0 @@
//! Milestone 0 — Blaze transport reachability observation.
//!
//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is
//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo
//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx
//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly
//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question:
//!
//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a
//! full menu+FUT session, or none at all?
//!
//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed
//! only to describe it in the log. We never change a resolution result or a
//! connection target — that redirect logic lives in the detours themselves and is
//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output;
//! disarmed (default) this module is inert (each entry point returns immediately).
//!
//! Future-reference note (beyond-beginner, deliberately NOT done here): a
//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet /
//! Short) and a `TransportEvent` and route them through the `tracing` crate with
//! structured fields, instead of hand-formatting strings into a flat log file. That
//! buys machine-parseable logs and log levels. For a one-shot observation gate,
//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice.
use core::sync::atomic::{AtomicBool, Ordering};
/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain
/// `static mut bool`) because the detours that read it run on arbitrary game threads;
/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a
/// standalone flag with no ordering relationship to other memory.
static ARMED: AtomicBool = AtomicBool::new(false);
/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain`
/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is
/// what makes the switch actually propagate through the umu/Proton launch — the same
/// gotcha the probe switches hit; it works because the launch script `export`s it.
pub fn arm_from_env() {
let on = std::env::var("OPENFUT_TRANSPORT_WATCH")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(on, Ordering::Relaxed);
crate::write_log(&format!(
"TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n",
if on { "ARMED" } else { "disarmed" }
));
}
fn armed() -> bool {
ARMED.load(Ordering::Relaxed)
}
/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log
/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing
/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is
/// deliberately narrower and unchanged.
fn is_blaze_flavored(host: &str) -> bool {
let h = host.to_ascii_lowercase();
[
"redirector",
"gosredirector",
"blaze",
"gosca",
"easfc",
"utas",
"fut",
"ea.com",
"easports",
]
.iter()
.any(|k| h.contains(k))
}
/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be
/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds
/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set
/// and a Blaze host stands out.
pub fn note_getaddrinfo(host: &str) {
if !armed() {
return;
}
let tag = if is_blaze_flavored(host) {
" <-- BLAZE/EA-FLAVORED"
} else {
""
};
crate::write_log(&format!(
"TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n"
));
}
const AF_INET: u16 = 2; // IPv4
const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI)
/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY
/// sockaddr, so reading this layout is safe enough to classify the family even when
/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've
/// confirmed `sin_family == AF_INET`.
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order;
/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact
/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector
/// variants, 3659 classic redirector.
fn is_blaze_port(port: u16) -> bool {
matches!(port, 42127 | 10744 | 3659 | 10041)
}
/// Log one outbound connect attempt. `api` names the call path (`connect` /
/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used.
///
/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr
/// the game just handed to a Winsock connect API, so that always holds at the call
/// sites. We read it read-only and never write through it. `s` is the socket handle,
/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is
/// distinguishable from UDP game/voice traffic.
pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
if !armed() {
return;
}
if name.is_null() || namelen < 8 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the
// address family for ANY sockaddr, so reading it is valid regardless of the real
// struct type. We only trust family-specific fields after matching the family.
let family = *(name as *const u16);
// SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad
// handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2.
let sock_type = {
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
let mut ty: i32 = -1;
let mut len: i32 = 4;
getsockopt(
s,
SOL_SOCKET,
SO_TYPE,
&mut ty as *mut i32 as *mut u8,
&mut len,
);
ty
};
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read.
let sa = &*(name as *const SockaddrIn);
// sin_addr holds the address in NETWORK byte order; on little-endian x86,
// to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted
// quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line
// prints these reversed — a cosmetic bug there; this M0 line is the correct
// one to trust.)
let b = sa.sin_addr.to_le_bytes();
let port = u16::from_be(sa.sin_port);
let is_loopback = b[0] == 127;
let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
// A loopback connect on anything other than LSX is the situation-(a) signal.
if is_loopback && !is_lsx {
tag.push_str(" <-- LOOPBACK non-LSX");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n",
b[0], b[1], b[2], b[3]
));
}
AF_INET6 => {
if namelen < 28 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa = &*(name as *const SockaddrIn6);
let a = sa.sin6_addr; // 16 bytes, network order
let port = u16::from_be(sa.sin6_port);
// Format as 8 colon-separated hex groups (not compressed — clarity over
// brevity for a log meant to be grepped).
let hex = (0..8)
.map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1]))
.collect::<Vec<_>>()
.join(":");
// ::1 = loopback: first 15 bytes zero, last byte 1.
let is_loopback = a[..15].iter().all(|&x| x == 0) && a[15] == 1;
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
if is_loopback {
tag.push_str(" <-- IPv6 LOOPBACK (::1)");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target=[{hex}]:{port} sock_type={sock_type} (IPv6){tag}\n"
));
}
other => {
// AF_UNIX=1 or anything else — where a named-pipe/unix-socket-style local
// Blaze transport would surface.
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family={other} (non-INET — possible AF_UNIX/pipe-like)\n"
));
}
}
}
+10
View File
@@ -0,0 +1,10 @@
#![cfg(windows)]
#![allow(dead_code)]
// Compile the production connect hook directly into an executable test target.
// The hook crate itself is a cdylib, whose unit-test artifact remains a DLL and
// therefore cannot be executed by the native Windows test runner.
fn write_log(_: &str) {}
#[path = "../src/connect_hook.rs"]
mod connect_hook;
+9 -1
View File
@@ -1804,7 +1804,15 @@ impl LauncherApp {
impl eframe::App for LauncherApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.request_repaint_after(std::time::Duration::from_millis(500));
// Render continuously (present every vsync) instead of reactively. egui
// normally idles at a low, bursty repaint rate; on a G-Sync / FreeSync
// (VRR) display a windowed app that presents in bursts with idle gaps
// makes DWM keep moving the window in and out of the VRR path and the
// refresh rate swing — which the panel shows as flicker. Presenting on
// every frame keeps the window continuously in VRR at the display's own
// (variable) refresh, which is stable. vsync (on by default) paces this to
// the monitor rather than spinning uncapped.
ctx.request_repaint();
self.drive_restart_queue();
egui::TopBottomPanel::top("header")
+14 -1
View File
@@ -25,6 +25,7 @@ use crate::config::LauncherConfig;
/// Accept only hostname/IP characters. These values come from config fields that
/// are ever only IPs or hostnames, so a surprising character is a bug — reject it
/// rather than try to escape it into an elevated shell command.
#[cfg(unix)]
fn safe_host(s: &str) -> anyhow::Result<&str> {
let t = s.trim();
if t.is_empty() {
@@ -41,6 +42,7 @@ fn safe_host(s: &str) -> anyhow::Result<&str> {
/// Build the privileged arming script. Pure and unit-tested; the effectful part
/// ([`arm`]) only validates config and hands this to the elevated runner.
#[cfg(unix)]
pub(crate) fn arming_script(
server: &str,
redirector_port: u16,
@@ -84,6 +86,7 @@ pub(crate) fn arming_script(
/// Human-readable list of what [`arm`] changed, in the order the script applies
/// it. Logged by the UI so the user sees exactly what was set — not just that
/// "something" ran under `pkexec`.
#[cfg(unix)]
pub(crate) fn arming_summary(
server: &str,
redirector_port: u16,
@@ -103,6 +106,16 @@ pub(crate) fn arming_summary(
/// Arm the client from config, under one elevated prompt. Requires the same
/// fields preflight reads; a missing one is a clear error, never a silent
/// loopback fallback. Returns the applied changes for the UI to surface.
/// On native Windows there is nothing to arm: routing is the `openfut.cfg` the
/// client-files step writes into the game directory (read by the version.dll
/// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns
/// no changes so the launch sequence treats client preparation as satisfied.
#[cfg(windows)]
pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
#[cfg(unix)]
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
@@ -128,7 +141,7 @@ pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
))
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
+35 -24
View File
@@ -54,13 +54,18 @@ pub struct GameProfile {
impl GameProfile {
/// Whether this profile is filled in enough to launch from.
pub fn configured(&self) -> bool {
!self.runner.trim().is_empty()
&& !self.executable.trim().is_empty()
&& !self.game_dir.trim().is_empty()
// Windows starts the executable directly (no runner); unix needs a
// runner such as umu-run.
#[cfg(windows)]
let runner_ok = true;
#[cfg(unix)]
let runner_ok = !self.runner.trim().is_empty();
runner_ok && !self.executable.trim().is_empty() && !self.game_dir.trim().is_empty()
}
/// Reject a half-filled profile rather than launching something surprising.
pub fn validate(&self) -> Result<(), String> {
#[cfg(unix)]
if self.runner.trim().is_empty() {
return Err("Game profile has no runner (e.g. umu-run).".into());
}
@@ -70,23 +75,30 @@ impl GameProfile {
if self.game_dir.trim().is_empty() {
return Err("Game profile has no game directory.".into());
}
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile defines prefix links but no wine_prefix.".into());
}
for l in &self.prefix_links {
if l.link.trim().is_empty() || l.target.trim().is_empty() {
return Err("Game profile has a prefix link with an empty link or target.".into());
// Wine-prefix links and the DRM licence precondition only exist on the
// unix/Proton launch path; native Windows has neither.
#[cfg(unix)]
{
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile defines prefix links but no wine_prefix.".into());
}
if std::path::Path::new(&l.link).is_absolute() {
return Err(format!(
"Prefix link {:?} must be relative to the Wine prefix.",
l.link
));
for l in &self.prefix_links {
if l.link.trim().is_empty() || l.target.trim().is_empty() {
return Err(
"Game profile has a prefix link with an empty link or target.".into(),
);
}
if std::path::Path::new(&l.link).is_absolute() {
return Err(format!(
"Prefix link {:?} must be relative to the Wine prefix.",
l.link
));
}
}
}
if let Some(lic) = &self.license {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
return Err("Game profile licence needs both a path and a generator.".into());
if let Some(lic) = &self.license {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
return Err("Game profile licence needs both a path and a generator.".into());
}
}
}
Ok(())
@@ -110,7 +122,8 @@ pub struct LauncherConfig {
pub bridge_tls_enabled: bool,
/// Path to the built openfut_hook.dll (Windows DLL for Proton injection).
pub hook_dll_path: String,
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
/// Game folder where the hook DLL (version.dll) is deployed. Empty means
/// "not configured" — the hook deploy/check is skipped until the user sets it.
pub fifa_game_dir: String,
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
/// hostname. Empty means "not configured" — launching is blocked until set.
@@ -225,11 +238,9 @@ impl Default for LauncherConfig {
.unwrap_or_default()
.to_string_lossy()
.into(),
fifa_game_dir: dirs::home_dir()
.map(|h| h.join(".steam/steam/steamapps/common/FIFA 23"))
.unwrap_or_default()
.to_string_lossy()
.into(),
// Empty by default, like the server host and game profile: the
// launcher never invents a path to somebody's game install.
fifa_game_dir: String::new(),
// No server configured by default — the user MUST enter one. There
// is deliberately no loopback/localhost default.
openfut_server_host: String::new(),
+72 -2
View File
@@ -24,11 +24,13 @@
//! falls back to it, so an existing working setup cannot be broken by upgrading.
use parking_lot::Mutex;
#[cfg(unix)]
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
#[cfg(unix)]
use std::time::{Duration, Instant};
use crate::config::GameProfile;
@@ -45,6 +47,7 @@ fn say(log: &Log, msg: impl Into<String>) {
/// Returns once the game process has been spawned; its output continues to
/// stream into `log` on background threads. `on_exit` fires when the process
/// ends, which is how the launch state machine leaves its Running state.
#[cfg(unix)]
pub fn launch(
profile: &GameProfile,
log: &Log,
@@ -96,6 +99,62 @@ pub fn launch(
Ok(())
}
/// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads
/// the `version.dll` hook from its own directory through the normal search
/// order), and no licence regeneration (the native loader handles DRM).
/// Routing is the `openfut.cfg` that the client-files step already wrote into
/// the game directory.
///
/// The launcher must itself be running elevated (its shortcut carries the
/// RunAsAdmin bit): the loader requires administrator rights, and a child
/// started with `CreateProcess` inherits the launcher's token instead of
/// raising its own UAC prompt.
#[cfg(windows)]
pub fn launch(
profile: &GameProfile,
log: &Log,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> {
profile.validate().map_err(anyhow::Error::msg)?;
let game_dir = PathBuf::from(&profile.game_dir);
if !game_dir.is_dir() {
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
}
let exe = game_dir.join(&profile.executable);
if !exe.is_file() {
anyhow::bail!("game executable not found: {}", exe.display());
}
let mut cmd = Command::new(&exe);
cmd.current_dir(&game_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &profile.env {
cmd.env(k, v);
}
say(
log,
format!(
"[launcher] launching {} (cwd {})",
exe.display(),
game_dir.display()
),
);
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?;
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
///
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
@@ -110,13 +169,17 @@ pub fn launch(
/// survives restarts and applies to every launch path, including Steam. This mirrors
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
/// environment) and what Proton itself already does in this prefix for other titles.
#[cfg(unix)]
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
#[cfg(unix)]
const HOOK_DLL_VALUE: &str = "version";
#[cfg(unix)]
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
/// repairs a prefix a player has reset or replaced.
#[cfg(unix)]
fn dll_override_args() -> [&'static str; 10] {
[
"reg",
@@ -138,6 +201,7 @@ fn dll_override_args() -> [&'static str; 10] {
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
/// error, since the player cannot act on the latter.
#[cfg(unix)]
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
if profile.wine_prefix.trim().is_empty() {
return;
@@ -163,7 +227,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) {
}
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod override_tests {
use super::*;
@@ -204,6 +268,7 @@ mod override_tests {
///
/// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled.
#[cfg(unix)]
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
const HOOK: &str = "version=n,b";
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
@@ -217,6 +282,7 @@ fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
/// an existing link is replaced, so re-running is harmless.
#[cfg(unix)]
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
return Ok(());
@@ -257,6 +323,7 @@ fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// A crashed or failed launch deletes the licence, so this runs before every
/// launch rather than only on first setup — that is the behaviour the shell
/// script proved, and it is why a crash is normally self-healing on the next try.
#[cfg(unix)]
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let Some(lic) = &profile.license else {
return Ok(());
@@ -316,6 +383,7 @@ fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// and it is reproduced deliberately — the pattern is a Windows executable name,
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
/// that *can* match its own caller is a real hazard; this one cannot.)
#[cfg(unix)]
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
let _ = child.kill();
let _ = child.wait();
@@ -333,6 +401,7 @@ fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Lo
/// A relative licence path is taken as relative to the Wine prefix; an absolute
/// one is used as given.
#[cfg(unix)]
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() || prefix.trim().is_empty() {
@@ -345,6 +414,7 @@ fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
/// as useless as a missing one, and treating it as valid would skip the
/// regeneration that fixes it.
#[cfg(unix)]
fn non_empty_file(path: &Path) -> bool {
std::fs::metadata(path)
.map(|m| m.len() > 0)
@@ -381,7 +451,7 @@ pub fn stream(
});
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::config::{LicenseCheck, PrefixLink};
+22 -2
View File
@@ -22,6 +22,7 @@ use std::{
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use crate::fifa17_capability::{
@@ -79,12 +80,18 @@ impl Service {
/// keeps `spawn` responsible for reporting a missing binary, with one error message
/// instead of two.
fn resolve_binary(service: Service) -> PathBuf {
let name = service.binary();
let base = service.binary();
// On Windows the built companion is `openfut-lsx.exe`; a bare name without the
// extension matches neither the sibling file nor CreateProcess resolution.
#[cfg(windows)]
let name = format!("{base}.exe");
#[cfg(unix)]
let name = base.to_string();
if let Some(dir) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
{
let sibling = dir.join(name);
let sibling = dir.join(&name);
if sibling.is_file() {
return sibling;
}
@@ -433,6 +440,18 @@ impl ServiceSupervisor {
/// Start `service` only if it is not already usable. Never restarts a healthy
/// service, and never adopts a foreign one as ours.
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
// On Windows the ProtoSSL cert-verify patch (autopatch's job on unix, via
// /proc/PID/mem) is performed in-process by the version.dll hook, so there
// is no autopatch process to run. LSX is different: the game dials it on
// 127.0.0.1:4216, so it MUST run locally here exactly as on unix.
#[cfg(windows)]
if service == Service::Autopatch {
self.log.lock().push(
"[launcher] autopatch runs in-process on Windows (version.dll hook) — nothing to start."
.to_string(),
);
return Ok(Ensured::Reused);
}
let runtime = self.observe(service);
if runtime.ready() {
self.log.lock().push(format!(
@@ -532,6 +551,7 @@ pub fn spawn(
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
}
// Put each companion in its own process group for lifecycle isolation.
#[cfg(unix)]
cmd.process_group(0);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
+3
View File
@@ -22,6 +22,9 @@ fn main() -> eframe::Result<()> {
.with_icon(app_icon())
.with_inner_size([1040.0, 720.0])
.with_min_inner_size([880.0, 600.0]),
// Pair vsync with the display's VRR (G-Sync + Vsync is the recommended
// combination): frames present on the monitor's own variable refresh.
vsync: true,
..Default::default()
};
+17 -1
View File
@@ -31,6 +31,7 @@ use std::time::Duration;
use crate::config::LauncherConfig;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(unix)]
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -86,6 +87,7 @@ impl Check {
}
/// Run every applicable check. Order is the order the game exercises them.
#[cfg(unix)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(),
@@ -96,6 +98,16 @@ pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
]
}
/// On native Windows the client-preparation checks (ptrace_scope, the EA
/// redirector DNAT, `/etc/hosts`) do not apply: there is no host to arm and
/// routing is entirely the `openfut.cfg` the hook reads. Only the two the game
/// truly depends on remain: the backend is reachable and the deployed hook
/// config agrees with the launcher's settings.
#[cfg(windows)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![backend_reachable(cfg), hook_config(cfg)]
}
/// Checks that will stop the game working.
pub fn failures(checks: &[Check]) -> usize {
checks.iter().filter(|c| c.state == State::Fail).count()
@@ -113,6 +125,7 @@ pub fn warnings(checks: &[Check]) -> usize {
/// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it.
#[cfg(unix)]
fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
match std::fs::read_to_string(PTRACE_SCOPE) {
@@ -127,6 +140,7 @@ fn ptrace_scope() -> Check {
/// Reading `/proc` in a test would assert facts about the machine running the
/// suite rather than about this code — and left inline, "any value is fine"
/// was a mutation no test could catch.
#[cfg(unix)]
fn ptrace_verdict(raw: &str) -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
let v = raw.trim();
@@ -146,6 +160,7 @@ fn ptrace_verdict(raw: &str) -> Check {
///
/// This tests the *effect* rather than reading firewall rules, so it needs no
/// privilege and stays honest about what the game will actually experience.
#[cfg(unix)]
fn ea_redirect(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA redirector IP is redirected";
let ip = cfg.ea_redirect_probe_ip.trim();
@@ -184,6 +199,7 @@ fn ea_redirect(cfg: &LauncherConfig) -> Check {
/// So this is a real misconfiguration worth fixing and not a reason to expect
/// failure. Reporting it as fatal, and then being contradicted by a working
/// game, is how a checklist trains its user to ignore it.
#[cfg(unix)]
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA hostnames point at OpenFUT";
if cfg.ea_hostnames.is_empty() {
@@ -320,7 +336,7 @@ fn join(ips: &[IpAddr]) -> String {
.join(",")
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
+7 -7
View File
@@ -70,13 +70,13 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
/// The file the injected hook reads its server address from, in the game dir.
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
/// openfut.cfg with the structured server configuration the hook reads.
/// `cfg_contents` must be the full `openfut.cfg` body (see
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
/// address itself, so a missing server can never silently become loopback.
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
/// the system copy, so Proton picks up our local one first.
/// Deploy openfut_hook.dll into the game directory and write openfut.cfg with the
/// structured server configuration the hook reads. `cfg_contents` must be the full
/// `openfut.cfg` body (see `LauncherConfig::hook_cfg_contents`) — this function
/// does not invent any address itself, so a missing server can never silently
/// become loopback. Uses `version.dll` as the hijack name: the game loads it but
/// defers to the system copy, so the loader (native or Wine) picks up our local
/// one first.
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
if !dll_src.exists() {
anyhow::bail!(
+9 -1
View File
@@ -299,5 +299,13 @@ fn install_style(ctx: &Context) {
v.widgets.open.rounding = radius;
style.visuals = v;
ctx.set_style(style);
// egui 0.29 keeps a separate `Style` per theme (dark/light) and renders with
// whichever the theme preference resolves to. `set_style` touches only the
// currently-active theme, so a later switch to the other one would drop our
// named text styles ("Hero", "Subheading", …) and panic in `TextStyle::resolve`.
// Install the full style into BOTH themes and pin the preference to Dark so
// the branded look is stable regardless of the host's system theme.
ctx.set_style_of(egui::Theme::Dark, style.clone());
ctx.set_style_of(egui::Theme::Light, style);
ctx.set_theme(egui::ThemePreference::Dark);
}