Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e83fe36c | |||
| c58e7326a1 | |||
| 5aec83ce97 |
@@ -207,7 +207,50 @@ Next: trace the FIFA-side FUT online-flow (what the game does after `GoOnline`
|
|||||||
and exactly which event/condition it waits on) — Ghidra on FIFA23.exe (import
|
and exactly which event/condition it waits on) — Ghidra on FIFA23.exe (import
|
||||||
saved at `C:\openfut\gh-proj`), or RE anadius's LSX event-send path. `TODO/CONFIRM`.
|
saved at `C:\openfut\gh-proj`), or RE anadius's LSX event-send path. `TODO/CONFIRM`.
|
||||||
|
|
||||||
### M2 directions (superseded — see above)
|
### M2 deeper finding — worker-thread + event architecture (confirmed)
|
||||||
|
A live call-stack capture from inside the GoOnline detour (manual stack scan,
|
||||||
|
bounded by `GetCurrentThreadStackLimits`) found **zero FIFA23.exe frames** and
|
||||||
|
showed `sp` sitting ~2.4 KB below the thread's stack top. So the handler runs at
|
||||||
|
the top of a short stack — i.e. on an **anadius worker thread** (IOCP/threadpool),
|
||||||
|
not FIFA's calling thread. anadius **queues** the GoOnline command and a worker
|
||||||
|
services it.
|
||||||
|
|
||||||
|
Combined with the retry behaviour, the architecture is now clear and three-way
|
||||||
|
corroborated: **FIFA calls `EbisuSDK::GoOnline` → anadius queues it → returns →
|
||||||
|
FIFA waits for an async "online established" event → anadius (offline-only, no
|
||||||
|
online-event code) never pushes it → timeout/retry.** No handler-response flip
|
||||||
|
can unblock this; the game waits on a *push* anadius never produces.
|
||||||
|
|
||||||
|
**Conclusion:** crossing this gate requires emulating the EA-app online-event
|
||||||
|
sequence (synthesize + inject the online-status event on the worker→game callback
|
||||||
|
/ LSX path, in EbisuSDK's expected format) — a research-grade emulation effort,
|
||||||
|
preceding the Blaze backend. The cheap in-process flips are exhausted.
|
||||||
|
|
||||||
|
Reaching the FIFA-side flow would need either: (a) locate `EbisuSDK::GoOnline` in
|
||||||
|
FIFA23.exe via anadius's detour table, then `callers` to the online-flow; or
|
||||||
|
(b) find anadius's LSX event-send path and reverse the online-event format. Both
|
||||||
|
are deep. `TODO/CONFIRM`.
|
||||||
|
|
||||||
|
### Path A attempt: reach the FIFA-side online-flow (blocked with live toolkit)
|
||||||
|
Goal: find `EbisuSDK::GoOnline` in FIFA23.exe → `callers` → the game's online-flow
|
||||||
|
→ read what event it waits on. Every angle our live-memory toolkit offers is
|
||||||
|
blocked:
|
||||||
|
- **String xref:** FIFA23.exe contains no `"GoOnline"` string (typed SDK call,
|
||||||
|
not a string-built command).
|
||||||
|
- **Call-stack from the handler:** GoOnline runs on an anadius worker thread; a
|
||||||
|
bounded stack scan finds zero FIFA frames.
|
||||||
|
- **Detour scan (`jmpscan`):** scanning FIFA23.exe for function-entry `E9` jumps
|
||||||
|
leaving the module yields ~3875 hits — overwhelmingly false positives, because
|
||||||
|
the 505 MB image is mostly embedded *data* (not code), and the real detours
|
||||||
|
don't cleanly cluster. (A .text-section-only scan would help but the chain
|
||||||
|
after — isolate GoOnline → callers → event format → emulate — remains long and
|
||||||
|
each link is gated by SDK abstraction / anadius indirection / worker threads.)
|
||||||
|
|
||||||
|
**Verdict:** crossing this gate to *playable* FUT is research-grade. It needs an
|
||||||
|
interactive disassembler (IDA/Ghidra GUI, human-driven) to trace the EbisuSDK
|
||||||
|
online-flow, and then a full EA-online + Blaze emulator. The live-memory toolkit
|
||||||
|
(string/xref/disasm/callers/read/jmpscan) has been exhausted for the FIFA side.
|
||||||
|
The clean-room spec (this document) is the finished, valuable artifact.
|
||||||
- Check whether the flags at `+0xCAB1A` / `+0xCAB1B` are settable via anadius
|
- Check whether the flags at `+0xCAB1A` / `+0xCAB1B` are settable via anadius
|
||||||
config / a hidden option (cheapest flip).
|
config / a hidden option (cheapest flip).
|
||||||
- Else out-detour `GetInternetConnectedState` in our `version.dll` to force the
|
- Else out-detour `GetInternetConnectedState` in our `version.dll` to force the
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ This document maps confirmed or suspected FIFA 23 FUT API endpoints to their Ope
|
|||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Unknown | `POST /matches/result` | ❌ | Needs capture |
|
| Unknown | `POST /matches/complete` | ❌ | Needs capture. Body must carry a `match_identity` (exactly-once key). `POST /matches/result` was removed — no transaction, no idempotency key. |
|
||||||
|
|
||||||
## Objectives
|
## Objectives
|
||||||
|
|
||||||
|
|||||||
+66
-1
@@ -31,7 +31,7 @@ use windows::Win32::System::ProcessStatus::{GetModuleInformation, MODULEINFO};
|
|||||||
use windows::Win32::System::SystemInformation::GetLocalTime;
|
use windows::Win32::System::SystemInformation::GetLocalTime;
|
||||||
use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
||||||
use windows::Win32::System::Threading::{
|
use windows::Win32::System::Threading::{
|
||||||
CreateThread, GetCurrentProcess, Sleep, THREAD_CREATION_FLAGS,
|
CreateThread, GetCurrentProcess, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,6 +185,7 @@ unsafe extern "system" fn hooked(
|
|||||||
unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 {
|
unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 {
|
||||||
// Connection capture first. Listen on the LSX port (gate 1, so the launcher
|
// Connection capture first. Listen on the LSX port (gate 1, so the launcher
|
||||||
// bootstrap succeeds) and on the redirect port (for external TLS/Blaze).
|
// bootstrap succeeds) and on the redirect port (for external TLS/Blaze).
|
||||||
|
store_exe_range();
|
||||||
start_listener(LSX_PORT, "LSX");
|
start_listener(LSX_PORT, "LSX");
|
||||||
start_listener(LOCAL_PORT, "BLZ");
|
start_listener(LOCAL_PORT, "BLZ");
|
||||||
hook_dns();
|
hook_dns();
|
||||||
@@ -418,6 +419,69 @@ unsafe fn install_detour_at(addr: usize, detour: *const (), slot: &AtomicUsize,
|
|||||||
// --- M1 read-only probe: anadius GoOnline handler -------------------------
|
// --- M1 read-only probe: anadius GoOnline handler -------------------------
|
||||||
|
|
||||||
static ORIG_GOONLINE: AtomicUsize = AtomicUsize::new(0);
|
static ORIG_GOONLINE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EXE_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EXE_SIZE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static STACK_LOGGED: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// Record FIFA23.exe's base + size so we can recognise its frames in a backtrace.
|
||||||
|
unsafe fn store_exe_range() {
|
||||||
|
if let Ok(h) = GetModuleHandleW(PCWSTR::null()) {
|
||||||
|
let mut mi = MODULEINFO::default();
|
||||||
|
if GetModuleInformation(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
h,
|
||||||
|
&mut mi,
|
||||||
|
core::mem::size_of::<MODULEINFO>() as u32,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
EXE_BASE.store(h.0 as usize, Ordering::SeqCst);
|
||||||
|
EXE_SIZE.store(mi.SizeOfImage as usize, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the raw stack for values that land in FIFA23.exe (the game-side
|
||||||
|
/// online-flow return addresses that called into GoOnline). Unwind-free, so it
|
||||||
|
/// survives the detour trampolines that break RtlCaptureStackBackTrace.
|
||||||
|
/// One-shot to avoid log spam.
|
||||||
|
unsafe fn log_fifa_callstack(tag: &str) {
|
||||||
|
if STACK_LOGGED.swap(1, Ordering::SeqCst) != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let base = EXE_BASE.load(Ordering::SeqCst);
|
||||||
|
let size = EXE_SIZE.load(Ordering::SeqCst);
|
||||||
|
if base == 0 || size == 0 {
|
||||||
|
log(&format!("{tag} stack scan skipped (exe range unknown)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Address of a local ~= current rsp; the stack grows down, so callers'
|
||||||
|
// return addresses sit at HIGHER addresses. Scan upward, but NEVER past the
|
||||||
|
// committed stack top (reading beyond it faults — that crashed the game).
|
||||||
|
let mut low: usize = 0;
|
||||||
|
let mut high: usize = 0;
|
||||||
|
GetCurrentThreadStackLimits(&mut low, &mut high);
|
||||||
|
let probe: usize = 0;
|
||||||
|
let sp = &probe as *const usize as usize;
|
||||||
|
let end = high; // scan the whole rest of the stack (committed, safe)
|
||||||
|
let mut line = format!(
|
||||||
|
"{tag} stack[low=0x{low:X} high=0x{high:X} sp=0x{sp:X}] FIFA23.exe refs:"
|
||||||
|
);
|
||||||
|
let mut count = 0;
|
||||||
|
let mut p = sp;
|
||||||
|
while p + 8 <= end {
|
||||||
|
let val = *(p as *const usize);
|
||||||
|
if val >= base && val < base + size {
|
||||||
|
line.push_str(&format!(" +0x{:X}", val - base));
|
||||||
|
count += 1;
|
||||||
|
if count >= 40 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p += 8;
|
||||||
|
}
|
||||||
|
log(&line);
|
||||||
|
}
|
||||||
|
|
||||||
/// M2 flip on anadius's GoOnline handler (anadius64.dll+0x2BB90). The original
|
/// M2 flip on anadius's GoOnline handler (anadius64.dll+0x2BB90). The original
|
||||||
/// handler is `mov rcx,rdx; lea r8,[+0xADD73]; lea rdx,[+0xADE64 = "0"]; call
|
/// handler is `mov rcx,rdx; lea r8,[+0xADD73]; lea rdx,[+0xADE64 = "0"]; call
|
||||||
@@ -425,6 +489,7 @@ static ORIG_GOONLINE: AtomicUsize = AtomicUsize::new(0);
|
|||||||
/// "0" (offline). We replicate it but pass "1" (+0xAF530 = the connected value),
|
/// "0" (offline). We replicate it but pass "1" (+0xAF530 = the connected value),
|
||||||
/// so GoOnline reports online, then return success (al=1).
|
/// so GoOnline reports online, then return success (al=1).
|
||||||
unsafe extern "system" fn hooked_goonline(_a: usize, b: usize, _c: usize, _d: usize) -> usize {
|
unsafe extern "system" fn hooked_goonline(_a: usize, b: usize, _c: usize, _d: usize) -> usize {
|
||||||
|
log_fifa_callstack("GoOnline");
|
||||||
let base = ANADIUS_BASE.load(Ordering::SeqCst);
|
let base = ANADIUS_BASE.load(Ordering::SeqCst);
|
||||||
if base != 0 {
|
if base != 0 {
|
||||||
log("FLIP GoOnline -> reporting online (\"1\")");
|
log("FLIP GoOnline -> reporting online (\"1\")");
|
||||||
|
|||||||
+9
-1
@@ -1651,12 +1651,20 @@ async function submitMatch() {
|
|||||||
const opponentName = currentOpponent?.opponent_name ?? `${diff.replace('_',' ')} Bot`;
|
const opponentName = currentOpponent?.opponent_name ?? `${diff.replace('_',' ')} Bot`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await api('POST', '/matches/result', {
|
// Each click is a distinct match, so it mints its own identity: the
|
||||||
|
// exactly-once route keys idempotency on it, and the old /matches/result
|
||||||
|
// path (no transaction, no identity) is closed. The dashboard drives Core's
|
||||||
|
// own match mode, so it opts into Core loan expiry and season progression.
|
||||||
|
const r = await api('POST', '/matches/complete', {
|
||||||
|
match_identity: `dashboard-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
result: gf > ga ? 'win' : gf === ga ? 'draw' : 'loss',
|
||||||
squad_id: 'dashboard',
|
squad_id: 'dashboard',
|
||||||
opponent_name: opponentName,
|
opponent_name: opponentName,
|
||||||
goals_for: gf,
|
goals_for: gf,
|
||||||
goals_against: ga,
|
goals_against: ga,
|
||||||
mode: 'squad_battles',
|
mode: 'squad_battles',
|
||||||
|
expire_loans: true,
|
||||||
|
advance_season: true,
|
||||||
});
|
});
|
||||||
const outcome = gf > ga ? 'Win' : gf === ga ? 'Draw' : 'Loss';
|
const outcome = gf > ga ? 'Win' : gf === ga ? 'Draw' : 'Loss';
|
||||||
const outcomeColor = gf > ga ? '#3fb950' : gf === ga ? '#8b949e' : '#f85149';
|
const outcomeColor = gf > ga ? '#3fb950' : gf === ga ? '#8b949e' : '#f85149';
|
||||||
|
|||||||
+8
-5
@@ -157,8 +157,10 @@ const EXACT: &[ExactRoute] = &[
|
|||||||
},
|
},
|
||||||
ExactRoute {
|
ExactRoute {
|
||||||
ea_method: "POST", ea_path: "/ut/game/fut/result",
|
ea_method: "POST", ea_path: "/ut/game/fut/result",
|
||||||
core_method: "POST", core_path: "/matches/result",
|
core_method: "POST", core_path: "/matches/complete",
|
||||||
notes: "FUT match result submit → Core match result",
|
notes: "FUT match result submit → Core exactly-once match completion. \
|
||||||
|
Core requires a match_identity on the body; /matches/result was \
|
||||||
|
removed because it had no transaction and no idempotency key.",
|
||||||
},
|
},
|
||||||
ExactRoute {
|
ExactRoute {
|
||||||
ea_method: "GET", ea_path: "/ut/game/fut/matches",
|
ea_method: "GET", ea_path: "/ut/game/fut/matches",
|
||||||
@@ -301,8 +303,9 @@ const EXACT: &[ExactRoute] = &[
|
|||||||
},
|
},
|
||||||
ExactRoute {
|
ExactRoute {
|
||||||
ea_method: "POST", ea_path: "/ut/game/fut/rivals/result",
|
ea_method: "POST", ea_path: "/ut/game/fut/rivals/result",
|
||||||
core_method: "POST", core_path: "/matches/result",
|
core_method: "POST", core_path: "/matches/complete",
|
||||||
notes: "FUT rivals match result → Core match result",
|
notes: "FUT rivals match result → Core exactly-once match completion \
|
||||||
|
(body must carry a match_identity).",
|
||||||
},
|
},
|
||||||
ExactRoute {
|
ExactRoute {
|
||||||
ea_method: "GET", ea_path: "/ut/game/fut/rivals/leaderboard",
|
ea_method: "GET", ea_path: "/ut/game/fut/rivals/leaderboard",
|
||||||
@@ -783,7 +786,7 @@ mod tests {
|
|||||||
fn test_rivals_result_maps() {
|
fn test_rivals_result_maps() {
|
||||||
let m = map_to_core("POST", "/ut/game/fut/rivals/result");
|
let m = map_to_core("POST", "/ut/game/fut/rivals/result");
|
||||||
assert!(m.is_some());
|
assert!(m.is_some());
|
||||||
assert_eq!(m.unwrap().core_path, "/matches/result");
|
assert_eq!(m.unwrap().core_path, "/matches/complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -183,6 +183,20 @@ fn main() {
|
|||||||
// callers <hex-addr | module+0xoffset> [pid|name]
|
// callers <hex-addr | module+0xoffset> [pid|name]
|
||||||
// Find direct call/jmp sites that target an address — walks up the call
|
// Find direct call/jmp sites that target an address — walks up the call
|
||||||
// graph (e.g. from a connect helper to the code that gates it).
|
// graph (e.g. from a connect helper to the code that gates it).
|
||||||
|
// jmpscan [module] [pid|name]
|
||||||
|
// Find E9 rel32 jumps inside a module whose target leaves the module —
|
||||||
|
// i.e. inline-detour entry points (MS Detours hooks). Default module:
|
||||||
|
// FIFA23.exe; targets reveal the detoured EbisuSDK functions.
|
||||||
|
Some("jmpscan") => {
|
||||||
|
let modname = args.get(1).cloned().unwrap_or_else(|| "FIFA23".to_string());
|
||||||
|
let pid = resolve_pid(args.get(2).map(|s| s.as_str()));
|
||||||
|
let process = open_for_read(pid);
|
||||||
|
let modules = enumerate_modules(pid);
|
||||||
|
run_jmpscan(process, &modules, &modname);
|
||||||
|
unsafe {
|
||||||
|
let _ = CloseHandle(process);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some("callers") => {
|
Some("callers") => {
|
||||||
let arg = match args.get(1) {
|
let arg = match args.get(1) {
|
||||||
Some(a) => a.clone(),
|
Some(a) => a.clone(),
|
||||||
@@ -471,6 +485,64 @@ fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) {
|
|||||||
/// Scan app-module executable memory for near `call`/`jmp` (E8/E9 + rel32)
|
/// Scan app-module executable memory for near `call`/`jmp` (E8/E9 + rel32)
|
||||||
/// instructions whose target is `target`. This walks UP the call graph — e.g.
|
/// instructions whose target is `target`. This walks UP the call graph — e.g.
|
||||||
/// from a connect helper to the code that decides whether to call it.
|
/// from a connect helper to the code that decides whether to call it.
|
||||||
|
/// Scan a module's executable memory for `E9 rel32` near-jumps whose target is
|
||||||
|
/// OUTSIDE the module — the signature of an inline detour (function entry patched
|
||||||
|
/// to jump to an external trampoline). Reports source -> target for each.
|
||||||
|
fn run_jmpscan(process: HANDLE, modules: &[ModuleInfo], modname: &str) {
|
||||||
|
let want = modname.to_ascii_lowercase();
|
||||||
|
let want = want.strip_suffix(".dll").unwrap_or(&want);
|
||||||
|
let want = want.strip_suffix(".exe").unwrap_or(want);
|
||||||
|
let m = match modules.iter().find(|m| {
|
||||||
|
let n = m.name.to_ascii_lowercase();
|
||||||
|
n.starts_with(want)
|
||||||
|
}) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
println!("module '{modname}' not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let base = m.base;
|
||||||
|
let end = m.base + m.size;
|
||||||
|
println!("== jmpscan {} [0x{base:X}..0x{end:X}] ==\n", m.name);
|
||||||
|
|
||||||
|
let allow = [(base, end)];
|
||||||
|
let mut hits: Vec<(usize, usize)> = Vec::new();
|
||||||
|
walk_regions(process, true, 4, Some(&allow), |chunk_base, bytes| {
|
||||||
|
if bytes.len() < 5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for i in 1..=bytes.len() - 5 {
|
||||||
|
// Real detours patch a function entry, which MSVC pads with int3
|
||||||
|
// (0xCC) just before it. Requiring that preceding 0xCC filters out
|
||||||
|
// the flood of 0xE9 data bytes that aren't real instructions.
|
||||||
|
if bytes[i] != 0xE9 || bytes[i - 1] != 0xCC {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rel = i32::from_le_bytes([bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4]]);
|
||||||
|
let src = chunk_base + i;
|
||||||
|
let tgt = (src + 5).wrapping_add(rel as i64 as usize);
|
||||||
|
if tgt < base || tgt >= end {
|
||||||
|
hits.push((src, tgt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hits.sort_unstable();
|
||||||
|
hits.dedup();
|
||||||
|
if hits.is_empty() {
|
||||||
|
println!(" no out-of-module E9 jumps found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!("-- {} out-of-module E9 jump(s) (detour entry candidates) --", hits.len());
|
||||||
|
for (src, tgt) in hits.iter().take(80) {
|
||||||
|
println!(" {} -> {}", describe(*src, modules), describe(*tgt, modules));
|
||||||
|
}
|
||||||
|
if hits.len() > 80 {
|
||||||
|
println!(" ... and {} more", hits.len() - 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_callers(process: HANDLE, modules: &[ModuleInfo], target: usize) {
|
fn run_callers(process: HANDLE, modules: &[ModuleInfo], target: usize) {
|
||||||
println!("== protossl-scan : callers of 0x{target:X} ==");
|
println!("== protossl-scan : callers of 0x{target:X} ==");
|
||||||
println!("({})\n", describe(target, modules));
|
println!("({})\n", describe(target, modules));
|
||||||
|
|||||||
Reference in New Issue
Block a user