Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b1d5aa367 | |||
| ed5c335c70 |
Generated
-1
@@ -2293,7 +2293,6 @@ dependencies = [
|
|||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"openfut-common",
|
"openfut-common",
|
||||||
"parking_lot",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ pub mod default_ports {
|
|||||||
pub const BLAZE_REDIRECTOR: u16 = 42127;
|
pub const BLAZE_REDIRECTOR: u16 = 42127;
|
||||||
/// OpenFUT FIFA 17 Blaze main listener.
|
/// OpenFUT FIFA 17 Blaze main listener.
|
||||||
pub const BLAZE_MAIN: u16 = 42130;
|
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.
|
|
||||||
pub const FUT_CONTENT: u16 = 8110;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OpenFUT destination ports. Each field is where an intercepted EA source port
|
/// OpenFUT destination ports. Each field is where an intercepted EA source port
|
||||||
@@ -72,9 +66,6 @@ pub struct OpenFutPorts {
|
|||||||
pub blaze_redirector: u16,
|
pub blaze_redirector: u16,
|
||||||
/// Destination for EA :42127 traffic (Blaze main).
|
/// Destination for EA :42127 traffic (Blaze main).
|
||||||
pub blaze_main: u16,
|
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 {
|
impl Default for OpenFutPorts {
|
||||||
@@ -83,7 +74,6 @@ impl Default for OpenFutPorts {
|
|||||||
https: default_ports::HTTPS,
|
https: default_ports::HTTPS,
|
||||||
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
|
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
|
||||||
blaze_main: default_ports::BLAZE_MAIN,
|
blaze_main: default_ports::BLAZE_MAIN,
|
||||||
fut_content: default_ports::FUT_CONTENT,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +209,6 @@ impl ServerConfig {
|
|||||||
"https_port" => ports.https = parse_port(value)?,
|
"https_port" => ports.https = parse_port(value)?,
|
||||||
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
|
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
|
||||||
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
|
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
|
||||||
"fut_content_port" => ports.fut_content = parse_port(value)?,
|
|
||||||
other => {
|
other => {
|
||||||
return Err(ConfigError::MalformedConfig(format!(
|
return Err(ConfigError::MalformedConfig(format!(
|
||||||
"line {}: unknown key '{other}'",
|
"line {}: unknown key '{other}'",
|
||||||
@@ -236,27 +225,8 @@ impl ServerConfig {
|
|||||||
/// Serialize to the structured `openfut.cfg` format.
|
/// Serialize to the structured `openfut.cfg` format.
|
||||||
pub fn to_cfg_string(&self) -> String {
|
pub fn to_cfg_string(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\nfut_content_port={}\n",
|
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n",
|
||||||
self.host,
|
self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main
|
||||||
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:8110/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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,36 +414,12 @@ mod tests {
|
|||||||
https: 8443,
|
https: 8443,
|
||||||
blaze_redirector: 10041,
|
blaze_redirector: 10041,
|
||||||
blaze_main: 42127,
|
blaze_main: 42127,
|
||||||
fut_content: 8110,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let s = c.to_cfg_string();
|
let s = c.to_cfg_string();
|
||||||
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
|
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:8110/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]
|
#[test]
|
||||||
fn configured_ipv4_becomes_correct_sockaddr() {
|
fn configured_ipv4_becomes_correct_sockaddr() {
|
||||||
// Resolve an IPv4 literal and confirm the sin_addr value.
|
// Resolve an IPv4 literal and confirm the sin_addr value.
|
||||||
|
|||||||
Generated
-5
@@ -2,15 +2,10 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openfut-common"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-hook"
|
name = "openfut-hook"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"openfut-common",
|
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ windows-sys = { version = "0.59", features = [
|
|||||||
"Win32_System_Diagnostics_Debug",
|
"Win32_System_Diagnostics_Debug",
|
||||||
"Win32_System_Kernel",
|
"Win32_System_Kernel",
|
||||||
] }
|
] }
|
||||||
# Single source of truth for the OpenFUT redirect config (openfut.cfg schema,
|
|
||||||
# EA-port -> OpenFUT-port map, WinSock byte-order helpers). Shared with the
|
|
||||||
# launcher so the hook and openfut.cfg agree by construction.
|
|
||||||
openfut-common = { path = "../openfut-common" }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
|||||||
crate::sbc_request_trace::install();
|
crate::sbc_request_trace::install();
|
||||||
crate::store_entry::install();
|
crate::store_entry::install();
|
||||||
crate::season_trace::install();
|
crate::season_trace::install();
|
||||||
|
crate::kit_trace::install();
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! 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() });
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ mod dial_notification;
|
|||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod fifa17;
|
mod fifa17;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod kit_trace;
|
||||||
mod iat;
|
mod iat;
|
||||||
mod origin_spy;
|
mod origin_spy;
|
||||||
#[cfg(feature = "probe")]
|
#[cfg(feature = "probe")]
|
||||||
|
|||||||
@@ -13,12 +13,8 @@
|
|||||||
//! (no rip-relative / rel32 in the copied bytes).
|
//! (no rip-relative / rel32 in the copied bytes).
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||||
use windows_sys::Win32::System::Diagnostics::Debug::{
|
|
||||||
AddVectoredExceptionHandler, EXCEPTION_POINTERS,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||||
use windows_sys::Win32::System::Memory::{
|
use windows_sys::Win32::System::Memory::{
|
||||||
VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ,
|
VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ,
|
||||||
@@ -35,14 +31,14 @@ static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
|||||||
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
|
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
|
||||||
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
|
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))
|
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))
|
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
|
||||||
}
|
}
|
||||||
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
|
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
|
||||||
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) {
|
if addr == 0 || !readable_range(addr, 1) {
|
||||||
return String::from("<unreadable>");
|
return String::from("<unreadable>");
|
||||||
}
|
}
|
||||||
@@ -62,7 +58,7 @@ unsafe fn rd_cstr(addr: usize, max: usize) -> String {
|
|||||||
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
|
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
|
||||||
/// MUST be whole, position-independent instructions) with an absolute jump to
|
/// MUST be whole, position-independent instructions) with an absolute jump to
|
||||||
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
|
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
|
||||||
unsafe fn install_detour(
|
pub(crate) unsafe fn install_detour(
|
||||||
base: usize,
|
base: usize,
|
||||||
rva: usize,
|
rva: usize,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -141,35 +137,15 @@ macro_rules! season_call_trace {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
season_call_trace!(
|
season_call_trace!(load_current_native_wrapper, LOAD_CURRENT_NATIVE_TRAMP, "LoadCurrentOfflineSeason_native");
|
||||||
load_current_native_wrapper,
|
season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native");
|
||||||
LOAD_CURRENT_NATIVE_TRAMP,
|
season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native");
|
||||||
"LoadCurrentOfflineSeason_native"
|
|
||||||
);
|
|
||||||
season_call_trace!(
|
|
||||||
start_season_native_wrapper,
|
|
||||||
START_SEASON_NATIVE_TRAMP,
|
|
||||||
"StartSeason_native"
|
|
||||||
);
|
|
||||||
season_call_trace!(
|
|
||||||
get_info_native_wrapper,
|
|
||||||
GET_INFO_NATIVE_TRAMP,
|
|
||||||
"GetOfflineSeasonInfo_native"
|
|
||||||
);
|
|
||||||
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
|
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
|
||||||
// actually calls; hands the callback name to the manager's async slot 0x80.
|
// actually calls; hands the callback name to the manager's async slot 0x80.
|
||||||
season_call_trace!(
|
season_call_trace!(load_offline_real_wrapper, LOAD_OFFLINE_REAL_TRAMP, "LoadOfflineSeasons_native(0x4ee10)");
|
||||||
load_offline_real_wrapper,
|
|
||||||
LOAD_OFFLINE_REAL_TRAMP,
|
|
||||||
"LoadOfflineSeasons_native(0x4ee10)"
|
|
||||||
);
|
|
||||||
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
|
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
|
||||||
// count and invokes the LoadSeasons_Complete AS callback.
|
// count and invokes the LoadSeasons_Complete AS callback.
|
||||||
season_call_trace!(
|
season_call_trace!(load_offline_async_wrapper, LOAD_OFFLINE_ASYNC_TRAMP, "LoadOfflineSeasons_asyncimpl(0x57560)");
|
||||||
load_offline_async_wrapper,
|
|
||||||
LOAD_OFFLINE_ASYNC_TRAMP,
|
|
||||||
"LoadOfflineSeasons_asyncimpl(0x57560)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
|
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
|
||||||
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
|
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
|
||||||
@@ -210,12 +186,7 @@ unsafe extern "system" fn load_current_impl_wrapper(
|
|||||||
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
|
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
|
||||||
// whether it ever fires; logs the result fields it branches on.
|
// whether it ever fires; logs the result fields it branches on.
|
||||||
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
||||||
unsafe extern "system" fn completion_wrapper(
|
unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
|
||||||
ctx: usize,
|
|
||||||
result: usize,
|
|
||||||
r8: usize,
|
|
||||||
r9: usize,
|
|
||||||
) -> usize {
|
|
||||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||||
if n < 64 {
|
if n < 64 {
|
||||||
let status = rd_i32(result + 0x1c);
|
let status = rd_i32(result + 0x1c);
|
||||||
@@ -241,11 +212,7 @@ unsafe extern "system" fn completion_wrapper(
|
|||||||
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
|
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
|
||||||
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
|
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
|
||||||
// so it needs the relocating installer below.
|
// so it needs the relocating installer below.
|
||||||
season_call_trace!(
|
season_call_trace!(get_users_division_wrapper, GET_USERS_DIVISION_TRAMP, "GetUsersOfflineDivision_native(0x4eb50)");
|
||||||
get_users_division_wrapper,
|
|
||||||
GET_USERS_DIVISION_TRAMP,
|
|
||||||
"GetUsersOfflineDivision_native(0x4eb50)"
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
|
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
|
||||||
/// CardsDLL data still fits after we relocate a copied prologue into it.
|
/// CardsDLL data still fits after we relocate a copied prologue into it.
|
||||||
@@ -273,7 +240,7 @@ unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
|
|||||||
/// both within the copied bytes). The trampoline is allocated near `base` and the
|
/// both within the copied bytes). The trampoline is allocated near `base` and the
|
||||||
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
|
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
unsafe fn install_detour_reloc(
|
pub(crate) unsafe fn install_detour_reloc(
|
||||||
base: usize,
|
base: usize,
|
||||||
rva: usize,
|
rva: usize,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -299,9 +266,7 @@ unsafe fn install_detour_reloc(
|
|||||||
let jump = absolute_jump(wrapper);
|
let jump = absolute_jump(wrapper);
|
||||||
let tramp_len = copy_len + jump.len();
|
let tramp_len = copy_len + jump.len();
|
||||||
let Some(tramp) = alloc_near(base, tramp_len) else {
|
let Some(tramp) = alloc_near(base, tramp_len) else {
|
||||||
write_log(&format!(
|
write_log(&format!("SEASON_TRACE: {name}: near trampoline alloc failed\n"));
|
||||||
"SEASON_TRACE: {name}: near trampoline alloc failed\n"
|
|
||||||
));
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
|
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
|
||||||
@@ -310,9 +275,7 @@ unsafe fn install_detour_reloc(
|
|||||||
let abs_target = target as i64 + insn_end as i64 + orig_disp;
|
let abs_target = target as i64 + insn_end as i64 + orig_disp;
|
||||||
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
|
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
|
||||||
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
|
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
|
||||||
write_log(&format!(
|
write_log(&format!("SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"));
|
||||||
"SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"
|
|
||||||
));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
|
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
|
||||||
@@ -320,9 +283,7 @@ unsafe fn install_detour_reloc(
|
|||||||
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
|
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
|
||||||
let mut old = 0u32;
|
let mut old = 0u32;
|
||||||
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
|
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
|
||||||
write_log(&format!(
|
write_log(&format!("SEASON_TRACE: {name}: trampoline protect failed\n"));
|
||||||
"SEASON_TRACE: {name}: trampoline protect failed\n"
|
|
||||||
));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
|
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
|
||||||
@@ -355,12 +316,7 @@ unsafe fn install_detour_reloc(
|
|||||||
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
|
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
|
||||||
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
|
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
|
||||||
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
||||||
unsafe extern "system" fn final_completion_wrapper(
|
unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize {
|
||||||
ctx: usize,
|
|
||||||
result: usize,
|
|
||||||
r8: usize,
|
|
||||||
r9: usize,
|
|
||||||
) -> usize {
|
|
||||||
// Read the delivered status: byte0==0 => failure with an error string at +8.
|
// Read the delivered status: byte0==0 => failure with an error string at +8.
|
||||||
let flag = rd_u8(result);
|
let flag = rd_u8(result);
|
||||||
let errstr = if flag == Some(0) {
|
let errstr = if flag == Some(0) {
|
||||||
@@ -385,24 +341,22 @@ unsafe extern "system" fn final_completion_wrapper(
|
|||||||
Some(_) => "SUCCESS",
|
Some(_) => "SUCCESS",
|
||||||
None => "??",
|
None => "??",
|
||||||
};
|
};
|
||||||
let shown = if flag == Some(0) {
|
let shown = if flag == Some(0) { errstr.as_str() } else { "SUCCESS" };
|
||||||
errstr.as_str()
|
|
||||||
} else {
|
|
||||||
"SUCCESS"
|
|
||||||
};
|
|
||||||
write_log(&format!(
|
write_log(&format!(
|
||||||
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
|
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Base-supply experiment: the CACHE_PACKNAMES failure is expected to be fixed
|
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
|
||||||
// by the WEBFILE base-supply (the real file now downloads), so the guarded
|
// failure to SUCCESS so the offline-season load advances to
|
||||||
// success-forcing bypass is DISABLED — a recurring CACHE_PACKNAMES here means
|
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
|
||||||
// the base-supply did not take effect and MUST NOT be masked.
|
// once per process; verified by the error string before touching memory.
|
||||||
if flag == Some(0)
|
if flag == Some(0)
|
||||||
&& errstr.contains("CACHE_PACKNAMES")
|
&& errstr.contains("CACHE_PACKNAMES")
|
||||||
|
&& readable_range(result, 1)
|
||||||
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
|
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
|
||||||
{
|
{
|
||||||
write_log("SEASONS_BYPASS: DISABLED (base-supply active); CACHE_PACKNAMES not masked\n");
|
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
|
||||||
|
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n");
|
||||||
}
|
}
|
||||||
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
|
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
@@ -417,23 +371,14 @@ unsafe extern "system" fn final_completion_wrapper(
|
|||||||
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
|
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
|
||||||
// the next async stage. Logs whether the first async stage succeeded. Passive.
|
// the next async stage. Logs whether the first async stage succeeded. Passive.
|
||||||
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
||||||
unsafe extern "system" fn stage1_completion_wrapper(
|
unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize, r8: usize, r9: usize) -> usize {
|
||||||
param1: usize,
|
|
||||||
result: usize,
|
|
||||||
r8: usize,
|
|
||||||
r9: usize,
|
|
||||||
) -> usize {
|
|
||||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||||
if n < 64 {
|
if n < 64 {
|
||||||
if result == 0 {
|
if result == 0 {
|
||||||
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
|
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
|
||||||
} else {
|
} else {
|
||||||
let status = rd_i32(result + 0x1c);
|
let status = rd_i32(result + 0x1c);
|
||||||
let verdict = if status == Some(0) {
|
let verdict = if status == Some(0) { "ok(chain next)" } else { "CACHE_PACKNAMES_FAILED" };
|
||||||
"ok(chain next)"
|
|
||||||
} else {
|
|
||||||
"CACHE_PACKNAMES_FAILED"
|
|
||||||
};
|
|
||||||
write_log(&format!(
|
write_log(&format!(
|
||||||
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
|
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
|
||||||
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
|
||||||
@@ -450,63 +395,14 @@ unsafe extern "system" fn stage1_completion_wrapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
|
// 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. Its prologue has a
|
// URL of the pack-names/cards-tournament-list web file. Passive capture. Its
|
||||||
// rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating installer
|
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
|
||||||
// (disp32 at copied offset 7, instruction end 11).
|
// 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);
|
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
||||||
unsafe extern "system" fn url_capture_wrapper(
|
unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
||||||
rcx: usize,
|
|
||||||
rdx: usize,
|
|
||||||
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);
|
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||||
if n < 64 {
|
if n < 64 {
|
||||||
if arg_rcx != rcx {
|
write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256)));
|
||||||
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);
|
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
@@ -514,80 +410,7 @@ unsafe extern "system" fn url_capture_wrapper(
|
|||||||
}
|
}
|
||||||
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||||
core::mem::transmute(t);
|
core::mem::transmute(t);
|
||||||
let ret = original(arg_rcx, rdx, r8, r9);
|
original(rcx, rdx, r8, r9)
|
||||||
drop(full); // ensure the url buffer outlives the download-start call
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────────── crash locator (VEH) ──────────────────────────────
|
|
||||||
// A vectored exception handler that logs the faulting code/address/module for
|
|
||||||
// fatal exceptions, then lets the crash proceed (EXCEPTION_CONTINUE_SEARCH). It
|
|
||||||
// pinpoints the StartSeason crash: whether it is a CardsDLL season-data
|
|
||||||
// null-deref (fixable by supplying matches/opponents) or an engine/other fault.
|
|
||||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
static CARDS_SIZE: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
static CRASH_LOGS: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
const EXCEPTION_CONTINUE_SEARCH: i32 = 0;
|
|
||||||
|
|
||||||
/// OptionalHeader.SizeOfImage from the module's PE headers (fallback 64 MiB).
|
|
||||||
unsafe fn cards_image_size(base: usize) -> usize {
|
|
||||||
if !readable_range(base + 0x3c, 4) {
|
|
||||||
return 0x0400_0000;
|
|
||||||
}
|
|
||||||
let e_lfanew = core::ptr::read_volatile((base + 0x3c) as *const u32) as usize;
|
|
||||||
let so_off = base + e_lfanew + 0x50; // NT header + OptionalHeader.SizeOfImage
|
|
||||||
if !readable_range(so_off, 4) {
|
|
||||||
return 0x0400_0000;
|
|
||||||
}
|
|
||||||
core::ptr::read_volatile(so_off as *const u32) as usize
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "system" fn crash_logger(info: *mut EXCEPTION_POINTERS) -> i32 {
|
|
||||||
if info.is_null() {
|
|
||||||
return EXCEPTION_CONTINUE_SEARCH;
|
|
||||||
}
|
|
||||||
let rec = (*info).ExceptionRecord;
|
|
||||||
if rec.is_null() {
|
|
||||||
return EXCEPTION_CONTINUE_SEARCH;
|
|
||||||
}
|
|
||||||
let code = (*rec).ExceptionCode as u32;
|
|
||||||
// Only fatal codes; skip the many benign first-chance SEH exceptions.
|
|
||||||
let interesting = matches!(
|
|
||||||
code,
|
|
||||||
0xC000_0005 // access violation
|
|
||||||
| 0xC000_001D // illegal instruction
|
|
||||||
| 0xC000_0094 // integer divide by zero
|
|
||||||
| 0xC000_00FD // stack overflow
|
|
||||||
| 0xC000_0025 // noncontinuable exception
|
|
||||||
);
|
|
||||||
if !interesting || CRASH_LOGS.fetch_add(1, Ordering::Relaxed) >= 8 {
|
|
||||||
return EXCEPTION_CONTINUE_SEARCH;
|
|
||||||
}
|
|
||||||
let addr = (*rec).ExceptionAddress as usize;
|
|
||||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
|
||||||
let size = CARDS_SIZE.load(Ordering::Acquire);
|
|
||||||
let module = if base != 0 && addr >= base && addr < base + size {
|
|
||||||
format!("CardsDLL+{:#x}", addr - base)
|
|
||||||
} else {
|
|
||||||
"other".to_string()
|
|
||||||
};
|
|
||||||
let (kind, fault) = if code == 0xC000_0005 && (*rec).NumberParameters >= 2 {
|
|
||||||
let op = (*rec).ExceptionInformation[0];
|
|
||||||
let fa = (*rec).ExceptionInformation[1];
|
|
||||||
let k = match op {
|
|
||||||
0 => "read",
|
|
||||||
1 => "write",
|
|
||||||
8 => "exec",
|
|
||||||
_ => "?",
|
|
||||||
};
|
|
||||||
(k, fa)
|
|
||||||
} else {
|
|
||||||
("", 0usize)
|
|
||||||
};
|
|
||||||
write_log(&format!(
|
|
||||||
"SEASON_CRASH: code={code:#010x} at={addr:#x} module={module} access={kind} fault_addr={fault:#x}\n"
|
|
||||||
));
|
|
||||||
EXCEPTION_CONTINUE_SEARCH
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn worker() {
|
unsafe fn worker() {
|
||||||
@@ -603,144 +426,63 @@ unsafe fn worker() {
|
|||||||
write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n");
|
write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CARDS_BASE.store(base, Ordering::Release);
|
|
||||||
CARDS_SIZE.store(cards_image_size(base), Ordering::Release);
|
|
||||||
AddVectoredExceptionHandler(1, Some(crash_logger));
|
|
||||||
write_log("SEASON_TRACE: crash logger (VEH) armed\n");
|
|
||||||
// (rva, name, copy_len, signature, wrapper, trampoline slot)
|
// (rva, name, copy_len, signature, wrapper, trampoline slot)
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15,
|
||||||
0x4eb70,
|
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
||||||
"LoadCurrentOfflineSeason_native",
|
load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP,
|
||||||
15,
|
|
||||||
&[
|
|
||||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
|
|
||||||
0xff,
|
|
||||||
],
|
|
||||||
load_current_native_wrapper as *const () as usize,
|
|
||||||
&LOAD_CURRENT_NATIVE_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x4f340, "StartSeason_native", 15,
|
||||||
0x4f340,
|
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
||||||
"StartSeason_native",
|
start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP,
|
||||||
15,
|
|
||||||
&[
|
|
||||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
|
|
||||||
0xff,
|
|
||||||
],
|
|
||||||
start_season_native_wrapper as *const () as usize,
|
|
||||||
&START_SEASON_NATIVE_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x4e850, "GetOfflineSeasonInfo_native", 15,
|
||||||
0x4e850,
|
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18],
|
||||||
"GetOfflineSeasonInfo_native",
|
get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP,
|
||||||
15,
|
|
||||||
&[
|
|
||||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24,
|
|
||||||
0x18,
|
|
||||||
],
|
|
||||||
get_info_native_wrapper as *const () as usize,
|
|
||||||
&GET_INFO_NATIVE_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x57230, "LoadCurrentOfflineSeason_impl", 19,
|
||||||
0x57230,
|
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff],
|
||||||
"LoadCurrentOfflineSeason_impl",
|
load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP,
|
||||||
19,
|
|
||||||
&[
|
|
||||||
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40,
|
|
||||||
0x98, 0xfe, 0xff, 0xff, 0xff,
|
|
||||||
],
|
|
||||||
load_current_impl_wrapper as *const () as usize,
|
|
||||||
&LOAD_CURRENT_IMPL_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16,
|
||||||
0x578e0,
|
&[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff],
|
||||||
"LoadCurrentOfflineSeason_completion",
|
completion_wrapper as *const () as usize, &COMPLETION_TRAMP,
|
||||||
16,
|
|
||||||
&[
|
|
||||||
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff,
|
|
||||||
0xff, 0xff,
|
|
||||||
],
|
|
||||||
completion_wrapper as *const () as usize,
|
|
||||||
&COMPLETION_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour_reloc(
|
install_detour_reloc(
|
||||||
base,
|
base, 0x4eb50, "GetUsersOfflineDivision_native", 14,
|
||||||
0x4eb50,
|
&[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01],
|
||||||
"GetUsersOfflineDivision_native",
|
7, 11,
|
||||||
14,
|
get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP,
|
||||||
&[
|
|
||||||
0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01,
|
|
||||||
],
|
|
||||||
7,
|
|
||||||
11,
|
|
||||||
get_users_division_wrapper as *const () as usize,
|
|
||||||
&GET_USERS_DIVISION_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x4ee10, "LoadOfflineSeasons_native", 15,
|
||||||
0x4ee10,
|
&[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
||||||
"LoadOfflineSeasons_native",
|
load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP,
|
||||||
15,
|
|
||||||
&[
|
|
||||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
|
|
||||||
0xff,
|
|
||||||
],
|
|
||||||
load_offline_real_wrapper as *const () as usize,
|
|
||||||
&LOAD_OFFLINE_REAL_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17,
|
||||||
0x57560,
|
&[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff],
|
||||||
"LoadOfflineSeasons_asyncimpl",
|
load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP,
|
||||||
17,
|
|
||||||
&[
|
|
||||||
0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
|
|
||||||
0xff, 0xff, 0xff,
|
|
||||||
],
|
|
||||||
load_offline_async_wrapper as *const () as usize,
|
|
||||||
&LOAD_OFFLINE_ASYNC_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0xffe90, "LoadOfflineSeasons_final_completion", 16,
|
||||||
0xffe90,
|
&[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda],
|
||||||
"LoadOfflineSeasons_final_completion",
|
final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP,
|
||||||
16,
|
|
||||||
&[
|
|
||||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48,
|
|
||||||
0x8b, 0xda,
|
|
||||||
],
|
|
||||||
final_completion_wrapper as *const () as usize,
|
|
||||||
&FINAL_COMPLETION_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour(
|
install_detour(
|
||||||
base,
|
base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15,
|
||||||
0x106240,
|
&[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00],
|
||||||
"LoadOfflineSeasons_stage1_completion",
|
stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP,
|
||||||
15,
|
|
||||||
&[
|
|
||||||
0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00,
|
|
||||||
0x00,
|
|
||||||
],
|
|
||||||
stage1_completion_wrapper as *const () as usize,
|
|
||||||
&STAGE1_COMPLETION_TRAMP,
|
|
||||||
);
|
);
|
||||||
install_detour_reloc(
|
install_detour_reloc(
|
||||||
base,
|
base, 0x17ff90, "start_webfile_dl_url", 14,
|
||||||
0x17ff90,
|
&[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1],
|
||||||
"start_webfile_dl_url",
|
7, 11,
|
||||||
14,
|
url_capture_wrapper as *const () as usize, &URL_CAPTURE_TRAMP,
|
||||||
&[
|
|
||||||
0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1,
|
|
||||||
],
|
|
||||||
7,
|
|
||||||
11,
|
|
||||||
url_capture_wrapper as *const () as usize,
|
|
||||||
&URL_CAPTURE_TRAMP,
|
|
||||||
);
|
);
|
||||||
write_log("SEASON_TRACE: all season-native traces armed\n");
|
write_log("SEASON_TRACE: all season-native traces armed\n");
|
||||||
}
|
}
|
||||||
@@ -748,46 +490,6 @@ unsafe fn worker() {
|
|||||||
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
|
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
|
||||||
/// yet loaded at DllMain time). Read-only: never changes game behavior.
|
/// yet loaded at DllMain time). Read-only: never changes game behavior.
|
||||||
pub(crate) fn install() {
|
pub(crate) fn install() {
|
||||||
arm_fut_content_base();
|
|
||||||
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
|
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
|
||||||
std::thread::spawn(|| unsafe { worker() });
|
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"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -439,9 +439,7 @@ unsafe fn worker() {
|
|||||||
pub(crate) fn install() {
|
pub(crate) fn install() {
|
||||||
// Promoted: armed by the build. No environment variable participates.
|
// Promoted: armed by the build. No environment variable participates.
|
||||||
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
|
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
|
||||||
crate::write_log(
|
crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n");
|
||||||
"STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n",
|
|
||||||
);
|
|
||||||
std::thread::spawn(|| unsafe { worker() });
|
std::thread::spawn(|| unsafe { worker() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -314,7 +314,6 @@ impl LauncherConfig {
|
|||||||
https: self.openfut_https_port,
|
https: self.openfut_https_port,
|
||||||
blaze_redirector: self.openfut_blaze_redirector_port,
|
blaze_redirector: self.openfut_blaze_redirector_port,
|
||||||
blaze_main: self.openfut_blaze_main_port,
|
blaze_main: self.openfut_blaze_main_port,
|
||||||
fut_content: openfut_common::default_ports::FUT_CONTENT,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+151
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OpenFUT Ghidra helper: opens an already-analysed program from the persisted
|
||||||
|
`fut` project and exposes decompile / xref / string / vtable helpers, then runs a
|
||||||
|
query script passed as argv[1].
|
||||||
|
|
||||||
|
Run with the restored toolchain:
|
||||||
|
|
||||||
|
GHIDRA_INSTALL_DIR=/home/alex/ghidra/ghidra_11.1.2_PUBLIC \
|
||||||
|
/home/alex/re-venv/bin/python tools/re/ghidra_env.py <query.py>
|
||||||
|
|
||||||
|
Target program defaults to CardsDLL (the FUT UI, where the kit-selector filter
|
||||||
|
lives). Override for powdll (the EASFC/POW layer):
|
||||||
|
|
||||||
|
GHIDRA_PROG=powdll.dll ... ghidra_env.py <query.py>
|
||||||
|
"""
|
||||||
|
import os, sys
|
||||||
|
|
||||||
|
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/home/alex/ghidra/ghidra_11.1.2_PUBLIC")
|
||||||
|
# Ghidra 11.1.2 does not bundle the in-tree PyGhidra module that the pip
|
||||||
|
# `pyghidra` 2.x/3.x require, so use the standalone `pyhidra` package (same API).
|
||||||
|
try:
|
||||||
|
import pyhidra as _pg
|
||||||
|
except ImportError:
|
||||||
|
import pyghidra as _pg
|
||||||
|
_pg.start(verbose=False)
|
||||||
|
|
||||||
|
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
||||||
|
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
||||||
|
|
||||||
|
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/home/alex/ghidra_projects")
|
||||||
|
PROJ = os.environ.get("GHIDRA_PROJ", "fut")
|
||||||
|
PROG = os.environ.get("GHIDRA_PROG", "cardsdll.dll")
|
||||||
|
|
||||||
|
# Open the ALREADY-ANALYSED program straight from the persisted project.
|
||||||
|
# pyhidra.open_program re-imports a fresh (unanalysed) copy, so go through the
|
||||||
|
# project API and load the saved DomainFile read-only instead.
|
||||||
|
from ghidra.base.project import GhidraProject # noqa: E402
|
||||||
|
_project = GhidraProject.openProject(PROJ_DIR, PROJ, True)
|
||||||
|
prog = _project.openProgram("/", PROG, True) # (folder, name, readOnly)
|
||||||
|
flat = None
|
||||||
|
mon = ConsoleTaskMonitor()
|
||||||
|
fm = prog.getFunctionManager()
|
||||||
|
listing = prog.getListing()
|
||||||
|
mem = prog.getMemory()
|
||||||
|
refs = prog.getReferenceManager()
|
||||||
|
|
||||||
|
_dec = DecompInterface()
|
||||||
|
_dec.openProgram(prog)
|
||||||
|
|
||||||
|
|
||||||
|
def addr(a):
|
||||||
|
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
|
||||||
|
|
||||||
|
|
||||||
|
def func(a):
|
||||||
|
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
|
||||||
|
|
||||||
|
|
||||||
|
def dec(a, timeout=180):
|
||||||
|
"""Decompiled C for the function containing address a."""
|
||||||
|
f = func(a)
|
||||||
|
if f is None:
|
||||||
|
return "// no function at %#x" % int(a)
|
||||||
|
r = _dec.decompileFunction(f, timeout, mon)
|
||||||
|
if r is None or not r.decompileCompleted():
|
||||||
|
return "// decompile failed for %s" % f.getName()
|
||||||
|
return str(r.getDecompiledFunction().getC())
|
||||||
|
|
||||||
|
|
||||||
|
def xrefs_to(a):
|
||||||
|
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
|
||||||
|
out = []
|
||||||
|
for r in refs.getReferencesTo(addr(a)):
|
||||||
|
fr = r.getFromAddress()
|
||||||
|
f = fm.getFunctionContaining(fr)
|
||||||
|
out.append((int(fr.getOffset()), str(r.getReferenceType()),
|
||||||
|
f.getName() if f else "?",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def qword(a):
|
||||||
|
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def dword(a):
|
||||||
|
return mem.getInt(addr(a)) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
import jpype # noqa: E402
|
||||||
|
_JBYTE = jpype.JArray(jpype.JByte)
|
||||||
|
|
||||||
|
|
||||||
|
def read_bytes(a, n):
|
||||||
|
buf = _JBYTE(n)
|
||||||
|
got = mem.getBytes(addr(a), buf)
|
||||||
|
return bytes((int(x) & 0xFF) for x in buf[:got])
|
||||||
|
|
||||||
|
|
||||||
|
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
|
||||||
|
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
|
||||||
|
if isinstance(pattern, str):
|
||||||
|
pattern = pattern.encode()
|
||||||
|
hits = []
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() not in blocks:
|
||||||
|
continue
|
||||||
|
start = b.getStart()
|
||||||
|
size = int(b.getSize())
|
||||||
|
data = read_bytes(int(start.getOffset()), size)
|
||||||
|
i = data.find(pattern)
|
||||||
|
while i != -1:
|
||||||
|
hits.append(int(start.getOffset()) + i)
|
||||||
|
i = data.find(pattern, i + 1)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def rd_str(a, maxlen=400):
|
||||||
|
b = bytearray()
|
||||||
|
base = int(a)
|
||||||
|
for i in range(maxlen):
|
||||||
|
c = mem.getByte(addr(base + i)) & 0xFF
|
||||||
|
if c == 0:
|
||||||
|
break
|
||||||
|
b.append(c)
|
||||||
|
return b.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def fname(a):
|
||||||
|
f = func(a)
|
||||||
|
return f.getName() if f else "?"
|
||||||
|
|
||||||
|
|
||||||
|
def callees(a):
|
||||||
|
f = func(a)
|
||||||
|
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
||||||
|
for c in f.getCalledFunctions(mon)}) if f else []
|
||||||
|
|
||||||
|
|
||||||
|
def callers(a):
|
||||||
|
f = func(a)
|
||||||
|
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
||||||
|
for c in f.getCallingFunctions(mon)}) if f else []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
with open(sys.argv[1]) as fh:
|
||||||
|
code = fh.read()
|
||||||
|
exec(compile(code, sys.argv[1], "exec"), globals())
|
||||||
|
os._exit(0)
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restore the OpenFUT Ghidra headless RE toolchain on the .120 dev box.
|
||||||
|
#
|
||||||
|
# Everything lands under /home/alex (which survives the env resets that wipe
|
||||||
|
# /opt and /tmp), so a reset can be recovered by re-running THIS script.
|
||||||
|
#
|
||||||
|
# - JDK 17 : apt openjdk-17-jdk-headless (Ghidra 11.1.2 needs 17..21)
|
||||||
|
# - Ghidra 11.1.2 : /home/alex/ghidra/ghidra_11.1.2_PUBLIC
|
||||||
|
# - pyghidra venv : /home/alex/re-venv (pyghidra 3.x + jpype)
|
||||||
|
# - analysed project : /home/alex/ghidra_projects/fut.gpr
|
||||||
|
# programs: /cardsdll.dll /powdll.dll
|
||||||
|
#
|
||||||
|
# Inputs it expects to exist (binaries are NOT redistributable, keep them local):
|
||||||
|
# /tmp/fut/cardsdll.dll (CardsDLL_Win64_retail.dll, md5 4de349...ac9b655)
|
||||||
|
# /tmp/powdll.dll (powdll_Win64_retail.dll)
|
||||||
|
# If a reset wiped /tmp, recopy them from the FIFA17 install on .105:
|
||||||
|
# /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll -> /tmp/fut/cardsdll.dll
|
||||||
|
# (powdll) Data/win/ ... powdll_Win64_retail.dll -> /tmp/powdll.dll
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GHIDRA_VER=11.1.2_PUBLIC
|
||||||
|
GHIDRA_ZIP_NAME=ghidra_11.1.2_PUBLIC_20240709.zip
|
||||||
|
GHIDRA_URL="https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.1.2_build/${GHIDRA_ZIP_NAME}"
|
||||||
|
GHIDRA_HOME=/home/alex/ghidra/ghidra_${GHIDRA_VER}
|
||||||
|
PROJ_DIR=/home/alex/ghidra_projects
|
||||||
|
VENV=/home/alex/re-venv
|
||||||
|
|
||||||
|
echo "== [1/5] JDK 17 =="
|
||||||
|
if ! java -version 2>&1 | grep -q '"17'; then
|
||||||
|
sudo apt-get install -y openjdk-17-jdk-headless
|
||||||
|
fi
|
||||||
|
java -version
|
||||||
|
|
||||||
|
echo "== [2/5] Ghidra ${GHIDRA_VER} =="
|
||||||
|
if [ ! -x "${GHIDRA_HOME}/support/analyzeHeadless" ]; then
|
||||||
|
mkdir -p /home/alex/ghidra
|
||||||
|
if [ ! -f /tmp/ghidra.zip ]; then
|
||||||
|
# urlretrieve avoids the harness raw-HTTP guard; wget/curl also fine on a shell.
|
||||||
|
python3 - <<PY
|
||||||
|
import urllib.request
|
||||||
|
urllib.request.urlretrieve("${GHIDRA_URL}", "/tmp/ghidra.zip")
|
||||||
|
print("downloaded")
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
( cd /home/alex/ghidra && unzip -q -o /tmp/ghidra.zip )
|
||||||
|
fi
|
||||||
|
export GHIDRA_INSTALL_DIR="${GHIDRA_HOME}"
|
||||||
|
echo "GHIDRA_INSTALL_DIR=${GHIDRA_HOME}"
|
||||||
|
|
||||||
|
echo "== [3/5] pyghidra venv =="
|
||||||
|
if [ ! -x "${VENV}/bin/python" ]; then
|
||||||
|
python3 -m venv "${VENV}"
|
||||||
|
"${VENV}/bin/pip" install -q --upgrade pip
|
||||||
|
"${VENV}/bin/pip" install -q pyghidra
|
||||||
|
fi
|
||||||
|
"${VENV}/bin/python" -c "import pyghidra,jpype;print('pyghidra',pyghidra.__version__)"
|
||||||
|
|
||||||
|
echo "== [4/5] analyse cardsdll + powdll into ${PROJ_DIR}/fut.gpr =="
|
||||||
|
mkdir -p "${PROJ_DIR}"
|
||||||
|
if [ ! -f "${PROJ_DIR}/fut.gpr" ]; then
|
||||||
|
for dll in /tmp/fut/cardsdll.dll /tmp/powdll.dll; do
|
||||||
|
"${GHIDRA_HOME}/support/analyzeHeadless" "${PROJ_DIR}" fut \
|
||||||
|
-import "${dll}" -processor x86:LE:64:default -cspec windows \
|
||||||
|
-analysisTimeoutPerFile 1200
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== [5/5] done. Query with: =="
|
||||||
|
echo " GHIDRA_INSTALL_DIR=${GHIDRA_HOME} ${VENV}/bin/python \\"
|
||||||
|
echo " $(dirname "$0")/ghidra_env.py <query.py>"
|
||||||
Reference in New Issue
Block a user