Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bee97055db | |||
| 021a044859 | |||
| d7641175be | |||
| 89cf5df71f | |||
| 0f2d66e8ca | |||
| 561e666dc3 | |||
| 9ba88c79fc | |||
| 5294f589ad | |||
| 7edf682291 |
Generated
+1
@@ -2293,6 +2293,7 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"openfut-common",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
+138
-2
@@ -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();
|
||||
|
||||
@@ -159,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -18,6 +18,10 @@ 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;
|
||||
@@ -28,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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! (no rip-relative / rel32 in the copied bytes).
|
||||
|
||||
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;
|
||||
@@ -29,14 +30,14 @@ use crate::write_log;
|
||||
|
||||
static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
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>");
|
||||
}
|
||||
@@ -56,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,
|
||||
@@ -267,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,
|
||||
@@ -388,6 +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"
|
||||
));
|
||||
}
|
||||
|
||||
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
|
||||
if t == 0 {
|
||||
return 0;
|
||||
@@ -434,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,
|
||||
@@ -444,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 {
|
||||
@@ -457,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() {
|
||||
@@ -614,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"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
+97
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user