6 Commits

Author SHA1 Message Date
funman300 658dbe2f24 wip(bridge): retained FIFA request/response mapper + docker packaging
RETAINED PRE-EXISTING WIP (brought forward after verification, not authored
here, not production-ready). Substantive mapper.rs proxy expansion plus a
multi-stage Dockerfile/.dockerignore (rustls, self-signed cert at startup).
No secrets/captures staged. Preserved off the detached base c58e7326a1.
2026-08-20 16:08:24 +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
funman300 5aec83ce97 M2: confirm worker-thread + event-driven gate (architectural wall)
Add a bounded manual stack-scan in the GoOnline detour (FIFA-frame finder).
Result: zero FIFA23.exe frames, sp ~2.4KB below stack top => GoOnline runs on an
anadius worker thread (queued), not FIFA's thread. Three-way corroboration that
the gate is an async "online established" event anadius (offline-only) never
pushes; FIFA waits/retries. No handler-response flip can cross it. Documented in
connection-gate-findings.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:21:42 -07:00
funman300 e2c6ae8e5b M2: flips fire but gate is async event (not a poll)
- Hook: force GetInternetConnectedState->connected (flags +0xCAB1A/+0xCAB1B) and
  flip GoOnline to report "1" (+0xAF530) instead of "0" (+0xADE64).
- protossl-scan: add `read` mode (hex/ascii dump at addr|module+off).
- Finding: neither flip unblocks the game; it keeps retrying GoOnline every ~7s.
  The FUT-online flow is event-driven -- the game waits for an async "online
  established" event anadius (offline-only) never pushes. Documented in
  connection-gate-findings.md. Next: trace FIFA-side flow (Ghidra) / anadius
  event-send to inject the online event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:45:15 -07:00
funman300 550b23bb26 M1 COMPLETE: gate named = GetInternetConnectedState @ anadius64.dll+0x27790
Found without Ghidra, via anadius's LSX command-registration table (handler list
is offset-by-one from the names; verified by +0x27060 = GetProfile). The gate is
GetInternetConnectedState @ anadius64.dll+0x27790: its LSX "connected" attribute
= (byte[+0xCAB1B]||byte[+0xCAB1A]) ? str(+0xAF530) : str(+0xADE64); both flags
default 0 -> reports offline. That is why the client aborts at "connecting".

M1 answers: (1) ProtoSSLConnect not reached (gate upstream); (2) connection-state
function named; (3) single actionable gate (token already provided by anadius
FakeAuth / GoOnline passes). M2 (next session) = make GetInternetConnectedState
report connected, then watch for the real Blaze connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:27:20 -07:00
funman300 4c57cf571c M1 probe: GoOnline passes, gate is downstream (token/status)
Add a read-only detour on anadius's GoOnline handler (anadius64.dll+0x2BB90).
Result: the game calls GoOnline during the "connecting" attempt and anadius
returns success, yet no Blaze connection follows. The gate is therefore
downstream of GoOnline -- the auth-token (GetAuthCode) and/or the "online
established" status callback. Recorded in connection-gate-findings.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:46:27 -07:00
15 changed files with 877 additions and 195 deletions
+12
View File
@@ -0,0 +1,12 @@
target/
**/target/
*.db
*.db-shm
*.db-wal
.env
.env.local
Dockerfile
.dockerignore
.git
.gitignore
captures/
+2
View File
@@ -1,5 +1,7 @@
# OpenFUT Bridge — Claude Code project context
> ⚠️ **Legacy / historical (FIFA 23).** OpenFUT's working target is now **FIFA 17**; this bridge (FIFA 23 integration) is superseded per `../docs/PROJECT_STATE.md`. Kept for reference. Canonical server: `../fifa17-recon/docker/fifa17-python` (`docker compose up -d`).
Read this first. It's the standing context for every task in this project. Each
working session will give you ONE bounded task plus a verification clause; this
file is the background that stays true across all of them.
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
authors = ["OpenFUT Contributors"]
description = "FIFA 23 integration layer and reverse-engineering proxy"
license = "MIT"
repository = "https://github.com/openfut/openfut-bridge"
repository = "https://git.aleshym.co/funman300/OpenFUT-Bridge.git"
[lib]
name = "openfut_bridge"
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# ---- OpenFUT Bridge: FIFA integration / reverse-engineering proxy ----
# Multi-stage build; slim Debian runtime. reqwest uses rustls-tls and the
# bridge self-signs its own cert at startup (rcgen) — no OpenSSL needed.
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock* ./
RUN mkdir -p src src/bin \
&& echo 'fn main() {}' > src/main.rs \
&& echo '' > src/lib.rs \
&& echo 'fn main() {}' > src/bin/replay.rs \
&& cargo build --release --bin openfut-bridge 2>/dev/null || true
RUN rm -rf src
COPY src ./src
RUN touch src/main.rs src/lib.rs \
&& cargo build --release --bin openfut-bridge
# ---- Runtime ----
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --uid 10002 --create-home --home-dir /app openfut
WORKDIR /app
COPY --from=builder /build/target/release/openfut-bridge /usr/local/bin/openfut-bridge
# Captures are written at runtime — keep them on a named volume.
RUN mkdir -p /app/captures && chown openfut:openfut /app/captures
USER openfut
ENV BRIDGE_LISTEN_ADDR=0.0.0.0:8443 \
CORE_URL=http://core:8080 \
CAPTURES_DIR=/app/captures \
PLACEHOLDER_MODE=true \
TLS_ENABLED=true \
RUST_LOG=openfut_bridge=info,tower_http=info
EXPOSE 8443
VOLUME ["/app/captures"]
# The bridge serves TLS; -k because the cert is self-signed. /_bridge/health
# is the bridge's own admin endpoint (not a proxied FIFA route).
HEALTHCHECK --interval=15s --timeout=4s --start-period=8s --retries=5 \
CMD curl -fsSk https://127.0.0.1:8443/_bridge/health || exit 1
ENTRYPOINT ["openfut-bridge"]
+141 -4
View File
@@ -119,7 +119,144 @@ only — no EA source.
- `TODO/CONFIRM` the exact abort point (no-token vs state-says-offline vs both) —
needs Ghidra-level control-flow tracing.
### Recommendation
Load `FIFA23.exe` into **Ghidra** to (a) name the connection-state getter
precisely and (b) confirm the gate mechanism, then return to `protossl-scan` +
the hook to map those addresses onto the live process and install the M2 hook.
### Dynamic probe result (read-only) — `GoOnline` is NOT the gate
A read-only detour on anadius's `GoOnline` handler (`anadius64.dll+0x2BB90`)
shows the game **does** call `GoOnline` during the "connecting" attempt (incl.
on the Ultimate Team click) and anadius returns success — **yet no Blaze
connection follows** (still zero external `CONNECT`/DNS).
Therefore the gate is **downstream of `GoOnline`**: the game decides to go
online and the request is accepted, then it aborts at the **auth-token step
(`GetAuthCode`) and/or while waiting for the "online established" status
callback** (`ONLINE_STATUS_EVENT`), and times out into offline.
This sharpens the two-gate picture: `GoOnline` passes; the real blocker is the
**token / online-status step**. `TODO/CONFIRM` which (token-missing vs
status-never-fires) — via a `GetAuthCode` probe and/or Ghidra.
### Recommendation / next
Name the downstream gate. Two complementary routes:
- **Dynamic:** probe anadius's `GetAuthCode` handler (does it return a token or
fail?) — distinguishes token-gate from status-callback.
- **Static (Ghidra):** xref the `GoOnline` / `GetAuthCode` / `ONLINE_STATUS_EVENT`
strings in `FIFA23.exe` to find the FUT online-flow code that issues `GoOnline`
then waits for the token/status, and decompile it. FIFA isn't ASLR'd, so Ghidra
addresses (image base `0x140000000`) map 1:1 to our recorded offsets.
---
## M1 COMPLETE — the gate is `GetInternetConnectedState`
Found without Ghidra, by reading anadius's LSX **command-registration** function
(`anadius64.dll+0x14C0`), which lists every command-name → handler inline. NB the
`lea rax,[handler]` is **offset by one** from the `lea rdx,[name]` in that listing
(verified empirically: `+0x27060` decompiles to `GetProfile` — it builds a
`GetProfileResponse` for persona "fun"). Corrected handler map:
| anadius LSX command | handler (anadius64.dll + …) |
|---|---|
| GetProfile | 0x27060 |
| **GetInternetConnectedState** | **0x27790** |
| GoOnline | 0x2BB90 |
| GetAuthCode | 0x2BBC0 |
### The gate, named: `GetInternetConnectedState` @ `anadius64.dll+0x27790`
It serializes an LSX `InternetConnectedState` response with a `connected`
attribute whose value is:
```
connected = (byte[+0xCAB1B] != 0 || byte[+0xCAB1A] != 0) ? str(+0xAF530)
: str(+0xADE64)
```
Both flag bytes **default to 0**, so it reports the offline value (`+0xADE64`).
That is precisely why the client sits at "connecting to the EA Servers" and falls
back offline.
### Answers to the three M1 points
1. **ProtoSSLConnect reached on the abort? NO — gate upstream.** Confirmed.
2. **The connection-state function:** `GetInternetConnectedState` @
`anadius64.dll+0x27790`. Decision = the two-flag branch above.
3. **Gate count: ONE actionable gate.** The auth token is already satisfied —
`GoOnline` (`+0x2BB90`) is called during the attempt and returns success, and
anadius provides a fake auth code; the blocker is purely the connection-state.
So M2 = make `GetInternetConnectedState` report **connected** (then the game
proceeds with its existing token).
### M2 attempt results — the gate is an async EVENT, not a poll
Tried (read/write, EAAC neutralized):
- Forced `GetInternetConnectedState` → connected (set flags +0xCAB1A/+0xCAB1B → value
`"1"`; strings confirmed: +0xADE64 = `"0"` offline, +0xAF530 = `"1"` connected).
- Flipped `GoOnline` to report `"1"` (replicated its builder `+0x25BE0` with the
connected string instead of `"0"`).
**Neither made the game proceed.** Both handlers fire, no crash — but the game
**keeps retrying `GoOnline` every ~7s** and never attempts the Blaze connect.
That retry-on-timeout pattern means the FUT-online flow is **event-driven**: the
game submits `GoOnline`, gets success, then **waits for an async "online
established" event** (ONLINE_STATUS_EVENT-class) that anadius — being offline-only
— never pushes (the only `<Event sender="EALS">` it ever sends is the Challenge
handshake). So flipping poll/return values can't unblock it.
**Implication:** getting past "connecting" requires **emulating the EA-app online
event sequence** the game waits for (inject the online-status event over LSX, in
the format/order EbisuSDK expects), not a single function flip. This is a
substantially deeper task (and precedes the Blaze backend emulation).
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
saved at `C:\openfut\gh-proj`), or RE anadius's LSX event-send path. `TODO/CONFIRM`.
### 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
config / a hidden option (cheapest flip).
- Else out-detour `GetInternetConnectedState` in our `version.dll` to force the
`+0xAF530` ("connected") path.
- Then watch for the client to attempt the real Blaze connect (our `connect`
redirect + ProtoSSL hook capture the first plaintext — milestone M3).
- `TODO/CONFIRM`: exact text of the offline/connected value strings
(`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after
the state flips.
+173 -5
View File
@@ -31,7 +31,7 @@ use windows::Win32::System::ProcessStatus::{GetModuleInformation, MODULEINFO};
use windows::Win32::System::SystemInformation::GetLocalTime;
use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
use windows::Win32::System::Threading::{
CreateThread, GetCurrentProcess, Sleep, THREAD_CREATION_FLAGS,
CreateThread, GetCurrentProcess, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
};
// ---------------------------------------------------------------------------
@@ -185,21 +185,38 @@ unsafe extern "system" fn hooked(
unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 {
// Connection capture first. Listen on the LSX port (gate 1, so the launcher
// bootstrap succeeds) and on the redirect port (for external TLS/Blaze).
store_exe_range();
start_listener(LSX_PORT, "LSX");
start_listener(LOCAL_PORT, "BLZ");
hook_dns();
hook_winsock_data();
hook_connect();
// Then the plaintext-capture detour on _ProtoSSLSendPacket.
// Then the plaintext-capture detour on _ProtoSSLSendPacket, plus the
// read-only anadius GoOnline probe (M1). Retry until both are installed.
let mut send_done = false;
let mut anadius_done = false;
for _ in 0..60 {
if let Some(addr) = find_send_packet() {
install_hook(addr);
if !send_done {
if let Some(addr) = find_send_packet() {
install_hook(addr);
send_done = true;
}
}
if !anadius_done && hook_anadius_probes() {
anadius_done = true;
}
if send_done && anadius_done {
return 0;
}
Sleep(1000);
}
log("ERROR: _ProtoSSLSendPacket pattern not found after 60s");
if !send_done {
log("ERROR: _ProtoSSLSendPacket pattern not found after 60s");
}
if !anadius_done {
log("ERROR: anadius64.dll not loaded after 60s; GoOnline probe not installed");
}
0
}
@@ -377,6 +394,12 @@ unsafe fn install_detour(
return;
}
};
install_detour_at(addr, detour, slot, label);
}
/// Install an inline detour at a raw address (for non-exported targets such as
/// internal anadius handlers located by module+offset).
unsafe fn install_detour_at(addr: usize, detour: *const (), slot: &AtomicUsize, label: &str) {
let d = match RawDetour::new(addr as *const (), detour) {
Ok(d) => d,
Err(e) => {
@@ -393,6 +416,151 @@ unsafe fn install_detour(
log(&format!("{label} hook installed"));
}
// --- M1 read-only probe: anadius GoOnline handler -------------------------
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
/// handler is `mov rcx,rdx; lea r8,[+0xADD73]; lea rdx,[+0xADE64 = "0"]; call
/// +0x25BE0; mov al,1` — i.e. it builds its ErrorSuccess response with the value
/// "0" (offline). We replicate it but pass "1" (+0xAF530 = the connected value),
/// so GoOnline reports online, then return success (al=1).
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);
if base != 0 {
log("FLIP GoOnline -> reporting online (\"1\")");
let builder: unsafe extern "system" fn(usize, usize, usize) -> usize =
core::mem::transmute(base + 0x25BE0);
// 0x25BE0(rcx = handler's rdx, rdx = "1", r8 = +0xADD73)
builder(b, base + 0xAF530, base + 0xADD73);
return 1;
}
let orig = ORIG_GOONLINE.load(Ordering::SeqCst);
if orig != 0 {
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(orig);
f(_a, b, _c, _d)
} else {
0
}
}
// --- M2 flip: force GetInternetConnectedState to report "connected" --------
static ORIG_ICS: AtomicUsize = AtomicUsize::new(0);
static ANADIUS_BASE: AtomicUsize = AtomicUsize::new(0);
/// anadius's GetInternetConnectedState handler (anadius64.dll+0x27790) builds an
/// LSX response whose `connected` value is:
/// (byte[+0xCAB1B] || byte[+0xCAB1A]) ? connected : offline
/// Both default to 0 → offline → the game aborts at "connecting". We force both
/// flags to 1 before the original runs, so it builds the "connected" response.
unsafe extern "system" fn hooked_ics(a: usize, b: usize, c: usize, d: usize) -> usize {
let base = ANADIUS_BASE.load(Ordering::SeqCst);
if base != 0 {
core::ptr::write_volatile((base + 0xCAB1A) as *mut u8, 1u8);
core::ptr::write_volatile((base + 0xCAB1B) as *mut u8, 1u8);
}
log("FLIP GetInternetConnectedState -> forcing connected (flags set)");
let orig = ORIG_ICS.load(Ordering::SeqCst);
if orig != 0 {
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(orig);
f(a, b, c, d)
} else {
0
}
}
/// Resolve anadius64.dll's runtime base, detour the GoOnline probe, and install
/// the M2 GetInternetConnectedState flip. Returns false if anadius isn't loaded.
unsafe fn hook_anadius_probes() -> bool {
let base = match GetModuleHandleW(PCWSTR(wide("anadius64.dll").as_ptr())) {
Ok(m) => m.0 as usize,
Err(_) => return false, // not loaded yet
};
ANADIUS_BASE.store(base, Ordering::SeqCst);
log(&format!("anadius64.dll base = 0x{base:X}"));
install_detour_at(
base + 0x2BB90,
hooked_goonline as *const (),
&ORIG_GOONLINE,
"PROBE anadius GoOnline @ +0x2BB90",
);
install_detour_at(
base + 0x27790,
hooked_ics as *const (),
&ORIG_ICS,
"FLIP anadius GetInternetConnectedState @ +0x27790",
);
true
}
/// Detour the DNS resolvers so we see every hostname lookup.
unsafe fn hook_dns() {
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
+8 -2
View File
@@ -59,9 +59,15 @@ async fn main() -> ExitCode {
for capture in &captures {
let result = replay_one(&client, bridge_url, capture).await;
match result {
Ok(status) => println!(" [{}] {} {}{status}", capture.id, capture.method, capture.path),
Ok(status) => println!(
" [{}] {} {}{status}",
capture.id, capture.method, capture.path
),
Err(e) => {
eprintln!(" [{}] {} {} → ERROR: {e}", capture.id, capture.method, capture.path);
eprintln!(
" [{}] {} {} → ERROR: {e}",
capture.id, capture.method, capture.path
);
failures += 1;
}
}
+9 -6
View File
@@ -102,7 +102,7 @@ async fn serve_tls(
let listener = tokio::net::TcpListener::bind(addr).await?;
loop {
let (tcp, _peer) = listener.accept().await?;
let (tcp, peer) = listener.accept().await?;
let acceptor = acceptor.clone();
let app = app.clone();
@@ -110,18 +110,21 @@ async fn serve_tls(
let tls_stream = match acceptor.accept(tcp).await {
Ok(s) => s,
Err(e) => {
tracing::warn!("TLS handshake failed: {e}");
if e.kind() == std::io::ErrorKind::UnexpectedEof {
tracing::debug!(%peer, "TCP probe closed before ClientHello");
} else {
tracing::warn!(%peer, "TLS handshake failed: {e}");
}
return;
}
};
let io = TokioIo::new(tls_stream);
let svc = hyper::service::service_fn(
move |req: hyper::Request<hyper::body::Incoming>| {
let svc =
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let app = app.clone();
async move { app.oneshot(req.map(axum::body::Body::new)).await }
},
);
});
if let Err(e) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
.serve_connection(io, svc)
+269 -133
View File
@@ -22,363 +22,495 @@ struct ExactRoute {
const EXACT: &[ExactRoute] = &[
// ── Auth ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "POST", ea_path: "/ut/auth",
core_method: "POST", core_path: "/auth/local",
ea_method: "POST",
ea_path: "/ut/auth",
core_method: "POST",
core_path: "/auth/local",
notes: "FUT login → Core local auth",
},
// ── Profile / Settings ───────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/user/settings",
core_method: "GET", core_path: "/profile",
ea_method: "GET",
ea_path: "/ut/game/fut/user/settings",
core_method: "GET",
core_path: "/profile",
notes: "FUT user settings → Core profile",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/user/settings",
core_method: "PUT", core_path: "/settings",
ea_method: "PUT",
ea_path: "/ut/game/fut/user/settings",
core_method: "PUT",
core_path: "/settings",
notes: "FUT update settings → Core settings",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/user/accountinfo",
core_method: "GET", core_path: "/profile",
ea_method: "GET",
ea_path: "/ut/game/fut/user/accountinfo",
core_method: "GET",
core_path: "/profile",
notes: "FUT account info → Core profile",
},
// ── Club / Mass info ──────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/usermassinfo",
core_method: "GET", core_path: "/club",
ea_method: "GET",
ea_path: "/ut/game/fut/usermassinfo",
core_method: "GET",
core_path: "/club",
notes: "FUT mass info (club + coins) → Core club",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/club",
core_method: "GET", core_path: "/club",
ea_method: "GET",
ea_path: "/ut/game/fut/club",
core_method: "GET",
core_path: "/club",
notes: "FUT club info → Core club",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/club",
core_method: "PUT", core_path: "/club",
ea_method: "PUT",
ea_path: "/ut/game/fut/club",
core_method: "PUT",
core_path: "/club",
notes: "FUT update club → Core update club",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/club/stats",
core_method: "GET", core_path: "/statistics",
ea_method: "GET",
ea_path: "/ut/game/fut/club/stats",
core_method: "GET",
core_path: "/statistics",
notes: "FUT club stats → Core statistics",
},
// ── Cards / Collection ────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/item",
core_method: "GET", core_path: "/collection",
ea_method: "GET",
ea_path: "/ut/game/fut/item",
core_method: "GET",
core_path: "/collection",
notes: "FUT collection → Core owned cards",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/item/search",
core_method: "GET", core_path: "/cards",
ea_method: "GET",
ea_path: "/ut/game/fut/item/search",
core_method: "GET",
core_path: "/cards",
notes: "FUT item search → Core card catalogue (query params forwarded)",
},
// ── Squad ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/active",
core_method: "GET", core_path: "/squad",
ea_method: "GET",
ea_path: "/ut/game/fut/squad/active",
core_method: "GET",
core_path: "/squad",
notes: "FUT active squad → Core squad",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/0",
core_method: "GET", core_path: "/squad",
ea_method: "GET",
ea_path: "/ut/game/fut/squad/0",
core_method: "GET",
core_path: "/squad",
notes: "FUT squad by slot 0 → Core squad (first squad)",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/squad/active",
core_method: "POST", core_path: "/squad",
ea_method: "PUT",
ea_path: "/ut/game/fut/squad/active",
core_method: "POST",
core_path: "/squad",
notes: "FUT save squad → Core save squad",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/chemistry",
core_method: "GET", core_path: "/squad",
ea_method: "GET",
ea_path: "/ut/game/fut/squad/chemistry",
core_method: "GET",
core_path: "/squad",
notes: "FUT squad chemistry → Core squad (chemistry included in response)",
},
// ── Packs ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/store/packdetails",
core_method: "GET", core_path: "/packs",
ea_method: "GET",
ea_path: "/ut/game/fut/store/packdetails",
core_method: "GET",
core_path: "/packs",
notes: "FUT pack store → Core pack list",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/store/purchase",
core_method: "POST", core_path: "/packs/buy",
ea_method: "POST",
ea_path: "/ut/game/fut/store/purchase",
core_method: "POST",
core_path: "/packs/buy",
notes: "FUT pack purchase → Core pack buy",
},
// ── Transfer Market ───────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/transfermarket",
core_method: "GET", core_path: "/market",
ea_method: "GET",
ea_path: "/ut/game/fut/transfermarket",
core_method: "GET",
core_path: "/market",
notes: "FUT transfer market search → Core NPC market",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/trade/bid",
core_method: "POST", core_path: "/market/buy",
ea_method: "POST",
ea_path: "/ut/game/fut/trade/bid",
core_method: "POST",
core_path: "/market/buy",
notes: "FUT bid/buy now → Core market buy",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trade/watchlist",
core_method: "GET", core_path: "/market",
ea_method: "GET",
ea_path: "/ut/game/fut/trade/watchlist",
core_method: "GET",
core_path: "/market",
notes: "FUT watchlist → Core market (approximation)",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trade/tradepile",
core_method: "GET", core_path: "/market/my-listings",
ea_method: "GET",
ea_path: "/ut/game/fut/trade/tradepile",
core_method: "GET",
core_path: "/market/my-listings",
notes: "FUT trade pile (my listings) → Core my-listings",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/auctionhouse",
core_method: "POST", core_path: "/market/sell",
ea_method: "POST",
ea_path: "/ut/game/fut/auctionhouse",
core_method: "POST",
core_path: "/market/sell",
notes: "FUT list card on AH → Core sell card",
},
// ── Objectives ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/objectives",
core_method: "GET", core_path: "/objectives",
ea_method: "GET",
ea_path: "/ut/game/fut/objectives",
core_method: "GET",
core_path: "/objectives",
notes: "FUT objectives → Core objectives list",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/objectives/claim",
core_method: "POST", core_path: "/objectives/claim",
ea_method: "POST",
ea_path: "/ut/game/fut/objectives/claim",
core_method: "POST",
core_path: "/objectives/claim",
notes: "FUT claim objective → Core claim",
},
// ── Events ────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/events",
core_method: "GET", core_path: "/events",
ea_method: "GET",
ea_path: "/ut/game/fut/events",
core_method: "GET",
core_path: "/events",
notes: "FUT events → Core events list",
},
// ── Matches / Squad Battles ───────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squadbattle/opponent",
core_method: "GET", core_path: "/matches/opponent",
ea_method: "GET",
ea_path: "/ut/game/fut/squadbattle/opponent",
core_method: "GET",
core_path: "/matches/opponent",
notes: "Squad battles opponent → Core match opponent generator",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/result",
core_method: "POST", core_path: "/matches/result",
ea_method: "POST",
ea_path: "/ut/game/fut/result",
core_method: "POST",
core_path: "/matches/result",
notes: "FUT match result submit → Core match result",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/matches",
core_method: "GET", core_path: "/matches",
ea_method: "GET",
ea_path: "/ut/game/fut/matches",
core_method: "GET",
core_path: "/matches",
notes: "FUT match history → Core match list",
},
// ── SBC ──────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/sbc",
core_method: "GET", core_path: "/sbc",
ea_method: "GET",
ea_path: "/ut/game/fut/sbc",
core_method: "GET",
core_path: "/sbc",
notes: "FUT SBC list → Core SBC list",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/sbc/challenges",
core_method: "GET", core_path: "/sbc",
ea_method: "GET",
ea_path: "/ut/game/fut/sbc/challenges",
core_method: "GET",
core_path: "/sbc",
notes: "FUT SBC challenges → Core SBC list",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/sbc/submission",
core_method: "POST", core_path: "/sbc/submit",
ea_method: "POST",
ea_path: "/ut/game/fut/sbc/submission",
core_method: "POST",
core_path: "/sbc/submit",
notes: "FUT SBC submission → Core SBC submit",
},
// ── Draft ─────────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/draft",
core_method: "GET", core_path: "/draft/squad",
ea_method: "GET",
ea_path: "/ut/game/fut/draft",
core_method: "GET",
core_path: "/draft/squad",
notes: "FUT draft view → Core draft squad",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/draft/new",
core_method: "POST", core_path: "/draft/start",
ea_method: "POST",
ea_path: "/ut/game/fut/draft/new",
core_method: "POST",
core_path: "/draft/start",
notes: "FUT new draft → Core draft start",
},
// ── Division / Season ─────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/division/rivals",
core_method: "GET", core_path: "/division",
ea_method: "GET",
ea_path: "/ut/game/fut/division/rivals",
core_method: "GET",
core_path: "/division",
notes: "FUT Division Rivals status → Core division",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/division/rivals/claim",
core_method: "POST", core_path: "/rivals/claim-weekly",
ea_method: "POST",
ea_path: "/ut/game/fut/division/rivals/claim",
core_method: "POST",
core_path: "/rivals/claim-weekly",
notes: "FUT rivals weekly claim → Core rivals claim",
},
// ── FUT Champions ─────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/champs",
core_method: "GET", core_path: "/fut-champs",
ea_method: "GET",
ea_path: "/ut/game/fut/champs",
core_method: "GET",
core_path: "/fut-champs",
notes: "FUT Champions status → Core fut-champs",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/champs/start",
core_method: "POST", core_path: "/fut-champs/start",
ea_method: "POST",
ea_path: "/ut/game/fut/champs/start",
core_method: "POST",
core_path: "/fut-champs/start",
notes: "FUT Champions start week → Core fut-champs start",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/champs/history",
core_method: "GET", core_path: "/fut-champs/history",
ea_method: "GET",
ea_path: "/ut/game/fut/champs/history",
core_method: "GET",
core_path: "/fut-champs/history",
notes: "FUT Champions history → Core history",
},
// ── Card Upgrades ─────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/chemistry",
core_method: "GET", core_path: "/chemistry-styles",
ea_method: "GET",
ea_path: "/ut/game/fut/chemistry",
core_method: "GET",
core_path: "/chemistry-styles",
notes: "FUT chemistry styles → Core chemistry styles list",
},
// ── Notifications ─────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/notification",
core_method: "GET", core_path: "/notifications",
ea_method: "GET",
ea_path: "/ut/game/fut/notification",
core_method: "GET",
core_path: "/notifications",
notes: "FUT notifications → Core notifications",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/notifications",
core_method: "GET", core_path: "/notifications",
ea_method: "GET",
ea_path: "/ut/game/fut/notifications",
core_method: "GET",
core_path: "/notifications",
notes: "FUT notifications (plural form) → Core notifications",
},
// ── Squad list ────────────────────────────────────────────────────────────
// ── Division / Season history / Leaderboard ───────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/division/history",
core_method: "GET", core_path: "/division/history",
ea_method: "GET",
ea_path: "/ut/game/fut/division/history",
core_method: "GET",
core_path: "/division/history",
notes: "FUT division history → Core season history",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/division/leaderboard",
core_method: "GET", core_path: "/division/leaderboard",
ea_method: "GET",
ea_path: "/ut/game/fut/division/leaderboard",
core_method: "GET",
core_path: "/division/leaderboard",
notes: "FUT division leaderboard → Core seeded NPC leaderboard",
},
// ── Market trade history ──────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trade/history",
core_method: "GET", core_path: "/market/trade-history",
ea_method: "GET",
ea_path: "/ut/game/fut/trade/history",
core_method: "GET",
core_path: "/market/trade-history",
notes: "FUT trade history → Core market trade history",
},
// ── Daily check-in ────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/dailyObjective",
core_method: "GET", core_path: "/club/checkin",
ea_method: "GET",
ea_path: "/ut/game/fut/dailyObjective",
core_method: "GET",
core_path: "/club/checkin",
notes: "FUT daily objective status → Core check-in status",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/dailyObjective/claim",
core_method: "POST", core_path: "/club/checkin",
ea_method: "POST",
ea_path: "/ut/game/fut/dailyObjective/claim",
core_method: "POST",
core_path: "/club/checkin",
notes: "FUT daily objective claim → Core check-in claim",
},
// ── Club milestones ───────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/milestones",
core_method: "GET", core_path: "/club/milestones",
ea_method: "GET",
ea_path: "/ut/game/fut/milestones",
core_method: "GET",
core_path: "/club/milestones",
notes: "FUT milestones → Core club milestones",
},
// ── Squad list ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
core_method: "GET", core_path: "/squads",
ea_method: "GET",
ea_path: "/ut/game/fut/squad/list",
core_method: "GET",
core_path: "/squads",
notes: "FUT squad list → Core all squads",
},
ExactRoute {
ea_method: "DELETE", ea_path: "/ut/game/fut/squad/active",
core_method: "DELETE", core_path: "/squad",
ea_method: "DELETE",
ea_path: "/ut/game/fut/squad/active",
core_method: "DELETE",
core_path: "/squad",
notes: "FUT delete active squad → Core delete squad (best-effort)",
},
// ── Achievements / Trophies ───────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trophies",
core_method: "GET", core_path: "/achievements",
ea_method: "GET",
ea_path: "/ut/game/fut/trophies",
core_method: "GET",
core_path: "/achievements",
notes: "FUT trophies → Core achievements",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/trophy",
core_method: "GET", core_path: "/achievements",
ea_method: "GET",
ea_path: "/ut/game/fut/trophy",
core_method: "GET",
core_path: "/achievements",
notes: "FUT trophy (singular) → Core achievements",
},
// ── Rivals extra endpoints ────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/rivals/rank",
core_method: "GET", core_path: "/division",
ea_method: "GET",
ea_path: "/ut/game/fut/rivals/rank",
core_method: "GET",
core_path: "/division",
notes: "FUT rivals rank → Core division",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/rivals/result",
core_method: "POST", core_path: "/matches/result",
ea_method: "POST",
ea_path: "/ut/game/fut/rivals/result",
core_method: "POST",
core_path: "/matches/result",
notes: "FUT rivals match result → Core match result",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/rivals/leaderboard",
core_method: "GET", core_path: "/statistics",
ea_method: "GET",
ea_path: "/ut/game/fut/rivals/leaderboard",
core_method: "GET",
core_path: "/statistics",
notes: "FUT rivals leaderboard → Core statistics (offline approximation)",
},
// ── Objectives sub-groups ─────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/objectives/group",
core_method: "GET", core_path: "/objectives",
ea_method: "GET",
ea_path: "/ut/game/fut/objectives/group",
core_method: "GET",
core_path: "/objectives",
notes: "FUT objectives group → Core objectives",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/objectives/daily",
core_method: "GET", core_path: "/objectives",
ea_method: "GET",
ea_path: "/ut/game/fut/objectives/daily",
core_method: "GET",
core_path: "/objectives",
notes: "FUT daily objectives → Core objectives",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/objectives/weekly",
core_method: "GET", core_path: "/objectives",
ea_method: "GET",
ea_path: "/ut/game/fut/objectives/weekly",
core_method: "GET",
core_path: "/objectives",
notes: "FUT weekly objectives → Core objectives",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/objectives/group/claim",
core_method: "POST", core_path: "/objectives/claim",
ea_method: "POST",
ea_path: "/ut/game/fut/objectives/group/claim",
core_method: "POST",
core_path: "/objectives/claim",
notes: "FUT objectives group claim → Core objectives claim",
},
// ── Catalogue / Card search ───────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/catalogue/item",
core_method: "GET", core_path: "/cards",
ea_method: "GET",
ea_path: "/ut/game/fut/catalogue/item",
core_method: "GET",
core_path: "/cards",
notes: "FUT catalogue items → Core card catalogue",
},
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/catalogue",
core_method: "GET", core_path: "/cards",
ea_method: "GET",
ea_path: "/ut/game/fut/catalogue",
core_method: "GET",
core_path: "/cards",
notes: "FUT catalogue → Core card catalogue",
},
// ── Store / Pricing ───────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/store/pricetiers",
core_method: "GET", core_path: "/packs/store",
ea_method: "GET",
ea_path: "/ut/game/fut/store/pricetiers",
core_method: "GET",
core_path: "/packs/store",
notes: "FUT price tiers → Core pack store definitions",
},
// ── Consumables / Chemistry / Fitness ─────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/consumables",
core_method: "GET", core_path: "/chemistry-styles",
ea_method: "GET",
ea_path: "/ut/game/fut/consumables",
core_method: "GET",
core_path: "/chemistry-styles",
notes: "FUT consumables → Core chemistry styles (approximation)",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/fitness",
core_method: "GET", core_path: "/club",
ea_method: "POST",
ea_path: "/ut/game/fut/fitness",
core_method: "GET",
core_path: "/club",
notes: "FUT fitness apply → Core club (placeholder, fitness not tracked)",
},
// ── Loan items ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/loanitems",
core_method: "GET", core_path: "/collection",
ea_method: "GET",
ea_path: "/ut/game/fut/loanitems",
core_method: "GET",
core_path: "/collection",
notes: "FUT loan items → Core collection (client filters is_loan)",
},
// ── Customization / Kit ───────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/customization",
core_method: "GET", core_path: "/settings",
ea_method: "GET",
ea_path: "/ut/game/fut/customization",
core_method: "GET",
core_path: "/settings",
notes: "FUT customization → Core settings",
},
ExactRoute {
ea_method: "PUT", ea_path: "/ut/game/fut/customization",
core_method: "PUT", core_path: "/settings",
ea_method: "PUT",
ea_path: "/ut/game/fut/customization",
core_method: "PUT",
core_path: "/settings",
notes: "FUT save customization → Core save settings",
},
// ── Active messages / MOTD ────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/activeMessage",
core_method: "GET", core_path: "/notifications",
ea_method: "GET",
ea_path: "/ut/game/fut/activeMessage",
core_method: "GET",
core_path: "/notifications",
notes: "FUT active messages → Core notifications (mapped to nearest equivalent)",
},
];
@@ -732,7 +864,11 @@ mod tests {
#[test]
fn test_total_exact_routes_count() {
assert!(EXACT.len() >= 61, "expected at least 61 exact mappings, got {}", EXACT.len());
assert!(
EXACT.len() >= 61,
"expected at least 61 exact mappings, got {}",
EXACT.len()
);
}
// ── Phase 22 new mappings ─────────────────────────────────────────────────
+7 -2
View File
@@ -107,8 +107,13 @@ pub async fn catch_all_handler(
.unwrap_or_default()
);
let mut capture =
CapturedRequest::new(&method, &path, query.as_deref(), headers.clone(), body_str.clone());
let mut capture = CapturedRequest::new(
&method,
&path,
query.as_deref(),
headers.clone(),
body_str.clone(),
);
let (response_body, status_code): (Value, u16) =
if let Some(mapping) = map_to_core(&method, &path) {
+21 -21
View File
@@ -158,10 +158,12 @@ pub async fn get_capture_diff(
captures.iter().find(|c| c.id == id)
};
let cap_a = find(&params.a)
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
let cap_b = find(&params.b)
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
let cap_a = find(&params.a).ok_or_else(|| {
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a))
})?;
let cap_b = find(&params.b).ok_or_else(|| {
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b))
})?;
// Structural JSON diff of request bodies
let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref());
@@ -184,13 +186,17 @@ pub async fn get_capture_diff(
.headers
.iter()
.filter_map(|(k, va)| {
cap_b.headers.iter().find(|(kb, _)| kb == k).and_then(|(_, vb)| {
if va != vb {
Some(json!({ "header": k, "a": va, "b": vb }))
} else {
None
}
})
cap_b
.headers
.iter()
.find(|(kb, _)| kb == k)
.and_then(|(_, vb)| {
if va != vb {
Some(json!({ "header": k, "a": va, "b": vb }))
} else {
None
}
})
})
.collect();
@@ -226,10 +232,8 @@ fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
return json!({ "same": true });
}
// Try to parse as JSON objects for a structured diff
let va: Option<serde_json::Map<String, Value>> =
serde_json::from_str(a_str).ok();
let vb: Option<serde_json::Map<String, Value>> =
serde_json::from_str(b_str).ok();
let va: Option<serde_json::Map<String, Value>> = serde_json::from_str(a_str).ok();
let vb: Option<serde_json::Map<String, Value>> = serde_json::from_str(b_str).ok();
match (va, vb) {
(Some(ma), Some(mb)) => {
@@ -282,8 +286,7 @@ pub async fn post_replay_capture(
State(state): State<ProxyState>,
Path(capture_id): Path<String>,
) -> BridgeResult<Json<Value>> {
let captures =
load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let capture = captures
.iter()
@@ -305,10 +308,7 @@ pub async fn post_replay_capture(
)
.await
{
Ok((body, status)) => (
status,
crate::shaper::shape_response(&m.core_path, body),
),
Ok((body, status)) => (status, crate::shaper::shape_response(&m.core_path, body)),
Err(e) => (
502u16,
json!({ "error": format!("core forwarding failed: {e}") }),
+6 -1
View File
@@ -69,7 +69,12 @@ pub async fn get_guide(State(state): State<ProxyState>) -> Html<String> {
.unwrap_or("localhost");
let tls = state.config.tls_enabled;
let scheme = if tls { "https" } else { "http" };
let port = state.config.listen_addr.split(':').next_back().unwrap_or("8080");
let port = state
.config
.listen_addr
.split(':')
.next_back()
.unwrap_or("8080");
let bridge_url = format!("{scheme}://{host}:{port}");
let core_url = &state.config.core_url;
+1 -4
View File
@@ -157,10 +157,7 @@ fn shape_squad_response(core: Value) -> Value {
/// Wraps Core market response in the FUT transfer market envelope.
fn shape_market_response(core: Value) -> Value {
let listings = core.get("listings").cloned().unwrap_or(json!([]));
let total = core
.get("total")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let total = core.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
json!({
"auctionInfo": listings,
+59 -16
View File
@@ -1,3 +1,4 @@
use axum::routing::{any, delete, get, post};
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -9,7 +10,6 @@ use openfut_bridge::{
proxy::ProxyState,
routes,
};
use axum::routing::{any, delete, get, post};
use tower::ServiceExt;
// ── Unit tests ────────────────────────────────────────────────────────────────
@@ -96,9 +96,15 @@ fn build_test_app() -> axum::Router {
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
.route("/_bridge/captures", get(routes::admin::get_captures))
.route("/_bridge/captures", delete(routes::admin::delete_captures))
.route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints))
.route(
"/_bridge/unknown",
get(routes::admin::get_unknown_endpoints),
)
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
.route("/_bridge/captures/:id/replay", post(routes::admin::post_replay_capture))
.route(
"/_bridge/captures/:id/replay",
post(routes::admin::post_replay_capture),
)
.fallback(any(openfut_bridge::proxy::catch_all_handler))
.with_state(state)
}
@@ -107,11 +113,18 @@ fn build_test_app() -> axum::Router {
async fn test_bridge_health_endpoint() {
let app = build_test_app();
let resp = app
.oneshot(Request::builder().uri("/_bridge/health").body(Body::empty()).unwrap())
.oneshot(
Request::builder()
.uri("/_bridge/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["service"], "openfut-bridge");
@@ -131,7 +144,9 @@ async fn test_bridge_placeholder_mode_returns_ok() {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert!(json["openfut_note"].is_string());
@@ -150,7 +165,9 @@ async fn test_bridge_captures_endpoint_returns_list() {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["captures"].is_array());
assert!(json["total"].is_number());
@@ -170,7 +187,9 @@ async fn test_bridge_delete_captures() {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["deleted"].is_number());
}
@@ -188,7 +207,9 @@ async fn test_bridge_status_endpoint() {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["endpoints"].is_array());
}
@@ -208,15 +229,25 @@ async fn test_dashboard_returns_html() {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp.headers().get("content-type").unwrap().to_str().unwrap();
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.contains("text/html"), "expected text/html, got {ct}");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("OpenFUT Dashboard"), "title missing");
assert!(html.contains("const CORE ="), "core URL injection missing");
// Placeholder URL injected in test mode
assert!(html.contains("127.0.0.1:9999"), "core URL not injected");
assert!(!html.contains("{{CORE_URL}}"), "template placeholder was not replaced");
assert!(
!html.contains("{{CORE_URL}}"),
"template placeholder was not replaced"
);
}
#[tokio::test]
@@ -231,7 +262,9 @@ async fn test_dashboard_contains_key_sections() {
)
.await
.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
// Verify all major tab sections are present
assert!(html.contains("tab-club"), "club tab missing");
@@ -250,8 +283,14 @@ async fn test_dashboard_contains_key_sections() {
assert!(html.contains("tab-statistics"), "statistics tab missing");
assert!(html.contains("tab-catalog"), "card catalog tab missing");
assert!(html.contains("tab-settings"), "settings tab missing");
assert!(html.contains("tab-notifications"), "notifications tab missing");
assert!(html.contains("tab-achievements"), "achievements tab missing");
assert!(
html.contains("tab-notifications"),
"notifications tab missing"
);
assert!(
html.contains("tab-achievements"),
"achievements tab missing"
);
}
#[tokio::test]
@@ -272,5 +311,9 @@ async fn test_tls_cert_generation() {
async fn test_tls_acceptor_construction() {
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert().unwrap();
let acceptor = openfut_bridge::tls::make_tls_acceptor(&cert_pem, &key_pem);
assert!(acceptor.is_ok(), "acceptor construction failed: {:?}", acceptor.err());
assert!(
acceptor.is_ok(),
"acceptor construction failed: {:?}",
acceptor.err()
);
}
+116
View File
@@ -136,9 +136,67 @@ fn main() {
let _ = CloseHandle(process);
}
}
// read <hex-addr | module+0xoffset> [len] [pid|name]
// Dump raw bytes (hex + ASCII) at an address — to read short strings /
// data the disassembler doesn't resolve.
Some("read") => {
let arg = match args.get(1) {
Some(a) => a.clone(),
None => {
eprintln!("Usage: protossl-scan read <hex-addr | module+0xoffset> [len] [pid]");
std::process::exit(1);
}
};
let len = args
.get(2)
.and_then(|s| s.parse::<usize>().ok().or_else(|| parse_hex(s)))
.unwrap_or(64);
let pid = resolve_pid(args.get(3).map(|s| s.as_str()));
let process = open_for_read(pid);
let modules = enumerate_modules(pid);
let target = match resolve_target(&arg, &modules) {
Some(t) => t,
None => {
eprintln!("Could not resolve '{arg}'");
std::process::exit(1);
}
};
println!("== read {} len {len} ==", describe(target, &modules));
match read_bytes(process, target, len) {
Some(b) => {
for off in (0..b.len()).step_by(16) {
let row = &b[off..(off + 16).min(b.len())];
let hexp: String = row.iter().map(|x| format!("{x:02X} ")).collect();
let asc: String = row
.iter()
.map(|&x| if (0x20..=0x7e).contains(&x) { x as char } else { '.' })
.collect();
println!(" 0x{:016X} {:<48} {}", target + off, hexp, asc);
}
}
None => println!(" (could not read memory at that address)"),
}
unsafe {
let _ = CloseHandle(process);
}
}
// callers <hex-addr | module+0xoffset> [pid|name]
// 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).
// 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") => {
let arg = match args.get(1) {
Some(a) => a.clone(),
@@ -427,6 +485,64 @@ fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) {
/// 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.
/// 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) {
println!("== protossl-scan : callers of 0x{target:X} ==");
println!("({})\n", describe(target, modules));