Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44ebc4b23c | |||
| b098617573 | |||
| 00ad631034 | |||
| 55ffbd8c7e | |||
| 16f3452990 | |||
| 966e92b304 | |||
| cf515f5584 | |||
| 6be75f5452 | |||
| 057cf92c3b |
@@ -115,6 +115,41 @@ pub struct ResolvedServer {
|
|||||||
pub ports: OpenFutPorts,
|
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
|
/// Errors loading/validating OpenFUT server configuration. Every one of these
|
||||||
/// must BLOCK operation — none of them may fall back to loopback.
|
/// must BLOCK operation — none of them may fall back to loopback.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -476,4 +511,58 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5
@@ -2,10 +2,15 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-common"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-hook"
|
name = "openfut-hook"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"openfut-common",
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+8
-11
@@ -13,17 +13,10 @@ edition = "2021"
|
|||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect,
|
# Per-game selection: each supported game is a feature enabling its module. Exactly
|
||||||
# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real
|
# one MUST be set (the crate emits a compile_error otherwise). Build the deployed
|
||||||
# responses). Default build keeps the redirect (LSX → our bridge).
|
# artifact with `--features fifa17`. Add a future game as a new feature here plus a
|
||||||
capture_baseline = []
|
# `mod <game>;` + dispatch arm in lib.rs — never by copying a retired game's code.
|
||||||
# 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.
|
|
||||||
fifa17 = []
|
fifa17 = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -39,6 +32,10 @@ windows-sys = { version = "0.59", features = [
|
|||||||
"Win32_System_Diagnostics_Debug",
|
"Win32_System_Diagnostics_Debug",
|
||||||
"Win32_System_Kernel",
|
"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]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ fn main() {
|
|||||||
{
|
{
|
||||||
let definition =
|
let definition =
|
||||||
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,6 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const AF_INET: u16 = 2;
|
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)]
|
#[repr(C)]
|
||||||
struct SockaddrIn {
|
struct SockaddrIn {
|
||||||
@@ -41,19 +27,37 @@ struct SockaddrIn6 {
|
|||||||
sin6_scope_id: u32,
|
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)
|
// Address of ws2_32!connect (set at hook installation)
|
||||||
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
|
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
// Original 14 bytes saved before we overwrite them
|
// Original 14 bytes saved before we overwrite them
|
||||||
static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
|
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
|
// For WSAConnect IAT fallback
|
||||||
type WsaConnectFn = unsafe extern "system" fn(
|
type WsaConnectFn = unsafe extern "system" fn(
|
||||||
s: usize,
|
s: usize,
|
||||||
@@ -89,82 +93,82 @@ unsafe fn restore_original(target: *mut u8) {
|
|||||||
VirtualProtect(target as _, 14, old, &mut old);
|
VirtualProtect(target as _, 14, old, &mut old);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
|
/// The armed redirect target, resolved once from `openfut.cfg` via `openfut-common`.
|
||||||
/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the
|
/// When set, `redirect_if_ea` rewrites matched EA connections to this configured
|
||||||
/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old
|
/// server; when unset, matched connections are left untouched (no redirect).
|
||||||
/// IPv4-only path let those slip straight past us to the real (dead) servers.
|
static REDIRECT: OnceLock<openfut_common::ResolvedServer> = OnceLock::new();
|
||||||
///
|
|
||||||
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
|
/// Arm the config-driven redirect (FIFA17). Idempotent: the first call wins.
|
||||||
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
|
pub fn set_redirect(server: openfut_common::ResolvedServer) {
|
||||||
/// ConnectEx path can share this one implementation.
|
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)> {
|
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
|
||||||
if namelen < 8 || name.is_null() {
|
if namelen < 8 || name.is_null() {
|
||||||
return None;
|
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 family = *(name as *const u16);
|
||||||
let mut buf = [0u8; 28];
|
let mut buf = [0u8; 28];
|
||||||
|
|
||||||
match family {
|
match family {
|
||||||
AF_INET => {
|
AF_INET => {
|
||||||
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
|
|
||||||
let sa = &*(name as *const SockaddrIn);
|
let sa = &*(name as *const SockaddrIn);
|
||||||
let new_port_nbo = match sa.sin_port {
|
let redir = server.redirect_for_ea_port(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();
|
|
||||||
crate::write_log(&format!(
|
crate::write_log(&format!(
|
||||||
"connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
"connect_hook: v4 :{} → {}:{}\n",
|
||||||
o[0],
|
|
||||||
o[1],
|
|
||||||
o[2],
|
|
||||||
o[3],
|
|
||||||
u16::from_be(sa.sin_port),
|
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);
|
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||||
out.sin_family = AF_INET;
|
out.sin_family = AF_INET;
|
||||||
out.sin_port = new_port_nbo;
|
out.sin_port = redir.port_nbo;
|
||||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
out.sin_addr = redir.addr_nbo;
|
||||||
Some((buf, 16))
|
Some((buf, 16))
|
||||||
}
|
}
|
||||||
AF_INET6 => {
|
AF_INET6 => {
|
||||||
if namelen < 28 {
|
if namelen < 28 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
|
|
||||||
let sa6 = &*(name as *const SockaddrIn6);
|
let sa6 = &*(name as *const SockaddrIn6);
|
||||||
// LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here.
|
let redir = server.redirect_for_ea_port(sa6.sin6_port)?;
|
||||||
let new_port_nbo = match sa6.sin6_port {
|
// ::ffff:<redirect_ip> — a v4-mapped v6 target so a v6 socket sends
|
||||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
// real IPv4 packets to the configured server.
|
||||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
let o = redir.redirect_ip.octets();
|
||||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
let mut v4mapped = [0u8; 16];
|
||||||
_ => return None,
|
v4mapped[10] = 0xff;
|
||||||
};
|
v4mapped[11] = 0xff;
|
||||||
let a = sa6.sin6_addr;
|
v4mapped[12..16].copy_from_slice(&o);
|
||||||
crate::write_log(&format!(
|
crate::write_log(&format!(
|
||||||
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
|
"connect_hook: v6 :{} → ::ffff:{}:{}\n",
|
||||||
a[0],
|
|
||||||
a[1],
|
|
||||||
a[14],
|
|
||||||
a[15],
|
|
||||||
u16::from_be(sa6.sin6_port),
|
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);
|
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
|
||||||
out.sin6_family = AF_INET6;
|
out.sin6_family = AF_INET6;
|
||||||
out.sin6_port = new_port_nbo;
|
out.sin6_port = redir.port_nbo;
|
||||||
out.sin6_flowinfo = 0;
|
out.sin6_flowinfo = 0;
|
||||||
out.sin6_addr = V4MAPPED_LOOPBACK;
|
out.sin6_addr = v4mapped;
|
||||||
out.sin6_scope_id = 0;
|
out.sin6_scope_id = 0;
|
||||||
Some((buf, 28))
|
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 {
|
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;
|
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
|
// Log every call so we can confirm the hook fires at all
|
||||||
if namelen >= 8 {
|
if namelen >= 8 {
|
||||||
let sa = &*(name as *const SockaddrIn);
|
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);
|
core::mem::transmute(addr);
|
||||||
f(s, buf.as_ptr(), len)
|
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);
|
write_hook(addr, hooked_connect as *const () as u64);
|
||||||
return r;
|
return r;
|
||||||
} else {
|
} 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);
|
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
|
||||||
f(s, call_name, call_len)
|
f(s, call_name, call_len)
|
||||||
};
|
};
|
||||||
|
let last_error = WsaLastErrorGuard::capture();
|
||||||
write_hook(addr, hooked_connect as *const () as u64);
|
write_hook(addr, hooked_connect as *const () as u64);
|
||||||
if namelen >= 8 {
|
if namelen >= 8 {
|
||||||
let sa = &*(call_name as *const SockaddrIn);
|
let sa = &*(call_name as *const SockaddrIn);
|
||||||
if sa.sin_family == AF_INET {
|
if sa.sin_family == AF_INET {
|
||||||
let err = if r != 0 {
|
let logged_error = if r != 0 { last_error.value() } else { 0 };
|
||||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
crate::write_log(&format!(
|
||||||
WSAGetLastError()
|
"connect_hook: result={r} wsa_err={logged_error}\n"
|
||||||
} else {
|
));
|
||||||
0
|
|
||||||
};
|
|
||||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r
|
r
|
||||||
@@ -251,8 +252,6 @@ pub unsafe extern "system" fn hooked_wsa_connect(
|
|||||||
sqos: *const (),
|
sqos: *const (),
|
||||||
gqos: *const (),
|
gqos: *const (),
|
||||||
) -> i32 {
|
) -> 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();
|
let real = REAL_WSA.get().copied().unwrap();
|
||||||
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
||||||
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
|
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);
|
write_hook(connect_fn, hooked_connect as *const () as u64);
|
||||||
true
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,10 +77,6 @@ unsafe extern "system" fn hooked_connectex(
|
|||||||
overlapped: *mut c_void,
|
overlapped: *mut c_void,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
|
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
|
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
|
||||||
// dials get the same IPv6 handling as plain connect().
|
// dials get the same IPv6 handling as plain connect().
|
||||||
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
|
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
-10
@@ -1,14 +1,12 @@
|
|||||||
//! FIFA 17 injection path (feature = "fifa17").
|
//! FIFA 17 injection path (feature = "fifa17").
|
||||||
//!
|
//!
|
||||||
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
|
//! This is the game module selected by the `fifa17` feature: `install()` spawns a
|
||||||
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
|
//! worker (off the loader lock) that dumps the module map, arms the config-driven
|
||||||
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
|
//! network redirect (connect / WSAConnect / ConnectEx, target from `openfut.cfg`
|
||||||
//! no-op and at worst crash. For now this proves the version.dll hijack actually
|
//! via `openfut-common`), and installs the FIFA-17 SBC dispatch repair plus the
|
||||||
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
|
//! store/season hooks. Structures and RVAs here are specific to FIFA17.exe /
|
||||||
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
|
//! CardsDLL_Win64_retail.dll; a future game gets its own module, never a copy of
|
||||||
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
|
//! this one.
|
||||||
//!
|
|
||||||
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
|
|
||||||
|
|
||||||
use crate::write_log;
|
use crate::write_log;
|
||||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
@@ -67,6 +65,29 @@ unsafe fn dump_modules() {
|
|||||||
CloseHandle(snap);
|
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
|
/// 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
|
/// 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.
|
/// 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();
|
dump_modules();
|
||||||
write_log("fifa17: worker complete (injection healthy)\n");
|
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
|
// 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
|
// 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`.
|
// remaining legacy experiment modules stay inert unless their env gate is `1`.
|
||||||
@@ -88,7 +159,6 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
|||||||
crate::sbc_request_trace::install();
|
crate::sbc_request_trace::install();
|
||||||
crate::store_entry::install();
|
crate::store_entry::install();
|
||||||
crate::season_trace::install();
|
crate::season_trace::install();
|
||||||
crate::kit_trace::install();
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -71,19 +71,6 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize {
|
|||||||
patch_module(module, original_fn, hook_fn)
|
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 {
|
unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||||
if module.is_null() {
|
if module.is_null() {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
//! Passive, behavior-preserving diagnostic traces for FIFA 17's FUT pre-match
|
|
||||||
//! KIT SELECTOR data flow.
|
|
||||||
//!
|
|
||||||
//! RE (2026-08-20, Ghidra on CardsDLL_Win64_retail.dll) established that the
|
|
||||||
//! pre-match kit selector is fed ENTIRELY client-side (NOT by POW/EASFC):
|
|
||||||
//!
|
|
||||||
//! * `FUT_GET_MATCH_KITS_DP` (id 0x7565) builder `FUN_1800be6a0` (rva 0xbe6a0)
|
|
||||||
//! reads a boolean gate `ctx+0x152` (`KITS_AVAILABLE`); when false, or when
|
|
||||||
//! the two available-kit vectors are empty, the selector renders blank/white.
|
|
||||||
//! * The available home/away kit-id lists live on `FutSquadServiceImpl`
|
|
||||||
//! (`this+0xe08` home, `this+0xe38` away) and are written by the setter
|
|
||||||
//! `FUN_180196760` (rva 0x96760, vtable slot 0x1d0): args (this, srcVec, side).
|
|
||||||
//! * A club KIT ITEM is turned into an available kit by `FUN_1801c3480`
|
|
||||||
//! (rva 0x1c3480): it reads item fields (`+0x4c==7`, `+0x60==4`,
|
|
||||||
//! `+0x5c`∈{101 home,102 away}, `+0x94` source teamid, `+0xba`
|
|
||||||
//! teamkittypetechid) and calls `FUN_1801c44b0` (rva 0x1c44b0) to clone that
|
|
||||||
//! team's kit rows from the CLIENT-LOCAL `teamkits` DB into the FUT club
|
|
||||||
//! (teamtechid 130000).
|
|
||||||
//!
|
|
||||||
//! These traces answer, in one operator-driven match, exactly WHERE the empty
|
|
||||||
//! selector originates: do kit club items reach the client (kit_item_clone), does
|
|
||||||
//! the clone into the FUT club happen (kit_db_clone), does the available list get
|
|
||||||
//! set non-empty (set_available_kits), and what does the selector finally read
|
|
||||||
//! (get_match_kits: KITS_AVAILABLE + count). Every trace is read-only: it logs,
|
|
||||||
//! then tail-calls the original through a trampoline. Copied prologues are whole,
|
|
||||||
//! position-independent instructions (the one rip-relative prologue uses the
|
|
||||||
//! relocating installer).
|
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
|
||||||
|
|
||||||
use crate::sbc_trace::{readable_range, validate_cards_build};
|
|
||||||
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32, rd_u8};
|
|
||||||
use crate::write_log;
|
|
||||||
|
|
||||||
static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
|
|
||||||
fn budget() -> bool {
|
|
||||||
REPORTS.fetch_add(1, Ordering::Relaxed) < 256
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn rd_usize(addr: usize) -> Option<usize> {
|
|
||||||
readable_range(addr, 8).then(|| core::ptr::read_volatile(addr as *const usize))
|
|
||||||
}
|
|
||||||
|
|
||||||
// FUT_GET_MATCH_KITS_DP builder FUN_1800be6a0 (0xbe6a0). rcx = DP model ctx.
|
|
||||||
// ctx+0x152 is the KITS_AVAILABLE bool that gates the whole selector list.
|
|
||||||
static GET_MATCH_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn get_match_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
if budget() {
|
|
||||||
let avail = rd_u8(rcx + 0x152);
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_GET: FUT_GET_MATCH_KITS_DP ctx={rcx:#x} KITS_AVAILABLE={avail:?}\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t = GET_MATCH_KITS_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
original(rcx, rdx, r8, r9)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setAvailableKits FUN_180196760 (0x96760): (this, srcVec, side). srcVec is an
|
|
||||||
// int vector {begin@+0, end@+8}; count = (end-begin)/4. side 0=home, 1=away.
|
|
||||||
static SET_AVAILABLE_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn set_available_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
if budget() {
|
|
||||||
let count = match (rd_usize(rdx), rd_usize(rdx + 8)) {
|
|
||||||
(Some(b), Some(e)) if e >= b => ((e - b) / 4) as i64,
|
|
||||||
_ => -1,
|
|
||||||
};
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_SET: setAvailableKits this={rcx:#x} side={r8} count={count}\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t = SET_AVAILABLE_KITS_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
original(rcx, rdx, r8, r9)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kit-item clone driver FUN_1801c3480 (0x1c3480): rdx = param_2, the club-item
|
|
||||||
// event; the item struct is at *(param_2+0x10). Logs the fields the function
|
|
||||||
// branches on so we can see whether a kit club item reaches the client and its
|
|
||||||
// home/away designator + source teamid.
|
|
||||||
static KIT_ITEM_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn kit_item_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
if budget() {
|
|
||||||
if let Some(item) = rd_usize(rdx + 0x10) {
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_ITEM: clone-driver item={item:#x} type[+0x4c]={:?} subid[+0x5c]={:?} \
|
|
||||||
cat[+0x60]={:?} teamid[+0x94]={:?} kittype[+0xba]={:?}\n",
|
|
||||||
rd_i32(item + 0x4c),
|
|
||||||
rd_i32(item + 0x5c),
|
|
||||||
rd_i32(item + 0x60),
|
|
||||||
rd_i32(item + 0x94),
|
|
||||||
rd_i32(item + 0xba),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
write_log(&format!("KIT_ITEM: clone-driver param_2={rdx:#x} (item ptr unreadable)\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let t = KIT_ITEM_CLONE_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
original(rcx, rdx, r8, r9)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kit DB clone FUN_1801c44b0 (0x1c44b0): (clubmgr, side, teamtechid, kittype).
|
|
||||||
// Fires only when the driver decided the item is a home(101)/away(102) kit, so
|
|
||||||
// this is the proof the FUT-club (teamtechid 130000) kit rows get synthesized.
|
|
||||||
static KIT_DB_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
unsafe extern "system" fn kit_db_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
|
||||||
if budget() {
|
|
||||||
write_log(&format!(
|
|
||||||
"KIT_DBCLONE: clone team kit side={rdx} src_teamtechid={r8} kittype={r9}\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let t = KIT_DB_CLONE_TRAMP.load(Ordering::Acquire);
|
|
||||||
if t == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
||||||
core::mem::transmute(t);
|
|
||||||
original(rcx, rdx, r8, r9)
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn worker() {
|
|
||||||
let mut base = 0usize;
|
|
||||||
for _ in 0..600u32 {
|
|
||||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
|
||||||
if base != 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
if base == 0 || !validate_cards_build(base) {
|
|
||||||
write_log("KIT_TRACE: CardsDLL unavailable/invalid; kit trace inactive\n");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// FUN_1800be6a0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 a1 (copy_len 16).
|
|
||||||
install_detour(
|
|
||||||
base, 0xbe6a0, "GetMatchKits_DP(0xbe6a0)", 16,
|
|
||||||
&[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xa1],
|
|
||||||
get_match_kits_wrapper as *const () as usize, &GET_MATCH_KITS_TRAMP,
|
|
||||||
);
|
|
||||||
// FUN_180196760: 48 89 54 24 10 53 48 83 ec 30 48 c7 44 24 20 fe ff ff ff (copy_len 19).
|
|
||||||
install_detour(
|
|
||||||
base, 0x96760, "setAvailableKits(0x96760)", 19,
|
|
||||||
&[0x48, 0x89, 0x54, 0x24, 0x10, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
|
||||||
set_available_kits_wrapper as *const () as usize, &SET_AVAILABLE_KITS_TRAMP,
|
|
||||||
);
|
|
||||||
// FUN_1801c3480: 48 89 5c 24 08 57 48 83 ec 60 <48 8b 05 disp32> (rip-relative
|
|
||||||
// MOV RAX,[rip+..] at copied offset 10; disp32 at 13, insn end 17; copy_len 17).
|
|
||||||
install_detour_reloc(
|
|
||||||
base, 0x1c3480, "kitItemClone(0x1c3480)", 17,
|
|
||||||
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0x8b, 0x05, 0x4f, 0x82, 0x11, 0x00],
|
|
||||||
13, 17,
|
|
||||||
kit_item_clone_wrapper as *const () as usize, &KIT_ITEM_CLONE_TRAMP,
|
|
||||||
);
|
|
||||||
// FUN_1801c44b0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 c8 (copy_len 16).
|
|
||||||
install_detour(
|
|
||||||
base, 0x1c44b0, "kitDbClone(0x1c44b0)", 16,
|
|
||||||
&[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xc8],
|
|
||||||
kit_db_clone_wrapper as *const () as usize, &KIT_DB_CLONE_TRAMP,
|
|
||||||
);
|
|
||||||
write_log("KIT_TRACE: all kit-selector traces armed\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Arm the passive kit-selector diagnostics on a deferred thread (CardsDLL is not
|
|
||||||
/// yet loaded at DllMain time). Read-only: never changes game behavior.
|
|
||||||
pub(crate) fn install() {
|
|
||||||
write_log("KIT_TRACE: requested; deferred signature validation starting\n");
|
|
||||||
std::thread::spawn(|| unsafe { worker() });
|
|
||||||
}
|
|
||||||
+18
-208
@@ -1,27 +1,24 @@
|
|||||||
// The `fifa17` feature compiles this shared crate but activates only the FIFA-17
|
// openfut-hook: the version.dll proxy that injects OpenFUT's client-side
|
||||||
// injection path (fifa17.rs + sbc_*): install_hooks() routes to fifa17::install()
|
// compatibility hooks into an EA FUT client.
|
||||||
// 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
|
// GAME-GENERIC BY FEATURE: each supported game is its own module, selected by a
|
||||||
// but unused under `fifa17` (the linker strips them from the cdylib). Scope the
|
// per-game Cargo feature (currently only `fifa17`). `install_hooks` dispatches to
|
||||||
// resulting dead-code/unused-import lints to that feature so both builds stay
|
// the selected game's `install()`. Generic infrastructure — the version proxy,
|
||||||
// `-D warnings` clean without dropping code the default (FIFA-23) build needs.
|
// the connect/WSAConnect/ConnectEx redirect, IAT primitives, and the shared
|
||||||
#![cfg_attr(feature = "fifa17", allow(dead_code, unused_imports))]
|
// `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 connect_hook;
|
||||||
mod connectex_hook;
|
mod connectex_hook;
|
||||||
mod dial_notification;
|
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod fifa17;
|
mod fifa17;
|
||||||
mod hooks;
|
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod kit_trace;
|
mod fifa17_tls;
|
||||||
mod iat;
|
mod iat;
|
||||||
mod origin_spy;
|
mod patch_mem;
|
||||||
#[cfg(feature = "probe")]
|
|
||||||
mod probe;
|
|
||||||
#[cfg(feature = "capture_baseline")]
|
|
||||||
mod recv_hook;
|
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_dispatch;
|
mod sbc_dispatch;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
@@ -32,16 +29,12 @@ mod sbc_request_trace;
|
|||||||
mod sbc_trace;
|
mod sbc_trace;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod season_trace;
|
mod season_trace;
|
||||||
mod ssl_patch;
|
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod store_entry;
|
mod store_entry;
|
||||||
mod tls_bypass;
|
|
||||||
mod transport_watch;
|
|
||||||
mod version_proxy;
|
mod version_proxy;
|
||||||
|
|
||||||
use windows_sys::Win32::{
|
use windows_sys::Win32::{
|
||||||
Foundation::{BOOL, HMODULE, TRUE},
|
Foundation::{BOOL, HMODULE, TRUE},
|
||||||
Networking::WinSock::ADDRINFOA,
|
|
||||||
System::SystemServices::DLL_PROCESS_ATTACH,
|
System::SystemServices::DLL_PROCESS_ATTACH,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,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
|
/// # Safety
|
||||||
///
|
///
|
||||||
/// This is the DLL entry point invoked by the Windows loader; it MUST NOT be
|
/// This is the DLL entry point invoked by the Windows loader; it MUST NOT be
|
||||||
@@ -90,177 +68,9 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ())
|
|||||||
TRUE
|
TRUE
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn install_hooks(module: HMODULE) {
|
/// Dispatch to the selected game's install path. Exactly one game feature must be
|
||||||
// FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every
|
/// enabled (enforced by the crate-level `compile_error!` above).
|
||||||
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
|
unsafe fn install_hooks(_module: HMODULE) {
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
{
|
fifa17::install();
|
||||||
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 (),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 (40–4F)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,7 @@
|
|||||||
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
//! 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
|
//! 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
|
//! defer off the loader lock and poll for it in a background thread.
|
||||||
//! `probe::install_probes_deferred` polling for anadius64.dll.
|
|
||||||
//!
|
//!
|
||||||
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
|
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
|
||||||
//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva.
|
//! 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 CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
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)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
#[repr(usize)]
|
#[repr(usize)]
|
||||||
enum RuntimeState {
|
enum RuntimeState {
|
||||||
@@ -192,7 +194,7 @@ fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationE
|
|||||||
Ok(())
|
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.
|
/// in a committed, readable page and the full 8 bytes fit inside the region.
|
||||||
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
|
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
|
||||||
if ptr < 0x10000 || ptr & 7 != 0 {
|
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))
|
.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 {
|
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
|
||||||
let Some(end) = ptr.checked_add(len) else {
|
let Some(end) = ptr.checked_add(len) else {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub(crate) const CATEGORY_FACTORY_RVA: usize = 0x17aa10;
|
|||||||
pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0;
|
pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0;
|
||||||
const COPY_LEN: usize = 19;
|
const COPY_LEN: usize = 19;
|
||||||
const ABS_JUMP_LEN: usize = 14;
|
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 TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN;
|
||||||
const NOTIFIER_RVA: usize = 0x17aa80;
|
const NOTIFIER_RVA: usize = 0x17aa80;
|
||||||
const NOTIFIER_COPY_LEN: usize = 15;
|
const NOTIFIER_COPY_LEN: usize = 15;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! flow. Targets are chosen so their copied prologues are position-independent
|
//! flow. Targets are chosen so their copied prologues are position-independent
|
||||||
//! (no rip-relative / rel32 in the copied bytes).
|
//! (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::Diagnostics::Debug::FlushInstructionCache;
|
||||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||||
@@ -28,17 +28,15 @@ use crate::sbc_trace::{
|
|||||||
use crate::write_log;
|
use crate::write_log;
|
||||||
|
|
||||||
static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
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);
|
|
||||||
|
|
||||||
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
|
unsafe fn rd_i32(addr: usize) -> Option<i32> {
|
||||||
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
|
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
|
||||||
}
|
}
|
||||||
pub(crate) unsafe fn rd_u8(addr: usize) -> Option<u8> {
|
unsafe fn rd_u8(addr: usize) -> Option<u8> {
|
||||||
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
|
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
|
||||||
}
|
}
|
||||||
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
|
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
|
||||||
pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String {
|
unsafe fn rd_cstr(addr: usize, max: usize) -> String {
|
||||||
if addr == 0 || !readable_range(addr, 1) {
|
if addr == 0 || !readable_range(addr, 1) {
|
||||||
return String::from("<unreadable>");
|
return String::from("<unreadable>");
|
||||||
}
|
}
|
||||||
@@ -58,7 +56,7 @@ pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String {
|
|||||||
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
|
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
|
||||||
/// MUST be whole, position-independent instructions) with an absolute jump to
|
/// MUST be whole, position-independent instructions) with an absolute jump to
|
||||||
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
|
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
|
||||||
pub(crate) unsafe fn install_detour(
|
unsafe fn install_detour(
|
||||||
base: usize,
|
base: usize,
|
||||||
rva: usize,
|
rva: usize,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -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!(
|
||||||
season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native");
|
load_current_native_wrapper,
|
||||||
season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native");
|
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
|
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
|
||||||
// actually calls; hands the callback name to the manager's async slot 0x80.
|
// 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
|
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
|
||||||
// count and invokes the LoadSeasons_Complete AS callback.
|
// 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
|
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
|
||||||
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
|
// 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
|
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
|
||||||
// whether it ever fires; logs the result fields it branches on.
|
// whether it ever fires; logs the result fields it branches on.
|
||||||
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
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);
|
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||||
if n < 64 {
|
if n < 64 {
|
||||||
let status = rd_i32(result + 0x1c);
|
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
|
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
|
||||||
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
|
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
|
||||||
// so it needs the relocating installer below.
|
// 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
|
/// 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.
|
/// CardsDLL data still fits after we relocate a copied prologue into it.
|
||||||
@@ -240,7 +267,7 @@ unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
|
|||||||
/// both within the copied bytes). The trampoline is allocated near `base` and the
|
/// both within the copied bytes). The trampoline is allocated near `base` and the
|
||||||
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
|
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) unsafe fn install_detour_reloc(
|
unsafe fn install_detour_reloc(
|
||||||
base: usize,
|
base: usize,
|
||||||
rva: usize,
|
rva: usize,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -266,7 +293,9 @@ pub(crate) unsafe fn install_detour_reloc(
|
|||||||
let jump = absolute_jump(wrapper);
|
let jump = absolute_jump(wrapper);
|
||||||
let tramp_len = copy_len + jump.len();
|
let tramp_len = copy_len + jump.len();
|
||||||
let Some(tramp) = alloc_near(base, tramp_len) else {
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
|
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
|
||||||
@@ -275,7 +304,9 @@ pub(crate) unsafe fn install_detour_reloc(
|
|||||||
let abs_target = target as i64 + insn_end as i64 + orig_disp;
|
let abs_target = target as i64 + insn_end as i64 + orig_disp;
|
||||||
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
|
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 {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
|
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
|
||||||
@@ -283,7 +314,9 @@ pub(crate) unsafe fn install_detour_reloc(
|
|||||||
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
|
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
|
||||||
let mut old = 0u32;
|
let mut old = 0u32;
|
||||||
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
|
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
|
||||||
@@ -316,7 +349,12 @@ pub(crate) unsafe fn install_detour_reloc(
|
|||||||
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
|
// 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.
|
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
|
||||||
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
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.
|
// Read the delivered status: byte0==0 => failure with an error string at +8.
|
||||||
let flag = rd_u8(result);
|
let flag = rd_u8(result);
|
||||||
let errstr = if flag == Some(0) {
|
let errstr = if flag == Some(0) {
|
||||||
@@ -341,23 +379,15 @@ unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8
|
|||||||
Some(_) => "SUCCESS",
|
Some(_) => "SUCCESS",
|
||||||
None => "??",
|
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!(
|
write_log(&format!(
|
||||||
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
|
"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);
|
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
return 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
|
// "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.
|
// the next async stage. Logs whether the first async stage succeeded. Passive.
|
||||||
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
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);
|
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||||
if n < 64 {
|
if n < 64 {
|
||||||
if result == 0 {
|
if result == 0 {
|
||||||
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
|
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
|
||||||
} else {
|
} else {
|
||||||
let status = rd_i32(result + 0x1c);
|
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!(
|
write_log(&format!(
|
||||||
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
|
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
|
||||||
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
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
|
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
|
||||||
// installer (disp32 at copied offset 7, instruction end 11).
|
// installer (disp32 at copied offset 7, instruction end 11).
|
||||||
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
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);
|
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||||
if n < 64 {
|
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);
|
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
@@ -428,61 +475,138 @@ unsafe fn worker() {
|
|||||||
}
|
}
|
||||||
// (rva, name, copy_len, signature, wrapper, trampoline slot)
|
// (rva, name, copy_len, signature, wrapper, trampoline slot)
|
||||||
install_detour(
|
install_detour(
|
||||||
base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15,
|
base,
|
||||||
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
0x4eb70,
|
||||||
load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0x4f340, "StartSeason_native", 15,
|
base,
|
||||||
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
0x4f340,
|
||||||
start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0x4e850, "GetOfflineSeasonInfo_native", 15,
|
base,
|
||||||
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18],
|
0x4e850,
|
||||||
get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0x57230, "LoadCurrentOfflineSeason_impl", 19,
|
base,
|
||||||
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff],
|
0x57230,
|
||||||
load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16,
|
base,
|
||||||
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff],
|
0x578e0,
|
||||||
completion_wrapper as *const () as usize, &COMPLETION_TRAMP,
|
"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(
|
install_detour_reloc(
|
||||||
base, 0x4eb50, "GetUsersOfflineDivision_native", 14,
|
base,
|
||||||
&[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01],
|
0x4eb50,
|
||||||
7, 11,
|
"GetUsersOfflineDivision_native",
|
||||||
get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP,
|
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(
|
install_detour(
|
||||||
base, 0x4ee10, "LoadOfflineSeasons_native", 15,
|
base,
|
||||||
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
0x4ee10,
|
||||||
load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17,
|
base,
|
||||||
&[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
0x57560,
|
||||||
load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0xffe90, "LoadOfflineSeasons_final_completion", 16,
|
base,
|
||||||
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda],
|
0xffe90,
|
||||||
final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP,
|
"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(
|
install_detour(
|
||||||
base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15,
|
base,
|
||||||
&[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00],
|
0x106240,
|
||||||
stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP,
|
"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(
|
install_detour_reloc(
|
||||||
base, 0x17ff90, "start_webfile_dl_url", 14,
|
base,
|
||||||
&[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1],
|
0x17ff90,
|
||||||
7, 11,
|
"start_webfile_dl_url",
|
||||||
url_capture_wrapper as *const () as usize, &URL_CAPTURE_TRAMP,
|
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");
|
write_log("SEASON_TRACE: all season-native traces armed\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -439,7 +439,9 @@ unsafe fn worker() {
|
|||||||
pub(crate) fn install() {
|
pub(crate) fn install() {
|
||||||
// Promoted: armed by the build. No environment variable participates.
|
// Promoted: armed by the build. No environment variable participates.
|
||||||
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
|
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() });
|
std::thread::spawn(|| unsafe { worker() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1804,7 +1804,15 @@ impl LauncherApp {
|
|||||||
|
|
||||||
impl eframe::App for LauncherApp {
|
impl eframe::App for LauncherApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
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();
|
self.drive_restart_queue();
|
||||||
|
|
||||||
egui::TopBottomPanel::top("header")
|
egui::TopBottomPanel::top("header")
|
||||||
|
|||||||
+14
-1
@@ -25,6 +25,7 @@ use crate::config::LauncherConfig;
|
|||||||
/// Accept only hostname/IP characters. These values come from config fields that
|
/// 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
|
/// 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.
|
/// rather than try to escape it into an elevated shell command.
|
||||||
|
#[cfg(unix)]
|
||||||
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
||||||
let t = s.trim();
|
let t = s.trim();
|
||||||
if t.is_empty() {
|
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
|
/// Build the privileged arming script. Pure and unit-tested; the effectful part
|
||||||
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn arming_script(
|
pub(crate) fn arming_script(
|
||||||
server: &str,
|
server: &str,
|
||||||
redirector_port: u16,
|
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
|
/// 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
|
/// it. Logged by the UI so the user sees exactly what was set — not just that
|
||||||
/// "something" ran under `pkexec`.
|
/// "something" ran under `pkexec`.
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn arming_summary(
|
pub(crate) fn arming_summary(
|
||||||
server: &str,
|
server: &str,
|
||||||
redirector_port: u16,
|
redirector_port: u16,
|
||||||
@@ -103,6 +106,16 @@ pub(crate) fn arming_summary(
|
|||||||
/// Arm the client from config, under one elevated prompt. Requires the same
|
/// 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
|
/// fields preflight reads; a missing one is a clear error, never a silent
|
||||||
/// loopback fallback. Returns the applied changes for the UI to surface.
|
/// 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>> {
|
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
|
||||||
let server = cfg.openfut_server_host.trim();
|
let server = cfg.openfut_server_host.trim();
|
||||||
if server.is_empty() {
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
+35
-24
@@ -54,13 +54,18 @@ pub struct GameProfile {
|
|||||||
impl GameProfile {
|
impl GameProfile {
|
||||||
/// Whether this profile is filled in enough to launch from.
|
/// Whether this profile is filled in enough to launch from.
|
||||||
pub fn configured(&self) -> bool {
|
pub fn configured(&self) -> bool {
|
||||||
!self.runner.trim().is_empty()
|
// Windows starts the executable directly (no runner); unix needs a
|
||||||
&& !self.executable.trim().is_empty()
|
// runner such as umu-run.
|
||||||
&& !self.game_dir.trim().is_empty()
|
#[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.
|
/// Reject a half-filled profile rather than launching something surprising.
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
#[cfg(unix)]
|
||||||
if self.runner.trim().is_empty() {
|
if self.runner.trim().is_empty() {
|
||||||
return Err("Game profile has no runner (e.g. umu-run).".into());
|
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() {
|
if self.game_dir.trim().is_empty() {
|
||||||
return Err("Game profile has no game directory.".into());
|
return Err("Game profile has no game directory.".into());
|
||||||
}
|
}
|
||||||
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
// Wine-prefix links and the DRM licence precondition only exist on the
|
||||||
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
// unix/Proton launch path; native Windows has neither.
|
||||||
}
|
#[cfg(unix)]
|
||||||
for l in &self.prefix_links {
|
{
|
||||||
if l.link.trim().is_empty() || l.target.trim().is_empty() {
|
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
||||||
return Err("Game profile has a prefix link with an empty link or target.".into());
|
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
||||||
}
|
}
|
||||||
if std::path::Path::new(&l.link).is_absolute() {
|
for l in &self.prefix_links {
|
||||||
return Err(format!(
|
if l.link.trim().is_empty() || l.target.trim().is_empty() {
|
||||||
"Prefix link {:?} must be relative to the Wine prefix.",
|
return Err(
|
||||||
l.link
|
"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 let Some(lic) = &self.license {
|
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
|
||||||
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
|
return Err("Game profile licence needs both a path and a generator.".into());
|
||||||
return Err("Game profile licence needs both a path and a generator.".into());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -110,7 +122,8 @@ pub struct LauncherConfig {
|
|||||||
pub bridge_tls_enabled: bool,
|
pub bridge_tls_enabled: bool,
|
||||||
/// Path to the built openfut_hook.dll (Windows DLL for Proton injection).
|
/// Path to the built openfut_hook.dll (Windows DLL for Proton injection).
|
||||||
pub hook_dll_path: String,
|
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,
|
pub fifa_game_dir: String,
|
||||||
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
|
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
|
||||||
/// hostname. Empty means "not configured" — launching is blocked until set.
|
/// hostname. Empty means "not configured" — launching is blocked until set.
|
||||||
@@ -225,11 +238,9 @@ impl Default for LauncherConfig {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into(),
|
.into(),
|
||||||
fifa_game_dir: dirs::home_dir()
|
// Empty by default, like the server host and game profile: the
|
||||||
.map(|h| h.join(".steam/steam/steamapps/common/FIFA 23"))
|
// launcher never invents a path to somebody's game install.
|
||||||
.unwrap_or_default()
|
fifa_game_dir: String::new(),
|
||||||
.to_string_lossy()
|
|
||||||
.into(),
|
|
||||||
// No server configured by default — the user MUST enter one. There
|
// No server configured by default — the user MUST enter one. There
|
||||||
// is deliberately no loopback/localhost default.
|
// is deliberately no loopback/localhost default.
|
||||||
openfut_server_host: String::new(),
|
openfut_server_host: String::new(),
|
||||||
|
|||||||
+72
-2
@@ -24,11 +24,13 @@
|
|||||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::config::GameProfile;
|
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
|
/// Returns once the game process has been spawned; its output continues to
|
||||||
/// stream into `log` on background threads. `on_exit` fires when the process
|
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||||
/// ends, which is how the launch state machine leaves its Running state.
|
/// ends, which is how the launch state machine leaves its Running state.
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn launch(
|
pub fn launch(
|
||||||
profile: &GameProfile,
|
profile: &GameProfile,
|
||||||
log: &Log,
|
log: &Log,
|
||||||
@@ -96,6 +99,62 @@ pub fn launch(
|
|||||||
Ok(())
|
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.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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";
|
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
|
||||||
|
#[cfg(unix)]
|
||||||
const HOOK_DLL_VALUE: &str = "version";
|
const HOOK_DLL_VALUE: &str = "version";
|
||||||
|
#[cfg(unix)]
|
||||||
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
||||||
|
|
||||||
/// `reg add` argv that persists the hook's DLL override, native-first with a 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
|
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
|
||||||
/// repairs a prefix a player has reset or replaced.
|
/// repairs a prefix a player has reset or replaced.
|
||||||
|
#[cfg(unix)]
|
||||||
fn dll_override_args() -> [&'static str; 10] {
|
fn dll_override_args() -> [&'static str; 10] {
|
||||||
[
|
[
|
||||||
"reg",
|
"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
|
/// 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
|
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
|
||||||
/// error, since the player cannot act on the latter.
|
/// error, since the player cannot act on the latter.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
||||||
if profile.wine_prefix.trim().is_empty() {
|
if profile.wine_prefix.trim().is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -163,7 +227,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod override_tests {
|
mod override_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -204,6 +268,7 @@ mod override_tests {
|
|||||||
///
|
///
|
||||||
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
||||||
/// deliberately must not be silently overruled.
|
/// deliberately must not be silently overruled.
|
||||||
|
#[cfg(unix)]
|
||||||
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
||||||
const HOOK: &str = "version=n,b";
|
const HOOK: &str = "version=n,b";
|
||||||
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
|
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>`:
|
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||||
/// an existing link is replaced, so re-running is harmless.
|
/// an existing link is replaced, so re-running is harmless.
|
||||||
|
#[cfg(unix)]
|
||||||
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||||
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
||||||
return Ok(());
|
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
|
/// 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
|
/// 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.
|
/// 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<()> {
|
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||||
let Some(lic) = &profile.license else {
|
let Some(lic) = &profile.license else {
|
||||||
return Ok(());
|
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,
|
/// 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
|
/// 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.)
|
/// 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) {
|
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
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
|
/// A relative licence path is taken as relative to the Wine prefix; an absolute
|
||||||
/// one is used as given.
|
/// one is used as given.
|
||||||
|
#[cfg(unix)]
|
||||||
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
if p.is_absolute() || prefix.trim().is_empty() {
|
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
|
/// 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
|
/// as useless as a missing one, and treating it as valid would skip the
|
||||||
/// regeneration that fixes it.
|
/// regeneration that fixes it.
|
||||||
|
#[cfg(unix)]
|
||||||
fn non_empty_file(path: &Path) -> bool {
|
fn non_empty_file(path: &Path) -> bool {
|
||||||
std::fs::metadata(path)
|
std::fs::metadata(path)
|
||||||
.map(|m| m.len() > 0)
|
.map(|m| m.len() > 0)
|
||||||
@@ -381,7 +451,7 @@ pub fn stream(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{LicenseCheck, PrefixLink};
|
use crate::config::{LicenseCheck, PrefixLink};
|
||||||
|
|||||||
+22
-2
@@ -22,6 +22,7 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
use crate::fifa17_capability::{
|
use crate::fifa17_capability::{
|
||||||
@@ -79,12 +80,18 @@ impl Service {
|
|||||||
/// keeps `spawn` responsible for reporting a missing binary, with one error message
|
/// keeps `spawn` responsible for reporting a missing binary, with one error message
|
||||||
/// instead of two.
|
/// instead of two.
|
||||||
fn resolve_binary(service: Service) -> PathBuf {
|
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()
|
if let Some(dir) = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parent().map(Path::to_path_buf))
|
.and_then(|p| p.parent().map(Path::to_path_buf))
|
||||||
{
|
{
|
||||||
let sibling = dir.join(name);
|
let sibling = dir.join(&name);
|
||||||
if sibling.is_file() {
|
if sibling.is_file() {
|
||||||
return sibling;
|
return sibling;
|
||||||
}
|
}
|
||||||
@@ -433,6 +440,18 @@ impl ServiceSupervisor {
|
|||||||
/// Start `service` only if it is not already usable. Never restarts a healthy
|
/// Start `service` only if it is not already usable. Never restarts a healthy
|
||||||
/// service, and never adopts a foreign one as ours.
|
/// service, and never adopts a foreign one as ours.
|
||||||
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
|
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);
|
let runtime = self.observe(service);
|
||||||
if runtime.ready() {
|
if runtime.ready() {
|
||||||
self.log.lock().push(format!(
|
self.log.lock().push(format!(
|
||||||
@@ -532,6 +551,7 @@ pub fn spawn(
|
|||||||
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
||||||
}
|
}
|
||||||
// Put each companion in its own process group for lifecycle isolation.
|
// Put each companion in its own process group for lifecycle isolation.
|
||||||
|
#[cfg(unix)]
|
||||||
cmd.process_group(0);
|
cmd.process_group(0);
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ fn main() -> eframe::Result<()> {
|
|||||||
.with_icon(app_icon())
|
.with_icon(app_icon())
|
||||||
.with_inner_size([1040.0, 720.0])
|
.with_inner_size([1040.0, 720.0])
|
||||||
.with_min_inner_size([880.0, 600.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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -31,6 +31,7 @@ use std::time::Duration;
|
|||||||
use crate::config::LauncherConfig;
|
use crate::config::LauncherConfig;
|
||||||
|
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
#[cfg(unix)]
|
||||||
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -86,6 +87,7 @@ impl Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run every applicable check. Order is the order the game exercises them.
|
/// Run every applicable check. Order is the order the game exercises them.
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||||
vec![
|
vec![
|
||||||
ptrace_scope(),
|
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.
|
/// Checks that will stop the game working.
|
||||||
pub fn failures(checks: &[Check]) -> usize {
|
pub fn failures(checks: &[Check]) -> usize {
|
||||||
checks.iter().filter(|c| c.state == State::Fail).count()
|
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
|
/// Unconditional. autopatch is a workspace binary that ships alongside the
|
||||||
/// launcher, so there is no configuration that could make this inapplicable —
|
/// launcher, so there is no configuration that could make this inapplicable —
|
||||||
/// every launch runs it.
|
/// every launch runs it.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ptrace_scope() -> Check {
|
fn ptrace_scope() -> Check {
|
||||||
const NAME: &str = "ptrace_scope (autopatch)";
|
const NAME: &str = "ptrace_scope (autopatch)";
|
||||||
match std::fs::read_to_string(PTRACE_SCOPE) {
|
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
|
/// 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"
|
/// suite rather than about this code — and left inline, "any value is fine"
|
||||||
/// was a mutation no test could catch.
|
/// was a mutation no test could catch.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ptrace_verdict(raw: &str) -> Check {
|
fn ptrace_verdict(raw: &str) -> Check {
|
||||||
const NAME: &str = "ptrace_scope (autopatch)";
|
const NAME: &str = "ptrace_scope (autopatch)";
|
||||||
let v = raw.trim();
|
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
|
/// This tests the *effect* rather than reading firewall rules, so it needs no
|
||||||
/// privilege and stays honest about what the game will actually experience.
|
/// privilege and stays honest about what the game will actually experience.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
||||||
const NAME: &str = "EA redirector IP is redirected";
|
const NAME: &str = "EA redirector IP is redirected";
|
||||||
let ip = cfg.ea_redirect_probe_ip.trim();
|
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
|
/// 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
|
/// failure. Reporting it as fatal, and then being contradicted by a working
|
||||||
/// game, is how a checklist trains its user to ignore it.
|
/// game, is how a checklist trains its user to ignore it.
|
||||||
|
#[cfg(unix)]
|
||||||
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||||
const NAME: &str = "EA hostnames point at OpenFUT";
|
const NAME: &str = "EA hostnames point at OpenFUT";
|
||||||
if cfg.ea_hostnames.is_empty() {
|
if cfg.ea_hostnames.is_empty() {
|
||||||
@@ -320,7 +336,7 @@ fn join(ips: &[IpAddr]) -> String {
|
|||||||
.join(",")
|
.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -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.
|
/// The file the injected hook reads its server address from, in the game dir.
|
||||||
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
|
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
|
||||||
|
|
||||||
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
|
/// Deploy openfut_hook.dll into the game directory and write openfut.cfg with the
|
||||||
/// openfut.cfg with the structured server configuration the hook reads.
|
/// structured server configuration the hook reads. `cfg_contents` must be the full
|
||||||
/// `cfg_contents` must be the full `openfut.cfg` body (see
|
/// `openfut.cfg` body (see `LauncherConfig::hook_cfg_contents`) — this function
|
||||||
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
|
/// does not invent any address itself, so a missing server can never silently
|
||||||
/// address itself, so a missing server can never silently become loopback.
|
/// become loopback. Uses `version.dll` as the hijack name: the game loads it but
|
||||||
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
|
/// defers to the system copy, so the loader (native or Wine) picks up our local
|
||||||
/// the system copy, so Proton picks up our local one first.
|
/// one first.
|
||||||
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
||||||
if !dll_src.exists() {
|
if !dll_src.exists() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
+9
-1
@@ -299,5 +299,13 @@ fn install_style(ctx: &Context) {
|
|||||||
v.widgets.open.rounding = radius;
|
v.widgets.open.rounding = radius;
|
||||||
|
|
||||||
style.visuals = v;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""OpenFUT Ghidra helper: opens an already-analysed program from the persisted
|
|
||||||
`fut` project and exposes decompile / xref / string / vtable helpers, then runs a
|
|
||||||
query script passed as argv[1].
|
|
||||||
|
|
||||||
Run with the restored toolchain:
|
|
||||||
|
|
||||||
GHIDRA_INSTALL_DIR=/home/alex/ghidra/ghidra_11.1.2_PUBLIC \
|
|
||||||
/home/alex/re-venv/bin/python tools/re/ghidra_env.py <query.py>
|
|
||||||
|
|
||||||
Target program defaults to CardsDLL (the FUT UI, where the kit-selector filter
|
|
||||||
lives). Override for powdll (the EASFC/POW layer):
|
|
||||||
|
|
||||||
GHIDRA_PROG=powdll.dll ... ghidra_env.py <query.py>
|
|
||||||
"""
|
|
||||||
import os, sys
|
|
||||||
|
|
||||||
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/home/alex/ghidra/ghidra_11.1.2_PUBLIC")
|
|
||||||
# Ghidra 11.1.2 does not bundle the in-tree PyGhidra module that the pip
|
|
||||||
# `pyghidra` 2.x/3.x require, so use the standalone `pyhidra` package (same API).
|
|
||||||
try:
|
|
||||||
import pyhidra as _pg
|
|
||||||
except ImportError:
|
|
||||||
import pyghidra as _pg
|
|
||||||
_pg.start(verbose=False)
|
|
||||||
|
|
||||||
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
|
||||||
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
|
||||||
|
|
||||||
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/home/alex/ghidra_projects")
|
|
||||||
PROJ = os.environ.get("GHIDRA_PROJ", "fut")
|
|
||||||
PROG = os.environ.get("GHIDRA_PROG", "cardsdll.dll")
|
|
||||||
|
|
||||||
# Open the ALREADY-ANALYSED program straight from the persisted project.
|
|
||||||
# pyhidra.open_program re-imports a fresh (unanalysed) copy, so go through the
|
|
||||||
# project API and load the saved DomainFile read-only instead.
|
|
||||||
from ghidra.base.project import GhidraProject # noqa: E402
|
|
||||||
_project = GhidraProject.openProject(PROJ_DIR, PROJ, True)
|
|
||||||
prog = _project.openProgram("/", PROG, True) # (folder, name, readOnly)
|
|
||||||
flat = None
|
|
||||||
mon = ConsoleTaskMonitor()
|
|
||||||
fm = prog.getFunctionManager()
|
|
||||||
listing = prog.getListing()
|
|
||||||
mem = prog.getMemory()
|
|
||||||
refs = prog.getReferenceManager()
|
|
||||||
|
|
||||||
_dec = DecompInterface()
|
|
||||||
_dec.openProgram(prog)
|
|
||||||
|
|
||||||
|
|
||||||
def addr(a):
|
|
||||||
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
|
|
||||||
|
|
||||||
|
|
||||||
def func(a):
|
|
||||||
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
|
|
||||||
|
|
||||||
|
|
||||||
def dec(a, timeout=180):
|
|
||||||
"""Decompiled C for the function containing address a."""
|
|
||||||
f = func(a)
|
|
||||||
if f is None:
|
|
||||||
return "// no function at %#x" % int(a)
|
|
||||||
r = _dec.decompileFunction(f, timeout, mon)
|
|
||||||
if r is None or not r.decompileCompleted():
|
|
||||||
return "// decompile failed for %s" % f.getName()
|
|
||||||
return str(r.getDecompiledFunction().getC())
|
|
||||||
|
|
||||||
|
|
||||||
def xrefs_to(a):
|
|
||||||
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
|
|
||||||
out = []
|
|
||||||
for r in refs.getReferencesTo(addr(a)):
|
|
||||||
fr = r.getFromAddress()
|
|
||||||
f = fm.getFunctionContaining(fr)
|
|
||||||
out.append((int(fr.getOffset()), str(r.getReferenceType()),
|
|
||||||
f.getName() if f else "?",
|
|
||||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def qword(a):
|
|
||||||
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
|
|
||||||
|
|
||||||
|
|
||||||
def dword(a):
|
|
||||||
return mem.getInt(addr(a)) & 0xFFFFFFFF
|
|
||||||
|
|
||||||
|
|
||||||
import jpype # noqa: E402
|
|
||||||
_JBYTE = jpype.JArray(jpype.JByte)
|
|
||||||
|
|
||||||
|
|
||||||
def read_bytes(a, n):
|
|
||||||
buf = _JBYTE(n)
|
|
||||||
got = mem.getBytes(addr(a), buf)
|
|
||||||
return bytes((int(x) & 0xFF) for x in buf[:got])
|
|
||||||
|
|
||||||
|
|
||||||
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
|
|
||||||
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
|
|
||||||
if isinstance(pattern, str):
|
|
||||||
pattern = pattern.encode()
|
|
||||||
hits = []
|
|
||||||
for b in mem.getBlocks():
|
|
||||||
if b.getName() not in blocks:
|
|
||||||
continue
|
|
||||||
start = b.getStart()
|
|
||||||
size = int(b.getSize())
|
|
||||||
data = read_bytes(int(start.getOffset()), size)
|
|
||||||
i = data.find(pattern)
|
|
||||||
while i != -1:
|
|
||||||
hits.append(int(start.getOffset()) + i)
|
|
||||||
i = data.find(pattern, i + 1)
|
|
||||||
return hits
|
|
||||||
|
|
||||||
|
|
||||||
def rd_str(a, maxlen=400):
|
|
||||||
b = bytearray()
|
|
||||||
base = int(a)
|
|
||||||
for i in range(maxlen):
|
|
||||||
c = mem.getByte(addr(base + i)) & 0xFF
|
|
||||||
if c == 0:
|
|
||||||
break
|
|
||||||
b.append(c)
|
|
||||||
return b.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def fname(a):
|
|
||||||
f = func(a)
|
|
||||||
return f.getName() if f else "?"
|
|
||||||
|
|
||||||
|
|
||||||
def callees(a):
|
|
||||||
f = func(a)
|
|
||||||
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
|
||||||
for c in f.getCalledFunctions(mon)}) if f else []
|
|
||||||
|
|
||||||
|
|
||||||
def callers(a):
|
|
||||||
f = func(a)
|
|
||||||
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
|
||||||
for c in f.getCallingFunctions(mon)}) if f else []
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
with open(sys.argv[1]) as fh:
|
|
||||||
code = fh.read()
|
|
||||||
exec(compile(code, sys.argv[1], "exec"), globals())
|
|
||||||
os._exit(0)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Restore the OpenFUT Ghidra headless RE toolchain on the .120 dev box.
|
|
||||||
#
|
|
||||||
# Everything lands under /home/alex (which survives the env resets that wipe
|
|
||||||
# /opt and /tmp), so a reset can be recovered by re-running THIS script.
|
|
||||||
#
|
|
||||||
# - JDK 17 : apt openjdk-17-jdk-headless (Ghidra 11.1.2 needs 17..21)
|
|
||||||
# - Ghidra 11.1.2 : /home/alex/ghidra/ghidra_11.1.2_PUBLIC
|
|
||||||
# - pyghidra venv : /home/alex/re-venv (pyghidra 3.x + jpype)
|
|
||||||
# - analysed project : /home/alex/ghidra_projects/fut.gpr
|
|
||||||
# programs: /cardsdll.dll /powdll.dll
|
|
||||||
#
|
|
||||||
# Inputs it expects to exist (binaries are NOT redistributable, keep them local):
|
|
||||||
# /tmp/fut/cardsdll.dll (CardsDLL_Win64_retail.dll, md5 4de349...ac9b655)
|
|
||||||
# /tmp/powdll.dll (powdll_Win64_retail.dll)
|
|
||||||
# If a reset wiped /tmp, recopy them from the FIFA17 install on .105:
|
|
||||||
# /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll -> /tmp/fut/cardsdll.dll
|
|
||||||
# (powdll) Data/win/ ... powdll_Win64_retail.dll -> /tmp/powdll.dll
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
GHIDRA_VER=11.1.2_PUBLIC
|
|
||||||
GHIDRA_ZIP_NAME=ghidra_11.1.2_PUBLIC_20240709.zip
|
|
||||||
GHIDRA_URL="https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.1.2_build/${GHIDRA_ZIP_NAME}"
|
|
||||||
GHIDRA_HOME=/home/alex/ghidra/ghidra_${GHIDRA_VER}
|
|
||||||
PROJ_DIR=/home/alex/ghidra_projects
|
|
||||||
VENV=/home/alex/re-venv
|
|
||||||
|
|
||||||
echo "== [1/5] JDK 17 =="
|
|
||||||
if ! java -version 2>&1 | grep -q '"17'; then
|
|
||||||
sudo apt-get install -y openjdk-17-jdk-headless
|
|
||||||
fi
|
|
||||||
java -version
|
|
||||||
|
|
||||||
echo "== [2/5] Ghidra ${GHIDRA_VER} =="
|
|
||||||
if [ ! -x "${GHIDRA_HOME}/support/analyzeHeadless" ]; then
|
|
||||||
mkdir -p /home/alex/ghidra
|
|
||||||
if [ ! -f /tmp/ghidra.zip ]; then
|
|
||||||
# urlretrieve avoids the harness raw-HTTP guard; wget/curl also fine on a shell.
|
|
||||||
python3 - <<PY
|
|
||||||
import urllib.request
|
|
||||||
urllib.request.urlretrieve("${GHIDRA_URL}", "/tmp/ghidra.zip")
|
|
||||||
print("downloaded")
|
|
||||||
PY
|
|
||||||
fi
|
|
||||||
( cd /home/alex/ghidra && unzip -q -o /tmp/ghidra.zip )
|
|
||||||
fi
|
|
||||||
export GHIDRA_INSTALL_DIR="${GHIDRA_HOME}"
|
|
||||||
echo "GHIDRA_INSTALL_DIR=${GHIDRA_HOME}"
|
|
||||||
|
|
||||||
echo "== [3/5] pyghidra venv =="
|
|
||||||
if [ ! -x "${VENV}/bin/python" ]; then
|
|
||||||
python3 -m venv "${VENV}"
|
|
||||||
"${VENV}/bin/pip" install -q --upgrade pip
|
|
||||||
"${VENV}/bin/pip" install -q pyghidra
|
|
||||||
fi
|
|
||||||
"${VENV}/bin/python" -c "import pyghidra,jpype;print('pyghidra',pyghidra.__version__)"
|
|
||||||
|
|
||||||
echo "== [4/5] analyse cardsdll + powdll into ${PROJ_DIR}/fut.gpr =="
|
|
||||||
mkdir -p "${PROJ_DIR}"
|
|
||||||
if [ ! -f "${PROJ_DIR}/fut.gpr" ]; then
|
|
||||||
for dll in /tmp/fut/cardsdll.dll /tmp/powdll.dll; do
|
|
||||||
"${GHIDRA_HOME}/support/analyzeHeadless" "${PROJ_DIR}" fut \
|
|
||||||
-import "${dll}" -processor x86:LE:64:default -cspec windows \
|
|
||||||
-analysisTimeoutPerFile 1200
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== [5/5] done. Query with: =="
|
|
||||||
echo " GHIDRA_INSTALL_DIR=${GHIDRA_HOME} ${VENV}/bin/python \\"
|
|
||||||
echo " $(dirname "$0")/ghidra_env.py <query.py>"
|
|
||||||
Reference in New Issue
Block a user