11 Commits

Author SHA1 Message Date
funman300 bee97055db Add gated FIFA17 Offline Seasons PMA repair 2026-08-27 22:39:55 +00:00
funman300 021a044859 fix(fifa17): preserve offline-season fixture through game setup 2026-08-25 20:14:16 +00:00
funman300 d7641175be Revert: remove the engine-provider kit detours entirely
Two client breakages in a row from detouring FUN_180033770 / sub_180033430 /
FUN_1800d73d0: the first cut froze FIFA at the "are both teams ready" prompt,
and the rate-limited rewrite CRASHED it at the same point. Rate limiting fixed
the I/O problem and the crash still happened, so the fault is the detours
themselves, not the logging.

Most likely cause: sub_180033430 is an address Ghidra never functionised, and at
least one of these is reached in a way a 14-byte inline patch cannot survive -
an interior branch target, or a callee taking stack arguments that the 4-register
wrapper silently drops when it tail-calls the original.

What the aborted runs did establish, and it is worth keeping:
  - KIT_SCAN fires for cardtype 7 with subtype 9 selector 2 AND selector 3, so
    BOTH the home and away club scans do run.
  - The FUT club enumerate (teamId 130000) did NOT occur in the crashed run
    before the kit screen, and KIT_DESC never fired at all.
  - KITS_AVAILABLE remains 0.
  - The "ret" value logged by kit_scan is the same constant for every call and
    is not a usable item pointer, so that reading was wrong.

Next attempt must NOT patch this code path. Read the state from outside the
process instead - /proc/PID/mem plus objdump against the live client, which
cannot crash the game because it never writes to it.
2026-08-23 18:39:44 +00:00
funman300 89cf5df71f kit_trace: rate-limited engine-provider traces (the first cut froze the client)
The previous version of these three detours hung FIFA at the "are both teams
ready" prompt. FUN_180033770 is POLLED - about 150 calls alternating between the
two real match team ids - and the wrapper did a synchronous write_log on every
one. That is the whole cause; the detours themselves were sound.

Rebuilt so the hot path costs nothing:

  - kit_enum returns immediately unless the team is the FUT club (130000), so
    the polled case does no formatting and no I/O at all. When it is the FUT
    club it reports the out-list length, i.e. how many kits were actually
    offered - the number that decides whether the carousel has anything.
  - kit_desc dedupes on the packed kit id, so a carousel that re-describes the
    same kit logs it once. It decodes teamid/year/slot for comparison against
    the active triple.
  - kit_scan only reports cardtype 7 and dedupes on (subtype, selector).

Dedupe is a fixed 16-slot lock-free SeenSet - no allocation, no locks, safe to
consult from a polled game thread. A full set stops reporting rather than
evicting, because the point is a bounded log.

Rule this file broke once and must not break again: no trace may log per-call on
a polled function.

Motivation changed too. The kit selector is not cosmetic: it is what blocks
entering a match, which also explains why every match in the capture corpus is a
DNF with an unpopulated params object - entered and backed out of. A screenshot
shows the carousel with two tiles, one named HOME and one labelled "undefined",
both with untextured white shirts, which is exactly sub_180033430 writing NAME
on a match and nothing at all on a miss.
2026-08-23 18:36:11 +00:00
funman300 0f2d66e8ca kit_trace: instrument the ENGINE-PROVIDER path, which nothing was watching
A live run refuted the model the existing traces were built on. KIT_SET never
fires, KIT_GET reports KITS_AVAILABLE=0, and FUN_1801c3480 is entered 810 times
without ever seeing a kit (772 players +0x60=1, 24 zeroed +0x60=4, 12 players
+0x60=6, 2 type-2 +0x60=4). So the DP command 0x7576 ->
FutSquadServiceImpl::setAvailableKits path is simply not the one in use.

The selector is fed by a provider CardsDLL registers into the FIFA engine -
singleton FUN_1800338f0, vtable 0x1801f1d68, slot +0x08 enumerate and +0x10
describe - and no trace covered it. Three passive detours added:

  KIT_ENUM  FUN_180033770  logs the teamId asked for; it answers only for the
                           FUT club 130000 and otherwise forwards to the engine
                           default, so a real team id here means our club items
                           were never in scope.
  KIT_DESC  sub_180033430  decodes the packed id into (teamid, year, slot). A
                           descriptor whose triple does not equal the active
                           home/away triple is left untouched, which is what
                           makes the engine substitute its own catalogue kit.
  KIT_SCAN  FUN_1800d73d0  the club scan behind getActiveKit: reports HIT with
                           the item fields, or MISS meaning the active triple
                           stays zero.

Prologues were dumped from the analysed Ghidra project (cardsdll.dll, base
0x180000000). Every copy length is instruction-aligned and none of the three
prologues is rip-relative, so the plain installer is correct for all of them -
unlike FUN_1801c3480, which needs the relocating installer for its
MOV RAX,[rip+...]. Note FUN_1800d73d0's prologue compares EDX against 2, so rdx
is the home/away selector (2/3), not the cardtype an earlier note assumed.

All three log then tail-call: behaviour is unchanged.
2026-08-23 17:55:04 +00:00
funman300 561e666dc3 config: never let an unreadable config.json become production defaults
load() did:

    read_to_string(&path).ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default()

so ANY parse failure silently produced compiled defaults -- blaze_main 42130 and
account_sync 8099, both PRODUCTION -- with an empty game_profile, and the next
save() wrote that over the operator's real settings. The launcher then could not
start the game and was pointed at the live service.

Observed 2026-08-23 from nothing worse than a UTF-8 BOM: PowerShell 5.1's
Set-Content -Encoding UTF8 prepends EF BB BF and serde_json rejects it. A
staging config (42327/42330/8299) was destroyed and replaced with production
ports without a word.

Two changes:

- parse_json() strips a leading BOM, since Windows editors and PowerShell both
  emit one. Split out from load() so it is testable without touching the real
  config path.
- A file that EXISTS but does not parse is no longer treated like a missing one.
  It is renamed to config.json.corrupt-<epoch> and the error is reported naming
  the production risk, so defaults can never overwrite a recoverable config.

A missing file still yields defaults: that is genuine first-run.

Tests cover the exact incident (BOM-prefixed config keeps 42327/42330/8299 and
does NOT fall back to 42130/8099) with a precondition asserting raw serde_json
really does reject the BOM, so the guard cannot rot into a tautology.
2026-08-23 02:06:38 +00:00
funman300 9ba88c79fc roster: redirect the FIFA 17 roster dial at the socket
FIFA 17's ProtoSSL verifies the roster certificate by dNSName only, so an
IP-addressed roster host is refused even with the IP in the SANs. The hostname
therefore has to survive into SNI while the connection lands on our server.

The connect/WSAConnect/ConnectEx detour already intercepted the dial; it just
did not rewrite it, because 8081 was absent from the EA port table. Adding
ea_ports::FIFA17_ROSTER plus an OpenFutPorts.roster destination makes the
existing, proven redirect handle it with no new hook surface, and removes the
need for any client-side DNS change.

to_cfg_string writes roster_port ONLY when it differs from the default: the
parser rejects unknown keys, so emitting it unconditionally would make an
already-deployed older hook reject the whole config and install no redirect at
all -- breaking the game instead of degrading.
2026-08-23 01:58:11 +00:00
funman300 5294f589ad fix(seasons): supply the FUT web-file base so Seasons stops failing
Entering single-player Seasons showed "There was a problem communicating with
the FIFA Ultimate Team Servers". The deployed trace caught the whole chain:

  SEASON_CALL: LoadOfflineSeasons_asyncimpl(0x57560)
  SEASONS_WEBFILE_URL: url="packs/loc/storepackdescriptions.en_us.xml"
  SEASONS_STAGE1: status(+0x1c)=999 -> CACHE_PACKNAMES_FAILED
  SEASONS_LOAD_CALLBACK: final kind=ERROR result="CACHE_PACKNAMES_FAILED"

Not a server fault: no /season/* request is ever made. The client's
RS4::ServerSettings CDN base is EMPTY in the emulator, so the pack-names web
file is requested as a BARE relative path and 999s, and Seasons aborts on that
prerequisite.

Ports the base-supply rewriter from wip/seasons/base-supply-veh onto the
deployed lineage (that branch forked before the TLS work and cannot be rebased),
taking only the URL supply: absolute urls pass through untouched, and the
success-forcing CACHE_PACKNAMES bypass is deliberately NOT taken — masking the
failure would hide whether the supply actually worked.

Corrects the port while porting: the branch hardcoded 8110, where nothing
listens. The content server is POW (`pow_server.py`, kind "content") on 8085 —
the port Blaze already advertises to the client as its content host. Verified
live: GET http://10.10.0.120:8085/fut/packs/loc/storepackdescriptions.en_us.xml
returns 200 with a 180-byte XLIFF document. `default_ports::FUT_CONTENT` is now
8085 and the base is still derived from openfut.cfg, so no address is compiled
in (confirmed absent from the artifact).

Artifact keeps the roster TLS gate patch (11 fifa17_tls markers) and the kit
trace alongside the new base supply.
2026-08-21 16:12:41 +00:00
funman300 7edf682291 diag(fifa17): port the passive kit-selector trace onto main
Cherry-pick of 4b1d5aa from wip/kit-selector-re, which forked before the TLS
work and cannot be rebased: that branch predates fifa17_tls.rs/patch_mem.rs and
carries a large unrelated lineage (probe/lsx/recv_hook). Only the kit_trace
commit's own contents are taken.

Traces the client-side FUT pre-match kit path in CardsDLL: the GetMatchKits_DP
gate (KITS_AVAILABLE), setAvailableKits (home/away list count), the kit-item
clone driver (item type/subid/teamid at FUN_1801c3480), and the local teamkits
DB clone. Read-only passive detours reusing season_trace's installers, which
this widens to pub(crate).

The one open unknown it answers: the selector requires item+0x60 == 4, a pile
value the server has never been observed to produce (/club emits 1,
/purchased 6).

Verified in the cross-built artifact that the roster TLS gate patch is intact
alongside the new trace (fifa17_tls markers present, KIT_* markers present).
2026-08-21 04:15:58 +00:00
funman300 44ebc4b23c fix(hook): preserve WinSock connect errors 2026-08-21 00:16:49 +00:00
funman300 b098617573 feat(fifa17-hook): patch FIFA17 TLS gates in-process
Milestone B: move FIFA17 ProtoSSL certificate compatibility into version.dll so
the client-local contract is openfut.cfg + LSX + version.dll with no external
/proc-writing patcher. The proven external openfut-autopatch remains the oracle
and is NOT removed; this reaches behavioral parity for the fail-closed patches.

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

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

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

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

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

Runtime validation (x64dbg site check + Windows/Linux retail) still outstanding.
2026-08-20 21:15:27 +00:00
14 changed files with 2187 additions and 42 deletions
Generated
+1
View File
@@ -2293,6 +2293,7 @@ dependencies = [
"eframe",
"egui",
"openfut-common",
"parking_lot",
"serde",
"serde_json",
"tokio",
+138 -2
View File
@@ -40,6 +40,21 @@ pub mod ea_ports {
pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230;
/// EA Blaze main server source port.
pub const BLAZE_MAIN: u16 = 42127;
/// The roster / "FUT Squad Update" port.
///
/// Unlike the others this number is OURS: the client only dials it because
/// our Blaze hands it `ROSTERUPDATE_URL = https://<roster-host>:8081/...`.
/// It is still a *signature* in exactly the same sense, because the IP the
/// client dials is whatever the roster hostname resolved to — in practice
/// EA's live `159.153.51.20` record — and we rewrite that to the configured
/// server while leaving the hostname (and therefore SNI) untouched.
///
/// Keeping the hostname is the whole point: FIFA 17's ProtoSSL verifies the
/// roster certificate by **dNSName only**, so redirecting at the socket
/// preserves certificate validity in a way an IP-addressed URL cannot. This
/// is what removes the need for a client hosts entry, an NRPT rule, or an
/// external DNS responder.
pub const FIFA17_ROSTER: u16 = 8081;
}
/// Default OpenFUT *destination* ports, derived from the current OpenFUT server
@@ -53,6 +68,22 @@ pub mod default_ports {
pub const BLAZE_REDIRECTOR: u16 = 42127;
/// OpenFUT FIFA 17 Blaze main listener.
pub const BLAZE_MAIN: u16 = 42130;
/// OpenFUT FUT web-file (CDN) content server. Unlike the others this is not
/// an EA redirect target: the client never dials it directly, because its
/// `RS4::ServerSettings` CDN base arrives EMPTY in the emulator. The hook
/// supplies the missing `<base>/fut/` prefix, and the base is built from the
/// configured server host plus this port.
///
/// 8085 is the POW content server (`pow_server.py`, kind "content"), which is
/// the port Blaze already advertises to the client as its content host and
/// which serves `/fut/packs/loc/storepackdescriptions.en_us.xml`. Verified
/// live: that path returns 200 with a 180-byte XLIFF document.
pub const FUT_CONTENT: u16 = 8085;
/// OpenFUT roster / "FUT Squad Update" listener. Same number as the
/// [`ea_ports::FIFA17_ROSTER`] signature because we advertise that port
/// ourselves; it is a separate constant so a deployment can move the roster
/// service without changing what the client dials.
pub const ROSTER: u16 = 8081;
}
/// OpenFUT destination ports. Each field is where an intercepted EA source port
@@ -66,6 +97,11 @@ pub struct OpenFutPorts {
pub blaze_redirector: u16,
/// Destination for EA :42127 traffic (Blaze main).
pub blaze_main: u16,
/// FUT web-file content server. Not a redirect destination — see
/// [`default_ports::FUT_CONTENT`].
pub fut_content: u16,
/// Destination for roster traffic ([`ea_ports::FIFA17_ROSTER`]).
pub roster: u16,
}
impl Default for OpenFutPorts {
@@ -74,6 +110,8 @@ impl Default for OpenFutPorts {
https: default_ports::HTTPS,
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
blaze_main: default_ports::BLAZE_MAIN,
fut_content: default_ports::FUT_CONTENT,
roster: default_ports::ROSTER,
}
}
}
@@ -89,6 +127,7 @@ impl OpenFutPorts {
Some(self.blaze_redirector)
}
ea_ports::BLAZE_MAIN => Some(self.blaze_main),
ea_ports::FIFA17_ROSTER => Some(self.roster),
_ => None,
}
}
@@ -244,6 +283,8 @@ impl ServerConfig {
"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)?,
"fut_content_port" => ports.fut_content = parse_port(value)?,
"roster_port" => ports.roster = parse_port(value)?,
other => {
return Err(ConfigError::MalformedConfig(format!(
"line {}: unknown key '{other}'",
@@ -258,10 +299,40 @@ impl ServerConfig {
}
/// Serialize to the structured `openfut.cfg` format.
///
/// `roster_port` is emitted ONLY when it differs from the default. The
/// parser rejects unknown keys, so a config written by a newer launcher and
/// read by an older hook would fail to parse and install NO redirect at all
/// — breaking the game rather than degrading. Withholding the default keeps
/// the common case byte-identical to what every deployed hook already
/// accepts, while still round-tripping a deliberately changed port.
pub fn to_cfg_string(&self) -> String {
let mut out = format!(
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\nfut_content_port={}\n",
self.host,
self.ports.https,
self.ports.blaze_redirector,
self.ports.blaze_main,
self.ports.fut_content
);
if self.ports.roster != default_ports::ROSTER {
out.push_str(&format!("roster_port={}\n", self.ports.roster));
}
out
}
/// Base URL the FUT web-file (CDN) prefix is built from, e.g.
/// `http://10.10.0.120:8085/fut/`.
///
/// The client's `RS4::ServerSettings` CDN base arrives EMPTY in the emulator,
/// so FUT web-file urls reach the download entry point as bare relative paths
/// and fail. The hook supplies this prefix. Built from the SAME configured
/// host as every other redirect, so a lab address is never compiled in.
pub fn fut_content_base(&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
"http://{}:{}/fut/",
self.host.trim(),
self.ports.fut_content
)
}
@@ -449,12 +520,37 @@ mod tests {
https: 8443,
blaze_redirector: 10041,
blaze_main: 42127,
fut_content: 8085,
roster: default_ports::ROSTER,
},
};
let s = c.to_cfg_string();
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
}
/// The FUT web-file prefix follows the CONFIGURED server, so no lab address
/// is ever compiled into the hook.
#[test]
fn fut_content_base_follows_the_configured_host() {
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
assert_eq!(c.fut_content_base(), "http://192.168.1.50:8085/fut/");
let c = ServerConfig::parse("host=fut.mylan.home\nfut_content_port=9110\n").unwrap();
assert_eq!(c.fut_content_base(), "http://fut.mylan.home:9110/fut/");
}
/// A cfg written before `fut_content_port` existed must still parse, taking
/// the default rather than failing the whole config (which would disarm the
/// network redirect too).
#[test]
fn cfg_without_content_port_takes_the_default() {
let c = ServerConfig::parse(
"host=10.0.0.5\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
)
.unwrap();
assert_eq!(c.ports.fut_content, default_ports::FUT_CONTENT);
}
#[test]
fn configured_ipv4_becomes_correct_sockaddr() {
// Resolve an IPv4 literal and confirm the sin_addr value.
@@ -496,6 +592,46 @@ mod tests {
assert_eq!(p.map_source_port(12345), None);
}
/// The roster dial is the whole point of the FIFA17_ROSTER signature: the
/// client resolves `winter15.gosredirector.ea.com` to EA's live record and
/// dials THAT ip on 8081, so the socket layer is the only place we can send
/// it to ourselves without touching the client's DNS.
#[test]
fn roster_port_is_redirected_to_the_configured_server() {
let server = ServerConfig::parse("host=10.10.0.120\n")
.unwrap()
.resolve()
.unwrap();
let redirect = server
.redirect_for_ea_port(sin_port_nbo(ea_ports::FIFA17_ROSTER))
.expect("roster dial must be recognised");
assert_eq!(redirect.redirect_ip, Ipv4Addr::new(10, 10, 0, 120));
// Port is preserved: we advertise 8081 and serve 8081.
assert_eq!(redirect.port_nbo, sin_port_nbo(8081));
}
#[test]
fn roster_destination_port_is_configurable() {
let c = ServerConfig::parse("host=10.10.0.120\nroster_port=9443\n").unwrap();
assert_eq!(c.ports.roster, 9443);
assert_eq!(c.ports.map_source_port(ea_ports::FIFA17_ROSTER), Some(9443));
}
/// A default roster port must NOT appear in the written config: the parser
/// rejects unknown keys, so emitting it unconditionally would make every
/// already-deployed hook reject the whole file and install no redirect.
#[test]
fn default_roster_port_is_not_emitted_but_a_custom_one_round_trips() {
let default_cfg = ServerConfig::parse("host=10.10.0.120\n").unwrap();
assert!(!default_cfg.to_cfg_string().contains("roster_port"));
let mut custom = default_cfg.clone();
custom.ports.roster = 9443;
let reparsed = ServerConfig::parse(&custom.to_cfg_string()).unwrap();
assert_eq!(reparsed.ports.roster, 9443);
assert_eq!(reparsed, custom);
}
#[test]
fn resolve_literal_ipv4_no_dns() {
let c = ServerConfig::parse("host=127.0.0.1\n").unwrap();
+1 -1
View File
@@ -12,6 +12,6 @@ fn main() {
{
let definition =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
println!("cargo:rustc-link-arg={}", definition.display());
println!("cargo:rustc-cdylib-link-arg={}", definition.display());
}
}
+53 -7
View File
@@ -33,6 +33,31 @@ static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
// Original 14 bytes saved before we overwrite them
static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
/// Restores the real WinSock call's thread-local last error after detour repair,
/// logging, and other instrumentation have run. Callers inspect this value after
/// `SOCKET_ERROR`; leaking a logger/VirtualProtect error changes connect semantics.
struct WsaLastErrorGuard(i32);
impl WsaLastErrorGuard {
unsafe fn capture() -> Self {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
Self(WSAGetLastError())
}
fn value(&self) -> i32 {
self.0
}
}
impl Drop for WsaLastErrorGuard {
fn drop(&mut self) {
unsafe {
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
WSASetLastError(self.0);
}
}
}
// For WSAConnect IAT fallback
type WsaConnectFn = unsafe extern "system" fn(
s: usize,
@@ -191,6 +216,8 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
core::mem::transmute(addr);
f(s, buf.as_ptr(), len)
};
// Named binding held until `return r`: its Drop restores the WSA error after `write_hook`.
let _last_error = WsaLastErrorGuard::capture();
write_hook(addr, hooked_connect as *const () as u64);
return r;
} else {
@@ -202,17 +229,15 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
f(s, call_name, call_len)
};
let last_error = WsaLastErrorGuard::capture();
write_hook(addr, hooked_connect as *const () as u64);
if namelen >= 8 {
let sa = &*(call_name as *const SockaddrIn);
if sa.sin_family == AF_INET {
let err = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else {
0
};
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
let logged_error = if r != 0 { last_error.value() } else { 0 };
crate::write_log(&format!(
"connect_hook: result={r} wsa_err={logged_error}\n"
));
}
}
r
@@ -260,3 +285,24 @@ pub unsafe fn install_inline_connect_hook() -> bool {
write_hook(connect_fn, hooked_connect as *const () as u64);
true
}
#[cfg(test)]
mod tests {
use super::WsaLastErrorGuard;
use windows_sys::Win32::Networking::WinSock::{
WSAGetLastError, WSASetLastError, WSAEWOULDBLOCK,
};
#[test]
fn restores_winsock_last_error_after_instrumentation() {
unsafe {
WSASetLastError(WSAEWOULDBLOCK);
{
let guard = WsaLastErrorGuard::capture();
assert_eq!(guard.value(), WSAEWOULDBLOCK);
WSASetLastError(0);
}
assert_eq!(WSAGetLastError(), WSAEWOULDBLOCK);
}
}
}
+9
View File
@@ -144,6 +144,12 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
"fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n",
),
}
// FIFA17 TLS/certificate + store crash-guard compatibility (Milestone B).
// Spawns its own bounded polling worker: patches the FIFA17.exe ProtoSSL cert
// gates once the packer unpacks them, then the CardsDLL store guard once UT
// loads it. Fail-closed and one-shot; replaces the external openfut-autopatch.
crate::fifa17_tls::install();
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
// itself from the build; its safety is the runtime signature/evidence gate. The
// remaining legacy experiment modules stay inert unless their env gate is `1`.
@@ -153,6 +159,9 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
crate::sbc_request_trace::install();
crate::store_entry::install();
crate::season_trace::install();
crate::season_team_compat::install();
crate::offline_seasons_pma::install();
crate::kit_trace::install();
0
}
+334
View File
@@ -0,0 +1,334 @@
//! FIFA 17 in-process TLS/certificate + store crash-guard compatibility.
//!
//! Ports the *proven* subset of the external `openfut-autopatch` patch set into
//! `version.dll`, so the client-local contract no longer needs an external
//! `/proc`-writing patcher. Two concerns, both fail-closed and one-shot:
//!
//! 1. ProtoSSL certificate gates in FIFA17.exe (REQUIRED_FOR_TLS) — let the
//! TLS handshake against the OpenFUT bridge cert succeed. Present only after
//! the STEAMPUNKS packer maps/decrypts the real code, so they are polled for.
//! 2. The empty-"My Packs" store resolver crash-guard in CardsDLL
//! (REQUIRED_FOR_STORE_TLS, bug 6c) — CardsDLL loads lazily on entering UT,
//! so it is applied once the module appears.
//!
//! Deliberately NOT ported: the eight unconditional `STORE_PATCHES` from the
//! external patcher. They carry no recovered original bytes (cannot be
//! fail-closed) and are re-applied every tick (would require the very
//! constant-rewrite loop this milestone forbids); the external patcher's own
//! source records no rationale for them. See the Vault ADR.
//!
//! Every address is ASLR-relocated from its preferred image base at runtime
//! (`live = module_base + (static_va - preferred_base)`); nothing patches an
//! absolute address. Every write goes through [`crate::patch_mem`]'s fail-closed
//! primitive: original → write+verify, already-patched → no-op, anything else →
//! logged and skipped.
use crate::patch_mem::{self, ApplyOutcome, Mem, PatchState, WinMem};
use crate::write_log;
use std::time::{Duration, Instant};
/// FIFA17.exe preferred image base (confirmed: futmem reports the client mapped
/// flat at this base; Wine honours it, native Windows ASLR may not — hence the
/// runtime-base + RVA model below).
const FIFA17_PREFERRED_BASE: u64 = 0x1_4000_0000;
/// CardsDLL_Win64_retail.dll preferred image base.
const CARDS_PREFERRED_BASE: u64 = 0x1_8000_0000;
/// Which module a site lives in.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Module {
Fifa17Exe,
CardsDll,
}
impl Module {
const fn preferred_base(self) -> u64 {
match self {
Module::Fifa17Exe => FIFA17_PREFERRED_BASE,
Module::CardsDll => CARDS_PREFERRED_BASE,
}
}
/// Runtime base of the loaded module, or `None` if not mapped yet. FIFA17.exe
/// is the main image (null name); CardsDLL is resolved by its retail name.
unsafe fn runtime_base(self) -> Option<usize> {
match self {
Module::Fifa17Exe => patch_mem::module_base(core::ptr::null()),
Module::CardsDll => {
patch_mem::module_base(c"CardsDLL_Win64_retail.dll".as_ptr().cast())
.or_else(|| patch_mem::module_base(c"CardsDLL.dll".as_ptr().cast()))
}
}
}
}
/// One fail-closed byte patch, expressed as a static VA in its module's preferred
/// image so the derivation `RVA = VA - preferred_base` is auditable.
struct Site {
module: Module,
static_va: u64,
orig: &'static [u8],
patch: &'static [u8],
label: &'static str,
}
impl Site {
const fn rva(&self) -> u64 {
patch_mem::rva(self.static_va, self.module.preferred_base())
}
fn live_addr(&self, base: usize) -> usize {
patch_mem::live_addr(base, self.rva())
}
}
// ── ProtoSSL certificate gates (FIFA17.exe) — REQUIRED_FOR_TLS ──────────────────
// GATE1: JNZ rel32 -> 6×NOP (fall through the cert-verify failure branch).
// GATE2: function prologue -> `xor eax,eax; ret` (cert-verify returns 0/false).
// Applied as a pair, exactly like the external patcher: written only when BOTH
// read their known original, treated as done when BOTH already hold the patch.
const GATE1: Site = Site {
module: Module::Fifa17Exe,
static_va: 0x1_4613_2548,
orig: &[0x0f, 0x85, 0x76, 0x01, 0x00, 0x00],
patch: &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90],
label: "GATE1",
};
const GATE2: Site = Site {
module: Module::Fifa17Exe,
static_va: 0x1_4613_61b0,
orig: &[0x48, 0x89, 0x5c],
patch: &[0x31, 0xc0, 0xc3],
label: "GATE2",
};
// ── Empty "My Packs" store resolver crash-guard (CardsDLL) — REQUIRED_FOR_STORE_TLS
// JNZ 0x14869 (75 0f) -> JG 0x14869 (7f 0f): routes zero/negative store category
// ids through the Browse path instead of a NULL deref. Fail-closed one-shot.
const STORE_GUARD: Site = Site {
module: Module::CardsDll,
static_va: 0x1_8001_4858,
orig: &[0x75, 0x0f],
patch: &[0x7f, 0x0f],
label: "empty-mypacks-store-guard",
};
/// Poll cadence while waiting for the packer to unpack / CardsDLL to load. Low
/// frequency: the thread sleeps between ticks, so idle CPU is negligible.
const POLL: Duration = Duration::from_millis(250);
/// Upper bound on the whole worker's lifetime so it can never spin forever if the
/// user never enters Ultimate Team (CardsDLL never loads).
const MAX_WAIT: Duration = Duration::from_secs(15 * 60);
/// Decision for the FIFA17.exe cert-gate pair.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CertAction {
/// Not both readable yet, or a mixed/unrecognised state — keep polling.
Wait,
/// Both gates hold their known original — safe to apply the pair.
Apply,
/// Both gates already hold the patch — nothing to do.
Done,
}
/// Pure pairing rule (unit-tested): only act when both gates agree.
fn cert_action(g1: Option<PatchState>, g2: Option<PatchState>) -> CertAction {
match (g1, g2) {
(Some(PatchState::AlreadyPatched), Some(PatchState::AlreadyPatched)) => CertAction::Done,
(Some(PatchState::Original), Some(PatchState::Original)) => CertAction::Apply,
_ => CertAction::Wait,
}
}
/// Arm the FIFA17 TLS/store compatibility patcher: spawns a bounded background
/// worker so it never touches the loader lock and never blocks `install()`.
pub fn install() {
std::thread::spawn(|| unsafe { worker() });
}
unsafe fn worker() {
write_log("fifa17_tls: patch worker start\n");
let mut mem = WinMem;
let start = Instant::now();
let mut cert_done = false;
let mut guard_done = false;
// Throttle the "still waiting" diagnostics to one line each.
let mut logged_cert_wait = false;
let mut logged_guard_wait = false;
loop {
if !cert_done {
cert_done = try_cert_gates(&mut mem, &mut logged_cert_wait);
}
if !guard_done {
match Module::CardsDll.runtime_base() {
Some(cbase) => guard_done = try_store_guard(&mut mem, cbase),
None => {
if !logged_guard_wait {
write_log("fifa17_tls: waiting for CardsDLL (enter Ultimate Team)\n");
logged_guard_wait = true;
}
}
}
}
if cert_done && guard_done {
write_log("fifa17_tls: TLS patch set complete\n");
return;
}
if start.elapsed() >= MAX_WAIT {
write_log(&format!(
"fifa17_tls: worker stop (timeout {MAX_WAIT:?}); cert_gates_done={cert_done} store_guard_done={guard_done}\n"
));
return;
}
std::thread::sleep(POLL);
}
}
/// Apply the FIFA17.exe cert-gate pair. Returns `true` once the pair is settled
/// (applied or already patched); `false` while still unpacking / not both ready.
unsafe fn try_cert_gates(mem: &mut WinMem, logged_wait: &mut bool) -> bool {
let base = match Module::Fifa17Exe.runtime_base() {
Some(b) => b,
None => return false,
};
let g1_addr = GATE1.live_addr(base);
let g2_addr = GATE2.live_addr(base);
let g1 = patch_mem::read_state(mem, g1_addr, GATE1.orig, GATE1.patch);
let g2 = patch_mem::read_state(mem, g2_addr, GATE2.orig, GATE2.patch);
match cert_action(g1, g2) {
CertAction::Done => {
write_log("fifa17_tls: cert gates already patched\n");
true
}
CertAction::Apply => {
let o1 = patch_mem::apply_checked(mem, g1_addr, GATE1.orig, GATE1.patch);
let o2 = patch_mem::apply_checked(mem, g2_addr, GATE2.orig, GATE2.patch);
if o1.is_patched() && o2.is_patched() {
write_log(&format!(
"fifa17_tls: PATCHED cert gates ({} @ {g1_addr:#x} {o1:?}; {} @ {g2_addr:#x} {o2:?})\n",
GATE1.label, GATE2.label
));
true
} else {
write_log(&format!(
"fifa17_tls: cert gate write FAILED ({} {o1:?}; {} {o2:?}) — TLS NOT installed\n",
GATE1.label, GATE2.label
));
// Terminal: a write/verify failure will not fix itself by retrying.
true
}
}
CertAction::Wait => {
if !*logged_wait {
write_log(&format!(
"fifa17_tls: cert gates not ready (still unpacking?) {}={g1:?} {}={g2:?}\n",
GATE1.label, GATE2.label
));
*logged_wait = true;
}
false
}
}
}
/// Apply the CardsDLL store crash-guard once CardsDLL is mapped. Returns `true`
/// once the site is settled (its bytes are final the moment CardsDLL is loaded,
/// so any read outcome is a terminal decision — no further polling).
unsafe fn try_store_guard(mem: &mut WinMem, cbase: usize) -> bool {
let addr = STORE_GUARD.live_addr(cbase);
let outcome = patch_mem::apply_checked(mem, addr, STORE_GUARD.orig, STORE_GUARD.patch);
match outcome {
ApplyOutcome::NotReadable => false, // CardsDLL mapped but this page not yet — retry
ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched => {
write_log(&format!(
"fifa17_tls: store guard {} @ {addr:#x} {outcome:?} (VERIFIED empty-My-Packs)\n",
STORE_GUARD.label
));
true
}
ApplyOutcome::Mismatch => {
let mut cur = [0u8; patch_mem::MAX_PATCH_LEN];
let n = STORE_GUARD.patch.len();
let seen = if mem.read(addr, &mut cur[..n]) {
patch_mem::hex(&cur[..n])
} else {
"unreadable".into()
};
write_log(&format!(
"fifa17_tls: SKIP store guard @ {addr:#x}: unexpected {seen} (build mismatch)\n"
));
true
}
ApplyOutcome::WriteFailed | ApplyOutcome::VerifyFailed => {
write_log(&format!(
"fifa17_tls: store guard @ {addr:#x} {outcome:?}\n"
));
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_site_is_well_formed() {
for s in [&GATE1, &GATE2, &STORE_GUARD] {
assert_eq!(
s.orig.len(),
s.patch.len(),
"{}: orig/patch length",
s.label
);
assert!(!s.orig.is_empty(), "{}: empty", s.label);
assert!(
s.patch.len() <= patch_mem::MAX_PATCH_LEN,
"{}: exceeds MAX_PATCH_LEN",
s.label
);
assert_ne!(s.orig, s.patch, "{}: orig == patch", s.label);
}
}
#[test]
fn rvas_match_the_recovered_derivation() {
assert_eq!(GATE1.rva(), 0x613_2548);
assert_eq!(GATE2.rva(), 0x613_61b0);
assert_eq!(STORE_GUARD.rva(), 0x1_4858);
}
#[test]
fn live_addresses_track_the_runtime_base() {
// At the preferred base the live address is the recorded static VA.
assert_eq!(GATE1.live_addr(0x1_4000_0000), 0x1_4613_2548);
assert_eq!(STORE_GUARD.live_addr(0x1_8000_0000), 0x1_8001_4858);
// Relocated bases shift every site by the same delta.
assert_eq!(GATE1.live_addr(0x3_0000_0000), 0x3_0613_2548);
}
#[test]
fn cert_pair_only_acts_when_both_gates_agree() {
use PatchState::*;
assert_eq!(
cert_action(Some(Original), Some(Original)),
CertAction::Apply
);
assert_eq!(
cert_action(Some(AlreadyPatched), Some(AlreadyPatched)),
CertAction::Done
);
// Not yet unpacked / partial / mismatched => never a blind half-write.
assert_eq!(cert_action(None, None), CertAction::Wait);
assert_eq!(cert_action(Some(Original), None), CertAction::Wait);
assert_eq!(
cert_action(Some(Original), Some(AlreadyPatched)),
CertAction::Wait
);
assert_eq!(
cert_action(Some(Mismatch), Some(Mismatch)),
CertAction::Wait
);
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Passive, behavior-preserving diagnostic traces for FIFA 17's FUT pre-match
//! KIT SELECTOR data flow.
//!
//! RE (2026-08-20, Ghidra on CardsDLL_Win64_retail.dll) established that the
//! pre-match kit selector is fed ENTIRELY client-side (NOT by POW/EASFC):
//!
//! * `FUT_GET_MATCH_KITS_DP` (id 0x7565) builder `FUN_1800be6a0` (rva 0xbe6a0)
//! reads a boolean gate `ctx+0x152` (`KITS_AVAILABLE`); when false, or when
//! the two available-kit vectors are empty, the selector renders blank/white.
//! * The available home/away kit-id lists live on `FutSquadServiceImpl`
//! (`this+0xe08` home, `this+0xe38` away) and are written by the setter
//! `FUN_180196760` (rva 0x96760, vtable slot 0x1d0): args (this, srcVec, side).
//! * A club KIT ITEM is turned into an available kit by `FUN_1801c3480`
//! (rva 0x1c3480): it reads item fields (`+0x4c==7`, `+0x60==4`,
//! `+0x5c`∈{101 home,102 away}, `+0x94` source teamid, `+0xba`
//! teamkittypetechid) and calls `FUN_1801c44b0` (rva 0x1c44b0) to clone that
//! team's kit rows from the CLIENT-LOCAL `teamkits` DB into the FUT club
//! (teamtechid 130000).
//!
//! These traces answer, in one operator-driven match, exactly WHERE the empty
//! selector originates: do kit club items reach the client (kit_item_clone), does
//! the clone into the FUT club happen (kit_db_clone), does the available list get
//! set non-empty (set_available_kits), and what does the selector finally read
//! (get_match_kits: KITS_AVAILABLE + count). Every trace is read-only: it logs,
//! then tail-calls the original through a trampoline. Copied prologues are whole,
//! position-independent instructions (the one rip-relative prologue uses the
//! relocating installer).
use core::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{readable_range, validate_cards_build};
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32, rd_u8};
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
fn budget() -> bool {
REPORTS.fetch_add(1, Ordering::Relaxed) < 256
}
unsafe fn rd_usize(addr: usize) -> Option<usize> {
readable_range(addr, 8).then(|| core::ptr::read_volatile(addr as *const usize))
}
// FUT_GET_MATCH_KITS_DP builder FUN_1800be6a0 (0xbe6a0). rcx = DP model ctx.
// ctx+0x152 is the KITS_AVAILABLE bool that gates the whole selector list.
static GET_MATCH_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn get_match_kits_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
if budget() {
let avail = rd_u8(rcx + 0x152);
write_log(&format!(
"KIT_GET: FUT_GET_MATCH_KITS_DP ctx={rcx:#x} KITS_AVAILABLE={avail:?}\n"
));
}
let t = GET_MATCH_KITS_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
// setAvailableKits FUN_180196760 (0x96760): (this, srcVec, side). srcVec is an
// int vector {begin@+0, end@+8}; count = (end-begin)/4. side 0=home, 1=away.
static SET_AVAILABLE_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn set_available_kits_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
if budget() {
let count = match (rd_usize(rdx), rd_usize(rdx + 8)) {
(Some(b), Some(e)) if e >= b => ((e - b) / 4) as i64,
_ => -1,
};
write_log(&format!(
"KIT_SET: setAvailableKits this={rcx:#x} side={r8} count={count}\n"
));
}
let t = SET_AVAILABLE_KITS_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
// Kit-item clone driver FUN_1801c3480 (0x1c3480): rdx = param_2, the club-item
// event; the item struct is at *(param_2+0x10). Logs the fields the function
// branches on so we can see whether a kit club item reaches the client and its
// home/away designator + source teamid.
static KIT_ITEM_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn kit_item_clone_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
if budget() {
if let Some(item) = rd_usize(rdx + 0x10) {
write_log(&format!(
"KIT_ITEM: clone-driver item={item:#x} type[+0x4c]={:?} subid[+0x5c]={:?} \
cat[+0x60]={:?} teamid[+0x94]={:?} kittype[+0xba]={:?}\n",
rd_i32(item + 0x4c),
rd_i32(item + 0x5c),
rd_i32(item + 0x60),
rd_i32(item + 0x94),
rd_i32(item + 0xba),
));
} else {
write_log(&format!(
"KIT_ITEM: clone-driver param_2={rdx:#x} (item ptr unreadable)\n"
));
}
}
let t = KIT_ITEM_CLONE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
// Kit DB clone FUN_1801c44b0 (0x1c44b0): (clubmgr, side, teamtechid, kittype).
// Fires only when the driver decided the item is a home(101)/away(102) kit, so
// this is the proof the FUT-club (teamtechid 130000) kit rows get synthesized.
static KIT_DB_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn kit_db_clone_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
if budget() {
write_log(&format!(
"KIT_DBCLONE: clone team kit side={rdx} src_teamtechid={r8} kittype={r9}\n"
));
}
let t = KIT_DB_CLONE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 || !validate_cards_build(base) {
write_log("KIT_TRACE: CardsDLL unavailable/invalid; kit trace inactive\n");
return;
}
// FUN_1800be6a0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 a1 (copy_len 16).
install_detour(
base,
0xbe6a0,
"GetMatchKits_DP(0xbe6a0)",
16,
&[
0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d,
0x68, 0xa1,
],
get_match_kits_wrapper as *const () as usize,
&GET_MATCH_KITS_TRAMP,
);
// FUN_180196760: 48 89 54 24 10 53 48 83 ec 30 48 c7 44 24 20 fe ff ff ff (copy_len 19).
install_detour(
base,
0x96760,
"setAvailableKits(0x96760)",
19,
&[
0x48, 0x89, 0x54, 0x24, 0x10, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24,
0x20, 0xfe, 0xff, 0xff, 0xff,
],
set_available_kits_wrapper as *const () as usize,
&SET_AVAILABLE_KITS_TRAMP,
);
// FUN_1801c3480: 48 89 5c 24 08 57 48 83 ec 60 <48 8b 05 disp32> (rip-relative
// MOV RAX,[rip+..] at copied offset 10; disp32 at 13, insn end 17; copy_len 17).
install_detour_reloc(
base,
0x1c3480,
"kitItemClone(0x1c3480)",
17,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0x8b, 0x05, 0x4f,
0x82, 0x11, 0x00,
],
13,
17,
kit_item_clone_wrapper as *const () as usize,
&KIT_ITEM_CLONE_TRAMP,
);
// FUN_1801c44b0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 c8 (copy_len 16).
install_detour(
base,
0x1c44b0,
"kitDbClone(0x1c44b0)",
16,
&[
0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d,
0x68, 0xc8,
],
kit_db_clone_wrapper as *const () as usize,
&KIT_DB_CLONE_TRAMP,
);
write_log("KIT_TRACE: all kit-selector traces armed\n");
}
/// Arm the passive kit-selector diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
write_log("KIT_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
+9
View File
@@ -15,8 +15,15 @@ mod connect_hook;
mod connectex_hook;
#[cfg(feature = "fifa17")]
mod fifa17;
#[cfg(feature = "fifa17")]
mod fifa17_tls;
mod iat;
#[cfg(feature = "fifa17")]
mod kit_trace;
#[cfg(feature = "fifa17")]
mod offline_seasons_pma;
mod patch_mem;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
#[cfg(feature = "fifa17")]
mod sbc_hook;
@@ -25,6 +32,8 @@ mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod season_team_compat;
#[cfg(feature = "fifa17")]
mod season_trace;
#[cfg(feature = "fifa17")]
mod store_entry;
+399
View File
@@ -0,0 +1,399 @@
//! FIFA 17 Offline Seasons PMA completion compatibility repair.
//!
//! Retail-compatible main-menu Kick Off completes the PMA instructions state by
//! broadcasting event `1` through the mode-zero child's callback dispatcher. FUT
//! Offline Seasons reaches the same PMA UI state but its completed drill scenario
//! broadcasts event `5`, which returns the UI to state `0` and leaves the drill
//! active. This default-off repair intercepts that shared callback dispatcher and
//! rewrites only the fully identified Offline Seasons `5` to `1`, then calls the
//! original dispatcher so every native subscriber observes the working completion.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{guarded_u8, guarded_usize, readable_range, validate_cards_build};
use crate::season_trace::install_detour;
use crate::write_log;
const ENABLE_ENV: &str = "OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX";
const CALLBACK_DISPATCHER_RVA: usize = 0x07ac_87b0;
const CALLBACK_DISPATCHER_COPY_LEN: usize = 15;
const CALLBACK_DISPATCHER_SIGNATURE: [u8; CALLBACK_DISPATCHER_COPY_LEN] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20,
];
const GAMEPLAY_GLOBAL_SLOT_RVA: usize = 0x04bf_b910;
const CALLBACK_DISPATCHER_VTABLE_RVA: usize = 0x03ae_9ba0;
const PMA_INSTRUCTIONS_VTABLE_RVA: usize = 0x03af_2750;
const PMA_INSTRUCTIONS_HANDLER_RVA: usize = 0x07ac_91e0;
const FREE_ROAM_VTABLE_RVA: usize = 0x03ae_df58;
const FREE_ROAM_DTOR_RVA: usize = 0x07a5_db70;
const FUT_SECONDARY_LISTENER_VTABLE_RVA: usize = 0x20e9b8;
const FUT_SELECTED_LISTENER_VTABLE_RVA: usize = 0x20fea8;
const EVENT_COMPLETE_ADVANCE: u32 = 1;
const EVENT_DRILL_COMPLETE: u32 = 5;
const PMA_UI_INSTRUCTIONS_STATE: usize = 4;
const FREE_ROAM_ACTIVE_PMA_STATE: i32 = 9;
const OFFLINE_SEASONS_MODE_ID: i32 = 21;
static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false);
static DISPATCHER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static CANDIDATE_REPORTS: AtomicUsize = AtomicUsize::new(0);
static MAIN_BASE: AtomicUsize = AtomicUsize::new(0);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Decision {
Rewrite,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Rejection {
None,
RepairDisabled,
DispatcherClass,
InstructionsState,
ListenerTopology,
FreeRoamClass,
FreeRoamState,
FreeRoamNotReady,
SecondaryListenerClass,
SelectedListenerClass,
OfflineSeasonsMode,
}
#[derive(Clone, Copy, Debug)]
struct DecisionInput {
repair_enabled: bool,
dispatcher_class: bool,
instructions_state: bool,
selected_index: Option<i32>,
free_roam_class: bool,
free_roam_state: Option<i32>,
free_roam_ready: Option<i32>,
secondary_listener_class: bool,
selected_listener_class: bool,
selected_mode: Option<i32>,
}
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
if !input.repair_enabled {
return Err(Rejection::RepairDisabled);
}
if !input.dispatcher_class {
return Err(Rejection::DispatcherClass);
}
if !input.instructions_state {
return Err(Rejection::InstructionsState);
}
if input.selected_index != Some(2) {
return Err(Rejection::ListenerTopology);
}
if !input.free_roam_class {
return Err(Rejection::FreeRoamClass);
}
if input.free_roam_state != Some(FREE_ROAM_ACTIVE_PMA_STATE) {
return Err(Rejection::FreeRoamState);
}
if input.free_roam_ready != Some(1) {
return Err(Rejection::FreeRoamNotReady);
}
if !input.secondary_listener_class {
return Err(Rejection::SecondaryListenerClass);
}
if !input.selected_listener_class {
return Err(Rejection::SelectedListenerClass);
}
if input.selected_mode != Some(OFFLINE_SEASONS_MODE_ID) {
return Err(Rejection::OfflineSeasonsMode);
}
Ok(Decision::Rewrite)
}
fn enabled(value: Option<&str>) -> bool {
value == Some("1")
}
unsafe fn read_i32(address: usize) -> Option<i32> {
readable_range(address, 4).then(|| core::ptr::read_volatile(address as *const i32))
}
unsafe fn expected_pointer(address: usize, expected: usize) -> bool {
guarded_usize(address) == Some(expected)
}
unsafe fn main_image_matches(base: usize) -> bool {
expected_pointer(
base + CALLBACK_DISPATCHER_VTABLE_RVA,
base + CALLBACK_DISPATCHER_RVA,
) && expected_pointer(
base + PMA_INSTRUCTIONS_VTABLE_RVA,
base + PMA_INSTRUCTIONS_HANDLER_RVA,
) && expected_pointer(base + FREE_ROAM_VTABLE_RVA, base + FREE_ROAM_DTOR_RVA)
}
unsafe fn instructions_state_active(dispatcher: usize, main_base: usize) -> bool {
let sentinel = match dispatcher.checked_add(8) {
Some(value) => value,
None => return false,
};
let mut node = match guarded_usize(sentinel) {
Some(value) => value,
None => return false,
};
for _ in 0..8 {
if node == sentinel {
return false;
}
let listener = match node
.checked_add(0x10)
.and_then(|address| guarded_usize(address))
{
Some(value) if value != 0 => value,
_ => return false,
};
if guarded_usize(listener) == Some(main_base + PMA_INSTRUCTIONS_VTABLE_RVA) {
let parent = listener
.checked_add(8)
.and_then(|address| guarded_usize(address));
let machine = parent
.and_then(|value| value.checked_add(8))
.and_then(|address| guarded_usize(address));
let states = machine
.and_then(|value| value.checked_add(8))
.and_then(|address| guarded_usize(address));
let current = machine
.and_then(|value| value.checked_add(0x10))
.and_then(|address| guarded_usize(address));
let state_four = states
.and_then(|value| value.checked_add(PMA_UI_INSTRUCTIONS_STATE * 8))
.and_then(|address| guarded_usize(address));
return current == Some(listener)
&& state_four == Some(listener)
&& guarded_u8(listener + 0x18) == Some(0);
}
node = match guarded_usize(node) {
Some(value) => value,
None => return false,
};
}
false
}
unsafe fn snapshot(dispatcher: usize) -> DecisionInput {
let main_base = MAIN_BASE.load(Ordering::Acquire);
let cards_base = CARDS_BASE.load(Ordering::Acquire);
let dispatcher_class =
guarded_usize(dispatcher) == Some(main_base + CALLBACK_DISPATCHER_VTABLE_RVA);
let gameplay_global = guarded_usize(main_base + GAMEPLAY_GLOBAL_SLOT_RVA);
let listener_manager = gameplay_global
.and_then(|value| value.checked_add(0x58))
.and_then(|address| guarded_usize(address));
let table = listener_manager.and_then(|value| guarded_usize(value));
let selected_index = table
.and_then(|value| value.checked_add(0x20))
.and_then(|address| read_i32(address));
let free_roam = table.and_then(|value| guarded_usize(value));
let secondary = table
.and_then(|value| value.checked_add(8))
.and_then(|address| guarded_usize(address));
let selected = match (table, selected_index) {
(Some(value), Some(index @ 0..=2)) => value
.checked_add(index as usize * 8)
.and_then(|address| guarded_usize(address)),
_ => None,
};
DecisionInput {
repair_enabled: REPAIR_ACTIVE.load(Ordering::Acquire),
dispatcher_class,
instructions_state: instructions_state_active(dispatcher, main_base),
selected_index,
free_roam_class: free_roam.and_then(|value| guarded_usize(value))
== Some(main_base + FREE_ROAM_VTABLE_RVA),
free_roam_state: free_roam
.and_then(|value| value.checked_add(0x30))
.and_then(|address| read_i32(address)),
free_roam_ready: free_roam
.and_then(|value| value.checked_add(0x124))
.and_then(|address| read_i32(address)),
secondary_listener_class: secondary.and_then(|value| guarded_usize(value))
== Some(cards_base + FUT_SECONDARY_LISTENER_VTABLE_RVA),
selected_listener_class: selected.and_then(|value| guarded_usize(value))
== Some(cards_base + FUT_SELECTED_LISTENER_VTABLE_RVA),
selected_mode: selected
.and_then(|value| value.checked_add(0x18))
.and_then(|address| read_i32(address)),
}
}
type DispatcherFn = unsafe extern "system" fn(usize, u32, usize, usize) -> usize;
unsafe extern "system" fn dispatcher_wrapper(
dispatcher: usize,
event: u32,
r8: usize,
r9: usize,
) -> usize {
let trampoline = DISPATCHER_TRAMPOLINE.load(Ordering::Acquire);
if trampoline == 0 {
return 0;
}
let original: DispatcherFn = core::mem::transmute(trampoline);
if event != EVENT_DRILL_COMPLETE {
return original(dispatcher, event, r8, r9);
}
let input = snapshot(dispatcher);
let decision = decide(input);
let rewritten = matches!(decision, Ok(Decision::Rewrite));
let forwarded_event = if rewritten {
EVENT_COMPLETE_ADVANCE
} else {
event
};
let report = CANDIDATE_REPORTS.fetch_add(1, Ordering::Relaxed);
if report < 16 {
write_log(&format!(
"[OpenFUT][OfflineSeasons] PMA completion observed event={event} dispatcher={dispatcher:#x} pma_state4={} selected_index={} selected_mode={} free_roam_state={} ready={} action={} rejection={:?}\n",
input.instructions_state,
input.selected_index.unwrap_or(-1),
input.selected_mode.unwrap_or(-1),
input.free_roam_state.unwrap_or(-1),
input.free_roam_ready.unwrap_or(-1),
if rewritten { "rewrite-5-to-1" } else { "native" },
decision.err().unwrap_or(Rejection::None),
));
}
original(dispatcher, forwarded_event, r8, r9)
}
unsafe fn worker() {
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
if main_base == 0 || !main_image_matches(main_base) {
write_log("[OpenFUT][OfflineSeasons] main FIFA image mismatch; inactive\n");
return;
}
let mut cards_base = 0usize;
for _ in 0..600u32 {
cards_base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if cards_base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if cards_base == 0 || !validate_cards_build(cards_base) {
write_log("[OpenFUT][OfflineSeasons] CardsDLL unavailable/invalid; inactive\n");
return;
}
MAIN_BASE.store(main_base, Ordering::Release);
CARDS_BASE.store(cards_base, Ordering::Release);
if !install_detour(
main_base,
CALLBACK_DISPATCHER_RVA,
"OfflineSeasons_PMA_callback_dispatcher",
CALLBACK_DISPATCHER_COPY_LEN,
&CALLBACK_DISPATCHER_SIGNATURE,
dispatcher_wrapper as *const () as usize,
&DISPATCHER_TRAMPOLINE,
) {
write_log("[OpenFUT][OfflineSeasons] callback dispatcher hook failed; inactive\n");
return;
}
REPAIR_ACTIVE.store(true, Ordering::Release);
write_log("[OpenFUT][OfflineSeasons] PMA completion repair ARMED\n");
}
pub(crate) fn install() {
if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) {
write_log("[OpenFUT][OfflineSeasons] PMA completion repair disabled\n");
return;
}
write_log("[OpenFUT][OfflineSeasons] PMA completion repair requested\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input() -> DecisionInput {
DecisionInput {
repair_enabled: true,
dispatcher_class: true,
instructions_state: true,
selected_index: Some(2),
free_roam_class: true,
free_roam_state: Some(9),
free_roam_ready: Some(1),
secondary_listener_class: true,
selected_listener_class: true,
selected_mode: Some(21),
}
}
#[test]
fn feature_is_default_off() {
assert!(!enabled(None));
assert!(!enabled(Some("0")));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn exact_offline_seasons_evidence_rewrites() {
assert_eq!(decide(valid_input()), Ok(Decision::Rewrite));
}
#[test]
fn every_runtime_gate_fails_closed() {
let cases: &[(Rejection, fn(&mut DecisionInput))] = &[
(Rejection::RepairDisabled, |input: &mut DecisionInput| {
input.repair_enabled = false
}),
(Rejection::DispatcherClass, |input: &mut DecisionInput| {
input.dispatcher_class = false
}),
(Rejection::InstructionsState, |input: &mut DecisionInput| {
input.instructions_state = false
}),
(Rejection::ListenerTopology, |input: &mut DecisionInput| {
input.selected_index = Some(1)
}),
(Rejection::FreeRoamClass, |input: &mut DecisionInput| {
input.free_roam_class = false
}),
(Rejection::FreeRoamState, |input: &mut DecisionInput| {
input.free_roam_state = Some(10)
}),
(Rejection::FreeRoamNotReady, |input: &mut DecisionInput| {
input.free_roam_ready = Some(0)
}),
(
Rejection::SecondaryListenerClass,
|input: &mut DecisionInput| input.secondary_listener_class = false,
),
(
Rejection::SelectedListenerClass,
|input: &mut DecisionInput| input.selected_listener_class = false,
),
(
Rejection::OfflineSeasonsMode,
|input: &mut DecisionInput| input.selected_mode = Some(1),
),
];
for &(expected, mutate) in cases {
let mut input = valid_input();
mutate(&mut input);
assert_eq!(decide(input), Err(expected));
}
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Generic, fail-closed byte-patch primitive shared by per-game compatibility
//! patch tables (currently FIFA 17's TLS/store gates in [`crate::fifa17_tls`]).
//!
//! The decision logic is expressed against the [`Mem`] trait rather than raw
//! process memory, so every outcome — ORIGINAL / ALREADY_PATCHED / MISMATCH and
//! the write/verify path — is unit-testable on the host without a live client.
//! [`WinMem`] is the in-process Windows implementation used at runtime.
//!
//! FAIL-CLOSED INVARIANT: a site is written only when its live bytes are *exactly*
//! the known original. Already-patched is an idempotent no-op; anything else is
//! reported and left untouched — an unrecognised or not-yet-unpacked build is
//! never blindly overwritten.
/// Longest patch payload across all tables (FIFA17 GATE1 is 6 bytes). Sizes the
/// fixed stack buffers so no slicing panic is reachable from the patch logic.
pub const MAX_PATCH_LEN: usize = 6;
/// Byte-level access to the target's address space.
pub trait Mem {
/// Fill `buf` from `addr`. `false` = not readable yet (page uncommitted /
/// module not mapped / not unpacked) — the caller waits, it is not an error.
fn read(&self, addr: usize, buf: &mut [u8]) -> bool;
/// Write `data` at `addr`. `false` = the write could not be performed.
fn write(&mut self, addr: usize, data: &[u8]) -> bool;
}
/// Fail-closed classification of live bytes against a site's original/replacement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchState {
/// Live bytes are the known original — safe to patch.
Original,
/// Live bytes already equal the replacement — idempotent.
AlreadyPatched,
/// Neither — unrecognised/not-yet-ready build; must be left untouched.
Mismatch,
}
/// Pure classification (no memory access).
pub fn classify(cur: &[u8], orig: &[u8], patch: &[u8]) -> PatchState {
if cur == patch {
PatchState::AlreadyPatched
} else if cur == orig {
PatchState::Original
} else {
PatchState::Mismatch
}
}
/// Outcome of a checked patch attempt at one site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyOutcome {
/// Bytes were the original and were written and re-read as the replacement.
Applied,
/// Bytes already equalled the replacement; nothing written.
AlreadyPatched,
/// Bytes were neither original nor replacement; nothing written.
Mismatch,
/// Bytes could not be read yet (module/page not available) — retry later.
NotReadable,
/// The write itself failed (protection change or copy).
WriteFailed,
/// Wrote, but the re-read did not equal the replacement.
VerifyFailed,
}
impl ApplyOutcome {
/// Whether the site now holds the replacement (freshly or already).
pub fn is_patched(self) -> bool {
matches!(self, ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched)
}
}
/// Read → classify → (only on ORIGINAL) write → re-read verify. Never writes on
/// MISMATCH; treats ALREADY_PATCHED as success. `orig`/`patch` must be equal,
/// non-empty and within [`MAX_PATCH_LEN`].
pub fn apply_checked<M: Mem>(mem: &mut M, addr: usize, orig: &[u8], patch: &[u8]) -> ApplyOutcome {
debug_assert_eq!(orig.len(), patch.len());
debug_assert!(!patch.is_empty() && patch.len() <= MAX_PATCH_LEN);
let n = patch.len();
let mut cur = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut cur[..n]) {
return ApplyOutcome::NotReadable;
}
match classify(&cur[..n], orig, patch) {
PatchState::AlreadyPatched => ApplyOutcome::AlreadyPatched,
PatchState::Mismatch => ApplyOutcome::Mismatch,
PatchState::Original => {
if !mem.write(addr, patch) {
return ApplyOutcome::WriteFailed;
}
let mut after = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut after[..n]) || &after[..n] != patch {
return ApplyOutcome::VerifyFailed;
}
ApplyOutcome::Applied
}
}
}
/// RVA of a static VA relative to an image's preferred base (pure).
pub const fn rva(static_va: u64, preferred_base: u64) -> u64 {
static_va - preferred_base
}
/// Read and classify a site without writing (`None` = not readable yet). Used to
/// decide multi-site patches (e.g. apply a gate pair only when both are original).
pub fn read_state<M: Mem>(mem: &M, addr: usize, orig: &[u8], patch: &[u8]) -> Option<PatchState> {
let n = patch.len();
let mut cur = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut cur[..n]) {
return None;
}
Some(classify(&cur[..n], orig, patch))
}
/// Live in-process address of an image-relative site given the module's runtime base.
pub const fn live_addr(module_base: usize, rva: u64) -> usize {
module_base + rva as usize
}
/// Lowercase, unseparated hex for diagnostics (matches the autopatch SKIP line).
pub fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
s.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
s
}
// ─── In-process Windows memory (runtime only; not exercised by host tests) ──────
/// In-process implementation of [`Mem`] over this (FIFA17.exe) address space.
pub struct WinMem;
impl Mem for WinMem {
fn read(&self, addr: usize, buf: &mut [u8]) -> bool {
unsafe { guarded_read(addr, buf) }
}
fn write(&mut self, addr: usize, data: &[u8]) -> bool {
unsafe { protected_write(addr, data) }
}
}
/// Resolve a loaded module's runtime base by name, or `None` if not loaded.
pub unsafe fn module_base(name: *const u8) -> Option<usize> {
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
let h = GetModuleHandleA(name);
if h.is_null() {
None
} else {
Some(h as usize)
}
}
/// Read `buf.len()` bytes from `addr` only if the whole range is committed and
/// readable (VirtualQuery-guarded), so a wrong base/RVA can never fault.
unsafe fn guarded_read(addr: usize, buf: &mut [u8]) -> bool {
use windows_sys::Win32::System::Memory::{
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READONLY,
PAGE_READWRITE, PAGE_WRITECOPY,
};
if addr == 0 || buf.is_empty() {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let want = core::mem::size_of::<MEMORY_BASIC_INFORMATION>();
if VirtualQuery(addr as _, &mut mbi, want) != want {
return false;
}
if mbi.State != MEM_COMMIT {
return false;
}
let readable = PAGE_READONLY
| PAGE_READWRITE
| PAGE_WRITECOPY
| PAGE_EXECUTE_READ
| PAGE_EXECUTE_READWRITE
| PAGE_EXECUTE_WRITECOPY;
if mbi.Protect & readable == 0 || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 {
return false;
}
// The full range must fit inside this single committed region.
let region_end = (mbi.BaseAddress as usize).wrapping_add(mbi.RegionSize);
if addr.checked_add(buf.len()).is_none_or(|e| e > region_end) {
return false;
}
core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), buf.len());
true
}
/// Make `[addr, addr+data.len())` writable, copy `data`, flush the instruction
/// cache, then restore the original protection. `false` if protection could not
/// be changed. Verification is the caller's re-read (see [`apply_checked`]).
unsafe fn protected_write(addr: usize, data: &[u8]) -> bool {
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
if addr == 0 || data.is_empty() {
return false;
}
let mut old: u32 = 0;
if VirtualProtect(addr as _, data.len(), PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, data.len());
FlushInstructionCache(GetCurrentProcess(), addr as _, data.len());
// Best-effort restore of the original page protection.
let mut restored: u32 = 0;
VirtualProtect(addr as _, data.len(), old, &mut restored);
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// Deterministic fake address space for the pure patch logic.
struct FakeMem {
cells: HashMap<usize, u8>,
readable: bool,
writable: bool,
}
impl FakeMem {
fn with(addr: usize, bytes: &[u8]) -> Self {
let mut cells = HashMap::new();
for (i, b) in bytes.iter().enumerate() {
cells.insert(addr + i, *b);
}
Self {
cells,
readable: true,
writable: true,
}
}
}
impl Mem for FakeMem {
fn read(&self, addr: usize, buf: &mut [u8]) -> bool {
if !self.readable {
return false;
}
for (i, slot) in buf.iter_mut().enumerate() {
match self.cells.get(&(addr + i)) {
Some(b) => *slot = *b,
None => return false,
}
}
true
}
fn write(&mut self, addr: usize, data: &[u8]) -> bool {
if !self.writable {
return false;
}
for (i, b) in data.iter().enumerate() {
self.cells.insert(addr + i, *b);
}
true
}
}
const ORIG: [u8; 2] = [0x75, 0x0f];
const PATCH: [u8; 2] = [0x7f, 0x0f];
#[test]
fn classify_recognises_all_three_states() {
assert_eq!(classify(&ORIG, &ORIG, &PATCH), PatchState::Original);
assert_eq!(classify(&PATCH, &ORIG, &PATCH), PatchState::AlreadyPatched);
assert_eq!(classify(&[0x12, 0x34], &ORIG, &PATCH), PatchState::Mismatch);
}
#[test]
fn original_bytes_are_applied_and_verified() {
let mut m = FakeMem::with(0x1000, &ORIG);
assert_eq!(
apply_checked(&mut m, 0x1000, &ORIG, &PATCH),
ApplyOutcome::Applied
);
// Memory now holds the replacement.
let mut got = [0u8; 2];
assert!(m.read(0x1000, &mut got));
assert_eq!(got, PATCH);
}
#[test]
fn already_patched_is_idempotent_noop() {
let mut m = FakeMem::with(0x2000, &PATCH);
assert_eq!(
apply_checked(&mut m, 0x2000, &ORIG, &PATCH),
ApplyOutcome::AlreadyPatched
);
}
#[test]
fn mismatch_never_writes() {
let junk = [0xde, 0xad];
let mut m = FakeMem::with(0x3000, &junk);
assert_eq!(
apply_checked(&mut m, 0x3000, &ORIG, &PATCH),
ApplyOutcome::Mismatch
);
// Untouched.
let mut got = [0u8; 2];
assert!(m.read(0x3000, &mut got));
assert_eq!(got, junk);
}
#[test]
fn unreadable_module_waits_without_crashing() {
let mut m = FakeMem::with(0x4000, &ORIG);
m.readable = false;
let out = apply_checked(&mut m, 0x4000, &ORIG, &PATCH);
assert_eq!(out, ApplyOutcome::NotReadable);
assert!(!out.is_patched());
}
#[test]
fn write_failure_is_reported_not_pretended() {
let mut m = FakeMem::with(0x5000, &ORIG);
m.writable = false;
assert_eq!(
apply_checked(&mut m, 0x5000, &ORIG, &PATCH),
ApplyOutcome::WriteFailed
);
}
#[test]
fn running_twice_does_not_corrupt() {
let mut m = FakeMem::with(0x6000, &ORIG);
assert_eq!(
apply_checked(&mut m, 0x6000, &ORIG, &PATCH),
ApplyOutcome::Applied
);
// Second pass sees the replacement and is a no-op.
assert_eq!(
apply_checked(&mut m, 0x6000, &ORIG, &PATCH),
ApplyOutcome::AlreadyPatched
);
let mut got = [0u8; 2];
assert!(m.read(0x6000, &mut got));
assert_eq!(got, PATCH);
}
#[test]
fn rva_and_live_addr_relocate_across_bases() {
// GATE1 example: preferred 0x140000000, VA 0x146132548.
assert_eq!(rva(0x1_4613_2548, 0x1_4000_0000), 0x613_2548);
// Applied at the preferred base gives the static VA back.
assert_eq!(live_addr(0x1_4000_0000, 0x613_2548), 0x1_4613_2548);
// Applied at a relocated (ASLR) base tracks the base exactly.
assert_eq!(live_addr(0x2_0000_0000, 0x613_2548), 0x2_0613_2548);
}
#[test]
fn hex_is_lowercase_unseparated() {
assert_eq!(hex(&[0x0f, 0x85, 0xde]), "0f85de");
}
}
+445
View File
@@ -0,0 +1,445 @@
//! FIFA 17 Offline Seasons game-setup team compatibility candidate.
//!
//! `FUT::SeasonsManagerOfflineHelper` first projects the authentic dynamic pair
//! `[fixture_team, user_team]`. Later, `futSelectTeam::SetupTeamsInfo()` asks
//! `CardsGameSetupAdapter.GetTeam(side)` while rebuilding its panel state. The
//! first native reads expose the correct fixture and user teams on distinct GetTeam
//! sides. A later repeated read of the user-returning side is fed into SetTeam's
//! inverse side mapping and duplicates the user's XI over the opponent.
//!
//! This default-off candidate corrects that source read, not SetTeam or the final
//! writer. It records the projector pair, requires one native observation of each
//! team on distinct sides, and permits one correction on the next repeated
//! user-team read. Missing or conflicting evidence always preserves native behavior.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Mutex;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use crate::sbc_trace::{readable_range, target_va, validate_cards_build};
use crate::season_trace::{install_detour, install_detour_reloc, rd_i32};
use crate::write_log;
const ENABLE_ENV: &str = "OPENFUT_FIFA17_SEASON_TEAM_COMPAT";
const FIXTURE_PROJECTOR_RVA: usize = 0x0fc500;
const GET_TEAM_RVA: usize = 0x0054a0;
const FIXTURE_PROJECTOR_SIGNATURE: [u8; 19] = [
0x40, 0x57, 0x41, 0x54, 0x41, 0x56, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
0xff, 0xff, 0xff,
];
const GET_TEAM_SIGNATURE: [u8; 17] = [
0x48, 0x8b, 0x05, 0xb9, 0x8a, 0x2d, 0x00, 0x4c, 0x8b, 0x80, 0x50, 0x03, 0x00, 0x00, 0x49, 0xff,
0xe0,
];
const UNKNOWN_SIDE: i32 = -1;
static REPAIR_ACTIVE: AtomicBool = AtomicBool::new(false);
static FIXTURE_PROJECTOR_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static GET_TEAM_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static TEAM_STATE: Mutex<TeamState> = Mutex::new(TeamState::empty());
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TeamState {
fixture_team: i32,
user_team: i32,
fixture_get_side: i32,
user_get_side: i32,
correction_used: bool,
}
impl TeamState {
const fn empty() -> Self {
Self {
fixture_team: 0,
user_team: 0,
fixture_get_side: UNKNOWN_SIDE,
user_get_side: UNKNOWN_SIDE,
correction_used: false,
}
}
fn capture(&mut self, fixture_team: i32, user_team: i32) -> bool {
if !valid_pair(fixture_team, user_team) {
*self = Self::empty();
return false;
}
*self = Self {
fixture_team,
user_team,
fixture_get_side: UNKNOWN_SIDE,
user_get_side: UNKNOWN_SIDE,
correction_used: false,
};
true
}
fn observe_get_team(&mut self, side: i32, native_team: i32) -> GetTeamDecision {
if !valid_side(side) || !valid_pair(self.fixture_team, self.user_team) {
return GetTeamDecision::native(native_team);
}
if native_team == self.fixture_team {
if (self.fixture_get_side != UNKNOWN_SIDE && self.fixture_get_side != side)
|| self.user_get_side == side
{
return self.clear_on_conflict(native_team);
}
let first_observation = self.fixture_get_side == UNKNOWN_SIDE;
self.fixture_get_side = side;
return GetTeamDecision {
team: native_team,
event: if self.user_get_side != UNKNOWN_SIDE {
DecisionEvent::NativePairConfirmed
} else if first_observation {
DecisionEvent::FixtureObserved
} else {
DecisionEvent::None
},
};
}
if native_team == self.user_team {
if self.user_get_side == UNKNOWN_SIDE {
if self.fixture_get_side == side {
return self.clear_on_conflict(native_team);
}
self.user_get_side = side;
return GetTeamDecision {
team: native_team,
event: if self.fixture_get_side != UNKNOWN_SIDE {
DecisionEvent::NativePairConfirmed
} else {
DecisionEvent::UserObserved
},
};
}
if self.user_get_side != side {
return self.clear_on_conflict(native_team);
}
if !self.correction_used && self.fixture_get_side != UNKNOWN_SIDE {
self.correction_used = true;
return GetTeamDecision {
team: self.fixture_team,
event: DecisionEvent::Corrected,
};
}
}
GetTeamDecision::native(native_team)
}
fn clear_on_conflict(&mut self, native_team: i32) -> GetTeamDecision {
*self = Self::empty();
GetTeamDecision {
team: native_team,
event: DecisionEvent::ConflictingNativePair,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DecisionEvent {
None,
FixtureObserved,
UserObserved,
NativePairConfirmed,
ConflictingNativePair,
Corrected,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct GetTeamDecision {
team: i32,
event: DecisionEvent,
}
impl GetTeamDecision {
const fn native(team: i32) -> Self {
Self {
team,
event: DecisionEvent::None,
}
}
}
const fn valid_side(side: i32) -> bool {
side == 0 || side == 1
}
const fn valid_pair(fixture_team: i32, user_team: i32) -> bool {
fixture_team > 0 && user_team > 0 && fixture_team != user_team
}
fn enabled(value: Option<&str>) -> bool {
value == Some("1")
}
fn exact_signature(current: &[u8], expected: &[u8]) -> bool {
current == expected
}
unsafe fn target_matches(base: usize, rva: usize, signature: &[u8]) -> bool {
let Some(target) = target_va(base, rva) else {
return false;
};
readable_range(target, signature.len())
&& exact_signature(
core::slice::from_raw_parts(target as *const u8, signature.len()),
signature,
)
}
type FixtureProjectorFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize;
type GetTeamFn = unsafe extern "system" fn(usize, i32) -> i32;
unsafe extern "system" fn fixture_projector_wrapper(
context: usize,
output_pair: usize,
r8: usize,
r9: usize,
) -> usize {
let trampoline = FIXTURE_PROJECTOR_TRAMPOLINE.load(Ordering::Acquire);
if trampoline == 0 {
return 0;
}
let original: FixtureProjectorFn = core::mem::transmute(trampoline);
let result = original(context, output_pair, r8, r9);
let pair = rd_i32(output_pair).zip(rd_i32(output_pair.saturating_add(4)));
let captured = pair.is_some_and(|(fixture_team, user_team)| {
TEAM_STATE
.lock()
.map(|mut state| state.capture(fixture_team, user_team))
.unwrap_or(false)
});
match pair {
Some((fixture_team, user_team)) if captured => write_log(&format!(
"SEASON_TEAM_COMPAT: fixture captured fixture={fixture_team} user={user_team}\n"
)),
Some((fixture_team, user_team)) => write_log(&format!(
"SEASON_TEAM_COMPAT: invalid fixture pair [{fixture_team},{user_team}]; inactive\n"
)),
None => {
if let Ok(mut state) = TEAM_STATE.lock() {
*state = TeamState::empty();
}
write_log("SEASON_TEAM_COMPAT: unreadable fixture pair; inactive\n");
}
}
result
}
unsafe extern "system" fn get_team_wrapper(adapter: usize, side: i32) -> i32 {
let trampoline = GET_TEAM_TRAMPOLINE.load(Ordering::Acquire);
if trampoline == 0 {
return 0;
}
let original: GetTeamFn = core::mem::transmute(trampoline);
let native_team = original(adapter, side);
if !REPAIR_ACTIVE.load(Ordering::Acquire) {
return native_team;
}
let decision = TEAM_STATE
.lock()
.map(|mut state| state.observe_get_team(side, native_team))
.unwrap_or_else(|_| GetTeamDecision::native(native_team));
match decision.event {
DecisionEvent::FixtureObserved => write_log(&format!(
"SEASON_TEAM_COMPAT: fixture observed side={side} team={native_team}\n"
)),
DecisionEvent::UserObserved => write_log(&format!(
"SEASON_TEAM_COMPAT: user observed side={side} team={native_team}\n"
)),
DecisionEvent::NativePairConfirmed => write_log(&format!(
"SEASON_TEAM_COMPAT: native pair confirmed side={side} team={native_team}\n"
)),
DecisionEvent::ConflictingNativePair => write_log(&format!(
"SEASON_TEAM_COMPAT: conflicting native pair at side={side}; state cleared\n"
)),
DecisionEvent::Corrected => write_log(&format!(
"SEASON_TEAM_COMPAT: corrected GetTeam side={side} native={native_team} fixture={}\n",
decision.team
)),
DecisionEvent::None => {}
}
decision.team
}
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 || !validate_cards_build(base) {
write_log("SEASON_TEAM_COMPAT: CardsDLL unavailable/invalid; inactive\n");
return;
}
if !target_matches(base, FIXTURE_PROJECTOR_RVA, &FIXTURE_PROJECTOR_SIGNATURE)
|| !target_matches(base, GET_TEAM_RVA, &GET_TEAM_SIGNATURE)
{
write_log("SEASON_TEAM_COMPAT: target signature mismatch; inactive\n");
return;
}
if !install_detour(
base,
FIXTURE_PROJECTOR_RVA,
"OfflineSeason_fixture_projector",
FIXTURE_PROJECTOR_SIGNATURE.len(),
&FIXTURE_PROJECTOR_SIGNATURE,
fixture_projector_wrapper as *const () as usize,
&FIXTURE_PROJECTOR_TRAMPOLINE,
) {
write_log("SEASON_TEAM_COMPAT: fixture projector hook failed; inactive\n");
return;
}
if !install_detour_reloc(
base,
GET_TEAM_RVA,
"CardsGameSetupAdapter_GetTeam",
GET_TEAM_SIGNATURE.len(),
&GET_TEAM_SIGNATURE,
3,
7,
get_team_wrapper as *const () as usize,
&GET_TEAM_TRAMPOLINE,
) {
write_log("SEASON_TEAM_COMPAT: GetTeam hook failed; inactive\n");
return;
}
REPAIR_ACTIVE.store(true, Ordering::Release);
write_log("SEASON_TEAM_COMPAT: candidate ARMED; exact fixture evidence gate enabled\n");
}
pub(crate) fn install() {
if !enabled(std::env::var(ENABLE_ENV).ok().as_deref()) {
write_log("SEASON_TEAM_COMPAT: disabled\n");
return;
}
write_log("SEASON_TEAM_COMPAT: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_is_default_off() {
assert!(!enabled(None));
assert!(!enabled(Some("0")));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn signature_validation_is_exact() {
assert!(exact_signature(&GET_TEAM_SIGNATURE, &GET_TEAM_SIGNATURE));
let mut changed = GET_TEAM_SIGNATURE;
changed[0] ^= 1;
assert!(!exact_signature(&changed, &GET_TEAM_SIGNATURE));
}
#[test]
fn invalid_fixture_never_arms_state() {
let mut state = TeamState::empty();
assert!(!state.capture(0, 130000));
assert!(!state.capture(73, 73));
assert_eq!(
state.observe_get_team(0, 130000),
GetTeamDecision::native(130000)
);
}
#[test]
fn fixture_must_be_observed_natively_before_correction() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision {
team: 130000,
event: DecisionEvent::UserObserved
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision::native(130000)
);
assert_eq!(state.fixture_get_side, UNKNOWN_SIDE);
assert!(!state.correction_used);
}
#[test]
fn correction_requires_native_pair_then_is_one_shot() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(
state.observe_get_team(0, 73),
GetTeamDecision {
team: 73,
event: DecisionEvent::FixtureObserved
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision {
team: 130000,
event: DecisionEvent::NativePairConfirmed
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision {
team: 73,
event: DecisionEvent::Corrected
}
);
assert_eq!(
state.observe_get_team(1, 130000),
GetTeamDecision::native(130000)
);
}
#[test]
fn second_fixture_replaces_all_prior_state() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(state.observe_get_team(0, 73).team, 73);
assert_eq!(state.observe_get_team(1, 130000).team, 130000);
assert_eq!(state.observe_get_team(1, 130000).team, 73);
assert!(state.capture(240, 130000));
assert_eq!(state.fixture_get_side, UNKNOWN_SIDE);
assert_eq!(state.user_get_side, UNKNOWN_SIDE);
assert!(!state.correction_used);
assert_eq!(state.observe_get_team(0, 240).team, 240);
assert_eq!(state.observe_get_team(1, 130000).team, 130000);
assert_eq!(state.observe_get_team(1, 130000).team, 240);
}
#[test]
fn conflicting_fixture_sides_fail_closed() {
let mut state = TeamState::empty();
assert!(state.capture(73, 130000));
assert_eq!(
state.observe_get_team(0, 73).event,
DecisionEvent::FixtureObserved
);
assert_eq!(
state.observe_get_team(1, 73).event,
DecisionEvent::ConflictingNativePair
);
assert_eq!(state, TeamState::empty());
}
}
+98 -28
View File
@@ -12,7 +12,8 @@
//! flow. Targets are chosen so their copied prologues are position-independent
//! (no rip-relative / rel32 in the copied bytes).
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use core::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
@@ -28,17 +29,15 @@ use crate::sbc_trace::{
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
unsafe fn rd_i32(addr: usize) -> Option<i32> {
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
unsafe fn rd_u8(addr: usize) -> Option<u8> {
pub(crate) unsafe fn rd_u8(addr: usize) -> Option<u8> {
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
}
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
unsafe fn rd_cstr(addr: usize, max: usize) -> String {
pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String {
if addr == 0 || !readable_range(addr, 1) {
return String::from("<unreadable>");
}
@@ -58,7 +57,7 @@ unsafe fn rd_cstr(addr: usize, max: usize) -> String {
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
/// MUST be whole, position-independent instructions) with an absolute jump to
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
unsafe fn install_detour(
pub(crate) unsafe fn install_detour(
base: usize,
rva: usize,
name: &str,
@@ -269,7 +268,7 @@ unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
/// both within the copied bytes). The trampoline is allocated near `base` and the
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
#[allow(clippy::too_many_arguments)]
unsafe fn install_detour_reloc(
pub(crate) unsafe fn install_detour_reloc(
base: usize,
rva: usize,
name: &str,
@@ -390,18 +389,7 @@ unsafe extern "system" fn final_completion_wrapper(
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
));
}
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
// failure to SUCCESS so the offline-season load advances to
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
// once per process; verified by the error string before touching memory.
if flag == Some(0)
&& errstr.contains("CACHE_PACKNAMES")
&& readable_range(result, 1)
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
{
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n");
}
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
@@ -448,9 +436,30 @@ unsafe extern "system" fn stage1_completion_wrapper(
}
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
// URL of the pack-names/cards-tournament-list web file. Passive capture. Its
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
// installer (disp32 at copied offset 7, instruction end 11).
// URL of the pack-names / cards-tournament-list web file. Its prologue has a
// rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating installer
// (disp32 at copied offset 7, instruction end 11).
//
// BASE-SUPPLY: the client's RS4::ServerSettings CDN base (DAT_1802e6408+0x30) is
// EMPTY in the emulator — FUN_180124270 only sets it when the OSDK getter
// slot0x3f8 is non-empty, and it has no default (unlike the API base). So every
// FUT WEBFILE url arrives here as a BARE relative path and 999s (client
// sentinel). We supply the missing intended `<CDN>/fut/` prefix so the REAL file
// downloads and parses. This is a data-supply, NOT a success-forcing bypass;
// absolute urls (containing "://", e.g. the "http://sbc/..." tile route) pass
// through untouched.
//
// The prefix comes from `openfut.cfg` via `openfut-common`, the same single
// source of truth as every redirect target, so no lab address is compiled in.
// Unset (config missing/unusable) means NO rewrite: a url is left exactly as the
// client built it rather than pointed at a guessed host.
static FUT_CONTENT_BASE: OnceLock<String> = OnceLock::new();
/// Arm the FUT web-file prefix from the resolved configuration. Idempotent: the
/// first call wins.
pub(crate) fn set_fut_content_base(base: String) {
let _ = FUT_CONTENT_BASE.set(base);
}
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn url_capture_wrapper(
rcx: usize,
@@ -458,12 +467,31 @@ unsafe extern "system" fn url_capture_wrapper(
r8: usize,
r9: usize,
) -> usize {
let orig = rd_cstr(rcx, 256);
let mut arg_rcx = rcx;
// Owned buffer that stays alive across the original() call below. The caller
// frees its own url buffer immediately after FUN_18017ff90 returns, so the
// client copies the url synchronously during the call — a local buffer is
// sufficient and nothing is leaked.
let mut full: Vec<u8> = Vec::new();
if let Some(base) = FUT_CONTENT_BASE.get() {
if !orig.is_empty() && !orig.contains("://") {
full.extend_from_slice(base.as_bytes());
full.extend_from_slice(orig.trim_start_matches('/').as_bytes());
full.push(0); // NUL terminator for the C-string
arg_rcx = full.as_ptr() as usize;
}
}
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!(
"SEASONS_WEBFILE_URL: url={:?}\n",
rd_cstr(rcx, 256)
));
if arg_rcx != rcx {
write_log(&format!(
"SEASONS_WEBFILE_URL: orig={orig:?} rewritten={:?}\n",
rd_cstr(arg_rcx, 256)
));
} else {
write_log(&format!("SEASONS_WEBFILE_URL: url={orig:?} (unchanged)\n"));
}
}
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
if t == 0 {
@@ -471,7 +499,9 @@ unsafe extern "system" fn url_capture_wrapper(
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
let ret = original(arg_rcx, rdx, r8, r9);
drop(full); // ensure the url buffer outlives the download-start call
ret
}
unsafe fn worker() {
@@ -628,6 +658,46 @@ unsafe fn worker() {
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
arm_fut_content_base();
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
/// Resolve the FUT web-file prefix from `openfut.cfg` next to the game exe, via
/// the shared `openfut-common` parser — the same single source of truth the
/// network redirect uses, so the lab address is never compiled in.
///
/// Fails SAFE: an absent or unusable config arms nothing, and the url rewriter
/// then leaves every url exactly as the client built it.
fn arm_fut_content_base() {
let path = match std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("openfut.cfg")))
{
Some(p) => p,
None => {
write_log("SEASONS_WEBFILE_BASE: cannot locate openfut.cfg — no url rewrite\n");
return;
}
};
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
write_log(&format!(
"SEASONS_WEBFILE_BASE: {} unreadable ({e}) — no url rewrite\n",
path.display()
));
return;
}
};
match openfut_common::ServerConfig::parse(&contents) {
Ok(cfg) => {
let base = cfg.fut_content_base();
write_log(&format!("SEASONS_WEBFILE_BASE: armed {base}\n"));
set_fut_content_base(base);
}
Err(e) => write_log(&format!(
"SEASONS_WEBFILE_BASE: openfut.cfg unusable ({e}) — no url rewrite\n"
)),
}
}
+10
View File
@@ -0,0 +1,10 @@
#![cfg(windows)]
#![allow(dead_code)]
// Compile the production connect hook directly into an executable test target.
// The hook crate itself is a cdylib, whose unit-test artifact remains a DLL and
// therefore cannot be executed by the native Windows test runner.
fn write_log(_: &str) {}
#[path = "../src/connect_hook.rs"]
mod connect_hook;
+97 -4
View File
@@ -274,12 +274,57 @@ impl LauncherConfig {
.join("config.json")
}
/// Parse a config body, tolerating a leading UTF-8 BOM.
///
/// Windows text editors and PowerShell's `Set-Content -Encoding UTF8` both
/// prepend `EF BB BF`, and `serde_json` rejects it. Kept separate from
/// [`Self::load`] so the BOM behaviour is testable without touching the
/// user's real config path.
pub fn parse_json(raw: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(raw.trim_start_matches('\u{feff}'))
}
/// Load the saved config.
///
/// A MISSING file is first-run and correctly yields defaults. A file that
/// exists but does not parse is NOT: silently returning defaults there means
/// the launcher comes up pointing at the **production** ports
/// (`blaze_main` 42130, `account_sync` 8099) with an empty `game_profile`,
/// and the next [`Self::save`] writes that over the user's real settings —
/// losing the configuration and silently retargeting the game. That happened
/// on 2026-08-23 from nothing worse than a BOM.
///
/// So an unparseable config is quarantined rather than overwritten: it is
/// renamed next to itself and the error is reported, leaving the operator
/// something to recover from.
pub fn load() -> Self {
let path = Self::config_path();
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
let Ok(raw) = std::fs::read_to_string(&path) else {
return Self::default();
};
match Self::parse_json(&raw) {
Ok(cfg) => cfg,
Err(e) => {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let quarantine = path.with_file_name(format!("config.json.corrupt-{stamp}"));
let moved = std::fs::rename(&path, &quarantine).is_ok();
eprintln!(
"openfut-launcher: {} is not valid JSON ({e}). Falling back to defaults, \
which point at the PRODUCTION ports check the server settings before \
launching.{}",
path.display(),
if moved {
format!(" Previous file kept at {}.", quarantine.display())
} else {
String::new()
}
);
Self::default()
}
}
}
pub fn save(&self) {
@@ -325,6 +370,8 @@ impl LauncherConfig {
https: self.openfut_https_port,
blaze_redirector: self.openfut_blaze_redirector_port,
blaze_main: self.openfut_blaze_main_port,
fut_content: openfut_common::default_ports::FUT_CONTENT,
roster: openfut_common::default_ports::ROSTER,
},
}
}
@@ -515,6 +562,52 @@ mod tests {
assert!(c.ea_hostnames.is_empty());
}
/// Regression, 2026-08-23: a config written by PowerShell's
/// `Set-Content -Encoding UTF8` carries a UTF-8 BOM. `serde_json` rejected
/// it, `load()` silently returned defaults, and the next `save()` wrote
/// those defaults over the operator's real settings — replacing the STAGING
/// ports with the PRODUCTION ones and emptying `game_profile`, so the
/// launcher could no longer start the game and would have pointed it at the
/// live service. Parsing must tolerate the BOM.
#[test]
fn a_bom_prefixed_config_still_parses_and_keeps_its_ports() {
let body = r#"{
"core_binary":"","bridge_binary":"","core_database_url":"",
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
"hook_dll_path":"","fifa_game_dir":"C:\\FIFA 17",
"openfut_server_host":"10.10.0.120",
"openfut_blaze_redirector_port":42327,
"openfut_blaze_main_port":42330,
"openfut_account_sync_port":8299
}"#;
let with_bom = format!("\u{feff}{body}");
assert!(
serde_json::from_str::<LauncherConfig>(&with_bom).is_err(),
"precondition: raw serde_json must reject the BOM, else this guards nothing"
);
let c = LauncherConfig::parse_json(&with_bom).expect("BOM must be tolerated");
assert_eq!(c.openfut_blaze_redirector_port, 42327);
assert_eq!(
c.openfut_blaze_main_port, 42330,
"must NOT fall back to 42130"
);
assert_eq!(
c.openfut_account_sync_port, 8299,
"must NOT fall back to 8099"
);
assert_eq!(c.fifa_game_dir, "C:\\FIFA 17");
}
/// Genuinely corrupt JSON must stay an error so `load()` quarantines the
/// file instead of overwriting it with defaults.
#[test]
fn a_corrupt_config_is_an_error_not_silent_defaults() {
assert!(LauncherConfig::parse_json("{not json").is_err());
}
#[test]
fn a_configured_profile_satisfies_launch_without_a_shell_command() {
let mut c = LauncherConfig {