Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f16828a584 | |||
| 958ff24546 |
@@ -4,3 +4,6 @@ target/
|
||||
openfut.db
|
||||
openfut.db-shm
|
||||
openfut.db-wal
|
||||
|
||||
# hook cross-build test output
|
||||
target-test/
|
||||
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared OpenFUT server-destination configuration, port mapping, and address conversion used by both the launcher and openfut_hook.dll. Pure std, no dependencies, so it stays small inside the injected DLL and is unit-testable on any host."
|
||||
|
||||
[lib]
|
||||
# Rlib only. Deliberately dependency-free so linking it into openfut_hook.dll
|
||||
# (a cdylib) adds no runtime weight and no cross-platform risk.
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Shared OpenFUT destination configuration.
|
||||
//!
|
||||
//! This crate is the **single source of truth** for where FIFA's intercepted
|
||||
//! EA traffic is redirected. It is consumed by both the launcher (which writes
|
||||
//! `openfut.cfg`) and `openfut_hook.dll` (which reads it and rewrites sockets).
|
||||
//!
|
||||
//! Design rules enforced here:
|
||||
//! * There is exactly ONE configured OpenFUT destination (host + ports).
|
||||
//! * Missing/invalid configuration is a hard error — it NEVER silently
|
||||
//! degrades to `127.0.0.1`/localhost. Loopback is only ever used if the
|
||||
//! user explicitly configures it.
|
||||
//! * The address/port -> WinSock representation helpers live here and are
|
||||
//! unit-tested on the host, so the byte-order logic the socket hooks depend
|
||||
//! on is verified without needing WinSock or FIFA.
|
||||
//!
|
||||
//! ## EA source ports vs OpenFUT destination ports
|
||||
//!
|
||||
//! FIFA connects to a handful of EA endpoints on fixed ports. Those source
|
||||
//! ports are *signatures* used to recognise traffic that must be intercepted —
|
||||
//! they are hardcoded on purpose (see [`ea_ports`]). Each recognised EA source
|
||||
//! port maps to an OpenFUT *destination* port, which comes from configuration
|
||||
//! ([`OpenFutPorts`]). Source port = fixed EA signature; destination port =
|
||||
//! configurable OpenFUT service.
|
||||
|
||||
use std::fmt;
|
||||
use std::net::{Ipv4Addr, ToSocketAddrs};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// EA source ports FIFA dials. These are fixed EA endpoint signatures used to
|
||||
/// recognise traffic for interception — NOT OpenFUT configuration. Do not make
|
||||
/// these configurable; changing them would stop us recognising EA traffic.
|
||||
pub mod ea_ports {
|
||||
/// EA HTTPS (UTAS / EAWebKit / footapi) — the game dials EA hosts on 443.
|
||||
pub const HTTPS: u16 = 443;
|
||||
/// EA Blaze redirector (gosredirector) source port.
|
||||
pub const BLAZE_REDIRECTOR: u16 = 10041;
|
||||
/// FIFA 17's `winter15.gosredirector.ea.com` endpoint source port. This is
|
||||
/// an observed, fixed vendor-port signature; it maps to the same configured
|
||||
/// OpenFUT redirector destination as the newer 10041 endpoint.
|
||||
pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230;
|
||||
/// EA Blaze main server source port.
|
||||
pub const BLAZE_MAIN: u16 = 42127;
|
||||
}
|
||||
|
||||
/// Default OpenFUT *destination* ports, derived from the current OpenFUT server
|
||||
/// implementation (bridge listens on 8443 for HTTPS; Blaze services keep their
|
||||
/// native ports). The launcher may pre-populate these; the user may change them
|
||||
/// without recompiling anything.
|
||||
pub mod default_ports {
|
||||
/// OpenFUT bridge HTTPS listener (EA :443 is redirected here).
|
||||
pub const HTTPS: u16 = 8443;
|
||||
/// OpenFUT FIFA 17 Blaze redirector listener.
|
||||
pub const BLAZE_REDIRECTOR: u16 = 42127;
|
||||
/// OpenFUT FIFA 17 Blaze main listener.
|
||||
pub const BLAZE_MAIN: u16 = 42130;
|
||||
}
|
||||
|
||||
/// OpenFUT destination ports. Each field is where an intercepted EA source port
|
||||
/// is redirected. Not collapsed into one port: OpenFUT runs distinct services
|
||||
/// (bridge HTTPS + two Blaze listeners) that FIFA reaches on distinct ports.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenFutPorts {
|
||||
/// Destination for EA :443 traffic (bridge HTTPS).
|
||||
pub https: u16,
|
||||
/// Destination for EA :10041 traffic (Blaze redirector).
|
||||
pub blaze_redirector: u16,
|
||||
/// Destination for EA :42127 traffic (Blaze main).
|
||||
pub blaze_main: u16,
|
||||
}
|
||||
|
||||
impl Default for OpenFutPorts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
https: default_ports::HTTPS,
|
||||
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
|
||||
blaze_main: default_ports::BLAZE_MAIN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenFutPorts {
|
||||
/// Map a recognised EA *source* port to its OpenFUT *destination* port.
|
||||
/// Returns `None` for ports we don't intercept (the socket hook then leaves
|
||||
/// the connection untouched).
|
||||
pub fn map_source_port(&self, ea_source_port: u16) -> Option<u16> {
|
||||
match ea_source_port {
|
||||
ea_ports::HTTPS => Some(self.https),
|
||||
ea_ports::BLAZE_REDIRECTOR | ea_ports::FIFA17_BLAZE_REDIRECTOR => {
|
||||
Some(self.blaze_redirector)
|
||||
}
|
||||
ea_ports::BLAZE_MAIN => Some(self.blaze_main),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed OpenFUT server configuration: what host to redirect EA traffic to
|
||||
/// and which destination ports to use. `host` may be an IPv4 literal or a
|
||||
/// hostname; resolution to a concrete [`Ipv4Addr`] happens in [`Self::resolve`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerConfig {
|
||||
/// User-configured OpenFUT server host (IPv4 literal or hostname). Never
|
||||
/// defaulted to loopback — an empty host is a [`ConfigError::ServerMissing`].
|
||||
pub host: String,
|
||||
pub ports: OpenFutPorts,
|
||||
}
|
||||
|
||||
/// A [`ServerConfig`] whose host has been resolved to a concrete IPv4 address.
|
||||
/// This is what the socket hooks consume — all three interception layers share
|
||||
/// exactly this resolved destination.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResolvedServer {
|
||||
/// The concrete IPv4 the EA connection's destination is rewritten to.
|
||||
pub redirect_ip: Ipv4Addr,
|
||||
pub ports: OpenFutPorts,
|
||||
}
|
||||
|
||||
/// Errors loading/validating OpenFUT server configuration. Every one of these
|
||||
/// must BLOCK operation — none of them may fall back to loopback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConfigError {
|
||||
/// No config file present (e.g. `openfut.cfg` missing).
|
||||
ConfigMissing,
|
||||
/// Config present but no server host set.
|
||||
ServerMissing,
|
||||
/// Host could not be parsed/resolved to an IPv4 address.
|
||||
InvalidAddress(String),
|
||||
/// A port value was not a valid `u16` / was zero.
|
||||
InvalidPort(String),
|
||||
/// Config bytes were structurally unparseable.
|
||||
MalformedConfig(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ConfigError::ConfigMissing => {
|
||||
write!(f, "No OpenFUT server configured (config file missing)")
|
||||
}
|
||||
ConfigError::ServerMissing => write!(
|
||||
f,
|
||||
"No OpenFUT server configured. Enter the hostname or IP address of your OpenFUT server."
|
||||
),
|
||||
ConfigError::InvalidAddress(a) => write!(f, "Invalid OpenFUT server address: {a}"),
|
||||
ConfigError::InvalidPort(p) => write!(f, "Invalid OpenFUT port: {p}"),
|
||||
ConfigError::MalformedConfig(m) => write!(f, "Malformed OpenFUT config: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl ServerConfig {
|
||||
/// Parse `openfut.cfg` contents.
|
||||
///
|
||||
/// Two accepted formats:
|
||||
/// * **Structured** (preferred), one `key=value` per line:
|
||||
/// ```text
|
||||
/// host=192.168.1.50
|
||||
/// https_port=8443
|
||||
/// blaze_redirector_port=10041
|
||||
/// blaze_main_port=42127
|
||||
/// ```
|
||||
/// `host` is required; any omitted port uses its default.
|
||||
/// * **Legacy**, a single bare line containing just the host
|
||||
/// (IPv4 or hostname). Ports default. This keeps old deployments working.
|
||||
///
|
||||
/// An empty/whitespace-only body is [`ConfigError::ServerMissing`], NOT a
|
||||
/// silent loopback fallback.
|
||||
pub fn parse(contents: &str) -> Result<Self, ConfigError> {
|
||||
let trimmed = contents.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
|
||||
// Legacy single-token form: no '=' anywhere and a single line.
|
||||
let has_kv = trimmed.lines().any(|l| l.contains('='));
|
||||
if !has_kv {
|
||||
let host = trimmed.trim();
|
||||
if host.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
return Ok(Self {
|
||||
host: host.to_string(),
|
||||
ports: OpenFutPorts::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut host: Option<String> = None;
|
||||
let mut ports = OpenFutPorts::default();
|
||||
|
||||
for (lineno, raw) in trimmed.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = line.split_once('=').ok_or_else(|| {
|
||||
ConfigError::MalformedConfig(format!("line {}: expected key=value", lineno + 1))
|
||||
})?;
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match key {
|
||||
"host" | "server" | "redirect_ip" => {
|
||||
if value.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
host = Some(value.to_string());
|
||||
}
|
||||
"https_port" => ports.https = parse_port(value)?,
|
||||
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
|
||||
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
|
||||
other => {
|
||||
return Err(ConfigError::MalformedConfig(format!(
|
||||
"line {}: unknown key '{other}'",
|
||||
lineno + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let host = host.ok_or(ConfigError::ServerMissing)?;
|
||||
Ok(Self { host, ports })
|
||||
}
|
||||
|
||||
/// Serialize to the structured `openfut.cfg` format.
|
||||
pub fn to_cfg_string(&self) -> String {
|
||||
format!(
|
||||
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
|
||||
self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate the host is a syntactically acceptable IPv4 literal or hostname.
|
||||
/// Does NOT perform DNS (no network) — use [`Self::resolve`] for that.
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
if Ipv4Addr::from_str(host).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if is_plausible_hostname(host) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConfigError::InvalidAddress(host.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the configured host to a concrete IPv4 destination.
|
||||
///
|
||||
/// This is the single shared resolution path all hooks rely on: an IPv4
|
||||
/// literal is used directly; a hostname is resolved via the platform
|
||||
/// resolver ([`ToSocketAddrs`]). Only IPv4 results are used (WinSock
|
||||
/// interception here is IPv4). Never falls back to loopback.
|
||||
pub fn resolve(&self) -> Result<ResolvedServer, ConfigError> {
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(ConfigError::ServerMissing);
|
||||
}
|
||||
|
||||
// IPv4 literal: no DNS needed.
|
||||
if let Ok(ip) = Ipv4Addr::from_str(host) {
|
||||
return Ok(ResolvedServer {
|
||||
redirect_ip: ip,
|
||||
ports: self.ports,
|
||||
});
|
||||
}
|
||||
|
||||
// Hostname: resolve via the platform resolver. Port is irrelevant to
|
||||
// the lookup; we only need an address. Take the first IPv4 answer.
|
||||
let ip = (host, 0u16)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| ConfigError::InvalidAddress(format!("{host}: {e}")))?
|
||||
.find_map(|sa| match sa.ip() {
|
||||
std::net::IpAddr::V4(v4) => Some(v4),
|
||||
std::net::IpAddr::V6(_) => None,
|
||||
})
|
||||
.ok_or_else(|| ConfigError::InvalidAddress(format!("{host}: no IPv4 address found")))?;
|
||||
|
||||
Ok(ResolvedServer {
|
||||
redirect_ip: ip,
|
||||
ports: self.ports,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_port(value: &str) -> Result<u16, ConfigError> {
|
||||
let n: u16 = value
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidPort(value.to_string()))?;
|
||||
if n == 0 {
|
||||
return Err(ConfigError::InvalidPort(value.to_string()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Lenient hostname sanity check (RFC-1123-ish). Not a full validator — just
|
||||
/// enough to reject obvious garbage while accepting `.local`/`.home` names.
|
||||
fn is_plausible_hostname(host: &str) -> bool {
|
||||
if host.is_empty() || host.len() > 253 {
|
||||
return false;
|
||||
}
|
||||
host.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
})
|
||||
}
|
||||
|
||||
// ── WinSock representation helpers ───────────────────────────────────────────
|
||||
// These convert a resolved destination into the exact in-memory representation
|
||||
// WinSock's `sockaddr_in` expects. Kept here (not in the DLL) so the byte-order
|
||||
// contract the socket hooks depend on is unit-tested on the host.
|
||||
|
||||
/// Value to store in `sockaddr_in.sin_addr.S_un.S_addr`.
|
||||
///
|
||||
/// WinSock stores the four IPv4 octets in network byte order in memory. Reading
|
||||
/// those bytes back as a native-endian `u32` (how the hook's `SockaddrIn.sin_addr`
|
||||
/// field is typed) is exactly `from_ne_bytes(octets)`. On little-endian x86_64
|
||||
/// this yields e.g. `127.0.0.1 -> 0x0100_007F`, matching the value the original
|
||||
/// hooks hardcoded — confirming the conversion is correct.
|
||||
pub fn sin_addr_from_ipv4(ip: Ipv4Addr) -> u32 {
|
||||
u32::from_ne_bytes(ip.octets())
|
||||
}
|
||||
|
||||
/// Value to store in `sockaddr_in.sin_port` — the port in network byte order.
|
||||
pub fn sin_port_nbo(port: u16) -> u16 {
|
||||
port.to_be()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_ipv4_survives_parsing() {
|
||||
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
||||
assert_eq!(c.host, "192.168.1.50");
|
||||
assert_eq!(c.ports, OpenFutPorts::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_bare_host_line_parses() {
|
||||
let c = ServerConfig::parse("10.0.0.7\n").unwrap();
|
||||
assert_eq!(c.host, "10.0.0.7");
|
||||
assert_eq!(c.ports, OpenFutPorts::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_server_does_not_become_loopback() {
|
||||
// Empty config is an explicit error, never 127.0.0.1.
|
||||
assert_eq!(
|
||||
ServerConfig::parse("").unwrap_err(),
|
||||
ConfigError::ServerMissing
|
||||
);
|
||||
assert_eq!(
|
||||
ServerConfig::parse(" \n\n").unwrap_err(),
|
||||
ConfigError::ServerMissing
|
||||
);
|
||||
assert_eq!(
|
||||
ServerConfig::parse("https_port=8443\n").unwrap_err(),
|
||||
ConfigError::ServerMissing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_server_is_rejected() {
|
||||
let c = ServerConfig {
|
||||
host: "not a host!!".into(),
|
||||
ports: OpenFutPorts::default(),
|
||||
};
|
||||
assert!(matches!(c.validate(), Err(ConfigError::InvalidAddress(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_port_survives_parsing() {
|
||||
let c = ServerConfig::parse(
|
||||
"host=srv.home\nhttps_port=9000\nblaze_redirector_port=11000\nblaze_main_port=42000\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.ports.https, 9000);
|
||||
assert_eq!(c.ports.blaze_redirector, 11000);
|
||||
assert_eq!(c.ports.blaze_main, 42000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_port_is_rejected() {
|
||||
assert!(matches!(
|
||||
ServerConfig::parse("host=x\nhttps_port=0\n"),
|
||||
Err(ConfigError::InvalidPort(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
ServerConfig::parse("host=x\nhttps_port=99999\n"),
|
||||
Err(ConfigError::InvalidPort(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_is_malformed() {
|
||||
assert!(matches!(
|
||||
ServerConfig::parse("host=x\nbogus=1\n"),
|
||||
Err(ConfigError::MalformedConfig(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_cfg_string() {
|
||||
let c = ServerConfig {
|
||||
host: "192.168.1.50".into(),
|
||||
ports: OpenFutPorts {
|
||||
https: 8443,
|
||||
blaze_redirector: 10041,
|
||||
blaze_main: 42127,
|
||||
},
|
||||
};
|
||||
let s = c.to_cfg_string();
|
||||
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_ipv4_becomes_correct_sockaddr() {
|
||||
// Resolve an IPv4 literal and confirm the sin_addr value.
|
||||
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
||||
let r = c.resolve().unwrap();
|
||||
assert_eq!(r.redirect_ip, Ipv4Addr::new(192, 168, 1, 50));
|
||||
// sin_addr is the octets as a native-endian u32.
|
||||
assert_eq!(
|
||||
sin_addr_from_ipv4(r.redirect_ip),
|
||||
u32::from_ne_bytes([192, 168, 1, 50])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_sockaddr_matches_legacy_constant() {
|
||||
// Guards the byte-order contract: the old hooks hardcoded 0x0100_007F
|
||||
// for 127.0.0.1. Our helper must reproduce it on this (LE) host.
|
||||
assert_eq!(sin_addr_from_ipv4(Ipv4Addr::new(127, 0, 0, 1)), 0x0100_007F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sin_port_is_network_byte_order() {
|
||||
// 443 -> 0xBB01, 42127 -> 0x8FA4 (matches the legacy hook constants).
|
||||
assert_eq!(sin_port_nbo(443), 0xBB01);
|
||||
assert_eq!(sin_port_nbo(42127), 0x8FA4);
|
||||
assert_eq!(sin_port_nbo(10041), 0x3927);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_mapping_source_to_destination() {
|
||||
let p = OpenFutPorts::default();
|
||||
assert_eq!(p.map_source_port(ea_ports::HTTPS), Some(8443));
|
||||
assert_eq!(p.map_source_port(ea_ports::BLAZE_REDIRECTOR), Some(42127));
|
||||
assert_eq!(
|
||||
p.map_source_port(ea_ports::FIFA17_BLAZE_REDIRECTOR),
|
||||
Some(42127)
|
||||
);
|
||||
assert_eq!(p.map_source_port(ea_ports::BLAZE_MAIN), Some(42130));
|
||||
assert_eq!(p.map_source_port(12345), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_literal_ipv4_no_dns() {
|
||||
let c = ServerConfig::parse("host=127.0.0.1\n").unwrap();
|
||||
let r = c.resolve().unwrap();
|
||||
assert_eq!(r.redirect_ip, Ipv4Addr::LOCALHOST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_empty_host_errors() {
|
||||
let c = ServerConfig {
|
||||
host: " ".into(),
|
||||
ports: OpenFutPorts::default(),
|
||||
};
|
||||
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ probe = []
|
||||
fifa17 = []
|
||||
|
||||
[dependencies]
|
||||
openfut-common = { path = "../openfut-common" }
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
|
||||
+37
-15
@@ -1,20 +1,16 @@
|
||||
/// 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.
|
||||
//! Load the configured OpenFUT host and destination ports from `openfut.cfg`.
|
||||
//! Missing or invalid configuration is a hard error; there is no loopback
|
||||
//! fallback. Both structured config and the legacy bare-host line are accepted
|
||||
//! by `openfut-common`.
|
||||
use openfut_common::{ConfigError, ServerConfig};
|
||||
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()
|
||||
pub fn load_config(
|
||||
module: windows_sys::Win32::Foundation::HMODULE,
|
||||
) -> Result<ServerConfig, ConfigError> {
|
||||
let cfg_path = config_path(module).ok_or(ConfigError::ConfigMissing)?;
|
||||
let content = std::fs::read_to_string(&cfg_path).map_err(|_| ConfigError::ConfigMissing)?;
|
||||
ServerConfig::parse(&content)
|
||||
}
|
||||
|
||||
fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::path::PathBuf> {
|
||||
@@ -30,3 +26,29 @@ fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::p
|
||||
let dll_path = std::path::Path::new(path);
|
||||
Some(dll_path.parent()?.join("openfut.cfg"))
|
||||
}
|
||||
|
||||
/// Read a raw feature-flag value (`key=value`) from `openfut.cfg` beside the DLL.
|
||||
///
|
||||
/// Returns the trimmed value, or `None` if the file or key is absent. This reads the
|
||||
/// SAME config file as [`load_config`] but does NOT go through the strict
|
||||
/// [`ServerConfig`] parser (which owns host/port validation and hard-errors on bad
|
||||
/// input) — optional client feature flags must never be able to break server config.
|
||||
pub fn feature_value(
|
||||
module: windows_sys::Win32::Foundation::HMODULE,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
let cfg_path = config_path(module)?;
|
||||
let content = std::fs::read_to_string(&cfg_path).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
if k.trim() == key {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/// Hooks ws2_32!connect via inline detour (no iptables needed).
|
||||
/// Uses unhook/rehook pattern: restores original bytes, calls real function, re-installs hook.
|
||||
/// This avoids trampoline RIP-relocation issues entirely.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicU16, AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const AF_INET: u16 = 2;
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_FIFA17_BLAZE_REDIRECTOR_NBO: u16 = 0xF6A4; // 42230 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
|
||||
@@ -18,7 +18,47 @@ const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
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
|
||||
/// Redirect target for rewritten EA connects, stored in **network byte order**
|
||||
/// (same layout as `sockaddr_in.sin_addr`). Zero means unconfigured and causes
|
||||
/// redirect_if_ea to leave traffic untouched; there is no loopback fallback.
|
||||
static TARGET_ADDR_NBO: AtomicU32 = AtomicU32::new(0);
|
||||
static TARGET_HTTPS_PORT_NBO: AtomicU16 = AtomicU16::new(0);
|
||||
static TARGET_BLAZE_REDIRECTOR_PORT_NBO: AtomicU16 = AtomicU16::new(0);
|
||||
static TARGET_BLAZE_MAIN_PORT_NBO: AtomicU16 = AtomicU16::new(0);
|
||||
|
||||
/// Install the single resolved destination shared by every socket path.
|
||||
pub fn set_server(server: openfut_common::ResolvedServer) {
|
||||
TARGET_ADDR_NBO.store(
|
||||
openfut_common::sin_addr_from_ipv4(server.redirect_ip),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
TARGET_HTTPS_PORT_NBO.store(
|
||||
openfut_common::sin_port_nbo(server.ports.https),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
TARGET_BLAZE_REDIRECTOR_PORT_NBO.store(
|
||||
openfut_common::sin_port_nbo(server.ports.blaze_redirector),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
TARGET_BLAZE_MAIN_PORT_NBO.store(
|
||||
openfut_common::sin_port_nbo(server.ports.blaze_main),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Current redirect target in network byte order.
|
||||
fn target_addr_nbo() -> u32 {
|
||||
TARGET_ADDR_NBO.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Build the 16-byte IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) for the current
|
||||
/// target, so an AF_INET6 socket reaches the same host as the AF_INET path.
|
||||
fn target_v4mapped() -> [u8; 16] {
|
||||
let o = target_addr_nbo().to_ne_bytes(); // a.b.c.d in memory order
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, o[0], o[1], o[2], o[3],
|
||||
]
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
@@ -41,12 +81,8 @@ struct SockaddrIn6 {
|
||||
sin6_scope_id: u32,
|
||||
}
|
||||
|
||||
/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to
|
||||
/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's
|
||||
/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own
|
||||
/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not
|
||||
/// `IPV6_V6ONLY` and will accept this target.
|
||||
const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1];
|
||||
/// IPv4-mapped IPv6 loopback is no longer hardcoded — the v4-mapped target is
|
||||
/// derived from the configurable `TARGET_ADDR_NBO` via `target_v4mapped()`.
|
||||
|
||||
// Address of ws2_32!connect (set at hook installation)
|
||||
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -110,30 +146,40 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed),
|
||||
#[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,
|
||||
PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => {
|
||||
TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed)
|
||||
}
|
||||
PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed),
|
||||
_ => return None,
|
||||
};
|
||||
if new_port_nbo == 0 || target_addr_nbo() == 0 {
|
||||
return None;
|
||||
}
|
||||
// sin_addr is network order; to_le_bytes gives memory order = the dotted
|
||||
// quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed).
|
||||
let o = sa.sin_addr.to_le_bytes();
|
||||
let t = target_addr_nbo().to_ne_bytes();
|
||||
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),
|
||||
t[0],
|
||||
t[1],
|
||||
t[2],
|
||||
t[3],
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
out.sin_addr = target_addr_nbo();
|
||||
Some((buf, 16))
|
||||
}
|
||||
AF_INET6 => {
|
||||
@@ -144,11 +190,16 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
let sa6 = &*(name as *const SockaddrIn6);
|
||||
// LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here.
|
||||
let new_port_nbo = match sa6.sin6_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed),
|
||||
PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => {
|
||||
TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed)
|
||||
}
|
||||
PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed),
|
||||
_ => return None,
|
||||
};
|
||||
if new_port_nbo == 0 || target_addr_nbo() == 0 {
|
||||
return None;
|
||||
}
|
||||
let a = sa6.sin6_addr;
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
|
||||
@@ -164,7 +215,7 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
out.sin6_family = AF_INET6;
|
||||
out.sin6_port = new_port_nbo;
|
||||
out.sin6_flowinfo = 0;
|
||||
out.sin6_addr = V4MAPPED_LOOPBACK;
|
||||
out.sin6_addr = target_v4mapped();
|
||||
out.sin6_scope_id = 0;
|
||||
Some((buf, 28))
|
||||
}
|
||||
@@ -215,7 +266,17 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
core::mem::transmute(addr);
|
||||
f(s, buf.as_ptr(), len)
|
||||
};
|
||||
let wsa_error = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
write_hook(addr, hooked_connect as *const () as u64);
|
||||
if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
|
||||
WSASetLastError(wsa_error);
|
||||
}
|
||||
return r;
|
||||
} else {
|
||||
(name, namelen)
|
||||
@@ -226,19 +287,23 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
|
||||
f(s, call_name, call_len)
|
||||
};
|
||||
let wsa_error = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
write_hook(addr, hooked_connect as *const () as u64);
|
||||
if namelen >= 8 {
|
||||
let sa = &*(call_name as *const SockaddrIn);
|
||||
if sa.sin_family == AF_INET {
|
||||
let err = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={wsa_error}\n"));
|
||||
}
|
||||
}
|
||||
if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
|
||||
WSASetLastError(wsa_error);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
+100
-9
@@ -2,13 +2,19 @@
|
||||
//!
|
||||
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
|
||||
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
|
||||
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
|
||||
//! no-op and at worst crash. For now this proves the version.dll hijack actually
|
||||
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
|
||||
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
|
||||
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
|
||||
//! of the FIFA-23 memory-layout-specific logic here (origin_spy, LSX dial, event
|
||||
//! deserializer probes) — that would at best no-op and at worst crash.
|
||||
//!
|
||||
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
|
||||
//! What it DOES do:
|
||||
//! 1. Prove the version.dll hijack loads us into FIFA17.exe (module dump).
|
||||
//! 2. Install the *generic*, memory-layout-independent network redirect:
|
||||
//! ws2_32 `getaddrinfo` (EA host → configured server) and an inline
|
||||
//! `connect` / `WSAConnect` detour (EA ports → bridge, dest → configured
|
||||
//! server IP). These key on hostnames/ports only, not on FIFA-23 offsets,
|
||||
//! so they are safe to reuse on FIFA 17.
|
||||
//!
|
||||
//! Not yet done (next milestone): DirtySDK/ProtoSSL cert-verify patch for the
|
||||
//! secure Blaze handshake. The module dump locates the DLL that needs it.
|
||||
|
||||
use crate::write_log;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
@@ -70,7 +76,7 @@ unsafe fn dump_modules() {
|
||||
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
|
||||
/// other loader-touching calls are unsafe under the loader lock, so we defer them
|
||||
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
|
||||
unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
unsafe extern "system" fn worker(param: *mut core::ffi::c_void) -> u32 {
|
||||
write_log("=== fifa17 hook: worker thread start ===\n");
|
||||
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
|
||||
let img = size_of_image(main_base);
|
||||
@@ -78,6 +84,28 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
|
||||
));
|
||||
dump_modules();
|
||||
|
||||
// Install the generic network redirect. `param` carries our own DLL's
|
||||
// HMODULE so config::load_config can find openfut.cfg beside the DLL.
|
||||
let dll_module = param as windows_sys::Win32::Foundation::HMODULE;
|
||||
let server = match crate::config::load_config(dll_module).and_then(|c| c.resolve()) {
|
||||
Ok(server) => server,
|
||||
Err(e) => {
|
||||
write_log(&format!(
|
||||
"fifa17: invalid/missing openfut.cfg ({e}); network redirect DISABLED\n"
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
write_log(&format!(
|
||||
"fifa17: OpenFUT server={} https={} blaze_redir={} blaze_main={}\n",
|
||||
server.redirect_ip,
|
||||
server.ports.https,
|
||||
server.ports.blaze_redirector,
|
||||
server.ports.blaze_main
|
||||
));
|
||||
install_network_redirect(server);
|
||||
|
||||
write_log("fifa17: worker complete (injection healthy)\n");
|
||||
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
|
||||
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
|
||||
@@ -86,19 +114,82 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
// It currently fails closed until safe relocating trampolines are proven.
|
||||
crate::sbc_trace::install();
|
||||
crate::sbc_request_trace::install();
|
||||
// Empty-My-Packs Store fix (inert unless store_mypacks_fix=1 in openfut.cfg).
|
||||
crate::store_hook::install(dll_module);
|
||||
0
|
||||
}
|
||||
|
||||
/// Install the generic network redirect (getaddrinfo + connect + WSAConnect).
|
||||
///
|
||||
/// `server` is the resolved host + configured destination ports from openfut.cfg.
|
||||
/// two independent mechanisms, both keyed only on EA hostnames/ports (no
|
||||
/// FIFA-version-specific memory layout):
|
||||
/// - getaddrinfo: EA hostnames resolve to `redirect_ip`.
|
||||
/// - connect/WSAConnect: EA source ports are remapped to the bridge ports and
|
||||
/// the destination address is rewritten to `redirect_ip`.
|
||||
///
|
||||
/// If `redirect_ip` parses as an IPv4 literal, the connect detour rewrites the
|
||||
/// destination directly (no DNS). When it is a hostname, getaddrinfo already
|
||||
/// resolves it, and the connect detour falls back to leaving the resolved
|
||||
/// address in place (only remapping the port).
|
||||
unsafe fn install_network_redirect(server: openfut_common::ResolvedServer) {
|
||||
let redirect_ip = server.redirect_ip.to_string();
|
||||
// Resolver redirect: EA hostnames → configured server. Uses INLINE detours at
|
||||
// the ws2_32 export addresses (getaddrinfo / GetAddrInfoW / gethostbyname),
|
||||
// not IAT patching — the IAT approach patched 0 slots on FIFA 17 because the
|
||||
// game doesn't import the resolver through its import table.
|
||||
crate::hooks::set_redirect_ip(redirect_ip.clone());
|
||||
let (ok, total) = crate::resolver_hook::install_resolver_hooks();
|
||||
write_log(&format!(
|
||||
"fifa17: resolver detours {ok}/{total} installed\n"
|
||||
));
|
||||
|
||||
crate::connect_hook::set_server(server);
|
||||
write_log(&format!(
|
||||
"fifa17: connect target set to {} (https={} blaze_redir={} blaze_main={})\n",
|
||||
server.redirect_ip,
|
||||
server.ports.https,
|
||||
server.ports.blaze_redirector,
|
||||
server.ports.blaze_main
|
||||
));
|
||||
|
||||
// Inline connect detour (port remap + destination rewrite).
|
||||
if crate::connect_hook::install_inline_connect_hook() {
|
||||
write_log("fifa17: connect inline-hooked\n");
|
||||
} else {
|
||||
write_log("fifa17: connect hook FAILED\n");
|
||||
}
|
||||
|
||||
// WSAConnect IAT fallback (some EA paths use WSAConnect instead of connect).
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker
|
||||
/// thread and return immediately, so we never touch the loader lock from here.
|
||||
pub unsafe fn install() {
|
||||
/// `module` is our own DLL's HMODULE, passed to the worker so it can locate
|
||||
/// openfut.cfg beside the DLL.
|
||||
pub unsafe fn install(module: windows_sys::Win32::Foundation::HMODULE) {
|
||||
use windows_sys::Win32::System::Threading::CreateThread;
|
||||
write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n");
|
||||
let h = CreateThread(
|
||||
core::ptr::null(),
|
||||
0,
|
||||
Some(worker),
|
||||
core::ptr::null(),
|
||||
module as *const core::ffi::c_void,
|
||||
0,
|
||||
core::ptr::null_mut(),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ type GetaddrinfoFn =
|
||||
|
||||
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
|
||||
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
static REDIRECT_IP_STR: OnceLock<String> = 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
|
||||
@@ -24,9 +25,21 @@ pub fn set_real(f: GetaddrinfoFn) {
|
||||
}
|
||||
|
||||
pub fn set_redirect_ip(ip: String) {
|
||||
let mut bytes = ip.into_bytes();
|
||||
let mut bytes = ip.clone().into_bytes();
|
||||
bytes.push(0);
|
||||
let _ = REDIRECT_IP.set(bytes);
|
||||
let _ = REDIRECT_IP_STR.set(ip);
|
||||
}
|
||||
|
||||
/// The redirect IP as a NUL-terminated C string pointer, or None if unset.
|
||||
/// Used by the resolver detours to rewrite an EA query's node name.
|
||||
pub fn redirect_ip_cstr() -> Option<*const u8> {
|
||||
REDIRECT_IP.get().map(|v| v.as_ptr())
|
||||
}
|
||||
|
||||
/// The redirect IP as a Rust &str, or None if unset (for the wide/UTF-16 path).
|
||||
pub fn redirect_ip_str() -> Option<&'static str> {
|
||||
REDIRECT_IP_STR.get().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Returns true if `host` is an EA / EA-Sports domain that should be redirected
|
||||
@@ -68,12 +81,13 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
}
|
||||
}
|
||||
|
||||
let redirect = REDIRECT_IP
|
||||
.get()
|
||||
.map(|v| v.as_ptr())
|
||||
.unwrap_or(b"127.0.0.1\0".as_ptr());
|
||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||
return real(redirect, service_name, hints, result);
|
||||
if let Some(redirect) = REDIRECT_IP.get() {
|
||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||
return real(redirect.as_ptr(), service_name, hints, result);
|
||||
}
|
||||
crate::write_log(
|
||||
"openfut_hook: EA hostname seen without configured server; not redirecting\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -11,12 +11,15 @@ mod origin_spy;
|
||||
mod probe;
|
||||
#[cfg(feature = "capture_baseline")]
|
||||
mod recv_hook;
|
||||
mod resolver_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_request_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod store_hook;
|
||||
mod ssl_patch;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
@@ -71,8 +74,7 @@ unsafe fn install_hooks(module: HMODULE) {
|
||||
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
|
||||
#[cfg(feature = "fifa17")]
|
||||
{
|
||||
let _ = module;
|
||||
fifa17::install();
|
||||
fifa17::install(module);
|
||||
return;
|
||||
}
|
||||
#[cfg(not(feature = "fifa17"))]
|
||||
@@ -85,8 +87,17 @@ unsafe fn install_hooks_fifa23(module: HMODULE) {
|
||||
// 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);
|
||||
match config::load_config(module).and_then(|c| c.resolve()) {
|
||||
Ok(server) => {
|
||||
hooks::set_redirect_ip(server.redirect_ip.to_string());
|
||||
connect_hook::set_server(server);
|
||||
}
|
||||
Err(e) => {
|
||||
write_log(&format!(
|
||||
"openfut_hook: invalid/missing openfut.cfg ({e}); redirection DISABLED\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
|
||||
if !ga.is_null() {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! DNS-resolver inline detours (getaddrinfo / GetAddrInfoW / gethostbyname).
|
||||
//!
|
||||
//! WHY THIS EXISTS (FIFA 17): the IAT approach in `hooks.rs` patched **0** slots on
|
||||
//! FIFA 17 (`getaddrinfo IAT patched 0+0`) because the game does not import the
|
||||
//! resolver through its import table — it resolves EA hostnames via a path the IAT
|
||||
//! scan never covers (dynamic `GetProcAddress`, a statically-linked DirtySDK
|
||||
//! resolver, or the legacy `gethostbyname`). An IAT patch can only rewrite callers
|
||||
//! that go through the table, so it missed every real resolution.
|
||||
//!
|
||||
//! FIX: detour the resolver **at its export address** in ws2_32.dll, exactly like
|
||||
//! `connect_hook` does for `connect`. An inline JMP at the function entry catches
|
||||
//! *every* caller regardless of how it found the function. We use the same
|
||||
//! unhook → call real → rehook pattern (no trampoline, no RIP relocation).
|
||||
//!
|
||||
//! We cover three resolvers:
|
||||
//! - `getaddrinfo` (ANSI, modern)
|
||||
//! - `GetAddrInfoW` (wide, modern) — EAWebKit/WinHTTP often use the W variant
|
||||
//! - `gethostbyname` (legacy, DirtySDK-era) — returns a `hostent`
|
||||
//!
|
||||
//! On an EA hostname we rewrite the query node to the configured redirect IP so the
|
||||
//! real resolver returns the bridge's address. The redirect IP string is owned by
|
||||
//! `hooks` (set once via `hooks::set_redirect_ip`); we read it back through
|
||||
//! `hooks::redirect_ip_cstr()`.
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Networking::WinSock::ADDRINFOA;
|
||||
|
||||
// ── EA host classifier (shared logic mirrors hooks::is_ea_host) ────────────────
|
||||
|
||||
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")
|
||||
|| h.ends_with(".dice.se")
|
||||
}
|
||||
|
||||
// ── getaddrinfo (ANSI) ────────────────────────────────────────────────────────
|
||||
|
||||
type GetaddrinfoFn =
|
||||
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
|
||||
|
||||
static GAI_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut GAI_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── GetAddrInfoW (wide) ───────────────────────────────────────────────────────
|
||||
|
||||
type GetAddrInfoWFn = unsafe extern "system" fn(
|
||||
*const u16,
|
||||
*const u16,
|
||||
*const core::ffi::c_void,
|
||||
*mut *mut core::ffi::c_void,
|
||||
) -> i32;
|
||||
|
||||
static GAIW_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut GAIW_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── gethostbyname (legacy) ────────────────────────────────────────────────────
|
||||
|
||||
type GethostbynameFn = unsafe extern "system" fn(*const u8) -> *mut core::ffi::c_void;
|
||||
|
||||
static GHBN_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut GHBN_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── inline-hook primitives (identical pattern to connect_hook) ────────────────
|
||||
|
||||
unsafe fn write_hook(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);
|
||||
// FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0u32);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
unsafe fn restore(target: *mut u8, orig: *const u8) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
core::ptr::copy_nonoverlapping(orig, target, 14);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
/// Save the first 14 bytes at `addr` into `orig`, store `addr`, and write the JMP.
|
||||
unsafe fn install_one(addr: *mut u8, orig: *mut u8, slot: &AtomicUsize, hook: *const ()) -> bool {
|
||||
if addr.is_null() {
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(addr, orig, 14);
|
||||
slot.store(addr as usize, Ordering::Relaxed);
|
||||
write_hook(addr, hook as u64);
|
||||
true
|
||||
}
|
||||
|
||||
// ── hooked entry points ───────────────────────────────────────────────────────
|
||||
|
||||
pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
node: *const u8,
|
||||
service: *const u8,
|
||||
hints: *const ADDRINFOA,
|
||||
result: *mut *mut ADDRINFOA,
|
||||
) -> i32 {
|
||||
let addr = GAI_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
let mut redirected = node;
|
||||
let redirect_cstr = crate::hooks::redirect_ip_cstr();
|
||||
|
||||
if !node.is_null() {
|
||||
if let Ok(host) = CStr::from_ptr(node as *const i8).to_str() {
|
||||
crate::write_log(&format!("resolver: getaddrinfo({host})\n"));
|
||||
if is_ea_host(host) {
|
||||
if let Some(ip) = redirect_cstr {
|
||||
redirected = ip;
|
||||
crate::write_log(&format!("resolver: getaddrinfo {host} → redirect\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore(addr, core::ptr::addr_of!(GAI_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: GetaddrinfoFn = core::mem::transmute(addr);
|
||||
f(redirected, service, hints, result)
|
||||
};
|
||||
write_hook(addr, hooked_getaddrinfo as *const () as u64);
|
||||
r
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_getaddrinfo_w(
|
||||
node: *const u16,
|
||||
service: *const u16,
|
||||
hints: *const core::ffi::c_void,
|
||||
result: *mut *mut core::ffi::c_void,
|
||||
) -> i32 {
|
||||
let addr = GAIW_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
|
||||
// Decode the wide hostname for classification/logging.
|
||||
let mut redirected_buf: Vec<u16> = Vec::new();
|
||||
let mut redirected = node;
|
||||
if !node.is_null() {
|
||||
let mut len = 0usize;
|
||||
while *node.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let host = String::from_utf16_lossy(core::slice::from_raw_parts(node, len));
|
||||
crate::write_log(&format!("resolver: GetAddrInfoW({host})\n"));
|
||||
if is_ea_host(&host) {
|
||||
if let Some(ip) = crate::hooks::redirect_ip_str() {
|
||||
redirected_buf = ip.encode_utf16().chain(core::iter::once(0)).collect();
|
||||
redirected = redirected_buf.as_ptr();
|
||||
crate::write_log(&format!("resolver: GetAddrInfoW {host} → redirect\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore(addr, core::ptr::addr_of!(GAIW_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: GetAddrInfoWFn = core::mem::transmute(addr);
|
||||
f(redirected, service, hints, result)
|
||||
};
|
||||
write_hook(addr, hooked_getaddrinfo_w as *const () as u64);
|
||||
// keep redirected_buf alive until after the call
|
||||
drop(redirected_buf);
|
||||
r
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_gethostbyname(name: *const u8) -> *mut core::ffi::c_void {
|
||||
let addr = GHBN_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
let mut redirected = name;
|
||||
let redirect_cstr = crate::hooks::redirect_ip_cstr();
|
||||
|
||||
if !name.is_null() {
|
||||
if let Ok(host) = CStr::from_ptr(name as *const i8).to_str() {
|
||||
crate::write_log(&format!("resolver: gethostbyname({host})\n"));
|
||||
if is_ea_host(host) {
|
||||
if let Some(ip) = redirect_cstr {
|
||||
redirected = ip;
|
||||
crate::write_log(&format!("resolver: gethostbyname {host} → redirect\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore(addr, core::ptr::addr_of!(GHBN_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: GethostbynameFn = core::mem::transmute(addr);
|
||||
f(redirected)
|
||||
};
|
||||
write_hook(addr, hooked_gethostbyname as *const () as u64);
|
||||
r
|
||||
}
|
||||
|
||||
// ── installer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Install inline detours on all three resolvers. Returns a (ok, total) count for
|
||||
/// logging. Safe to call once from the fifa17 worker after ws2_32 is loaded.
|
||||
pub unsafe fn install_resolver_hooks() -> (u32, u32) {
|
||||
let mut ok = 0u32;
|
||||
let total = 3u32;
|
||||
|
||||
let gai = crate::iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0") as *mut u8;
|
||||
if install_one(
|
||||
gai,
|
||||
core::ptr::addr_of_mut!(GAI_ORIG) as *mut u8,
|
||||
&GAI_ADDR,
|
||||
hooked_getaddrinfo as *const (),
|
||||
) {
|
||||
ok += 1;
|
||||
crate::write_log("resolver: getaddrinfo inline-hooked\n");
|
||||
} else {
|
||||
crate::write_log("resolver: getaddrinfo resolve FAILED\n");
|
||||
}
|
||||
|
||||
let gaiw = crate::iat::resolve(b"ws2_32.dll\0", b"GetAddrInfoW\0") as *mut u8;
|
||||
if install_one(
|
||||
gaiw,
|
||||
core::ptr::addr_of_mut!(GAIW_ORIG) as *mut u8,
|
||||
&GAIW_ADDR,
|
||||
hooked_getaddrinfo_w as *const (),
|
||||
) {
|
||||
ok += 1;
|
||||
crate::write_log("resolver: GetAddrInfoW inline-hooked\n");
|
||||
} else {
|
||||
crate::write_log("resolver: GetAddrInfoW resolve FAILED\n");
|
||||
}
|
||||
|
||||
let ghbn = crate::iat::resolve(b"ws2_32.dll\0", b"gethostbyname\0") as *mut u8;
|
||||
if install_one(
|
||||
ghbn,
|
||||
core::ptr::addr_of_mut!(GHBN_ORIG) as *mut u8,
|
||||
&GHBN_ADDR,
|
||||
hooked_gethostbyname as *const (),
|
||||
) {
|
||||
ok += 1;
|
||||
crate::write_log("resolver: gethostbyname inline-hooked\n");
|
||||
} else {
|
||||
crate::write_log("resolver: gethostbyname resolve FAILED\n");
|
||||
}
|
||||
|
||||
(ok, total)
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! with this module compiled in changes nothing unless a var is set:
|
||||
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
||||
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
||||
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
|
||||
//! 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
|
||||
@@ -19,11 +20,14 @@
|
||||
//! See the spec for the verified disassembly behind each one.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
|
||||
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
|
||||
PAGE_WRITECOPY,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||
const IMAGE_BASE: usize = 0x180000000;
|
||||
@@ -42,6 +46,13 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
|
||||
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
|
||||
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
|
||||
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
|
||||
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
||||
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
||||
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
||||
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
||||
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
|
||||
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
|
||||
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
|
||||
const B_DTOR_RVA: usize = 0x63040;
|
||||
const B_ISVALID_RVA: usize = 0x65d40;
|
||||
const B_CLEAR_RVA: usize = 0x65d20;
|
||||
@@ -76,9 +87,11 @@ mod rva {
|
||||
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||
static COMMIT: AtomicBool = AtomicBool::new(false);
|
||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -134,6 +147,13 @@ enum ValidationError {
|
||||
CollectionUnreadable,
|
||||
CollectionNotNull,
|
||||
ReadyByteNotWritable,
|
||||
ModelEmpty,
|
||||
ControllerMissing,
|
||||
ControllerVtableMismatch,
|
||||
ControllerModelMismatch,
|
||||
CompletionBranchMismatch,
|
||||
CompletionBranchProtectFailed,
|
||||
CompletionBranchFlushFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -263,6 +283,26 @@ unsafe fn writable_u8(ptr: usize) -> bool {
|
||||
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
|
||||
}
|
||||
|
||||
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
|
||||
let Some(end) = ptr.checked_add(len) else {
|
||||
return false;
|
||||
};
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return false;
|
||||
}
|
||||
let protection = mbi.Protect & 0xff;
|
||||
matches!(
|
||||
protection,
|
||||
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)
|
||||
}
|
||||
|
||||
/// Guarded 16-bit read (M category count is a WORD).
|
||||
unsafe fn read_u16(ptr: usize) -> Option<u16> {
|
||||
let lo = read_u8(ptr)? as u16;
|
||||
@@ -388,6 +428,12 @@ pub fn install() {
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
COMMIT.store(
|
||||
std::env::var("OPENFUT_SBC_COMMIT")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
POPULATE.store(
|
||||
std::env::var("OPENFUT_SBC_POPULATE")
|
||||
.map(|v| v == "1")
|
||||
@@ -398,6 +444,192 @@ pub fn install() {
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
|
||||
/// The registration hook is observational; all structural checks happen again on
|
||||
/// the notifier thread before this address is trusted.
|
||||
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
let valid = base != 0
|
||||
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
&& controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
|
||||
if valid {
|
||||
SBC_CONTROLLER.store(controller, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
|
||||
));
|
||||
} else {
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn log_controller_model(native_model: usize) {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
let controller_model = controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
let main_vtable = read_ptr(controller).unwrap_or(0);
|
||||
let event_vtable = controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
|
||||
controller != 0 && controller_model == native_model,
|
||||
));
|
||||
}
|
||||
|
||||
unsafe fn validated_sbc_controller(
|
||||
base: usize,
|
||||
native_model: usize,
|
||||
) -> Result<usize, ValidationError> {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
if controller == 0 {
|
||||
return Err(ValidationError::ControllerMissing);
|
||||
}
|
||||
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
|| controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
|
||||
{
|
||||
return Err(ValidationError::ControllerVtableMismatch);
|
||||
}
|
||||
if controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= Some(native_model)
|
||||
{
|
||||
return Err(ValidationError::ControllerModelMismatch);
|
||||
}
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
/// Route the already-scheduled category completion through CardsDLL's own success
|
||||
/// branch. The original function first rejects a non-zero status with a two-byte
|
||||
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
|
||||
/// to the stale scheduler completion rather than the category HTTP transaction.
|
||||
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
|
||||
let target = base
|
||||
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
|
||||
.ok_or(ValidationError::AddressOverflow)?;
|
||||
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|
||||
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
|
||||
!= SBC_COMPLETION_STATUS_JNE
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchMismatch);
|
||||
}
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchProtectFailed);
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
|
||||
target as *mut u8,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
);
|
||||
let flushed = FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
) != 0;
|
||||
let mut ignored = 0u32;
|
||||
let protected = VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
old,
|
||||
&mut ignored,
|
||||
) != 0;
|
||||
if !flushed || !protected {
|
||||
return Err(ValidationError::CompletionBranchFlushFailed);
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
|
||||
GetCurrentThreadId(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit the already-populated native SBC model after the category success notifier.
|
||||
///
|
||||
/// This is called synchronously by the passive notifier wrapper *after* the original
|
||||
/// notifier returns. It never invokes a parser or constructs game objects. The only
|
||||
/// mutation is the established cache-ready byte, and only when the normal parser has
|
||||
/// produced at least one category and every pointer/vtable invariant still matches.
|
||||
pub(crate) unsafe fn commit_after_native_parse() {
|
||||
if !COMMIT.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
if base == 0 || !control_matches(base) {
|
||||
set_failed(ValidationError::AUnreadable);
|
||||
return;
|
||||
}
|
||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
if snapshot.m == 0
|
||||
|| read_u16(snapshot.m + M_COUNT_OFF)
|
||||
.filter(|&count| count > 0)
|
||||
.is_none()
|
||||
{
|
||||
return Err(ValidationError::ModelEmpty);
|
||||
}
|
||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
||||
return Err(ValidationError::ReadyByteNotWritable);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
|
||||
log_controller_model(snapshot.m);
|
||||
if DONE.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
|
||||
snapshot.m,
|
||||
count,
|
||||
snapshot.b + B_READY_OFF,
|
||||
));
|
||||
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
|
||||
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|
||||
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
|
||||
{
|
||||
set_failed(ValidationError::ReadyByteUnexpected);
|
||||
return;
|
||||
}
|
||||
let _controller = match validated_sbc_controller(base, snapshot.m) {
|
||||
Ok(controller) => controller,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = arm_native_completion_success(base) {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
crate::write_log(
|
||||
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
|
||||
);
|
||||
}
|
||||
|
||||
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
||||
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
||||
/// exactly once.
|
||||
|
||||
@@ -55,6 +55,12 @@ static CONSUMER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
|
||||
fn enabled(value: Option<&str>) -> bool {
|
||||
matches!(value, Some("1"))
|
||||
@@ -130,6 +136,22 @@ unsafe fn guarded_ptr(address: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn guarded_u32(address: usize) -> Option<u32> {
|
||||
if readable_range(address, 4) {
|
||||
Some(core::ptr::read_volatile(address as *const u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||
if readable_range(address, 1) {
|
||||
Some(core::ptr::read_volatile(address as *const u8))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn field_ptr(object: usize, offset: usize) -> usize {
|
||||
object
|
||||
.checked_add(offset)
|
||||
@@ -149,14 +171,44 @@ unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void
|
||||
let consumer = field_ptr(owner_vtable, 0x18);
|
||||
let owner_slot_before = guarded_ptr(argument_address);
|
||||
let response_vtable = guarded_ptr(owner_slot_before);
|
||||
let owner_inner = field_ptr(owner, 8);
|
||||
let owner_state_before = owner_inner
|
||||
.checked_add(8)
|
||||
.and_then(|p| guarded_u32(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
let owner_flags = owner_inner
|
||||
.checked_add(0x0c)
|
||||
.and_then(|p| guarded_u8(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
let owner_manager = field_ptr(owner_inner, 0x14d0);
|
||||
let owner_manager_state = owner_manager
|
||||
.checked_add(0x1dc0)
|
||||
.and_then(|p| guarded_u32(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
OWNER_88.store(owner, Ordering::Relaxed);
|
||||
OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed);
|
||||
CONSUMER_88.store(consumer, Ordering::Relaxed);
|
||||
RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed);
|
||||
OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed);
|
||||
OWNER_INNER_88.store(owner_inner, Ordering::Relaxed);
|
||||
OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed);
|
||||
OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed);
|
||||
OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed);
|
||||
OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed);
|
||||
let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire));
|
||||
original(request, argument);
|
||||
OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed);
|
||||
OWNER_STATE_AFTER_88.store(
|
||||
owner_inner
|
||||
.checked_add(8)
|
||||
.and_then(|p| guarded_u32(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
EXIT_88.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
|
||||
@@ -334,7 +386,7 @@ unsafe fn worker() {
|
||||
let count_90 = ENTER_90.load(Ordering::Acquire);
|
||||
if count_88 != seen_88 || count_90 != seen_90 {
|
||||
crate::write_log(&format!(
|
||||
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
|
||||
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
|
||||
count_88,
|
||||
EXIT_88.load(Ordering::Acquire),
|
||||
LAST_REQUEST_88.load(Ordering::Relaxed),
|
||||
@@ -346,6 +398,12 @@ unsafe fn worker() {
|
||||
RESPONSE_VTABLE_88.load(Ordering::Relaxed),
|
||||
OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed),
|
||||
OWNER_SLOT_AFTER_88.load(Ordering::Relaxed),
|
||||
OWNER_INNER_88.load(Ordering::Relaxed),
|
||||
OWNER_STATE_BEFORE_88.load(Ordering::Relaxed),
|
||||
OWNER_STATE_AFTER_88.load(Ordering::Relaxed),
|
||||
OWNER_FLAGS_88.load(Ordering::Relaxed),
|
||||
OWNER_MANAGER_88.load(Ordering::Relaxed),
|
||||
OWNER_MANAGER_STATE_88.load(Ordering::Relaxed),
|
||||
count_90,
|
||||
EXIT_90.load(Ordering::Acquire),
|
||||
LAST_REQUEST_90.load(Ordering::Relaxed),
|
||||
|
||||
@@ -32,6 +32,12 @@ const ABS_JUMP_LEN: usize = 14;
|
||||
const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN;
|
||||
const NOTIFIER_RVA: usize = 0x17aa80;
|
||||
const NOTIFIER_COPY_LEN: usize = 15;
|
||||
const CONTROLLER_REGISTER_RVA: usize = 0x1a4a70;
|
||||
const CONTROLLER_REGISTER_COPY_LEN: usize = 15;
|
||||
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
|
||||
const CONTROLLER_REGISTER_SIGNATURE: [u8; CONTROLLER_REGISTER_COPY_LEN] = [
|
||||
0x89, 0x54, 0x24, 0x10, 0x48, 0x83, 0xec, 0x28, 0x4c, 0x8d, 0x81, 0xc0, 0x00, 0x00, 0x00,
|
||||
];
|
||||
const NOTIFIER_SIGNATURE: [u8; 32] = [
|
||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
|
||||
0x8b, 0x59, 0x58, 0x48, 0x8b, 0x71, 0x60, 0x33, 0xff, 0x48, 0x2b, 0xf3, 0xc6, 0x81, 0x88, 0x00,
|
||||
@@ -75,6 +81,7 @@ static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
static NOTIFIER_EXITS: AtomicU64 = AtomicU64::new(0);
|
||||
static NOTIFIER_CTX: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -521,6 +528,77 @@ unsafe fn write_notifier_entry(
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn restore_controller_register_entry(target: usize) -> bool {
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
target as _,
|
||||
CONTROLLER_REGISTER_COPY_LEN,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(
|
||||
CONTROLLER_REGISTER_SIGNATURE.as_ptr(),
|
||||
target as *mut u8,
|
||||
CONTROLLER_REGISTER_COPY_LEN,
|
||||
);
|
||||
let flushed = FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target as _,
|
||||
CONTROLLER_REGISTER_COPY_LEN,
|
||||
) != 0;
|
||||
let mut ignored = 0u32;
|
||||
flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0
|
||||
}
|
||||
|
||||
unsafe fn write_controller_register_entry(
|
||||
target: usize,
|
||||
destination: usize,
|
||||
published: &mut bool,
|
||||
) -> Result<(), bool> {
|
||||
let mut patch = [0x90u8; CONTROLLER_REGISTER_COPY_LEN];
|
||||
patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination));
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
target as _,
|
||||
CONTROLLER_REGISTER_COPY_LEN,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return Err(true);
|
||||
}
|
||||
*published = true;
|
||||
core::ptr::copy_nonoverlapping(
|
||||
patch.as_ptr(),
|
||||
target as *mut u8,
|
||||
CONTROLLER_REGISTER_COPY_LEN,
|
||||
);
|
||||
let flushed = FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target as _,
|
||||
CONTROLLER_REGISTER_COPY_LEN,
|
||||
) != 0;
|
||||
let mut ignored = 0u32;
|
||||
if flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(restore_controller_register_entry(target))
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, event: u32) {
|
||||
let original: unsafe extern "system" fn(*mut c_void, u32) =
|
||||
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(controller, event);
|
||||
if event == FUT_SBS_CATEGORIES_EVENT {
|
||||
crate::sbc_hook::note_sbc_controller(controller as usize);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
|
||||
NOTIFIER_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||
let address = ctx as usize;
|
||||
@@ -552,6 +630,7 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
|
||||
let original: unsafe extern "system" fn(*mut c_void) =
|
||||
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(ctx);
|
||||
crate::sbc_hook::commit_after_native_parse();
|
||||
NOTIFIER_BYTE_AFTER.store(
|
||||
address
|
||||
.checked_add(0x88)
|
||||
@@ -946,10 +1025,78 @@ unsafe fn notifier_worker() {
|
||||
crate::write_log("SBC_NOTIFIER_TRACE: report cap reached; hook remains passive\n");
|
||||
}
|
||||
|
||||
unsafe fn controller_register_worker() {
|
||||
let _pending = CodeInstallerPending;
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let Some(target) = target_va(base, CONTROLLER_REGISTER_RVA) else {
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: target resolution failed; inactive\n");
|
||||
return;
|
||||
};
|
||||
if base == 0
|
||||
|| !valid_cards_image(base)
|
||||
|| !executable_range_in_image(base, target, CONTROLLER_REGISTER_SIGNATURE.len())
|
||||
|| core::slice::from_raw_parts(target as *const u8, CONTROLLER_REGISTER_SIGNATURE.len())
|
||||
!= CONTROLLER_REGISTER_SIGNATURE
|
||||
{
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: PE/signature validation failed; inactive\n");
|
||||
return;
|
||||
}
|
||||
let Some(trampoline) = allocate_trampoline(target, CONTROLLER_REGISTER_COPY_LEN) else {
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: trampoline allocation failed; inactive\n");
|
||||
return;
|
||||
};
|
||||
CONTROLLER_REGISTER_TRAMPOLINE.store(trampoline, Ordering::Release);
|
||||
let Some(_installer_gate) = acquire_patch_installer_gate() else {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release);
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: installer gate timeout; inactive\n");
|
||||
return;
|
||||
};
|
||||
let mut peers = match suspend_peers(target, target) {
|
||||
Ok(peers) => peers,
|
||||
Err(_) => {
|
||||
crate::write_log(
|
||||
"SBC_CONTROLLER_TRACE: quiescence failed; terminate game if unresponsive\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut published = false;
|
||||
let installed = write_controller_register_entry(
|
||||
target,
|
||||
controller_register_wrapper as *const () as usize,
|
||||
&mut published,
|
||||
)
|
||||
.is_ok();
|
||||
let resumed = peers.resume_all();
|
||||
drop(_installer_gate);
|
||||
drop(_pending);
|
||||
if !installed || !resumed {
|
||||
if !published && resumed {
|
||||
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
||||
CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release);
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: clean install failure; inactive\n");
|
||||
} else {
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: DEGRADED state; terminate game now\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: category controller registration hook installed\n");
|
||||
}
|
||||
|
||||
fn install_notifier(enabled: bool) {
|
||||
if enabled {
|
||||
crate::write_log("SBC_NOTIFIER_TRACE: requested; deferred install starting\n");
|
||||
std::thread::spawn(|| unsafe { notifier_worker() });
|
||||
crate::write_log("SBC_CONTROLLER_TRACE: requested; deferred install starting\n");
|
||||
std::thread::spawn(|| unsafe { controller_register_worker() });
|
||||
} else {
|
||||
crate::write_log("SBC_NOTIFIER_TRACE: disabled\n");
|
||||
}
|
||||
@@ -959,7 +1106,7 @@ pub(crate) fn install() {
|
||||
let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
||||
let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
||||
CODE_PATCH_PENDING.store(
|
||||
enabled as usize + notifier_enabled as usize,
|
||||
enabled as usize + (notifier_enabled as usize * 2),
|
||||
Ordering::Release,
|
||||
);
|
||||
install_notifier(notifier_enabled);
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! FIFA 17 empty-"My Packs" client fix (config flag `store_mypacks_fix=1`).
|
||||
//!
|
||||
//! ## What this does
|
||||
//! When the account owns **zero unopened packs**, FIFA 17's Store still selects the
|
||||
//! "My Packs" category on open. CardsDLL's category resolver (`FUN_1800147f0` →
|
||||
//! `FUN_180014420`) then looks up the My-Packs group ordinal and, if no such group
|
||||
//! exists, dereferences a NULL group pointer → crash (`0x180014882`, read of `0x48`).
|
||||
//! The backend currently avoids this with an active placeholder pack (sentinel 65534)
|
||||
//! that leaves a fake empty tile.
|
||||
//!
|
||||
//! This hook removes the need for that sentinel *for a validated build*: it detours the
|
||||
//! Store render entry `FUN_18007dab0` and, **only when the requested category is My Packs
|
||||
//! AND the client's unopened-pack count is 0**, rewrites the requested category id at
|
||||
//! `screen+0x290` to `0` (list-all = "Browse Packs"). The Store then opens on Browse
|
||||
//! Packs, never resolves the absent My-Packs group, and neither crashes nor shows a fake
|
||||
//! tile. With a real unopened pack (count > 0) nothing is changed and My Packs works
|
||||
//! normally.
|
||||
//!
|
||||
//! ## Safety model
|
||||
//! - **Inert unless enabled**: reads `store_mypacks_fix` from `openfut.cfg`; default OFF.
|
||||
//! - **Validated build only**: refuses to install unless CardsDLL matches the known FIFA
|
||||
//! 17 build (PE timestamp + SizeOfImage + a slide-proof control prologue + the target
|
||||
//! function's own prologue signature). An unknown build → no patch, log, and the
|
||||
//! backend sentinel remains the fallback.
|
||||
//! - **Deferred**: CardsDLL loads lazily on entering Ultimate Team, so we poll off the
|
||||
//! loader lock, exactly like `sbc_hook`.
|
||||
//! - **Fail-safe count**: if the unopened-pack count cannot be read, we DO NOT redirect
|
||||
//! (leave the category unchanged and call the original) — never a forced Browse.
|
||||
//! - **Inline detour**: same proven `unhook → call real → rehook` primitive as
|
||||
//! `resolver_hook`/`connect_hook` (no trampoline, no RIP relocation).
|
||||
//!
|
||||
//! Addresses are RVAs (static VA − image base `0x180000000`); see
|
||||
//! `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART II for the disassembly evidence.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Foundation::HMODULE;
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualProtect, 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,
|
||||
};
|
||||
|
||||
// ── Build identity (verified against CardsDLL_Win64_retail.dll 4706a881…) ────────
|
||||
const IMAGE_BASE: usize = 0x1_8000_0000;
|
||||
const PE_TIMESTAMP: u32 = 1_497_050_156; // 2017-06-09T23:15:56Z
|
||||
const SIZE_OF_IMAGE: u32 = 0x31d000;
|
||||
/// Slide-proof FNV-hasher control prologue at VA 0x180180d00 (same control sbc_hook uses).
|
||||
const CTRL_RVA: usize = 0x180d00;
|
||||
const CTRL_BYTES: [u8; 12] = [
|
||||
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
|
||||
];
|
||||
|
||||
// ── Target + helper RVAs ─────────────────────────────────────────────────────────
|
||||
/// FUN_18007dab0 — Store render entry (Flash message 0x753f). arg0 = store screen (RCX).
|
||||
const RENDER_RVA: usize = 0x7dab0;
|
||||
/// First 14 bytes of FUN_18007dab0 (PUSH RDI; SUB RSP,0x40; MOV [RSP+0x30],-2 …).
|
||||
/// Doubles as the target-site signature and the bytes we save/restore for the detour.
|
||||
const RENDER_PROLOGUE: [u8; 14] = [
|
||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff,
|
||||
];
|
||||
/// FUN_180014580(store, tab) → category id (1-based group ordinal, or -1 if absent).
|
||||
const TABMAP_RVA: usize = 0x14580;
|
||||
/// FUN_1800d7170() → registry (no args).
|
||||
const REGISTRY_GETTER_RVA: usize = 0xd7170;
|
||||
/// FUN_180009c80(out, registry, 0, 0) → writes the data-manager singleton into *out.
|
||||
const MANAGER_GETTER_RVA: usize = 0x9c80;
|
||||
/// manager->vtbl[+0x4d8]() → unopened-pack count (i32).
|
||||
const UNOPENED_COUNT_VSLOT: usize = 0x4d8;
|
||||
/// manager->vtbl[+0x08]() → release.
|
||||
const RELEASE_VSLOT: usize = 0x08;
|
||||
/// screen+0x290 = requested CATEGORY_ID (movie-written; the resolver's input).
|
||||
const SCREEN_CATEGORY_OFF: usize = 0x290;
|
||||
/// FUN_180014580 tab index for "mypacks".
|
||||
const MYPACKS_TAB: u32 = 0;
|
||||
/// Category 0 = list-all group tiles = "Browse Packs".
|
||||
const CAT_BROWSE: i32 = 0;
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────────
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static RENDER_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut RENDER_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── Internal CardsDLL function types (MS x64 ABI) ─────────────────────────────────
|
||||
type RegistryGetterFn = unsafe extern "system" fn() -> usize;
|
||||
type ManagerGetterFn = unsafe extern "system" fn(*mut usize, usize, usize, usize) -> *mut usize;
|
||||
type TabMapFn = unsafe extern "system" fn(usize, u32) -> u32;
|
||||
type CountGetterFn = unsafe extern "system" fn(usize) -> i32;
|
||||
type ReleaseFn = unsafe extern "system" fn(usize);
|
||||
type RenderFn = unsafe extern "system" fn(usize) -> usize;
|
||||
|
||||
// ── Pure decision (host-testable; the correctness core) ───────────────────────────
|
||||
/// Redirect the Store to Browse Packs iff the feature is enabled, the requested
|
||||
/// category is exactly the My-Packs category, and the client owns zero unopened packs.
|
||||
/// A `None` count (read failed) is treated as "do not redirect".
|
||||
fn should_redirect(enabled: bool, count: Option<i32>, requested: i32, mypacks: i32) -> bool {
|
||||
enabled && requested == mypacks && count == Some(0)
|
||||
}
|
||||
|
||||
// ── Guarded memory access (no blind dereferences) ─────────────────────────────────
|
||||
unsafe fn readable(ptr: usize, len: usize) -> bool {
|
||||
if ptr < 0x1_0000 || len == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT {
|
||||
return false;
|
||||
}
|
||||
let prot = mbi.Protect;
|
||||
if prot & PAGE_GUARD != 0 || prot == PAGE_NOACCESS {
|
||||
return false;
|
||||
}
|
||||
const READABLE: u32 = PAGE_READONLY
|
||||
| PAGE_READWRITE
|
||||
| PAGE_WRITECOPY
|
||||
| PAGE_EXECUTE_READ
|
||||
| PAGE_EXECUTE_READWRITE
|
||||
| PAGE_EXECUTE_WRITECOPY;
|
||||
if prot & READABLE == 0 {
|
||||
return false;
|
||||
}
|
||||
let region_end = mbi.BaseAddress as usize + mbi.RegionSize;
|
||||
ptr.checked_add(len).is_some_and(|end| end <= region_end)
|
||||
}
|
||||
|
||||
unsafe fn writable(ptr: usize, len: usize) -> bool {
|
||||
if ptr < 0x1_0000 || len == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT {
|
||||
return false;
|
||||
}
|
||||
let prot = mbi.Protect;
|
||||
if prot & PAGE_GUARD != 0 {
|
||||
return false;
|
||||
}
|
||||
const WRITABLE: u32 =
|
||||
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
if prot & WRITABLE == 0 {
|
||||
return false;
|
||||
}
|
||||
let region_end = mbi.BaseAddress as usize + mbi.RegionSize;
|
||||
ptr.checked_add(len).is_some_and(|end| end <= region_end)
|
||||
}
|
||||
|
||||
unsafe fn executable(ptr: usize) -> bool {
|
||||
if ptr < 0x1_0000 {
|
||||
return false;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT {
|
||||
return false;
|
||||
}
|
||||
const EXEC: u32 = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
mbi.Protect & PAGE_GUARD == 0 && mbi.Protect & EXEC != 0
|
||||
}
|
||||
|
||||
unsafe fn read_u8(ptr: usize) -> Option<u8> {
|
||||
readable(ptr, 1).then(|| *(ptr as *const u8))
|
||||
}
|
||||
|
||||
unsafe fn read_u32(ptr: usize) -> Option<u32> {
|
||||
(ptr & 3 == 0 && readable(ptr, 4)).then(|| *(ptr as *const u32))
|
||||
}
|
||||
|
||||
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
|
||||
(ptr & 7 == 0 && readable(ptr, 8)).then(|| *(ptr as *const usize))
|
||||
}
|
||||
|
||||
unsafe fn bytes_match(addr: usize, want: &[u8]) -> bool {
|
||||
want.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &b)| read_u8(addr + i) == Some(b))
|
||||
}
|
||||
|
||||
// ── Inline-hook primitive (identical to resolver_hook/connect_hook) ───────────────
|
||||
unsafe fn write_hook(target: *mut u8, dest: u64) {
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
// FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0u32);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
unsafe fn restore(target: *mut u8, orig: *const u8) {
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
core::ptr::copy_nonoverlapping(orig, target, 14);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
// ── Runtime helpers ────────────────────────────────────────────────────────────
|
||||
unsafe fn resolve_cards_base() -> usize {
|
||||
let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr() as *const u8);
|
||||
if !h.is_null() {
|
||||
return h as usize;
|
||||
}
|
||||
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr() as *const u8);
|
||||
if !h2.is_null() {
|
||||
return h2 as usize;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Read the client's unopened-pack count via the data-manager singleton
|
||||
/// (`registry → manager → vtbl[0x4d8]`), releasing the manager afterwards. Returns
|
||||
/// `None` on any unreadable pointer/vtable so the caller never redirects on a bad read.
|
||||
unsafe fn read_unopened_count(base: usize) -> Option<i32> {
|
||||
if !executable(base + REGISTRY_GETTER_RVA) || !executable(base + MANAGER_GETTER_RVA) {
|
||||
return None;
|
||||
}
|
||||
let registry_getter: RegistryGetterFn = core::mem::transmute(base + REGISTRY_GETTER_RVA);
|
||||
let registry = registry_getter();
|
||||
if registry == 0 {
|
||||
return None;
|
||||
}
|
||||
let manager_getter: ManagerGetterFn = core::mem::transmute(base + MANAGER_GETTER_RVA);
|
||||
let mut out: usize = 0;
|
||||
manager_getter(&mut out, registry, 0, 0);
|
||||
let manager = out;
|
||||
if manager == 0 {
|
||||
return None;
|
||||
}
|
||||
let vtbl = read_ptr(manager)?;
|
||||
let count_fn = read_ptr(vtbl + UNOPENED_COUNT_VSLOT)?;
|
||||
let release_fn = read_ptr(vtbl + RELEASE_VSLOT)?;
|
||||
if !executable(count_fn) || !executable(release_fn) {
|
||||
return None;
|
||||
}
|
||||
let getter: CountGetterFn = core::mem::transmute(count_fn);
|
||||
let count = getter(manager);
|
||||
let release: ReleaseFn = core::mem::transmute(release_fn);
|
||||
release(manager);
|
||||
Some(count)
|
||||
}
|
||||
|
||||
/// The redirect decision + write, executed before the original render runs.
|
||||
unsafe fn maybe_redirect(store: usize) {
|
||||
if store == 0 {
|
||||
return;
|
||||
}
|
||||
let base = CARDS_BASE.load(Ordering::Relaxed);
|
||||
if base == 0 {
|
||||
return;
|
||||
}
|
||||
let cat_ptr = store + SCREEN_CATEGORY_OFF;
|
||||
if !readable(cat_ptr, 4) {
|
||||
return;
|
||||
}
|
||||
let requested = *(cat_ptr as *const i32);
|
||||
if !executable(base + TABMAP_RVA) {
|
||||
return;
|
||||
}
|
||||
let tabmap: TabMapFn = core::mem::transmute(base + TABMAP_RVA);
|
||||
let mypacks_id = tabmap(store, MYPACKS_TAB) as i32;
|
||||
// Only pay for the count read when the requested category is actually My Packs.
|
||||
if requested != mypacks_id {
|
||||
return;
|
||||
}
|
||||
let count = read_unopened_count(base);
|
||||
if should_redirect(
|
||||
ENABLED.load(Ordering::Relaxed),
|
||||
count,
|
||||
requested,
|
||||
mypacks_id,
|
||||
) {
|
||||
if writable(cat_ptr, 4) {
|
||||
*(cat_ptr as *mut i32) = CAT_BROWSE;
|
||||
crate::write_log("[store-hook] zero unopened packs: My Packs -> Browse Packs\n");
|
||||
} else {
|
||||
crate::write_log("[store-hook] category slot not writable; left unchanged\n");
|
||||
}
|
||||
}
|
||||
// requested == mypacks with count > 0 or unknown: leave My Packs unchanged.
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_render(store: usize) -> usize {
|
||||
let addr = RENDER_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
if addr.is_null() {
|
||||
return 0;
|
||||
}
|
||||
maybe_redirect(store);
|
||||
restore(addr, core::ptr::addr_of!(RENDER_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: RenderFn = core::mem::transmute(addr as *const ());
|
||||
f(store)
|
||||
};
|
||||
write_hook(addr, hooked_render as *const () as u64);
|
||||
r
|
||||
}
|
||||
|
||||
// ── Build guard + install ─────────────────────────────────────────────────────
|
||||
unsafe fn build_supported(base: usize) -> bool {
|
||||
let fail = |why: &str| {
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] CardsDLL build UNSUPPORTED ({why}); not installing (backend sentinel remains)\n"
|
||||
));
|
||||
false
|
||||
};
|
||||
let Some(e_lfanew) = read_u32(base + 0x3c) else {
|
||||
return fail("PE header unreadable");
|
||||
};
|
||||
let pe = base + e_lfanew as usize;
|
||||
if read_u32(pe) != Some(0x0000_4550) {
|
||||
return fail("PE signature");
|
||||
}
|
||||
if read_u32(pe + 8) != Some(PE_TIMESTAMP) {
|
||||
return fail("PE timestamp");
|
||||
}
|
||||
if read_u32(pe + 24 + 0x38) != Some(SIZE_OF_IMAGE) {
|
||||
return fail("SizeOfImage");
|
||||
}
|
||||
if !bytes_match(base + CTRL_RVA, &CTRL_BYTES) {
|
||||
return fail("control prologue");
|
||||
}
|
||||
if !bytes_match(base + RENDER_RVA, &RENDER_PROLOGUE) {
|
||||
return fail("FUN_18007dab0 prologue");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Deferred worker: CardsDLL loads only on entering Ultimate Team, so poll for it
|
||||
/// (≤5 min) off the loader lock, then validate the build and install the detour once.
|
||||
unsafe fn worker() {
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = resolve_cards_base();
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
if base == 0 {
|
||||
crate::write_log("[store-hook] CardsDLL never loaded; hook not installed\n");
|
||||
return;
|
||||
}
|
||||
let slide = base.wrapping_sub(IMAGE_BASE);
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] CardsDLL base={base:#x} slide={slide:#x}; validating build\n"
|
||||
));
|
||||
if !build_supported(base) {
|
||||
return;
|
||||
}
|
||||
CARDS_BASE.store(base, Ordering::Relaxed);
|
||||
let render = base + RENDER_RVA;
|
||||
core::ptr::copy_nonoverlapping(
|
||||
render as *const u8,
|
||||
core::ptr::addr_of_mut!(RENDER_ORIG) as *mut u8,
|
||||
14,
|
||||
);
|
||||
RENDER_ADDR.store(render, Ordering::Relaxed);
|
||||
write_hook(render as *mut u8, hooked_render as *const () as u64);
|
||||
INSTALLED.store(true, Ordering::Relaxed);
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] build supported; installed at CardsDLL+{RENDER_RVA:#x} (VA {render:#x})\n"
|
||||
));
|
||||
}
|
||||
|
||||
/// Public entry, called from `fifa17::worker`. Reads `store_mypacks_fix` from
|
||||
/// `openfut.cfg`; if enabled, spawns the deferred CardsDLL-load worker. Fully inert
|
||||
/// otherwise (no thread, no patch).
|
||||
pub fn install(module: HMODULE) {
|
||||
let enabled = match crate::config::feature_value(module, "store_mypacks_fix").as_deref() {
|
||||
Some("1") => true,
|
||||
Some("0") | None => false,
|
||||
Some(other) => {
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] invalid store_mypacks_fix={other:?}; feature disabled\n"
|
||||
));
|
||||
false
|
||||
}
|
||||
};
|
||||
ENABLED.store(enabled, Ordering::Relaxed);
|
||||
if !enabled {
|
||||
crate::write_log("[store-hook] disabled (set store_mypacks_fix=1 in openfut.cfg)\n");
|
||||
return;
|
||||
}
|
||||
crate::write_log("[store-hook] enabled; deferring until CardsDLL loads\n");
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_redirect;
|
||||
|
||||
#[test]
|
||||
fn disabled_never_redirects() {
|
||||
assert!(!should_redirect(false, Some(0), 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_mypacks_redirects() {
|
||||
assert!(should_redirect(true, Some(0), 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_one_pack_keeps_mypacks() {
|
||||
assert!(!should_redirect(true, Some(1), 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_browse_untouched() {
|
||||
// requested Browse (0) != mypacks ordinal (3)
|
||||
assert!(!should_redirect(true, Some(0), 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_other_tab_untouched() {
|
||||
// e.g. bronze ordinal 4 != mypacks 3
|
||||
assert!(!should_redirect(true, Some(0), 4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_unknown_count_does_not_redirect() {
|
||||
assert!(!should_redirect(true, None, 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_absent_mypacks_group_redirects() {
|
||||
// With no sentinel, both the requested id and mypacks id are -1 (group absent).
|
||||
assert!(should_redirect(true, Some(0), -1, -1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user