diff --git a/Cargo.lock b/Cargo.lock
index 24d9295..bc53fc9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2293,6 +2293,7 @@ dependencies = [
"eframe",
"egui",
"openfut-common",
+ "parking_lot",
"serde",
"serde_json",
"tokio",
diff --git a/openfut-common/src/lib.rs b/openfut-common/src/lib.rs
index 71eaeca..45db714 100644
--- a/openfut-common/src/lib.rs
+++ b/openfut-common/src/lib.rs
@@ -53,6 +53,17 @@ 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 `/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 destination ports. Each field is where an intercepted EA source port
@@ -66,6 +77,9 @@ 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,
}
impl Default for OpenFutPorts {
@@ -74,6 +88,7 @@ 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,
}
}
}
@@ -244,6 +259,7 @@ 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)?,
other => {
return Err(ConfigError::MalformedConfig(format!(
"line {}: unknown key '{other}'",
@@ -260,8 +276,27 @@ impl ServerConfig {
/// Serialize to the structured `openfut.cfg` format.
pub fn to_cfg_string(&self) -> String {
format!(
- "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
- self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
+ "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
+ )
+ }
+
+ /// 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!(
+ "http://{}:{}/fut/",
+ self.host.trim(),
+ self.ports.fut_content
)
}
@@ -449,12 +484,36 @@ mod tests {
https: 8443,
blaze_redirector: 10041,
blaze_main: 42127,
+ fut_content: 8085,
},
};
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.
diff --git a/openfut-hook/src/season_trace.rs b/openfut-hook/src/season_trace.rs
index 96c9536..9dd135c 100644
--- a/openfut-hook/src/season_trace.rs
+++ b/openfut-hook/src/season_trace.rs
@@ -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;
@@ -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 `/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 = 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 = 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"
+ )),
+ }
+}
diff --git a/src/config.rs b/src/config.rs
index 2587efd..158607b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -325,6 +325,7 @@ 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,
},
}
}