Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 658dbe2f24 | |||
| c58e7326a1 | |||
| 5aec83ce97 | |||
| e2c6ae8e5b | |||
| 550b23bb26 | |||
| 4c57cf571c | |||
| 55c9757e74 | |||
| 49b3030e34 | |||
| 15a6f877ee | |||
| ce8a3b32ec |
@@ -0,0 +1,12 @@
|
||||
target/
|
||||
**/target/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
.env.local
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
captures/
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -0,0 +1,262 @@
|
||||
# Connection-gate findings (clean-room)
|
||||
|
||||
**Status:** observed behaviour only — derived from running the client and reading
|
||||
the repack's own files. No EA source used. Unconfirmed values are marked
|
||||
`TODO/CONFIRM` rather than guessed.
|
||||
|
||||
## Components (observed)
|
||||
|
||||
| Component | Role |
|
||||
|---|---|
|
||||
| `FIFA23.exe` | Game. Statically links EA's **Ebisu SDK** (online) and **DirtySDK/ProtoSSL** (transport). Loads at fixed base `0x140000000` — no ASLR seen across launches. |
|
||||
| `_ProtoSSLSendPacket` @ `FIFA23.exe+0xEFA530` | DirtySDK TLS record assembler — plaintext in, encrypts in place. Already located + hooked. |
|
||||
| `anadius64.dll` | anadius EA-app/Origin **LSX server** emulator (ASLR'd). Provides offline DRM / entitlements / persona so the game *launches*; deliberately keeps it **offline**. |
|
||||
| `FakeEAACLauncher` | Anti-cheat bypass only. Irrelevant to the online gate. |
|
||||
|
||||
## Observed online-startup flow
|
||||
|
||||
1. Ebisu SDK opens LSX socket(s) to anadius; a **challenge/response handshake**
|
||||
completes — captured XML: `<Challenge>` / `<ChallengeResponse>` /
|
||||
`<ChallengeAccepted>`, `sender="EALS"`, `ContentId 16115019`.
|
||||
2. River/PIN telemetry `<session type=boot>` is emitted.
|
||||
3. **No further socket traffic.** Clicking Ultimate Team → "connecting to the EA
|
||||
Servers…" → **zero** network attempts (no Blaze DNS, no `connect`, nothing on
|
||||
`send`/`recv`/`WSASend`/`WSARecv`) → falls back to offline.
|
||||
|
||||
## Conclusion: the decision is upstream and in-process
|
||||
|
||||
- The online/offline verdict is delivered through anadius's **in-process
|
||||
detoured Ebisu calls**, not socket messages. (No `GetAuthCode`/`GoOnline`/
|
||||
entitlement query appears on any socket, sync or async.)
|
||||
- The game therefore **never reaches DirtySDK/ProtoSSL for Blaze** — it aborts
|
||||
before any `ProtoSSLConnect(gosredirector…)`. The gate is an **Ebisu-SDK
|
||||
connection-state / online-session check upstream of the transport.**
|
||||
- `ONLINE_ACCESS` is a `Blaze::Nucleus::EntitlementType` (enum value `1`).
|
||||
anadius's `GoOnline` LSX handler (`anadius64.dll+0x2BB90`) is a success stub;
|
||||
`anadius64.dll+0xB6B1` is a large entitlement/profile builder that *does*
|
||||
reference `ONLINE_ACCESS`. Reverse-engineering that entitlement-status logic
|
||||
is the **wrong fight** (see strategy).
|
||||
|
||||
## Strategy (decided)
|
||||
|
||||
Do **not** make anadius report "online." anadius exists to keep the game
|
||||
offline, and it can't be removed without breaking launch/DRM. Instead:
|
||||
|
||||
> Let the game run its **real** online flow and answer that flow ourselves via
|
||||
> the hook + brain. Target the Ebisu connection-state check the FUT-entry path
|
||||
> polls; make the game *pass* it so it issues the real `ProtoSSLConnect` to the
|
||||
> Blaze redirector → our redirect + ProtoSSL hook capture the real frames.
|
||||
|
||||
This deletes the "reverse-engineer anadius's entitlement logic" problem.
|
||||
|
||||
## Next RE step (converges with the ProtoSSL hook)
|
||||
|
||||
1. Locate and **read-only hook `ProtoSSLConnect`** (same DirtySDK module and
|
||||
technique that found `_ProtoSSLSendPacket`). Confirms the game never
|
||||
initiates the Blaze connection (pins the gate strictly upstream) and gives us
|
||||
the exact symbol that will flip from "never called" to "called" on success.
|
||||
2. Locate the Ebisu **connection-state getter** the FUT-entry / "connecting" UI
|
||||
polls. Candidate string anchors (observed): `ONLINE_STATUS_EVENT`,
|
||||
`GoOnline` result handling, the "connecting to the EA Servers" UI trigger.
|
||||
3. Determine the minimal intervention to make that getter report **connected**
|
||||
(out-hook it in our `version.dll`, winning over anadius's detour) so the game
|
||||
proceeds to `ProtoSSLConnect`.
|
||||
- `TODO/CONFIRM`: whether out-hooking the getter is sufficient, or secondary
|
||||
checks (auth-token presence) also gate the attempt.
|
||||
|
||||
---
|
||||
|
||||
## M1 results (read-only investigation)
|
||||
|
||||
Method: `protossl-scan` (xref / disasm / callers, app-module-restricted) against
|
||||
the live client, plus the existing `connect`/DNS/LSX hooks. Observed behaviour
|
||||
only — no EA source.
|
||||
|
||||
### Point 1 — is `ProtoSSLConnect` reached on the "connecting" abort? **NO — gate is upstream. CONFIRMED.**
|
||||
- The existing `connect` / `GetAddrInfoW` / `getaddrinfo` hooks show the client
|
||||
makes **zero** network attempts during the "connecting to the EA Servers"
|
||||
abort: no DNS for any `gosredirector.*` host, no `connect` to any external
|
||||
address.
|
||||
- The DirtySDK/ProtoSSL transport is therefore never reached — the abort is
|
||||
entirely upstream and in-process.
|
||||
- `ProtoSSLConnect` has no debug string and sits behind the DirtySDK API, so an
|
||||
explicit hook on it isn't needed to prove this; the behavioural evidence is
|
||||
conclusive. `TODO/CONFIRM`: locate `ProtoSSLConnect` by signature later, as a
|
||||
positive M2 trip-wire (it should fire once we flip the gate).
|
||||
|
||||
### Surface mapped (clean-room)
|
||||
- **Redirector host table** (`FIFA23.exe` .rdata, pointer table @ `+0x83FC858`):
|
||||
`spring18.gosredirector.{sdev,stest,scert}.ea.com` + production
|
||||
`spring18.gosredirector.ea.com`.
|
||||
- **Redirector request/response config** (same region): `X-BLAZE-ERRORCODE`,
|
||||
**`Authorization:`**, `<errorCode>` — the redirector request carries an
|
||||
**Authorization header (a Nucleus token)**.
|
||||
- **Enum-name tables** (serialization only, NOT decision code): `ONLINE_ACCESS`
|
||||
(`Blaze::Nucleus::EntitlementType` = 1), `ONLINE_STATUS_EVENT`, … These are
|
||||
reflection tables keyed by enum *value*; string-xref of them is a dead end for
|
||||
the decision (the decision compares values, not strings).
|
||||
|
||||
### Point 2 — name the connection-state function: **PARTIAL.**
|
||||
- The exact connection-state decision is behind heavy C++ vtable indirection
|
||||
(the redirector config's two code pointers resolved to a virtual-dispatch
|
||||
thunk @ `FIFA23.exe+0x27BD4C0` and a `ret 0` stub @ `+0x4F3CBC0`). The
|
||||
memory-scan toolkit (string / pattern / xref / callers) cannot efficiently
|
||||
navigate this.
|
||||
- **Pinning the exact function needs a static disassembler with decompilation
|
||||
(Ghidra / IDA) on `FIFA23.exe`.** `protossl-scan` remains the bridge to the
|
||||
live process (mapping static addresses to the ASLR'd runtime, confirming hits,
|
||||
installing hooks).
|
||||
- `TODO/CONFIRM`: name the connection-state getter by module+offset via Ghidra.
|
||||
|
||||
### Point 3 — gate count: **TWO gates (state + token). Evidence-backed.**
|
||||
- The online-connect path **reads/requires an auth (Nucleus) token**: the
|
||||
redirector request carries an `Authorization:` header, and the SDK surface has
|
||||
`GetAuthCode` / `FakeAuth` / `<GameToken>`. So it is **not** a single
|
||||
connection-state boolean flip — even with the state forced "online", the client
|
||||
needs a valid token to build the redirector request.
|
||||
- **Conclusion for M2: plan for two gates** — (a) the connection-state decision,
|
||||
and (b) supplying an auth token the client accepts.
|
||||
- `TODO/CONFIRM` the exact abort point (no-token vs state-says-offline vs both) —
|
||||
needs Ghidra-level control-flow tracing.
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,91 @@
|
||||
# OpenFUT Bridge roadmap — from here to "Squad Battles loads"
|
||||
|
||||
Two **first-class** outcomes (not one goal + a consolation prize):
|
||||
|
||||
- **A. Full playable AI FUT** — the emulator build (M1–M7). Realistically a
|
||||
multi-month, expert-level reverse-engineering effort.
|
||||
- **B. Clean-room spec deliverable** — a documented map of the auth / Blaze /
|
||||
ProtoSSL / Fire2 surface (transport, gates, framing, decision points). Produced
|
||||
incrementally as the findings from M1–M5; **finishable and valuable on its
|
||||
own**, and the foundation any future emulator needs.
|
||||
|
||||
Every milestone's "done" is a **client-observable** result. Unconfirmed
|
||||
dependencies are flagged.
|
||||
|
||||
## Done so far
|
||||
|
||||
- In-process `version.dll` hook (injects, forwards all 17 real exports).
|
||||
- Transport confirmed: DirtySDK/ProtoSSL. `_ProtoSSLSendPacket` @
|
||||
`FIFA23.exe+0xEFA530` hooked (plaintext-capture ready).
|
||||
- `connect` / DNS / LSX (`send`/`recv` + `WSASend`/`WSARecv`) capture; mutexed log.
|
||||
- Gate identified: **upstream Ebisu connection-state, in-process** (see
|
||||
`connection-gate-findings.md`).
|
||||
- RE toolkit: `protossl-scan` (scan / xref / disasm / module+offset).
|
||||
|
||||
## M1 — Locate the connection-state decision point · Outcome B core
|
||||
- Read-only hook `ProtoSSLConnect`; find the Ebisu connection-state getter the
|
||||
FUT-entry path polls.
|
||||
- **Done:** we can name the exact function/return that gates "attempt online."
|
||||
- Unknown: coupling to anadius's detour. **Effort:** ~days.
|
||||
|
||||
## M2 — Flip the gate (force "connected") · pure gate-flip proof
|
||||
- Out-hook the getter / patch the polled state so the game attempts the real
|
||||
connection.
|
||||
- **Done (observable):** the game emits a DNS lookup / `connect` for the Blaze
|
||||
redirector (`gosredirector.*`).
|
||||
- Unknown: a secondary auth-token gate may also block. `TODO/CONFIRM`.
|
||||
**Effort:** ~days.
|
||||
|
||||
## M3 — First real ProtoSSL plaintext on the Blaze connection · SMALLEST END-TO-END PROOF (see "Smallest milestone")
|
||||
- Our redirect catches the Blaze connect; the ProtoSSL hook logs the first
|
||||
plaintext it emits (the TLS **ClientHello** to the redirector).
|
||||
- **Done:** `hook.log` shows the ClientHello from the *real* Blaze connection.
|
||||
- Note: this is a TLS handshake frame, **not yet** a Blaze/Fire2 app-data frame.
|
||||
**Effort:** small once M2 lands.
|
||||
|
||||
## M4 — Answer the redirector + decode first Fire2 frame · Outcome B: first decode
|
||||
- Inject a response via the ProtoSSL recv side so the client advances; decode
|
||||
the first Blaze/Fire2 request frame from real captured bytes.
|
||||
- **Done:** the client advances past the redirector (sends the next gate's frame).
|
||||
- Unknown (**BIG**): **Fire2 framing UNCONFIRMED**; **ProtoSSL recv-injection /
|
||||
TLS-bypass convention UNCONFIRMED**; **Blaze component/command IDs UNCONFIRMED**.
|
||||
**Effort:** high.
|
||||
|
||||
## M5 — Blaze preauth / login / postauth (online session) · Outcome B: full auth surface
|
||||
- Answer UTIL preauth, AUTHENTICATION login (reusing anadius's persona surface),
|
||||
UTIL postauth.
|
||||
- **Done:** client reports online / reaches the FUT entry check.
|
||||
- Depends on M4 framing. **Effort:** high.
|
||||
|
||||
## M6 — FUT entry + hub load (route to OpenFUT Core) · Outcome A
|
||||
- Answer the FUT eligibility check; serve the FUT hub (club/squad) from OpenFUT
|
||||
Core via the bridge.
|
||||
- **Done:** the FUT hub UI loads (club/squad screen).
|
||||
- Depends on Core's FUT REST surface. **Effort:** high.
|
||||
|
||||
## M7 — Squad Battles (AI FUT) · Outcome A goal
|
||||
- Wire Squad Battles match setup / rewards against Core.
|
||||
- **Done:** a Squad Battles match starts and rewards apply.
|
||||
- **Effort:** medium-high after M6.
|
||||
|
||||
## Outcome mapping
|
||||
- **B (spec)** completes as M1–M5 are documented — valuable even if A stalls.
|
||||
- **A (playable AI FUT)** requires M1–M7.
|
||||
|
||||
## Smallest provable milestone (step 4)
|
||||
|
||||
Refined from the proposed candidate. The single smallest result that validates
|
||||
the whole architecture end-to-end:
|
||||
|
||||
> **M3 — the client emits its first ProtoSSL plaintext onto the _real_ Blaze
|
||||
> connection (the ClientHello to the redirector), captured by our hook.**
|
||||
|
||||
It proves both halves at once: (1) we got the game past its connection-state
|
||||
check — it went online **for real**; and (2) our redirect + ProtoSSL hook
|
||||
capture real plaintext from that connection.
|
||||
|
||||
It deliberately stops short of the candidate's "first **Blaze** frame" (a Fire2
|
||||
app-data message), which requires answering the TLS handshake (M4) and depends
|
||||
on the unconfirmed Fire2 / recv-injection work. ClientHello capture needs none
|
||||
of that — making it the smallest, safest proof. The first Fire2 frame is the
|
||||
immediate follow-on (M4).
|
||||
+313
-5
@@ -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,20 +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
|
||||
}
|
||||
|
||||
@@ -376,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) => {
|
||||
@@ -392,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())) {
|
||||
@@ -405,6 +574,145 @@ unsafe fn hook_dns() {
|
||||
install_detour(ws2, b"getaddrinfo\0", hooked_gai as *const (), &ORIG_GAI, "getaddrinfo");
|
||||
}
|
||||
|
||||
// --- LSX capture: read the Ebisu-SDK <-> anadius XML conversation ----------
|
||||
|
||||
static ORIG_SEND: AtomicUsize = AtomicUsize::new(0);
|
||||
static ORIG_RECV: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
type SendFn = unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32;
|
||||
type RecvFn = unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32;
|
||||
|
||||
/// Cheap test: does this buffer look like LSX/Ebisu XML (not TLS/binary)?
|
||||
fn looks_like_lsx(buf: &[u8]) -> bool {
|
||||
let n = buf.len().min(64);
|
||||
let head = &buf[..n];
|
||||
let has_lt = head.iter().any(|&b| b == b'<');
|
||||
let has_gt = head.iter().any(|&b| b == b'>');
|
||||
head.windows(3).any(|w| w == b"LSX")
|
||||
|| head.windows(5).any(|w| w == b"Ebisu")
|
||||
|| (has_lt && has_gt)
|
||||
}
|
||||
|
||||
unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
|
||||
if len > 0 && !buf.is_null() {
|
||||
let head = core::slice::from_raw_parts(buf, (len as usize).min(64));
|
||||
if looks_like_lsx(head) {
|
||||
let show = core::slice::from_raw_parts(buf, (len as usize).min(800));
|
||||
log(&format!("LSX send sock={s} {len}B: {}", ascii_render(show)));
|
||||
}
|
||||
}
|
||||
let orig: SendFn = core::mem::transmute(ORIG_SEND.load(Ordering::SeqCst));
|
||||
orig(s, buf, len, flags)
|
||||
}
|
||||
|
||||
unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
|
||||
let orig: RecvFn = core::mem::transmute(ORIG_RECV.load(Ordering::SeqCst));
|
||||
let ret = orig(s, buf, len, flags);
|
||||
if ret > 0 && !buf.is_null() {
|
||||
let head = core::slice::from_raw_parts(buf, (ret as usize).min(64));
|
||||
if looks_like_lsx(head) {
|
||||
let show = core::slice::from_raw_parts(buf, (ret as usize).min(800));
|
||||
log(&format!("LSX recv sock={s} {ret}B: {}", ascii_render(show)));
|
||||
}
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
// Async (overlapped/IOCP) variants. A WSABUF is { len, buf }.
|
||||
#[repr(C)]
|
||||
struct WsaBuf {
|
||||
len: u32,
|
||||
buf: *mut u8,
|
||||
}
|
||||
|
||||
static ORIG_WSASEND: AtomicUsize = AtomicUsize::new(0);
|
||||
static ORIG_WSARECV: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
type WsaSendFn = unsafe extern "system" fn(
|
||||
usize,
|
||||
*const WsaBuf,
|
||||
u32,
|
||||
*mut u32,
|
||||
u32,
|
||||
*mut c_void,
|
||||
*mut c_void,
|
||||
) -> i32;
|
||||
type WsaRecvFn = unsafe extern "system" fn(
|
||||
usize,
|
||||
*const WsaBuf,
|
||||
u32,
|
||||
*mut u32,
|
||||
*mut u32,
|
||||
*mut c_void,
|
||||
*mut c_void,
|
||||
) -> i32;
|
||||
|
||||
unsafe extern "system" fn hooked_wsasend(
|
||||
s: usize,
|
||||
bufs: *const WsaBuf,
|
||||
count: u32,
|
||||
sent: *mut u32,
|
||||
flags: u32,
|
||||
ovl: *mut c_void,
|
||||
cr: *mut c_void,
|
||||
) -> i32 {
|
||||
// Outgoing data is readable before the call — capture the first buffer.
|
||||
if !bufs.is_null() && count > 0 {
|
||||
let b0 = &*bufs;
|
||||
if b0.len > 0 && !b0.buf.is_null() {
|
||||
let head = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(64));
|
||||
if looks_like_lsx(head) {
|
||||
let show = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(800));
|
||||
log(&format!("LSX WSASend sock={s} {}B: {}", b0.len, ascii_render(show)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let orig: WsaSendFn = core::mem::transmute(ORIG_WSASEND.load(Ordering::SeqCst));
|
||||
orig(s, bufs, count, sent, flags, ovl, cr)
|
||||
}
|
||||
|
||||
unsafe extern "system" fn hooked_wsarecv(
|
||||
s: usize,
|
||||
bufs: *const WsaBuf,
|
||||
count: u32,
|
||||
recvd: *mut u32,
|
||||
flags: *mut u32,
|
||||
ovl: *mut c_void,
|
||||
cr: *mut c_void,
|
||||
) -> i32 {
|
||||
let orig: WsaRecvFn = core::mem::transmute(ORIG_WSARECV.load(Ordering::SeqCst));
|
||||
let ret = orig(s, bufs, count, recvd, flags, ovl, cr);
|
||||
// Only the synchronous case (no overlapped) has data ready on return.
|
||||
if ret == 0 && ovl.is_null() && !recvd.is_null() && !bufs.is_null() && count > 0 {
|
||||
let n = *recvd as usize;
|
||||
let b0 = &*bufs;
|
||||
if n > 0 && !b0.buf.is_null() {
|
||||
let cap = n.min(b0.len as usize);
|
||||
let head = core::slice::from_raw_parts(b0.buf, cap.min(64));
|
||||
if looks_like_lsx(head) {
|
||||
let show = core::slice::from_raw_parts(b0.buf, cap.min(800));
|
||||
log(&format!("LSX WSARecv sock={s} {n}B: {}", ascii_render(show)));
|
||||
}
|
||||
}
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Detour ws2_32 send/recv (sync) and WSASend/WSARecv (async) to capture LSX XML.
|
||||
unsafe fn hook_winsock_data() {
|
||||
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log(&format!("ERROR: load ws2_32 for send/recv: {e:?}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
install_detour(ws2, b"send\0", hooked_send as *const (), &ORIG_SEND, "send");
|
||||
install_detour(ws2, b"recv\0", hooked_recv as *const (), &ORIG_RECV, "recv");
|
||||
install_detour(ws2, b"WSASend\0", hooked_wsasend as *const (), &ORIG_WSASEND, "WSASend");
|
||||
install_detour(ws2, b"WSARecv\0", hooked_wsarecv as *const (), &ORIG_WSARECV, "WSARecv");
|
||||
}
|
||||
|
||||
/// Resolve and detour ws2_32 `connect`.
|
||||
unsafe fn hook_connect() {
|
||||
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
||||
|
||||
+8
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -158,10 +158,12 @@ pub async fn get_capture_diff(
|
||||
captures.iter().find(|c| c.id == id)
|
||||
};
|
||||
|
||||
let cap_a = find(¶ms.a)
|
||||
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
|
||||
let cap_b = find(¶ms.b)
|
||||
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
|
||||
let cap_a = find(¶ms.a).ok_or_else(|| {
|
||||
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a))
|
||||
})?;
|
||||
let cap_b = find(¶ms.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}") }),
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
+305
-20
@@ -100,11 +100,28 @@ fn main() {
|
||||
let process = open_for_read(pid);
|
||||
let modules = enumerate_modules(pid);
|
||||
|
||||
let addrs = find_string_addresses(process, text.as_bytes());
|
||||
if addrs.is_empty() {
|
||||
let all = find_string_addresses(process, text.as_bytes());
|
||||
// Only xref hits inside the app modules (FIFA23.exe / anadius64.dll).
|
||||
// Hits in system DLLs are noise and each one would trigger a slow
|
||||
// full-memory scan, so we skip them.
|
||||
let addrs: Vec<usize> = all
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|a| in_app_module(*a, &modules))
|
||||
.collect();
|
||||
if all.is_empty() {
|
||||
println!("String \"{text}\" not found in PID {pid}. Did you reach the menu?");
|
||||
} else if addrs.is_empty() {
|
||||
println!(
|
||||
"Found \"{text}\" at {} location(s), but none in FIFA23.exe/anadius64.dll.",
|
||||
all.len()
|
||||
);
|
||||
} else {
|
||||
println!("Found \"{text}\" at {} location(s):", addrs.len());
|
||||
println!(
|
||||
"Found \"{text}\" at {} location(s) ({} in app modules):",
|
||||
all.len(),
|
||||
addrs.len()
|
||||
);
|
||||
for a in addrs {
|
||||
println!();
|
||||
// Show the surrounding bytes and find the TRUE start of the
|
||||
@@ -119,22 +136,115 @@ fn main() {
|
||||
let _ = CloseHandle(process);
|
||||
}
|
||||
}
|
||||
// disasm <hex-addr> [pid|name]
|
||||
// Disassemble the function enclosing an address, annotating string
|
||||
// loads and call targets. Use this on a code anchor (e.g. the lea that
|
||||
// loads the "_ProtoSSLSendPacket" string) to read the real code.
|
||||
Some("disasm") => {
|
||||
let target = match args.get(1).and_then(|s| parse_hex(s)) {
|
||||
// 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!("Usage: protossl-scan disasm <hex-addr> [pid|name]");
|
||||
eprintln!("Example: protossl-scan disasm 0x140EFA631");
|
||||
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(),
|
||||
None => {
|
||||
eprintln!("Usage: protossl-scan callers <hex-addr | module+0xoffset> [pid|name]");
|
||||
eprintln!("Example: protossl-scan callers anadius64.dll+0x2BB90");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let pid = resolve_pid(args.get(2).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}' (unknown module or bad address)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
run_callers(process, &modules, target);
|
||||
unsafe {
|
||||
let _ = CloseHandle(process);
|
||||
}
|
||||
}
|
||||
// disasm <hex-addr> [pid|name]
|
||||
// Disassemble the function enclosing an address, annotating string
|
||||
// loads and call targets. Use this on a code anchor (e.g. the lea that
|
||||
// loads the "_ProtoSSLSendPacket" string) to read the real code.
|
||||
Some("disasm") => {
|
||||
let arg = match args.get(1) {
|
||||
Some(a) => a.clone(),
|
||||
None => {
|
||||
eprintln!("Usage: protossl-scan disasm <hex-addr | module+0xoffset> [pid|name]");
|
||||
eprintln!("Examples: protossl-scan disasm 0x140EFA631");
|
||||
eprintln!(" protossl-scan disasm anadius64.dll+0x2BB90");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let pid = resolve_pid(args.get(2).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}' (unknown module or bad address)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
run_disasm(process, &modules, target);
|
||||
unsafe {
|
||||
let _ = CloseHandle(process);
|
||||
@@ -142,12 +252,11 @@ fn main() {
|
||||
}
|
||||
// xref <hex-addr> [pid|name] (power-user form, exact absolute address)
|
||||
Some("xref") => {
|
||||
let target = match args.get(1).and_then(|s| parse_hex(s)) {
|
||||
Some(t) => t,
|
||||
let arg = match args.get(1) {
|
||||
Some(a) => a.clone(),
|
||||
None => {
|
||||
eprintln!("Usage: protossl-scan xref <hex-addr> [pid|name]");
|
||||
eprintln!("Usage: protossl-scan xref <hex-addr | module+0xoffset> [pid|name]");
|
||||
eprintln!("Example: protossl-scan xref 0x147D198B9");
|
||||
eprintln!("Tip: pass the FULL absolute address, not the +offset.");
|
||||
eprintln!("Or just use: protossl-scan xref-str ProtoSSLSend");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -155,6 +264,13 @@ fn main() {
|
||||
let pid = resolve_pid(args.get(2).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}' (unknown module or bad address)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
run_xref(process, &modules, target);
|
||||
unsafe {
|
||||
let _ = CloseHandle(process);
|
||||
@@ -180,7 +296,7 @@ fn find_string_addresses(process: HANDLE, text: &[u8]) -> Vec<usize> {
|
||||
let mut hits: HashSet<usize> = HashSet::new();
|
||||
let overlap = text.len().saturating_sub(1);
|
||||
let finder = memmem::Finder::new(text);
|
||||
walk_regions(process, false, overlap, |chunk_base, bytes| {
|
||||
walk_regions(process, false, overlap, None, |chunk_base, bytes| {
|
||||
for off in finder.find_iter(bytes) {
|
||||
hits.insert(chunk_base + off);
|
||||
}
|
||||
@@ -206,7 +322,7 @@ fn run_marker_scan(process: HANDLE, modules: &[ModuleInfo]) {
|
||||
// Build one SIMD finder per marker, reused across every chunk.
|
||||
let finders: Vec<memmem::Finder> = MARKERS.iter().map(|m| memmem::Finder::new(m)).collect();
|
||||
|
||||
walk_regions(process, false, overlap, |chunk_base, bytes| {
|
||||
walk_regions(process, false, overlap, None, |chunk_base, bytes| {
|
||||
for (i, finder) in finders.iter().enumerate() {
|
||||
for off in finder.find_iter(bytes) {
|
||||
hits[i].insert(chunk_base + off);
|
||||
@@ -259,13 +375,18 @@ fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) {
|
||||
println!("== protossl-scan : xref of 0x{target:X} ==");
|
||||
println!("({})", describe(target, modules));
|
||||
|
||||
// Restrict the (expensive) scans to the app modules; the code/tables that
|
||||
// reference our target live in FIFA23.exe or anadius64.dll, not system DLLs.
|
||||
let app = app_module_ranges(modules);
|
||||
let allow = Some(app.as_slice());
|
||||
|
||||
// (a) Absolute 8-byte pointers to `target`. These usually live in a
|
||||
// read-only data table. If the table pairs names with functions, a
|
||||
// neighbouring slot will hold the function pointer we actually want.
|
||||
let needle = (target as u64).to_le_bytes();
|
||||
let ptr_finder = memmem::Finder::new(&needle);
|
||||
let mut ptr_hits: HashSet<usize> = HashSet::new();
|
||||
walk_regions(process, false, needle.len() - 1, |chunk_base, bytes| {
|
||||
walk_regions(process, false, needle.len() - 1, allow, |chunk_base, bytes| {
|
||||
for off in ptr_finder.find_iter(bytes) {
|
||||
ptr_hits.insert(chunk_base + off);
|
||||
}
|
||||
@@ -296,7 +417,7 @@ fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) {
|
||||
// at address P are `disp`, the referenced target is `P + 4 + disp`.
|
||||
// We scan executable pages for any P where that equals our target.
|
||||
let mut code_hits: HashSet<usize> = HashSet::new();
|
||||
walk_regions(process, true, 3, |chunk_base, bytes| {
|
||||
walk_regions(process, true, 3, allow, |chunk_base, bytes| {
|
||||
if bytes.len() < 4 {
|
||||
return;
|
||||
}
|
||||
@@ -357,6 +478,116 @@ fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode 2b: find callers (who calls/jmps to a function)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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));
|
||||
|
||||
let app = app_module_ranges(modules);
|
||||
let allow = Some(app.as_slice());
|
||||
let mut hits: Vec<(usize, u8)> = Vec::new();
|
||||
|
||||
walk_regions(process, true, 4, allow, |chunk_base, bytes| {
|
||||
if bytes.len() < 5 {
|
||||
return;
|
||||
}
|
||||
for i in 0..=bytes.len() - 5 {
|
||||
let op = bytes[i];
|
||||
if op != 0xE8 && op != 0xE9 {
|
||||
continue;
|
||||
}
|
||||
let rel = i32::from_le_bytes([bytes[i + 1], bytes[i + 2], bytes[i + 3], bytes[i + 4]]);
|
||||
let after = chunk_base + i + 5; // address just past the rel32
|
||||
let tgt = after.wrapping_add(rel as i64 as usize);
|
||||
if tgt == target {
|
||||
hits.push((chunk_base + i, op));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hits.sort_unstable();
|
||||
hits.dedup();
|
||||
if hits.is_empty() {
|
||||
println!(" no direct call/jmp sites found (may be called indirectly via a pointer)");
|
||||
} else {
|
||||
println!("-- {} call/jmp site(s) --", hits.len());
|
||||
for (at, op) in hits.iter().take(40) {
|
||||
let kind = if *op == 0xE8 { "call" } else { "jmp " };
|
||||
println!(" {kind} from {}", describe(*at, modules));
|
||||
}
|
||||
if hits.len() > 40 {
|
||||
println!(" ... and {} more", hits.len() - 40);
|
||||
}
|
||||
println!("\n-- Next --");
|
||||
println!("`disasm <one of the call sites>` to read the calling function and find");
|
||||
println!("the branch/condition that gates the call.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode 3: disassemble the enclosing function
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -525,6 +756,51 @@ fn parse_hex(s: &str) -> Option<usize> {
|
||||
usize::from_str_radix(trimmed, 16).ok()
|
||||
}
|
||||
|
||||
/// Is this address inside one of the app modules we care about
|
||||
/// (FIFA23.exe or anadius64.dll)? Used to skip noisy system-DLL hits.
|
||||
fn in_app_module(addr: usize, modules: &[ModuleInfo]) -> bool {
|
||||
for m in modules {
|
||||
if addr >= m.base && addr < m.base + m.size {
|
||||
let n = m.name.to_ascii_lowercase();
|
||||
return n.starts_with("fifa23") || n.starts_with("anadius64");
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `[base, base+size)` ranges for the app modules (FIFA23.exe / anadius64.dll),
|
||||
/// used to restrict expensive scans to the code we care about.
|
||||
fn app_module_ranges(modules: &[ModuleInfo]) -> Vec<(usize, usize)> {
|
||||
modules
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let n = m.name.to_ascii_lowercase();
|
||||
n.starts_with("fifa23") || n.starts_with("anadius64")
|
||||
})
|
||||
.map(|m| (m.base, m.base + m.size))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a target that is either a raw hex address or a `module+0xoffset`
|
||||
/// form (e.g. `anadius64.dll+0x2BB90`). The module form is ASLR-robust: it adds
|
||||
/// the offset to the module's CURRENT base in the running process.
|
||||
fn resolve_target(s: &str, modules: &[ModuleInfo]) -> Option<usize> {
|
||||
if let Some(idx) = s.find('+') {
|
||||
let name = s[..idx].trim().to_ascii_lowercase();
|
||||
let want = name.strip_suffix(".dll").unwrap_or(&name);
|
||||
let off = parse_hex(s[idx + 1..].trim())?;
|
||||
for m in modules {
|
||||
let mn = m.name.to_ascii_lowercase();
|
||||
let mn = mn.strip_suffix(".dll").unwrap_or(&mn);
|
||||
if mn == want {
|
||||
return Some(m.base + off);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
parse_hex(s)
|
||||
}
|
||||
|
||||
/// Read a single u64 from the target process at `addr`. Returns None if the
|
||||
/// memory can't be read (e.g. unmapped).
|
||||
fn read_u64(process: HANDLE, addr: usize) -> Option<u64> {
|
||||
@@ -635,6 +911,7 @@ fn walk_regions<F: FnMut(usize, &[u8])>(
|
||||
process: HANDLE,
|
||||
exec_only: bool,
|
||||
overlap: usize,
|
||||
allow: Option<&[(usize, usize)]>,
|
||||
mut f: F,
|
||||
) {
|
||||
let mut buf = vec![0u8; CHUNK];
|
||||
@@ -662,7 +939,15 @@ fn walk_regions<F: FnMut(usize, &[u8])>(
|
||||
} else {
|
||||
is_readable(mbi.Protect.0)
|
||||
};
|
||||
if mbi.State == MEM_COMMIT && wanted {
|
||||
// If an allow-list of ranges is given, only scan regions that overlap
|
||||
// one of them (e.g. restrict to the FIFA23.exe / anadius64.dll images).
|
||||
let in_allow = match allow {
|
||||
None => true,
|
||||
Some(ranges) => ranges
|
||||
.iter()
|
||||
.any(|&(b, e)| region_base < e && region_base + region_size > b),
|
||||
};
|
||||
if mbi.State == MEM_COMMIT && wanted && in_allow {
|
||||
// Read this region in overlapping chunks.
|
||||
let end = region_base.saturating_add(region_size);
|
||||
let mut pos = region_base;
|
||||
|
||||
Reference in New Issue
Block a user