2 Commits

Author SHA1 Message Date
funman300 4b1d5aa367 diag(fifa17): passive kit-selector data-flow trace (kit_trace)
Traces the client-side FUT pre-match kit path in CardsDLL: GetMatchKits_DP
gate (KITS_AVAILABLE), setAvailableKits (home/away list count), the kit-item
clone driver (item type/subid/teamid), and the local teamkits DB clone. Proves
in one operator match where the empty selector originates. Read-only; reuses
season_trace's passive-detour installers.
2026-08-20 16:55:50 +00:00
funman300 ed5c335c70 tooling(re): restore Ghidra 11.1.2 headless + pyhidra for cardsdll/powdll 2026-08-20 16:28:40 +00:00
12 changed files with 436 additions and 163 deletions
+1
View File
@@ -88,6 +88,7 @@ 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::kit_trace::install();
0
}
+184
View File
@@ -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() });
}
+2
View File
@@ -14,6 +14,8 @@ mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
mod hooks;
#[cfg(feature = "fifa17")]
mod kit_trace;
mod iat;
mod origin_spy;
#[cfg(feature = "probe")]
+5 -5
View File
@@ -31,14 +31,14 @@ static REPORTS: AtomicUsize = AtomicUsize::new(0);
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
unsafe fn rd_i32(addr: usize) -> Option<i32> {
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
unsafe fn rd_u8(addr: usize) -> Option<u8> {
pub(crate) unsafe fn rd_u8(addr: usize) -> Option<u8> {
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
}
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
unsafe fn rd_cstr(addr: usize, max: usize) -> String {
pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String {
if addr == 0 || !readable_range(addr, 1) {
return String::from("<unreadable>");
}
@@ -58,7 +58,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,
@@ -240,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
/// 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,
+1 -14
View File
@@ -25,7 +25,6 @@ use crate::config::LauncherConfig;
/// Accept only hostname/IP characters. These values come from config fields that
/// are ever only IPs or hostnames, so a surprising character is a bug — reject it
/// rather than try to escape it into an elevated shell command.
#[cfg(unix)]
fn safe_host(s: &str) -> anyhow::Result<&str> {
let t = s.trim();
if t.is_empty() {
@@ -42,7 +41,6 @@ fn safe_host(s: &str) -> anyhow::Result<&str> {
/// Build the privileged arming script. Pure and unit-tested; the effectful part
/// ([`arm`]) only validates config and hands this to the elevated runner.
#[cfg(unix)]
pub(crate) fn arming_script(
server: &str,
redirector_port: u16,
@@ -86,7 +84,6 @@ pub(crate) fn arming_script(
/// Human-readable list of what [`arm`] changed, in the order the script applies
/// it. Logged by the UI so the user sees exactly what was set — not just that
/// "something" ran under `pkexec`.
#[cfg(unix)]
pub(crate) fn arming_summary(
server: &str,
redirector_port: u16,
@@ -106,16 +103,6 @@ pub(crate) fn arming_summary(
/// Arm the client from config, under one elevated prompt. Requires the same
/// fields preflight reads; a missing one is a clear error, never a silent
/// loopback fallback. Returns the applied changes for the UI to surface.
/// On native Windows there is nothing to arm: routing is the `openfut.cfg` the
/// client-files step writes into the game directory (read by the version.dll
/// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns
/// no changes so the launch sequence treats client preparation as satisfied.
#[cfg(windows)]
pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
#[cfg(unix)]
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
@@ -141,7 +128,7 @@ pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
))
}
#[cfg(all(test, unix))]
#[cfg(test)]
mod tests {
use super::*;
+18 -28
View File
@@ -54,18 +54,13 @@ pub struct GameProfile {
impl GameProfile {
/// Whether this profile is filled in enough to launch from.
pub fn configured(&self) -> bool {
// Windows starts the executable directly (no runner); unix needs a
// runner such as umu-run.
#[cfg(windows)]
let runner_ok = true;
#[cfg(unix)]
let runner_ok = !self.runner.trim().is_empty();
runner_ok && !self.executable.trim().is_empty() && !self.game_dir.trim().is_empty()
!self.runner.trim().is_empty()
&& !self.executable.trim().is_empty()
&& !self.game_dir.trim().is_empty()
}
/// Reject a half-filled profile rather than launching something surprising.
pub fn validate(&self) -> Result<(), String> {
#[cfg(unix)]
if self.runner.trim().is_empty() {
return Err("Game profile has no runner (e.g. umu-run).".into());
}
@@ -75,28 +70,23 @@ impl GameProfile {
if self.game_dir.trim().is_empty() {
return Err("Game profile has no game directory.".into());
}
// Wine-prefix links and the DRM licence precondition only exist on the
// unix/Proton launch path; native Windows has neither.
#[cfg(unix)]
{
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile defines prefix links but no wine_prefix.".into());
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile defines prefix links but no wine_prefix.".into());
}
for l in &self.prefix_links {
if l.link.trim().is_empty() || l.target.trim().is_empty() {
return Err("Game profile has a prefix link with an empty link or target.".into());
}
for l in &self.prefix_links {
if l.link.trim().is_empty() || l.target.trim().is_empty() {
return Err("Game profile has a prefix link with an empty link or target.".into());
}
if std::path::Path::new(&l.link).is_absolute() {
return Err(format!(
"Prefix link {:?} must be relative to the Wine prefix.",
l.link
));
}
if std::path::Path::new(&l.link).is_absolute() {
return Err(format!(
"Prefix link {:?} must be relative to the Wine prefix.",
l.link
));
}
if let Some(lic) = &self.license {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
return Err("Game profile licence needs both a path and a generator.".into());
}
}
if let Some(lic) = &self.license {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
return Err("Game profile licence needs both a path and a generator.".into());
}
}
Ok(())
+2 -72
View File
@@ -24,13 +24,11 @@
//! falls back to it, so an existing working setup cannot be broken by upgrading.
use parking_lot::Mutex;
#[cfg(unix)]
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
#[cfg(unix)]
use std::time::{Duration, Instant};
use crate::config::GameProfile;
@@ -47,7 +45,6 @@ fn say(log: &Log, msg: impl Into<String>) {
/// Returns once the game process has been spawned; its output continues to
/// stream into `log` on background threads. `on_exit` fires when the process
/// ends, which is how the launch state machine leaves its Running state.
#[cfg(unix)]
pub fn launch(
profile: &GameProfile,
log: &Log,
@@ -99,62 +96,6 @@ pub fn launch(
Ok(())
}
/// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads
/// the `version.dll` hook from its own directory through the normal search
/// order), and no licence regeneration (the native loader handles DRM).
/// Routing is the `openfut.cfg` that the client-files step already wrote into
/// the game directory.
///
/// The launcher must itself be running elevated (its shortcut carries the
/// RunAsAdmin bit): the loader requires administrator rights, and a child
/// started with `CreateProcess` inherits the launcher's token instead of
/// raising its own UAC prompt.
#[cfg(windows)]
pub fn launch(
profile: &GameProfile,
log: &Log,
on_exit: impl FnOnce() + Send + 'static,
) -> anyhow::Result<()> {
profile.validate().map_err(anyhow::Error::msg)?;
let game_dir = PathBuf::from(&profile.game_dir);
if !game_dir.is_dir() {
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
}
let exe = game_dir.join(&profile.executable);
if !exe.is_file() {
anyhow::bail!("game executable not found: {}", exe.display());
}
let mut cmd = Command::new(&exe);
cmd.current_dir(&game_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &profile.env {
cmd.env(k, v);
}
say(
log,
format!(
"[launcher] launching {} (cwd {})",
exe.display(),
game_dir.display()
),
);
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?;
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
///
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
@@ -169,17 +110,13 @@ pub fn launch(
/// survives restarts and applies to every launch path, including Steam. This mirrors
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
/// environment) and what Proton itself already does in this prefix for other titles.
#[cfg(unix)]
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
#[cfg(unix)]
const HOOK_DLL_VALUE: &str = "version";
#[cfg(unix)]
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
/// repairs a prefix a player has reset or replaced.
#[cfg(unix)]
fn dll_override_args() -> [&'static str; 10] {
[
"reg",
@@ -201,7 +138,6 @@ fn dll_override_args() -> [&'static str; 10] {
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
/// error, since the player cannot act on the latter.
#[cfg(unix)]
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
if profile.wine_prefix.trim().is_empty() {
return;
@@ -227,7 +163,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) {
}
}
#[cfg(all(test, unix))]
#[cfg(test)]
mod override_tests {
use super::*;
@@ -268,7 +204,6 @@ mod override_tests {
///
/// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled.
#[cfg(unix)]
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
const HOOK: &str = "version=n,b";
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
@@ -282,7 +217,6 @@ fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
/// an existing link is replaced, so re-running is harmless.
#[cfg(unix)]
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
return Ok(());
@@ -323,7 +257,6 @@ fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// A crashed or failed launch deletes the licence, so this runs before every
/// launch rather than only on first setup — that is the behaviour the shell
/// script proved, and it is why a crash is normally self-healing on the next try.
#[cfg(unix)]
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let Some(lic) = &profile.license else {
return Ok(());
@@ -383,7 +316,6 @@ fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
/// and it is reproduced deliberately — the pattern is a Windows executable name,
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
/// that *can* match its own caller is a real hazard; this one cannot.)
#[cfg(unix)]
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
let _ = child.kill();
let _ = child.wait();
@@ -401,7 +333,6 @@ fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Lo
/// A relative licence path is taken as relative to the Wine prefix; an absolute
/// one is used as given.
#[cfg(unix)]
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() || prefix.trim().is_empty() {
@@ -414,7 +345,6 @@ fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
/// as useless as a missing one, and treating it as valid would skip the
/// regeneration that fixes it.
#[cfg(unix)]
fn non_empty_file(path: &Path) -> bool {
std::fs::metadata(path)
.map(|m| m.len() > 0)
@@ -451,7 +381,7 @@ pub fn stream(
});
}
#[cfg(all(test, unix))]
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LicenseCheck, PrefixLink};
-18
View File
@@ -22,7 +22,6 @@ use std::{
time::{Duration, Instant},
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use crate::fifa17_capability::{
@@ -434,21 +433,6 @@ impl ServiceSupervisor {
/// Start `service` only if it is not already usable. Never restarts a healthy
/// service, and never adopts a foreign one as ours.
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
// On native Windows the "companion services" are NOT separate processes:
// LSX/Origin login and the ProtoSSL cert path are provided in-process by
// stp-origin_emu.dll and the version.dll hook once the game runs. There is
// nothing for the launcher to spawn or supervise, so report ready.
#[cfg(windows)]
{
let _ = spec;
self.log.lock().push(format!(
"[launcher] {} is in-process on Windows (stp/hook) — nothing to start.",
service.label()
));
return Ok(Ensured::Reused);
}
#[cfg(unix)]
{
let runtime = self.observe(service);
if runtime.ready() {
self.log.lock().push(format!(
@@ -476,7 +460,6 @@ impl ServiceSupervisor {
.map_err(|e| e.to_string())?;
*self.slot(service) = ManagedService::from_child(child);
Ok(Ensured::Started)
}
}
/// Stop a service the launcher owns. A foreign process is reported, never
@@ -549,7 +532,6 @@ pub fn spawn(
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
}
// Put each companion in its own process group for lifecycle isolation.
#[cfg(unix)]
cmd.process_group(0);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
+1 -17
View File
@@ -31,7 +31,6 @@ use std::time::Duration;
use crate::config::LauncherConfig;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(unix)]
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -87,7 +86,6 @@ impl Check {
}
/// Run every applicable check. Order is the order the game exercises them.
#[cfg(unix)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(),
@@ -98,16 +96,6 @@ pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
]
}
/// On native Windows the client-preparation checks (ptrace_scope, the EA
/// redirector DNAT, `/etc/hosts`) do not apply: there is no host to arm and
/// routing is entirely the `openfut.cfg` the hook reads. Only the two the game
/// truly depends on remain: the backend is reachable and the deployed hook
/// config agrees with the launcher's settings.
#[cfg(windows)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![backend_reachable(cfg), hook_config(cfg)]
}
/// Checks that will stop the game working.
pub fn failures(checks: &[Check]) -> usize {
checks.iter().filter(|c| c.state == State::Fail).count()
@@ -125,7 +113,6 @@ pub fn warnings(checks: &[Check]) -> usize {
/// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it.
#[cfg(unix)]
fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
match std::fs::read_to_string(PTRACE_SCOPE) {
@@ -140,7 +127,6 @@ fn ptrace_scope() -> Check {
/// Reading `/proc` in a test would assert facts about the machine running the
/// suite rather than about this code — and left inline, "any value is fine"
/// was a mutation no test could catch.
#[cfg(unix)]
fn ptrace_verdict(raw: &str) -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
let v = raw.trim();
@@ -160,7 +146,6 @@ fn ptrace_verdict(raw: &str) -> Check {
///
/// This tests the *effect* rather than reading firewall rules, so it needs no
/// privilege and stays honest about what the game will actually experience.
#[cfg(unix)]
fn ea_redirect(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA redirector IP is redirected";
let ip = cfg.ea_redirect_probe_ip.trim();
@@ -199,7 +184,6 @@ fn ea_redirect(cfg: &LauncherConfig) -> Check {
/// So this is a real misconfiguration worth fixing and not a reason to expect
/// failure. Reporting it as fatal, and then being contradicted by a working
/// game, is how a checklist trains its user to ignore it.
#[cfg(unix)]
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA hostnames point at OpenFUT";
if cfg.ea_hostnames.is_empty() {
@@ -336,7 +320,7 @@ fn join(ips: &[IpAddr]) -> String {
.join(",")
}
#[cfg(all(test, unix))]
#[cfg(test)]
mod tests {
use super::*;
+1 -9
View File
@@ -299,13 +299,5 @@ fn install_style(ctx: &Context) {
v.widgets.open.rounding = radius;
style.visuals = v;
// egui 0.29 keeps a separate `Style` per theme (dark/light) and renders with
// whichever the theme preference resolves to. `set_style` touches only the
// currently-active theme, so a later switch to the other one would drop our
// named text styles ("Hero", "Subheading", …) and panic in `TextStyle::resolve`.
// Install the full style into BOTH themes and pin the preference to Dark so
// the branded look is stable regardless of the host's system theme.
ctx.set_style_of(egui::Theme::Dark, style.clone());
ctx.set_style_of(egui::Theme::Light, style);
ctx.set_theme(egui::ThemePreference::Dark);
ctx.set_style(style);
}
+151
View File
@@ -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)
+70
View File
@@ -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>"