2 Commits

Author SHA1 Message Date
funman300 07e83fe36c fix(bridge): submit matches to Core's exactly-once completion route
Core removed `POST /matches/result` as an economy path: it had no transaction
and no idempotency key, so it re-credited the same match on every call. The two
EA result routes and the dashboard now target `POST /matches/complete`.

The dashboard mints a fresh `match_identity` per submission — each click is a
distinct match — and opts into `expire_loans` / `advance_season`, which the old
route used to trigger implicitly. The mapper entries and the endpoint map record
that a body must carry `match_identity`; those EA mappings were already marked
"Needs capture", so the body shape stays unverified either way.
2026-08-21 04:48:07 +00:00
funman300 c58e7326a1 Path A: add jmpscan; document FIFA-side flow blocked with live toolkit
protossl-scan: add `jmpscan` (find function-entry E9 detours leaving a module).
Used to try to locate EbisuSDK::GoOnline in FIFA23.exe, but the 505MB image is
mostly embedded data => ~3875 false positives, no clean detour cluster. Combined
with the no-string and worker-thread blocks, the FIFA-side online-flow is not
cleanly reachable with the live-memory toolkit. Documented the verdict in
connection-gate-findings.md: playable FUT is research-grade (needs interactive
IDA/Ghidra GUI + full EA-online/Blaze emulator); the clean-room spec is the
finished deliverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:07:17 -07:00
5 changed files with 110 additions and 8 deletions
+20 -1
View File
@@ -231,7 +231,26 @@ 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 (b) find anadius's LSX event-send path and reverse the online-event format. Both
are deep. `TODO/CONFIRM`. are deep. `TODO/CONFIRM`.
### M2 directions (superseded — see above) ### 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
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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
View File
@@ -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]
+72
View File
@@ -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));