29 Commits

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:07:17 -07:00
funman300 5aec83ce97 M2: confirm worker-thread + event-driven gate (architectural wall)
Add a bounded manual stack-scan in the GoOnline detour (FIFA-frame finder).
Result: zero FIFA23.exe frames, sp ~2.4KB below stack top => GoOnline runs on an
anadius worker thread (queued), not FIFA's thread. Three-way corroboration that
the gate is an async "online established" event anadius (offline-only) never
pushes; FIFA waits/retries. No handler-response flip can cross it. Documented in
connection-gate-findings.md.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:46:27 -07:00
funman300 55c9757e74 M1: gate is upstream + two-gate (state+token) finding; add callers mode
Read-only M1 investigation results appended to connection-gate-findings.md:
- Confirmed ProtoSSLConnect is never reached on the "connecting" abort (gate is
  upstream/in-process) via the existing connect/DNS hooks.
- Mapped the surface: redirector host table (gosredirector.* per env), the
  redirector request config (X-BLAZE-ERRORCODE, Authorization:, <errorCode>),
  and the enum-name serialization tables (ONLINE_ACCESS, ONLINE_STATUS_EVENT).
- Key answer: the online-connect path requires a Nucleus auth token (the
  redirector request carries an Authorization header) => TWO gates (state +
  token), not a single boolean flip.
- The exact connection-state function is behind C++ vtable indirection; pinning
  it needs Ghidra. protossl-scan stays the live-process bridge.

protossl-scan: add `callers` mode (find call/jmp sites to a function) and
restrict xref/callers scans to the app modules (FIFA23.exe/anadius64.dll) for
speed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:34:35 -07:00
funman300 49b3030e34 docs: connection-gate findings + roadmap (strategy pivot)
Stop fighting anadius's offline verdict. Document the clean-room findings
(the gate is an upstream in-process Ebisu connection-state check, not socket
traffic) and a milestone roadmap with two first-class outcomes: full playable
AI FUT (A) and a clean-room spec deliverable (B). Name M3 (first ProtoSSL
plaintext on the real Blaze connection) as the smallest end-to-end proof.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:00:27 -07:00
funman300 15a6f877ee feat(protossl-scan): accept module+offset targets for disasm/xref
Add `module+0xoffset` addressing (e.g. `anadius64.dll+0x2BB90`) to the
disasm and xref commands. Offsets are stable across launches while the
base is ASLR'd, so this resolves the module's current base in the running
process instead of requiring a stale absolute address each session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:56:23 -07:00
funman300 ce8a3b32ec feat(hook): capture LSX/Winsock traffic and harden logging
- Hook send/recv and WSASend/WSARecv, filtered to LSX/XML content, to read
  the Ebisu-SDK <-> anadius conversation (challenge handshake, etc.).
- Hook getaddrinfo/GetAddrInfoW to log DNS lookups.
- Log connect() return value + WSA error; add accept/close diagnostics on
  the local listeners.
- Serialize log writes behind a mutex so concurrent threads don't corrupt
  each other's lines.

Used to establish that the online/offline decision is made via in-process
detoured calls, not socket traffic (no GetAuthCode/GoOnline on the wire).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:56:23 -07:00
funman300 bc6612697a feat: add ProtoSSL RE tooling and FIFA 23 in-process hook
Add the reverse-engineering work for FIFA 23 online integration:

- tools/protossl-scan: read-only memory scanner that confirms the
  DirtySDK/ProtoSSL transport, cross-references strings to code
  (xref/xref-str), and disassembles the enclosing function (disasm).
  Located _ProtoSSLSendPacket at FIFA23.exe+0xEFA530 (plaintext TLS
  record assembler) via the "_ProtoSSLSendPacket" debug string xref.

- openfut-hook: version.dll proxy (forwards all 17 real exports) that
  injects into FIFA 23 and installs an inline detour on
  _ProtoSSLSendPacket, plus connect/DNS (getaddrinfo) capture and local
  LSX/redirect listeners. Logs to C:\openfut\hook.log.

- CLAUDE.md: bridge project context + the gate-sequence task roadmap.

Findings so far: with the anadius offline emulator the game never
attempts the online Blaze connection (local auth gate), so capture of
live Blaze frames is blocked pending an online-session fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:09:26 -07:00
funman300 77af7ce3c9 Phase 25: leaderboard table + trade history in dashboard
- Division tab: leaderboard table (10 clubs, color-coded promo/relegation rows,
  player row highlighted, sorted by pts)
- Market tab: trade history table (last 30 buy/sell events, card name/OVR/price/date)
  loaded alongside market listings and my listings
- Mapper: added GET /division/leaderboard and GET /trade/history exact routes
- Mapper assertion bumped to ≥61

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:13:33 -07:00
funman300 7c779e0d72 Phase 24: pack reveal animation, daily check-in UI, club milestones panel
- mapper.rs: 3 new exact routes (daily objective GET/claim → /club/checkin,
  milestones GET → /club/milestones); bump exact routes assertion to ≥59
- dashboard: pack reveal animation (card-flip-in keyframe, unrevealed
  placeholder slots, Reveal Next / Reveal All buttons, one-at-a-time reveal);
  daily check-in panel at top of club tab (7-dot streak visualisation, claim
  button, next reward preview); club milestones grid (10 stats: wins, goals,
  best streak, seasons, highest division, packs, cards, SBCs, check-ins, level)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:06:04 -07:00
funman300 ab6021adac Phase 23: formation pitch view, season zone bar, season history UI
- mapper.rs: add GET /division/history route; bump exact routes assertion to ≥56
- dashboard: formation pitch view in Squad tab with CSS pitch markings, player
  chips positioned by line, bench row, Grid/Pitch toggle button; season zone
  bar in Division tab (relegation/safe/promotion colour bands + current points
  marker); season history list from GET /division/history; division response
  now includes promotion_pts, relegation_pts, season_length for richer display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:59:40 -07:00
funman300 893ee369c2 Phase 22: mapper round 2, response shaper, card detail modal, market filters
- mapper.rs: 22 new exact routes (squad list, trophies, rivals, objectives
  sub-groups, catalogue, consumables, loan items, active messages, price
  tiers, fitness, customization) + 5 new prefix routes (squad GET/DELETE by
  ID, item chemistry PUT, SBC set GET); total exact routes now ≥55
- shaper.rs: shape /achievements → trophyData envelope; /squads → squads
  list envelope; /packs/store → purchasablePacks envelope; also shapes
  parameterised /squads/{id} responses
- dashboard: card detail modal (click any collection card → full stat view,
  chemistry/training/loan info, quick-sell action); loan-only checkbox in
  collection filters; position + min/max price filters in transfer market

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:28:43 -07:00
funman300 72ffe9a806 Phase 21: onboarding flow and new game reset
Onboarding overlay: on DOMContentLoaded the dashboard calls GET
/auth/status. If no profile exists, a full-screen setup card replaces
the normal UI (header, nav, and main are hidden). The user enters a
username and clicks Start Playing — POST /auth/local is called, then
the overlay hides and the Club tab loads normally. Pressing Enter in
the username field also submits. Validation: minimum 2 characters,
error message inline below the button.

Danger Zone in Settings tab: a red-bordered section with a Reset All
Progress button. Clicking opens a confirmation modal that requires the
user to type the word RESET before the action is enabled. On confirm,
POST /auth/reset is called, a toast is shown, and the page reloads
after 1.5 s — which triggers the onboarding flow again since the
profile was deleted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:09:00 -07:00
funman300 335398df72 Phase 20: achievements tab and unlock toasts
New Achievements tab (tab-achievements) shows all 18 achievement cards in
a responsive grid. Each card displays: icon, title, rarity badge (color-
coded: common=grey, rare=blue, epic=purple), description, reward coins, and
either a green "Unlocked <date>" label or a dimmed "Locked" state. Unlocked
achievements sort first (newest-first); locked sort alphabetically after.

Header nav button shows earned count badge next to "Achievements".

Match result handler reads achievements_unlocked from the Core response and
fires one toast per newly unlocked achievement immediately after match
submission. Level-up toasts continue to work independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:05:16 -07:00
funman300 44c3a0ce5b Phase 19: notifications tab with mark-read support
Notifications tab now fully functional. Each notification shows a type
icon, colored left border (gold=level_up, green=objective, amber=warning,
red=expired, blue=season), bold title when unread, body text, timestamp,
and a per-item Mark read button for persistent notifications.

Header badge and the unread count label now use unread_count from the
Core response rather than total notification count.

A Mark all read button calls POST /notifications/read-all and refreshes
the list. Per-item PATCH /notifications/:id/read hides the button and
dims the card on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:57:40 -07:00
funman300 30dfb25cf7 Phase 18: collection quick-sell button
Each card in the Collection tab now shows a Sell button displaying the
quick-sell coin value (computed from overall rating using the same tiers
as the backend: 85+→1500c, 80-84→900c, 75-79→600c, 65-74→300c, <65→150c).
Clicking calls DELETE /collection/:id with a confirmation prompt, shows a
toast with the actual coins received, and refreshes both the collection
and the header coin counter. Loan cards show a disabled button instead
to prevent accidental early sale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:50:16 -07:00
funman300 ba1b0a09f2 Phase 17: level-up toast, XP progress bar, multi-squad management
Match result display now shows a level-up row per level gained (new
level, coins granted, milestone pack) and fires a toast notification.

Settings/Profile card now uses xp_to_next_level and xp_for_next_level
from the enhanced GET /profile response so the XP bar accurately shows
progress within the current level rather than total XP %.

Squad View mode gains an All Squads panel below Chemistry listing every
saved squad (name + formation badge). Each row has a Delete button that
calls DELETE /squads/:id and refreshes the view. Squads are loaded via
GET /squads alongside the active squad on every Squad tab open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:42:35 -07:00
funman300 8087564df1 Phase 16: Card Catalog, Settings/Profile, Squad Builder tabs
Card Catalog — full card database browser using GET /cards with client-
side filters (name, position, rarity, nation, league, club, OVR range),
pagination in 80-card chunks, "Owned" green overlay/badge for cards
already in the collection (cross-referenced via GET /collection).

Settings/Profile — profile card with username, level, XP progress bar;
editable game settings (default match difficulty, preferred formation)
via GET/PUT /settings; confirmation flash on save.

Squad Builder — Squad tab gains a Build/View toggle. Build mode shows
11 starter slots (labelled by position for the selected formation) plus
7 bench slots; clicking any slot opens an inline search-picker filtered
to unused collection cards; GK slot highlighted; filled slots show
player name, OVR, and position with a one-click clear button; Save
Squad POSTs the full squad to Core (validates 11 starters required) then
switches back to View mode and refreshes the chemistry display.

Formations supported: 4-4-2, 4-3-3, 4-2-3-1, 4-1-2-1-2, 3-5-2, 5-3-2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:31:15 -07:00
funman300 67b84abe7c Phase 15: add Matches, Pack Store, SBC, Statistics tabs to dashboard
Matches — difficulty selector, generate opponent (shows AI squad rating
and formation), quick-score buttons (3-0 / 1-1 / 0-2), submit custom
score via POST /matches/result, inline reward display (coins, XP,
objective triggers, season points), live match history list.

Pack Store — lists all purchasable definitions from GET /packs/store
(name, description, cost, card count) with one-click Buy that calls
POST /packs/buy; coin balance refreshes after purchase.

SBC — renders all challenges with requirements as pills (min OVR,
required nations/leagues/clubs, same-club counts), card picker
(select from owned collection, click pill to remove), Submit fires
POST /sbc/submit and shows reward or validation failures; cards
re-loaded from Core after a successful submission.

Statistics — career stat grid (matches, W/D/L, win rate, goals, coin
totals, streaks, packs, SBCs), goals-by-position bar chart, paginated
match log with outcome badges and date, sourced from
GET /statistics and GET /statistics/history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:27:45 -07:00
funman300 aabbe775b3 Phase 14: add Squad, Draft, Market, Events tabs to dashboard
Four new tabs complete the dashboard UI for all existing Core endpoints:

Squad — view active squad with per-player chemistry dots and team
chemistry bar (colour-coded green/amber/red).

Draft — start a session with difficulty selector, pick from 5 candidates
per slot through all 11 positions, see picks accumulate in real time,
reward shown on completion.

Market — browse NPC listings with live search filter, one-click buy,
sell own cards with price input, cancel own listings; Refresh button
repopulates NPC inventory via POST /market/refresh.

Events — list all data-driven events with active/inactive state, bonus
effects summary (score/coin multipliers, extra market cards, pack
discounts), Activate/Deactivate buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:22:26 -07:00
funman300 eae3e72fd9 Phase 13: web dashboard at /_bridge/dashboard
Single-page club management UI served by the bridge. The Core URL is
injected server-side so client JS calls Core directly without routing
through the proxy. Vanilla HTML/CSS/JS, dark theme matching admin.html.

Sections: Club (name/manager/coins/stats edit), Collection (filterable
card grid with stat bars + chemistry/training pills), Packs (open with
modal, history), Objectives (progress bars + one-click claim), Division
(W/D/L record, season progress, rivals weekly claim), FUT Champions
(current session with in-browser match simulation, claim, history),
Notifications (badge count in nav).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:18:10 -07:00
funman300 b7da8b6e83 Phase 12: bridge completeness — expanded mapper, request replay, full shaper coverage
Mapper (src/mapper.rs):
- Restructured into ExactRoute + PrefixRoute tables (was a flat match list)
- 38 exact mappings (up from 18): SBC, Draft, FUT Champs, Division Rivals,
  Card Upgrades, Notifications, Settings, Trade pile, Club update, Stats, etc.
- 7 prefix-matched routes for path-param endpoints:
  /ut/game/fut/draft/{id}[/pick|/abandon]  → /draft/sessions/:id[/pick|/abandon]
  /ut/game/fut/champs/{id}/result|claim    → /fut-champs/:id/result|claim
  /ut/game/fut/store/pack/{id}/open        → /packs/open/:id
  /ut/game/fut/item/{id} DELETE            → /collection/:id (quick-sell)
  /ut/game/fut/trade/{id} DELETE           → /market/listings/:id (cancel)
  /ut/game/fut/sbc/set/{id}/submission     → /sbc/submit
- map_to_core_with_method() public helper preserves HTTP method for prefix routes
- 19 mapper unit tests (up from 6)

Replay (src/routes/admin.rs):
- POST /_bridge/captures/:id/replay — loads a capture from disk, re-runs it
  against Core with original headers+body, returns original vs new response
  plus a structured JSON diff for iterative shaper/handler development

Shaper (src/shaper.rs):
- 14 new shaper functions covering every mapped Core path:
  collection, cards, objectives, notifications, division, statistics,
  sbc, pack-open, draft, fut-champs, chemistry-styles, my-listings
- Prefix dispatch for /packs/open/, /draft/sessions/, /fut-champs/ paths
- All 25 unit tests pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:11:06 -07:00
funman300 00839f5f1b Phase 9 bridge: cert download and HTML setup guide endpoints
- GET /_bridge/cert.pem — serves the TLS CA cert as a downloadable PEM file
  (only when TLS_ENABLED=true; 404 otherwise)
- GET /_bridge/guide — HTML setup page with hosts-file instructions, cert install
  steps per OS, and troubleshooting tips
- ProxyState gains cert_pem field (Arc<Vec<u8>>); set via with_cert() builder
- main.rs generates cert before state construction so /_bridge/cert.pem can serve it;
  both cert_pem and key_pem passed to serve_tls() for acceptor + state separately
- All 13 bridge tests pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:53:12 -07:00
funman300 f3ff291ff8 Phase 8 (Bridge): SID/phishing header passthrough, updated TODO
- proxy.rs: forward_to_core() now accepts the incoming headers vec
  and passes X-UT-SID, X-UT-PHISHING-TOKEN, and X-Request-ID through
  to Core on every mapped request (#19, #20)
- TODO.md: mark #15-#20 as complete with implementation notes;
  update test counts; clean up duplicate #15 entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:42:09 -07:00
funman300 b8e766e9dd Phase 7 (Bridge): response shaper, diff tool, expanded mapper
- shaper.rs: shape_response() dispatches on core_path to wrap Core
  JSON in FUT envelope format; shapes auth (/ut/auth → sid/pid/
  phishingToken/persona envelope), profile, club, squad, market,
  packs; unknown paths pass through unchanged
- proxy.rs: calls shape_response() on every successful Core response
  before returning to the FIFA 23 client
- mapper.rs: expanded from 6 to 18 speculative FUT endpoint mappings
  covering auth, profile/settings, club/usermassinfo, item/collection,
  squad (GET+PUT), packs+purchase, transfer market+watchlist+bid,
  objectives, events, squad battles, and match result submission
- admin.rs: GET /_bridge/captures/diff?a=<id>&b=<id> compares two
  captures; reports method/path/status changes, header diffs, and
  structured JSON body diffs (key-level for objects, length for others)
- main.rs: registers /_bridge/captures/diff route
- Unit tests: 4 shaper tests, 6 mapper tests (10 total, all passing)
- All 13 integration tests still passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:32:37 -07:00
funman300 3b7a4928c9 feat: Phase 6 — proxy polish, TLS, admin UI, replay CLI
## TLS (#11)
- Self-signed cert generated at startup via rcgen (covers localhost, 127.0.0.1,
  fut.ea.com, utas.mob.v4.fut.ea.com); activated with TLS_ENABLED=true
- Custom accept loop: tokio-rustls acceptor → hyper-util auto Builder → axum
  Router (no axum-server dependency — uses hyper 1.x natively)

## Replay CLI (#12)
- New binary: openfut-bridge-replay <file.json|dir> [bridge-url]
- Replays single capture or entire directory against Bridge
- Accepts self-signed certs automatically

## Capture quality (#13, #14)
- DELETE /_bridge/captures — wipe all capture files from disk
- Deduplication: same method+path within 1 s is skipped (configurable constant)

## Admin UI (#21, #22, #23)
- GET /_bridge/admin — embedded HTML dashboard; auto-refresh every 10 s
  Shows: live stats, SSE log of incoming traffic, endpoint status table,
  recent captures list with delete button
- GET /_bridge/status — per-endpoint mapped/known/unknown status
- GET /_bridge/captures/stream — SSE stream; event: capture on each request
  Uses tokio::sync::broadcast channel (capacity 256) in ProxyState

## Tests (#24, #25)
- 9 new tests: placeholder format, full HTTP integration (health, placeholder,
  captures list, delete captures, status), TLS cert generation + acceptor build
- Total bridge tests: 13/13 passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:15:48 -07:00
26 changed files with 8815 additions and 125 deletions
+190
View File
@@ -0,0 +1,190 @@
# OpenFUT Bridge — Claude Code project context
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.
## What this project is
OpenFUT is an **offline FIFA 23 FUT (Ultimate Team) emulator** for single-player
game preservation. EA permanently shut down FIFA 23's online servers in October
2025, which removed FUT. This project lets a legitimately-owned copy run FUT
against a **local emulated backend** instead of EA's (dead) servers.
It has two repos:
- **OpenFUT Core** — the game-independent FUT economy backend (already largely built).
- **OpenFUT Bridge** — the FIFA 23 integration / reverse-engineering layer (this work).
## Hard constraints (do not violate)
- **Clean-room only.** Everything is derived from observing the running client's
own behaviour. NEVER use, reference, or reproduce leaked EA source code (e.g.
the 2021 FIFA 21 / Blaze leak). If a task seems to need it, stop and say so.
Clean-room provenance is the legal foundation of the whole project.
- **Mark uncertainty, don't invent.** Several things are genuinely unconfirmed:
the Fire2 packet framing, ProtoSSL non-blocking return values, and FIFA 23's
Blaze component/command IDs. When you don't know a value, leave a clearly
labelled `TODO/CONFIRM` rather than guessing a confident-looking number.
- **Verify against the client.** The dead servers mean responses are *synthesized
and tested against the live offline client*, never captured from EA. The client
is the oracle: a gate is "done" when the client advances to the next command.
## Architecture
```
FIFA 23 client (offline, native Windows)
| ProtoSSLConnect / ProtoSSLSend / ProtoSSLRecv (in-process)
openfut_hook.dll — fakes the connection, captures requests, injects responses
| plain localhost TCP, length-prefixed frames (no TLS)
blaze_brain (Rust) — decodes requests, crafts Blaze responses <-- iteration surface
|
OpenFUT Core — supplies real FUT economy data for the FUT gates
```
Why in-process (not a localhost TLS server): FIFA 23's ProtoSSL uses modern TLS
with cert pinning, so a fake server gets its cert rejected. Hooking ABOVE the
crypto (ProtoSSLSend/Recv take/return plaintext) sidesteps TLS entirely.
## The entry sequence (the gates to reach FUT)
The client runs these in order; each must be satisfied before the next fires:
1. **LSX** (port 3216, plaintext loopback) — launcher→game bootstrap.
2. **Blaze redirector** — returns a server host/port.
3. **Blaze preauth** (UTIL) — config / component list.
4. **Blaze login** (AUTHENTICATION) — persona, session key, UID.
5. **Blaze postauth** (UTIL) — telemetry/ticker; "fully online".
6. **FUT entry check** — FIFA-specific eligibility/entitlement wall.
7. **FUT hub load** — club/squad over REST; hand off to OpenFUT Core.
Gates 25 are largely static/replayable (the client validates little). Gates 67
need internally-consistent data and connect to OpenFUT Core.
## Environment
- Native Windows boot (chosen for predictable PE/hooking behaviour over Proton).
- FIFA 23, with EA Anticheat in its offline/neutralized state. Do NOT assume a
task is safe to hook unless EAAC is confirmed neutralized.
- Rust is the primary language. Hook = `cdylib`, Windows target. Brain = portable.
- Self-hosted Gitea (git.aleshym.co, org Quaternions). Self-hosted CI runner.
## How to work here
- One rung at a time. Don't run ahead into a gate that depends on an unconfirmed
earlier answer.
- When decoding a frame, work from REAL captured bytes the user pastes in — not
from a guessed layout. Ask for the bytes if they're not provided.
- End each change with how the user verifies it on the client.
# OpenFUT Bridge — Task prompts (run in order)
Each task is its own Claude Code session. Paste the task, attach the named
starting file(s), and don't move to the next until the verification clause passes.
The project CLAUDE.md is assumed to be in context.
---
## TASK 1 — Confirm the transport (read-only memory scan)
**Goal:** prove whether FIFA 23 uses EA DirtySDK / ProtoSSL before we build any
hook around it. Read-only; no hooking, patching, or injection.
**Do this:**
Write a standalone Windows console program in Rust (target
`x86_64-pc-windows-msvc` or `-gnu`) that:
1. Finds the running FIFA 23 process by name (and accepts a PID argument).
2. Enumerates its modules and committed memory regions (e.g. `EnumProcessModules`
/ `VirtualQueryEx`), readable regions only.
3. Reads memory with `ReadProcessMemory` in chunks (with small overlap so a
marker spanning a chunk boundary is still found).
4. Searches for these ASCII markers and prints each hit with its absolute address:
`protossl:`, `ProtoSSLSend`, `ProtoSSLRecv`, `ProtoSSLConnect`,
`gosredirector`, `DirtySDK`, `blaze`.
5. Prints a summary: which markers were found, and a clear verdict line
("ProtoSSL present" vs "no ProtoSSL markers found").
Keep it dependency-light (the `windows` crate is fine). Comment it for a Rust
beginner. It must only read.
**Verification:** with FIFA 23 running and sitting at the main menu, the program
prints whether the ProtoSSL markers are present. If `protossl:` and the
send/recv strings appear, the transport is confirmed and we proceed. If nothing
appears after the menu has loaded, STOP — we rethink the transport, don't build
the hook.
**Note:** reach the main menu before scanning; the networking code/strings may
not be paged in at the title screen.
---
## TASK 2 — Loadable DLL shell (prove we can get code in)
**Goal:** a `version.dll` proxy that loads into FIFA 23 and runs our code, before
any hook logic exists. This isolates "can we inject at all" from "is the hook
correct".
**Do this:**
Create a Rust `cdylib` (Windows target) that:
1. Exports the functions a real `version.dll` exports, forwarding each to the
system `version.dll` (proxy/sideload pattern). List the needed exports and
implement the forwarding.
2. In `DllMain`, on `DLL_PROCESS_ATTACH`, spawns a thread (NOT work in DllMain
itself — loader lock) that writes a line to a log file in a known path, e.g.
`C:\openfut\hook.log`, with a timestamp.
3. Provide the `Cargo.toml` (`crate-type = ["cdylib"]`) and the exact build
command.
Clean-room: this is generic proxy/loader scaffolding, nothing EA-derived.
**Verification:** drop the built DLL next to the FIFA 23 executable, launch the
game, and confirm `hook.log` gets the timestamped line. Game still reaches the
menu normally. If it crashes or the line never appears, fix the proxy/forwarding
before continuing.
**Caution:** confirm EAAC is in its offline/neutralized state before loading the
DLL. Do not load it into an anticheat-active session.
---
## TASK 3 — One landing hook on ProtoSSLSend
**Goal:** prove we can hook a ProtoSSL function and see real outbound frames.
Just one function, and it calls the original (passive observation).
**Do this:**
Extend the Task 2 DLL:
1. Add the `retour` crate. Resolve the `ProtoSSLSend` address — first pass: use
the absolute address Task 1 reported, converted to a module-relative offset
plus the live module base. (We'll switch to a byte-pattern scan later for
patch-resilience; leave a TODO for that.)
2. Install a `retour` inline detour on `ProtoSSLSend` with signature
`extern "C" fn(*mut c_void, *const u8, i32) -> i32` (x64 = one calling
convention; `extern "C"` is correct).
3. In the detour: log `length` and the first ~32 bytes of the buffer as hex,
then CALL THE ORIGINAL and return its result (do not block or alter traffic
yet).
Mark the ProtoSSL return-value conventions and the eventual pattern-scan as
`TODO/CONFIRM` per the project rules.
**Verification:** launch the game, attempt to go online / load FUT, and confirm
`hook.log` shows ProtoSSLSend calls with non-trivial byte dumps — these are the
client's real outbound Blaze frames (still TLS-bound on the wire, but plaintext
here, which is the whole point). Seeing real frames = hooking works, and we now
have actual bytes to decode in later tasks. Paste a few of those captured frames
into the next session.
---
## What to attach to each task
- Task 1: nothing (fresh program), or the Linux `protossl_scan.rs` as a logic
reference to port.
- Task 2: nothing, or your existing `version.dll` proxy shell if you have one.
- Task 3: the Task 2 DLL, plus the `openfut_hook_sketch.rs` as the structural
reference, plus the address Task 1 reported.
Once Task 3 yields real captured frames, later tasks (the brain handlers per
gate) should always include the actual pasted bytes — decoding real frames is far
more reliable than guessing the Fire2 layout.
Generated
+302 -7
View File
@@ -78,7 +78,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper 1.0.2", "sync_wrapper 1.0.2",
"tokio", "tokio",
"tower", "tower 0.5.3",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -122,6 +122,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -202,6 +208,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@@ -325,6 +337,17 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@@ -355,6 +378,25 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.4.2",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -438,7 +480,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2 0.3.27",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"httparse", "httparse",
@@ -462,6 +504,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.15",
"http 1.4.2", "http 1.4.2",
"http-body 1.0.1", "http-body 1.0.1",
"httparse", "httparse",
@@ -472,6 +515,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"rustls",
"tokio",
"tokio-rustls",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.5.0" version = "0.5.0"
@@ -763,6 +820,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -788,11 +851,19 @@ dependencies = [
"chrono", "chrono",
"dotenvy", "dotenvy",
"http 1.4.2", "http 1.4.2",
"hyper 1.10.1",
"hyper-util",
"rcgen",
"reqwest", "reqwest",
"rustls",
"rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-rustls",
"tokio-stream",
"tower 0.4.13",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -865,12 +936,42 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -892,6 +993,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -916,6 +1023,18 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rcgen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6"
dependencies = [
"pem",
"ring 0.16.20",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -948,15 +1067,16 @@ version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [ dependencies = [
"base64", "base64 0.21.7",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2 0.3.27",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"hyper 0.14.32", "hyper 0.14.32",
"hyper-rustls",
"hyper-tls", "hyper-tls",
"ipnet", "ipnet",
"js-sys", "js-sys",
@@ -966,6 +1086,7 @@ dependencies = [
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
@@ -974,14 +1095,45 @@ dependencies = [
"system-configuration", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots",
"winreg", "winreg",
] ]
[[package]]
name = "ring"
version = "0.16.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
dependencies = [
"cc",
"libc",
"once_cell",
"spin",
"untrusted 0.7.1",
"web-sys",
"winapi",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted 0.9.0",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -995,13 +1147,35 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring 0.17.14",
"rustls-webpki",
"sct",
]
[[package]] [[package]]
name = "rustls-pemfile" name = "rustls-pemfile"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [ dependencies = [
"base64", "base64 0.21.7",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring 0.17.14",
"untrusted 0.9.0",
] ]
[[package]] [[package]]
@@ -1031,6 +1205,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring 0.17.14",
"untrusted 0.9.0",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -1177,6 +1361,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -1245,7 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -1280,6 +1470,25 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "time"
version = "0.3.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -1328,6 +1537,28 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -1341,6 +1572,21 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1460,6 +1706,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -1484,7 +1742,7 @@ version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.4.3",
"js-sys", "js-sys",
"serde_core", "serde_core",
"wasm-bindgen", "wasm-bindgen",
@@ -1582,6 +1840,34 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -1805,6 +2091,15 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
+16 -1
View File
@@ -15,11 +15,17 @@ path = "src/lib.rs"
name = "openfut-bridge" name = "openfut-bridge"
path = "src/main.rs" path = "src/main.rs"
[[bin]]
name = "openfut-bridge-replay"
path = "src/bin/replay.rs"
[dependencies] [dependencies]
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["sync"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace"] } tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -27,10 +33,19 @@ thiserror = "1"
anyhow = "1" anyhow = "1"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
dotenvy = "0.15" dotenvy = "0.15"
http = "1" http = "1"
bytes = "1" bytes = "1"
rcgen = "0.11"
# TLS: same rustls version as reqwest 0.11 uses internally
rustls = "0.21"
rustls-pemfile = "1"
tokio-rustls = "0.24"
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
hyper = { version = "1", features = ["http1", "http2"] }
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["util"] }
+34 -15
View File
@@ -13,24 +13,43 @@
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify) - [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
## Proxy ## Proxy
- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS - [x] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json` `TLS_ENABLED=true` generates cert at startup via rcgen; uses tokio-rustls
- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder → Cert covers localhost, 127.0.0.1, fut.ea.com, utas.mob.v4.fut.ea.com
- [ ] #14 Add capture deduplication (same method+path within 1 second) - [x] #12 Add replay CLI: `openfut-bridge-replay captures/some_file.json [bridge-url]`
- [ ] #15 Add request diff tool: show what changed between two captures → Accepts single file or entire captures/ directory
→ TLS: danger_accept_invalid_certs=true so self-signed certs work
- [x] #13 Add `DELETE /_bridge/captures` to wipe capture folder
- [x] #14 Add capture deduplication (same method+path within 1 second)
- [x] #15 Add request diff tool: `GET /_bridge/captures/diff?a=<id>&b=<id>`
→ Reports method/path/status changes, header diffs, JSON body key-level diff
## Mapper ## Mapper
- [ ] #16 Implement actual auth endpoint mapping (static token response) - [x] #16 Implement auth endpoint mapping + response shaping
- [ ] #17 Map squad read endpoint when confirmed `src/shaper.rs` wraps Core `/auth/local` response in FUT auth envelope
- [ ] #18 Map pack details endpoint when confirmed → Includes sid, phishingToken, persona, pid, userAccountInfo
- [ ] #19 Add X-UT-SID session header pass-through to Core - [x] #17 Map squad read/write endpoints
- [ ] #20 Add phishing token passthrough `GET /ut/game/fut/squad/active` → Core GET /squad
`PUT /ut/game/fut/squad/active` → Core POST /squad
- [x] #18 Map pack details endpoint
`GET /ut/game/fut/store/packdetails` → Core GET /packs
→ Response shaped into FUT purchasedPacks envelope
- [x] #19 Add X-UT-SID session header pass-through to Core
→ Forwarded verbatim on every mapped Core request
- [x] #20 Add phishing token passthrough
→ X-UT-PHISHING-TOKEN forwarded on every mapped Core request
## Admin UI ## Admin UI
- [ ] #21 Build a simple web dashboard for viewing captures - [x] #21 Build a simple web dashboard for viewing captures (`GET /_bridge/admin`)
- [ ] #22 Add endpoint status page (known vs unknown vs confirmed) → Auto-refreshes every 10s; shows stats, endpoint table, recent captures
- [ ] #23 Add live capture stream via SSE - [x] #22 Add endpoint status page (`GET /_bridge/status`)
→ Reports each unique endpoint as mapped/known/unknown
- [x] #23 Add live capture stream via SSE (`GET /_bridge/captures/stream`)
`event: capture` events pushed on each new capture
## Testing ## Testing
- [ ] #24 Add test for placeholder response format - [x] #24 Add test for placeholder response format
- [ ] #25 Add integration test that fires real HTTP at the Bridge - [x] #25 Add integration test that fires real HTTP at the Bridge
→ 13 tests: health, placeholder, captures CRUD, status, TLS cert gen
- [x] Shaper unit tests (4) — auth envelope shape, dispatch, passthrough, market
- [x] Mapper unit tests (6) — auth, market, events, squad PUT, unknown, trailing slash
+262
View File
@@ -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.
+1 -1
View File
@@ -48,7 +48,7 @@ This document maps confirmed or suspected FIFA 23 FUT API endpoints to their Ope
| FUT Endpoint | Core Endpoint | Status | Notes | | FUT Endpoint | Core Endpoint | Status | Notes |
|---|---|---|---| |---|---|---|---|
| Unknown | `POST /matches/result` | ❌ | Needs capture | | Unknown | `POST /matches/complete` | ❌ | Needs capture. Body must carry a `match_identity` (exactly-once key). `POST /matches/result` was removed — no transaction, no idempotency key. |
## Objectives ## Objectives
+91
View File
@@ -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 (M1M7). 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 M1M5; **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 M1M5 are documented — valuable even if A stalls.
- **A (playable AI FUT)** requires M1M7.
## 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).
+336
View File
@@ -0,0 +1,336 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cc"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "generic-array"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libudis86-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139bbf9ddb1bfc90c1ac64dd2923d9c957cd433cee7315c018125d72ab08a6b0"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "mmap-fixed-fixed"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"retour",
"windows",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "region"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7"
dependencies = [
"bitflags",
"libc",
"mach2",
"windows-sys",
]
[[package]]
name = "retour"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9af44d40e2400b44d491bfaf8eae111b09f23ac4de6e92728e79d93e699c527"
dependencies = [
"cfg-if",
"generic-array",
"libc",
"libudis86-sys",
"mmap-fixed-fixed",
"once_cell",
"region",
"slice-pool2",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slice-pool2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "openfut-hook"
version = "0.1.0"
edition = "2021"
description = "FIFA 23 version.dll proxy + ProtoSSL plaintext capture hook"
[lib]
# Output is `version.dll` (the proxy/sideload name FIFA 23 loads).
name = "version"
crate-type = ["cdylib"]
[dependencies]
# Inline-hooking library (installs the detour on _ProtoSSLSendPacket).
retour = "0.3"
# Win32 bindings for the loader/threading/module APIs the hook needs.
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Networking_WinSock",
"Win32_System_LibraryLoader",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
"Win32_System_SystemInformation",
"Win32_System_SystemServices",
] }
# Own workspace root so Cargo doesn't attach this to the openfut-bridge package
# in the parent directory.
[workspace]
+920
View File
@@ -0,0 +1,920 @@
//! openfut-hook — FIFA 23 `version.dll` proxy + ProtoSSL plaintext capture.
//!
//! This DLL is built as `version.dll` and dropped next to `FIFA23.exe`. The
//! game loads it (DLL search-order / sideload), at which point we:
//!
//! 1. (Task 2) Forward every real `version.dll` export to the genuine system
//! DLL, so the game keeps working. We prove load with a log line.
//! 2. (Task 3) Pattern-scan FIFA23.exe for `_ProtoSSLSendPacket` (the DirtySDK
//! TLS record assembler we located at FIFA23.exe+0xEFA530) and install an
//! inline detour. At its entry the record payload is still PLAINTEXT, so we
//! log the content type + the source buffers, then call the original.
//!
//! Everything is written to `C:\openfut\hook.log` with timestamps, in stages,
//! so the log alone tells us how far initialization got.
//!
//! Clean-room: this is generic proxy/loader/hook scaffolding plus a byte
//! pattern derived from observing the binary the user owns. No EA source.
use core::ffi::c_void;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use retour::RawDetour;
use windows::core::{PCSTR, PCWSTR};
use windows::Win32::Foundation::{BOOL, HMODULE};
use windows::Win32::Networking::WinSock::{WSAGetLastError, AF_INET, SOCKADDR, SOCKADDR_IN};
use windows::Win32::System::LibraryLoader::{
DisableThreadLibraryCalls, GetModuleHandleW, GetProcAddress, LoadLibraryW,
};
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, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
};
// ---------------------------------------------------------------------------
// Task 2: version.dll export forwarding
// ---------------------------------------------------------------------------
/// Resolved addresses of the real system `version.dll` exports. Each proxy stub
/// below tail-jumps to its slot. AtomicUsize (not `static mut`) keeps this sound
/// and lets the naked stubs read the raw pointer via a simple memory load.
static REAL: [AtomicUsize; 17] = [const { AtomicUsize::new(0) }; 17];
/// The 17 exports a real `version.dll` provides, in the SAME order as the proxy
/// stubs' indices below.
const EXPORTS: [&str; 17] = [
"GetFileVersionInfoA",
"GetFileVersionInfoByHandle",
"GetFileVersionInfoExA",
"GetFileVersionInfoExW",
"GetFileVersionInfoSizeA",
"GetFileVersionInfoSizeExA",
"GetFileVersionInfoSizeExW",
"GetFileVersionInfoSizeW",
"GetFileVersionInfoW",
"VerFindFileA",
"VerFindFileW",
"VerInstallFileA",
"VerInstallFileW",
"VerLanguageNameA",
"VerLanguageNameW",
"VerQueryValueA",
"VerQueryValueW",
];
/// Generate an exported, naked proxy stub that tail-jumps to `REAL[idx]`.
/// A tail `jmp` leaves every register/stack arg untouched, so it forwards any
/// signature correctly regardless of how many arguments the real function takes.
macro_rules! proxy_stub {
($idx:literal, $name:ident) => {
#[no_mangle]
#[unsafe(naked)]
pub unsafe extern "system" fn $name() {
core::arch::naked_asm!(
"jmp qword ptr [rip + {base} + {off}]",
base = sym REAL,
off = const $idx * 8,
);
}
};
}
proxy_stub!(0, GetFileVersionInfoA);
proxy_stub!(1, GetFileVersionInfoByHandle);
proxy_stub!(2, GetFileVersionInfoExA);
proxy_stub!(3, GetFileVersionInfoExW);
proxy_stub!(4, GetFileVersionInfoSizeA);
proxy_stub!(5, GetFileVersionInfoSizeExA);
proxy_stub!(6, GetFileVersionInfoSizeExW);
proxy_stub!(7, GetFileVersionInfoSizeW);
proxy_stub!(8, GetFileVersionInfoW);
proxy_stub!(9, VerFindFileA);
proxy_stub!(10, VerFindFileW);
proxy_stub!(11, VerInstallFileA);
proxy_stub!(12, VerInstallFileW);
proxy_stub!(13, VerLanguageNameA);
proxy_stub!(14, VerLanguageNameW);
proxy_stub!(15, VerQueryValueA);
proxy_stub!(16, VerQueryValueW);
/// Load the genuine `version.dll` by full path (so we don't re-load ourselves)
/// and fill `REAL[]` with each export's address. Done synchronously in DllMain
/// so the slots are ready before the game calls any version function.
unsafe fn resolve_exports() {
let path = wide("C:\\Windows\\System32\\version.dll");
let module = match LoadLibraryW(PCWSTR(path.as_ptr())) {
Ok(m) => m,
Err(e) => {
log(&format!("FATAL: could not load real version.dll: {e:?}"));
return;
}
};
let mut missing = 0;
for (i, name) in EXPORTS.iter().enumerate() {
let cname = std::ffi::CString::new(*name).unwrap();
let addr = GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8))
.map(|f| f as usize)
.unwrap_or(0);
REAL[i].store(addr, Ordering::SeqCst);
if addr == 0 {
missing += 1;
log(&format!("warn: real version.dll missing export {name}"));
}
}
log(&format!("forwarded {} version.dll exports", 17 - missing));
}
// ---------------------------------------------------------------------------
// Task 3: the _ProtoSSLSendPacket detour
// ---------------------------------------------------------------------------
/// Trampoline to the original `_ProtoSSLSendPacket`, stored after we hook.
static ORIG: AtomicUsize = AtomicUsize::new(0);
/// Signature derived from the disassembly (Windows x64 ABI):
/// `_ProtoSSLSendPacket(pState, contentType, bufA, lenA, bufB, lenB) -> i32`.
type SendPacket =
unsafe extern "system" fn(*mut c_void, u32, *const u8, i32, *const u8, i32) -> i32;
/// Byte pattern for the `_ProtoSSLSendPacket` prologue. The four `0x00` bytes at
/// the masked positions are the stack-cookie `mov rax,[rip+disp32]` displacement
/// (position-dependent), so they're wildcarded via `MASK`.
const PATTERN: [u8; 36] = [
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x57, 0x48, 0x81, 0xEC, 0xB0,
0x00, 0x00, 0x00, 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00, 0x48, 0x33, 0xC4, 0x48, 0x89,
0x84, 0x24, 0xA0, 0x00, 0x00, 0x00,
];
/// `false` = wildcard byte (don't compare). Indices 21..=24 are the disp32.
const MASK: [bool; 36] = {
let mut m = [true; 36];
m[21] = false;
m[22] = false;
m[23] = false;
m[24] = false;
m
};
/// Our detour. At entry the record payload is still plaintext, so we log the
/// content type and source buffers, then forward to the original unchanged.
unsafe extern "system" fn hooked(
p_state: *mut c_void,
content_type: u32,
buf_a: *const u8,
len_a: i32,
buf_b: *const u8,
len_b: i32,
) -> i32 {
log_frame(content_type as u8, buf_a, len_a, buf_b, len_b);
let orig = ORIG.load(Ordering::SeqCst);
if orig != 0 {
let orig: SendPacket = core::mem::transmute(orig);
orig(p_state, content_type, buf_a, len_a, buf_b, len_b)
} else {
// Should never happen (we store ORIG before the game can call us), but
// never crash the game if it does.
0
}
}
/// Background init: pattern-scan FIFA23.exe for the function, then detour it.
/// Retries for a while in case the image isn't fully paged in at load.
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, 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 !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);
}
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
}
/// Scan the FIFA23.exe image for the `_ProtoSSLSendPacket` prologue pattern.
unsafe fn find_send_packet() -> Option<usize> {
let module = GetModuleHandleW(PCWSTR::null()).ok()?; // null => the EXE itself
let base = module.0 as usize;
let mut info = MODULEINFO::default();
GetModuleInformation(
GetCurrentProcess(),
module,
&mut info,
core::mem::size_of::<MODULEINFO>() as u32,
)
.ok()?;
let size = info.SizeOfImage as usize;
let hay = core::slice::from_raw_parts(base as *const u8, size);
let plen = PATTERN.len();
if hay.len() < plen {
return None;
}
for i in 0..=hay.len() - plen {
// Cheap first-byte gate before the full compare.
if hay[i] != PATTERN[0] {
continue;
}
let mut ok = true;
for j in 1..plen {
if MASK[j] && hay[i + j] != PATTERN[j] {
ok = false;
break;
}
}
if ok {
return Some(base + i);
}
}
None
}
/// Install the inline detour on the resolved function address.
unsafe fn install_hook(addr: usize) {
let detour = match RawDetour::new(addr as *const (), hooked as *const ()) {
Ok(d) => d,
Err(e) => {
log(&format!("ERROR: could not create detour: {e:?}"));
return;
}
};
if let Err(e) = detour.enable() {
log(&format!("ERROR: could not enable detour: {e:?}"));
return;
}
// Leak the detour so it lives forever (dropping it would un-hook), and
// publish its trampoline so `hooked` can call the original.
let detour: &'static RawDetour = Box::leak(Box::new(detour));
ORIG.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
log(&format!(
"hook installed: _ProtoSSLSendPacket @ 0x{addr:X} (FIFA23.exe+0x{:X})",
addr - module_base(),
));
}
// ---------------------------------------------------------------------------
// Connection capture: log every connect() and redirect external attempts to a
// local listener, so the client's TCP succeeds and it starts sending TLS.
// ---------------------------------------------------------------------------
/// Local port our in-DLL listener binds; redirected connections land here.
const LOCAL_PORT: u16 = 7777;
/// The EA launcher LSX port. The game connects here for launcher↔game bootstrap
/// (gate 1). We listen so the connect succeeds and we can see the LSX protocol.
const LSX_PORT: u16 = 3216;
/// Trampoline to the original ws2_32 `connect`.
static ORIG_CONNECT: AtomicUsize = AtomicUsize::new(0);
type ConnectFn = unsafe extern "system" fn(usize, *const SOCKADDR, i32) -> i32;
/// Our `connect` detour: log the real destination, and for any non-loopback
/// IPv4 target, rewrite it to 127.0.0.1:LOCAL_PORT before calling the original.
unsafe extern "system" fn hooked_connect(s: usize, name: *const SOCKADDR, namelen: i32) -> i32 {
let orig: ConnectFn = core::mem::transmute(ORIG_CONNECT.load(Ordering::SeqCst));
if !name.is_null() && (*name).sa_family == AF_INET {
let sin = name as *const SOCKADDR_IN;
let port = u16::from_be((*sin).sin_port);
let octets = (*sin).sin_addr.S_un.S_addr.to_ne_bytes();
let is_loopback = octets[0] == 127;
let dst = format!("{}.{}.{}.{}:{}", octets[0], octets[1], octets[2], octets[3], port);
if !is_loopback {
// Build a fresh 127.0.0.1:LOCAL_PORT address and connect there.
let mut local: SOCKADDR_IN = core::mem::zeroed();
local.sin_family = AF_INET;
local.sin_port = LOCAL_PORT.to_be();
local.sin_addr.S_un.S_addr = u32::from_ne_bytes([127, 0, 0, 1]);
let ret = orig(
s,
&local as *const SOCKADDR_IN as *const SOCKADDR,
core::mem::size_of::<SOCKADDR_IN>() as i32,
);
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
log(&format!("CONNECT -> {dst} [redirected->7777] ret={ret} err={err}"));
return ret;
} else {
let ret = orig(s, name, namelen);
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
log(&format!("CONNECT -> {dst} ret={ret} err={err}"));
return ret;
}
}
orig(s, name, namelen)
}
// --- DNS logging: what hostnames does the client try to resolve? ----------
static ORIG_GAIW: AtomicUsize = AtomicUsize::new(0);
static ORIG_GAI: AtomicUsize = AtomicUsize::new(0);
type GaiWFn = unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *mut *mut c_void) -> i32;
type GaiFn = unsafe extern "system" fn(PCSTR, PCSTR, *const c_void, *mut *mut c_void) -> i32;
unsafe extern "system" fn hooked_gaiw(
node: PCWSTR,
svc: PCWSTR,
hints: *const c_void,
res: *mut *mut c_void,
) -> i32 {
let name = if node.is_null() {
"<null>".to_string()
} else {
node.to_string().unwrap_or_else(|_| "<?>".into())
};
let orig: GaiWFn = core::mem::transmute(ORIG_GAIW.load(Ordering::SeqCst));
let ret = orig(node, svc, hints, res);
log(&format!("DNS GetAddrInfoW(\"{name}\") ret={ret}"));
ret
}
unsafe extern "system" fn hooked_gai(
node: PCSTR,
svc: PCSTR,
hints: *const c_void,
res: *mut *mut c_void,
) -> i32 {
let name = if node.is_null() {
"<null>".to_string()
} else {
node.to_string().unwrap_or_else(|_| "<?>".into())
};
let orig: GaiFn = core::mem::transmute(ORIG_GAI.load(Ordering::SeqCst));
let ret = orig(node, svc, hints, res);
log(&format!("DNS getaddrinfo(\"{name}\") ret={ret}"));
ret
}
/// Generic: resolve `name` in `module`, install a detour to `detour`, store the
/// trampoline in `slot`.
unsafe fn install_detour(
module: HMODULE,
name: &[u8],
detour: *const (),
slot: &AtomicUsize,
label: &str,
) {
let addr = match GetProcAddress(module, PCSTR(name.as_ptr())) {
Some(f) => f as usize,
None => {
log(&format!("ERROR: {label} not found"));
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) => {
log(&format!("ERROR: {label} detour create: {e:?}"));
return;
}
};
if d.enable().is_err() {
log(&format!("ERROR: {label} detour enable failed"));
return;
}
let d: &'static RawDetour = Box::leak(Box::new(d));
slot.store(d.trampoline() as *const () as usize, Ordering::SeqCst);
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())) {
Ok(m) => m,
Err(e) => {
log(&format!("ERROR: load ws2_32 for DNS: {e:?}"));
return;
}
};
install_detour(ws2, b"GetAddrInfoW\0", hooked_gaiw as *const (), &ORIG_GAIW, "GetAddrInfoW");
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())) {
Ok(m) => m,
Err(e) => {
log(&format!("ERROR: could not load ws2_32.dll: {e:?}"));
return;
}
};
let addr = match GetProcAddress(ws2, PCSTR(b"connect\0".as_ptr())) {
Some(f) => f as usize,
None => {
log("ERROR: connect not found in ws2_32.dll");
return;
}
};
let detour = match RawDetour::new(addr as *const (), hooked_connect as *const ()) {
Ok(d) => d,
Err(e) => {
log(&format!("ERROR: could not create connect detour: {e:?}"));
return;
}
};
if let Err(e) = detour.enable() {
log(&format!("ERROR: could not enable connect detour: {e:?}"));
return;
}
let detour: &'static RawDetour = Box::leak(Box::new(detour));
ORIG_CONNECT.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
log("connect hook installed (external IPv4 -> 127.0.0.1:7777)");
}
/// Spawn a TCP listener on `127.0.0.1:port`, tagging logged traffic with `tag`.
/// Accepts connections and logs what the client sends — as readable text (for
/// the text-based LSX protocol) plus a hex prefix (for binary TLS/Blaze).
fn start_listener(port: u16, tag: &'static str) {
std::thread::spawn(move || {
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
log(&format!("ERROR: listener[{tag}] bind {port} failed: {e}"));
return;
}
};
let bound = listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_default();
log(&format!("listener[{tag}] up, bound {bound}, accepting"));
// Explicit accept loop so accept errors are visible (not swallowed).
loop {
match listener.accept() {
Ok((stream, peer)) => {
log(&format!("ACCEPT[{tag}] from {peer}"));
std::thread::spawn(move || handle_conn(stream, tag, peer.to_string()));
}
Err(e) => {
log(&format!("ACCEPT[{tag}] error: {e}"));
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
}
});
}
fn handle_conn(mut stream: std::net::TcpStream, tag: &'static str, peer: String) {
use std::io::Read;
let mut buf = [0u8; 2048];
let mut total = 0usize;
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
total += n;
let ascii = ascii_render(&buf[..n.min(400)]);
let hexp = hex(&buf[..n.min(24)]);
log(&format!("RECV[{tag}] {n}B | hex: {hexp} | text: {ascii}"));
}
}
}
log(&format!("CLOSE[{tag}] from {peer} after {total}B total"));
}
/// Render bytes as printable ASCII (non-printable -> '.'), for text protocols.
fn ascii_render(bytes: &[u8]) -> String {
bytes
.iter()
.map(|&b| {
if (0x20..=0x7e).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t' {
b as char
} else {
'.'
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
fn log_frame(content_type: u8, buf_a: *const u8, len_a: i32, buf_b: *const u8, len_b: i32) {
let label = match content_type {
0x14 => "ccs",
0x15 => "alert",
0x16 => "handshake",
0x17 => "appdata",
_ => "?",
};
let mut line = format!("SEND type=0x{content_type:02X}({label}) lenA={len_a} lenB={len_b}");
if !buf_a.is_null() && len_a > 0 {
let n = (len_a as usize).min(48);
let bytes = unsafe { core::slice::from_raw_parts(buf_a, n) };
line.push_str(&format!(" | A: {}", hex(bytes)));
}
if !buf_b.is_null() && len_b > 0 {
let n = (len_b as usize).min(16);
let bytes = unsafe { core::slice::from_raw_parts(buf_b, n) };
line.push_str(&format!(" | B: {}", hex(bytes)));
}
log(&line);
}
fn hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ")
}
/// Serializes log writes so concurrent threads don't corrupt each other's lines.
static LOG_LOCK: Mutex<()> = Mutex::new(());
/// Append a timestamped line to `C:\openfut\hook.log`.
fn log(msg: &str) {
use std::io::Write;
let _guard = LOG_LOCK.lock();
let _ = std::fs::create_dir_all("C:\\openfut");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\openfut\\hook.log")
{
let _ = writeln!(f, "[{}] {}", now(), msg);
}
}
fn now() -> String {
let st = unsafe { GetLocalTime() };
format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond
)
}
// ---------------------------------------------------------------------------
// Helpers + entry point
// ---------------------------------------------------------------------------
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// FIFA23.exe base address (the main module), for pretty logging.
fn module_base() -> usize {
unsafe {
GetModuleHandleW(PCWSTR::null())
.map(|h| h.0 as usize)
.unwrap_or(0)
}
}
#[no_mangle]
pub extern "system" fn DllMain(module: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
unsafe {
let _ = DisableThreadLibraryCalls(module);
let host = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_default();
log(&format!(
"openfut-hook loaded | pid {} | host {host}",
std::process::id()
));
// Forward exports synchronously (must be ready before any call)...
resolve_exports();
// ...then do the pattern scan + hook on a background thread (never
// do real work directly in DllMain — loader lock).
let _ = CreateThread(
None,
0,
Some(init_thread),
None,
THREAD_CREATION_FLAGS(0),
None,
);
}
}
BOOL(1)
}
+184
View File
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenFUT Bridge — Admin</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
header h1 { font-size: 1.1rem; color: #58a6ff; }
header .badge { font-size: 0.75rem; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; }
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
section { margin-bottom: 32px; }
h2 { font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e; border-bottom: 1px solid #30363d; padding-bottom: 8px; margin-bottom: 16px; }
.stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.stat { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px 24px; min-width: 140px; }
.stat .num { font-size: 2rem; font-weight: 700; color: #58a6ff; }
.stat .label { font-size: 0.8rem; color: #8b949e; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; padding: 8px 12px; background: #161b22; color: #8b949e; border-bottom: 1px solid #30363d; white-space: nowrap; }
td { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; word-break: break-all; max-width: 400px; }
tr:hover td { background: #161b22; }
.badge-method { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.GET { background: #1f6feb; color: #fff; }
.POST { background: #2ea043; color: #fff; }
.PUT { background: #9e6a03; color: #fff; }
.DELETE { background: #b91c1c; color: #fff; }
.status-mapped { color: #3fb950; }
.status-unknown { color: #f85149; }
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn:hover { background: #30363d; }
.btn-danger { border-color: #b91c1c; color: #f85149; }
.btn-danger:hover { background: #b91c1c22; }
#live-log { height: 220px; overflow-y: auto; background: #010409; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 0.78rem; color: #7ee787; }
#live-log .entry { margin-bottom: 4px; }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.toolbar .spacer { flex: 1; }
.empty { color: #8b949e; font-style: italic; font-size: 0.85rem; }
</style>
</head>
<body>
<header>
<h1>OpenFUT Bridge</h1>
<span class="badge">Admin Dashboard</span>
<span id="status-dot" style="margin-left:auto;font-size:0.8rem;color:#8b949e">connecting…</span>
</header>
<main>
<div class="stats" id="stats">
<div class="stat"><div class="num" id="s-total"></div><div class="label">Total captures</div></div>
<div class="stat"><div class="num" id="s-unknown"></div><div class="label">Unknown endpoints</div></div>
<div class="stat"><div class="num" id="s-endpoints"></div><div class="label">Unique endpoints</div></div>
</div>
<section>
<h2>Live capture stream</h2>
<div id="live-log"><span class="empty">Waiting for traffic…</span></div>
</section>
<section>
<h2>Endpoint status</h2>
<div id="endpoints-table"><span class="empty">Loading…</span></div>
</section>
<section>
<h2>Recent captures</h2>
<div class="toolbar">
<button class="btn" onclick="loadCaptures()">Refresh</button>
<span class="spacer"></span>
<button class="btn btn-danger" onclick="clearCaptures()">Delete all captures</button>
</div>
<div id="captures-table"><span class="empty">Loading…</span></div>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
async function api(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
}
function methodBadge(m) {
return `<span class="badge-method ${m}">${m}</span>`;
}
async function loadStats() {
try {
const d = await api('/_bridge/captures');
$('s-total').textContent = d.total;
$('s-unknown').textContent = d.unknown_endpoints;
} catch (e) { console.error(e); }
}
async function loadEndpoints() {
try {
const d = await api('/_bridge/status');
$('s-endpoints').textContent = d.total_unique_endpoints;
if (!d.endpoints.length) {
$('endpoints-table').innerHTML = '<span class="empty">No endpoints seen yet.</span>';
return;
}
const rows = d.endpoints.map(ep => `
<tr>
<td>${methodBadge(ep.method)}</td>
<td><code>${ep.path}</code></td>
<td class="status-${ep.status}">${ep.status}</td>
<td>${ep.mapped_to || '—'}</td>
<td>${ep.first_seen ? ep.first_seen.slice(0,19).replace('T',' ') : '—'}</td>
</tr>`).join('');
$('endpoints-table').innerHTML = `
<table>
<thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Maps to</th><th>First seen</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
} catch (e) { $('endpoints-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
}
async function loadCaptures() {
try {
const d = await api('/_bridge/captures');
if (!d.captures.length) {
$('captures-table').innerHTML = '<span class="empty">No captures yet.</span>';
return;
}
const rows = d.captures.slice(-50).reverse().map(c => `
<tr>
<td>${c.timestamp ? c.timestamp.slice(0,19).replace('T',' ') : ''}</td>
<td>${methodBadge(c.method)}</td>
<td><code>${c.path}${c.query ? '?' + c.query : ''}</code></td>
<td>${c.response_status || '—'}</td>
<td class="status-${c.mapped_to_core ? 'mapped' : 'unknown'}">${c.mapped_to_core || 'unknown'}</td>
</tr>`).join('');
$('captures-table').innerHTML = `
<table>
<thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Status</th><th>Core mapping</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
} catch (e) { $('captures-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
}
async function clearCaptures() {
if (!confirm('Delete all capture files from disk?')) return;
try {
const r = await fetch('/_bridge/captures', { method: 'DELETE' });
const d = await r.json();
alert(`Deleted ${d.deleted} capture(s).`);
loadCaptures(); loadStats();
} catch (e) { alert('Error: ' + e.message); }
}
function connectSSE() {
const es = new EventSource('/_bridge/captures/stream');
const log = $('live-log');
es.onopen = () => { $('status-dot').textContent = '● live'; $('status-dot').style.color = '#3fb950'; };
es.onerror = () => { $('status-dot').textContent = '○ disconnected'; $('status-dot').style.color = '#f85149'; };
es.addEventListener('capture', e => {
try {
const c = JSON.parse(e.data);
const entry = document.createElement('div');
entry.className = 'entry';
entry.textContent = `${c.timestamp?.slice(11,19) || ''} ${c.method} ${c.path}${c.response_status || '?'} [${c.mapped_to_core || 'unknown'}]`;
if (log.firstChild?.className === 'empty') log.innerHTML = '';
log.prepend(entry);
if (log.children.length > 100) log.lastChild?.remove();
loadStats();
} catch {}
});
}
// Initial load
loadStats();
loadEndpoints();
loadCaptures();
connectSSE();
setInterval(() => { loadEndpoints(); loadCaptures(); }, 10_000);
</script>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
//! openfut-bridge-replay — replay a captured request file against the bridge.
//!
//! Usage:
//! openfut-bridge-replay \<capture.json\> [bridge-url]
//! openfut-bridge-replay captures/ (replay all captures in a dir)
//!
//! The bridge URL defaults to `http://127.0.0.1:8443`.
//! Self-signed TLS certificates are accepted automatically.
use openfut_bridge::capture::CapturedRequest;
use std::process::ExitCode;
#[tokio::main]
async fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: openfut-bridge-replay <capture.json|dir> [bridge-url]");
eprintln!(" bridge-url defaults to http://127.0.0.1:8443");
return ExitCode::FAILURE;
}
let path_arg = &args[1];
let bridge_url = args
.get(2)
.map(|s| s.as_str())
.unwrap_or("http://127.0.0.1:8443");
let client = match reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(e) => {
eprintln!("Failed to build HTTP client: {e}");
return ExitCode::FAILURE;
}
};
let captures = match collect_captures(path_arg) {
Ok(c) => c,
Err(e) => {
eprintln!("Error loading captures: {e}");
return ExitCode::FAILURE;
}
};
if captures.is_empty() {
eprintln!("No capture files found at {path_arg}");
return ExitCode::FAILURE;
}
println!(
"Replaying {} capture(s) against {bridge_url}",
captures.len()
);
let mut failures = 0u32;
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),
Err(e) => {
eprintln!(" [{}] {} {} → ERROR: {e}", capture.id, capture.method, capture.path);
failures += 1;
}
}
}
if failures > 0 {
eprintln!("{failures} replay(s) failed.");
ExitCode::FAILURE
} else {
println!("All replays succeeded.");
ExitCode::SUCCESS
}
}
fn collect_captures(path_arg: &str) -> anyhow::Result<Vec<CapturedRequest>> {
let path = std::path::Path::new(path_arg);
if path.is_dir() {
openfut_bridge::capture::load_all_captures(path_arg)
} else {
let content = std::fs::read_to_string(path)?;
let capture: CapturedRequest = serde_json::from_str(&content)?;
Ok(vec![capture])
}
}
async fn replay_one(
client: &reqwest::Client,
bridge_url: &str,
capture: &CapturedRequest,
) -> anyhow::Result<u16> {
let url = format!("{}{}", bridge_url, capture.path);
let builder = match capture.method.to_uppercase().as_str() {
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => client.get(&url),
};
let builder = if let Some(body) = &capture.body {
builder
.header("content-type", "application/json")
.body(body.clone())
} else {
builder
};
let resp = builder.send().await?;
Ok(resp.status().as_u16())
}
+5
View File
@@ -10,6 +10,8 @@ pub struct Config {
pub captures_dir: String, pub captures_dir: String,
/// If true, return placeholder 200 responses for unknown routes /// If true, return placeholder 200 responses for unknown routes
pub placeholder_mode: bool, pub placeholder_mode: bool,
/// If true, serve over TLS with a generated self-signed certificate
pub tls_enabled: bool,
} }
impl Config { impl Config {
@@ -22,6 +24,9 @@ impl Config {
placeholder_mode: std::env::var("PLACEHOLDER_MODE") placeholder_mode: std::env::var("PLACEHOLDER_MODE")
.map(|v| v == "true" || v == "1") .map(|v| v == "true" || v == "1")
.unwrap_or(true), .unwrap_or(true),
tls_enabled: std::env::var("TLS_ENABLED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false),
}) })
} }
} }
+2947
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,3 +4,5 @@ pub mod error;
pub mod mapper; pub mod mapper;
pub mod proxy; pub mod proxy;
pub mod routes; pub mod routes;
pub mod shaper;
pub mod tls;
+92 -10
View File
@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
routing::{any, get}, routing::{any, delete, get, post},
Router, Router,
}; };
use openfut_bridge::{config::Config, proxy::ProxyState, routes}; use openfut_bridge::{config::Config, proxy::ProxyState, routes};
@@ -22,31 +22,113 @@ async fn main() -> Result<()> {
let cfg = Config::from_env()?; let cfg = Config::from_env()?;
let listen_addr = cfg.listen_addr.clone(); let listen_addr = cfg.listen_addr.clone();
let tls_enabled = cfg.tls_enabled;
info!("OpenFUT Bridge starting on {listen_addr}"); info!("OpenFUT Bridge starting on {listen_addr}");
info!("Core URL: {}", cfg.core_url); info!("Core URL: {}", cfg.core_url);
info!("Placeholder mode: {}", cfg.placeholder_mode); info!("Placeholder mode: {}", cfg.placeholder_mode);
info!("Captures dir: {}", cfg.captures_dir); info!("Captures dir: {}", cfg.captures_dir);
info!("TLS enabled: {tls_enabled}");
std::fs::create_dir_all(&cfg.captures_dir)?; std::fs::create_dir_all(&cfg.captures_dir)?;
let state = ProxyState::new(cfg); let addr: std::net::SocketAddr = listen_addr.parse()?;
let app = Router::new() if tls_enabled {
// Generate cert/key before building state so the cert can be served via /_bridge/cert.pem
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?;
let state = ProxyState::new(cfg).with_cert(cert_pem.clone());
let app = build_router(state);
serve_tls(app, cert_pem, key_pem, addr).await
} else {
let state = ProxyState::new(cfg);
let app = build_router(state);
info!("Bridge listening on http://{addr}");
info!("Set TLS_ENABLED=true to serve over HTTPS");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
}
fn build_router(state: ProxyState) -> Router {
Router::new()
.route("/_bridge/health", get(routes::health::get_health)) .route("/_bridge/health", get(routes::health::get_health))
.route("/_bridge/cert.pem", get(routes::health::get_cert))
.route("/_bridge/guide", get(routes::health::get_guide))
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
.route("/_bridge/admin", get(routes::admin::get_admin_dashboard))
.route("/_bridge/captures", get(routes::admin::get_captures)) .route("/_bridge/captures", get(routes::admin::get_captures))
.route("/_bridge/captures", delete(routes::admin::delete_captures))
.route(
"/_bridge/captures/stream",
get(routes::admin::get_captures_stream),
)
.route( .route(
"/_bridge/unknown", "/_bridge/unknown",
get(routes::admin::get_unknown_endpoints), get(routes::admin::get_unknown_endpoints),
) )
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
.route(
"/_bridge/captures/diff",
get(routes::admin::get_capture_diff),
)
.route(
"/_bridge/captures/:id/replay",
post(routes::admin::post_replay_capture),
)
.fallback(any(openfut_bridge::proxy::catch_all_handler)) .fallback(any(openfut_bridge::proxy::catch_all_handler))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state)
}
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
info!("Bridge listening on http://{listen_addr}"); async fn serve_tls(
axum::serve(listener, app).await?; app: Router,
cert_pem: Vec<u8>,
Ok(()) key_pem: Vec<u8>,
addr: std::net::SocketAddr,
) -> Result<()> {
use hyper_util::rt::{TokioExecutor, TokioIo};
use openfut_bridge::tls::make_tls_acceptor;
use tower::ServiceExt;
let acceptor = make_tls_acceptor(&cert_pem, &key_pem)?;
info!("Bridge listening on https://{addr} (self-signed TLS)");
info!("Download the CA cert from /_bridge/cert.pem and install it as trusted");
info!("Setup guide: https://{addr}/_bridge/guide");
let listener = tokio::net::TcpListener::bind(addr).await?;
loop {
let (tcp, _peer) = listener.accept().await?;
let acceptor = acceptor.clone();
let app = app.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(tcp).await {
Ok(s) => s,
Err(e) => {
tracing::warn!("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 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)
.await
{
tracing::debug!("TLS connection closed: {e}");
}
});
}
} }
+774 -68
View File
@@ -4,84 +4,569 @@
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CoreMapping { pub struct CoreMapping {
pub method: &'static str, pub method: &'static str,
pub core_path: &'static str, pub core_path: String,
#[allow(dead_code)] #[allow(dead_code)]
pub notes: &'static str, pub notes: &'static str,
} }
// ── Exact mappings (static path, no dynamic segments) ─────────────────────────
struct ExactRoute {
ea_method: &'static str,
ea_path: &'static str,
core_method: &'static str,
core_path: &'static str,
notes: &'static str,
}
const EXACT: &[ExactRoute] = &[
// ── Auth ─────────────────────────────────────────────────────────────────
ExactRoute {
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",
notes: "FUT user settings → Core profile",
},
ExactRoute {
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",
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",
notes: "FUT mass info (club + coins) → Core club",
},
ExactRoute {
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",
notes: "FUT update club → Core update club",
},
ExactRoute {
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",
notes: "FUT collection → Core owned cards",
},
ExactRoute {
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",
notes: "FUT active squad → Core squad",
},
ExactRoute {
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",
notes: "FUT save squad → Core save squad",
},
ExactRoute {
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",
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",
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",
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",
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",
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",
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",
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",
notes: "FUT objectives → Core objectives list",
},
ExactRoute {
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",
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",
notes: "Squad battles opponent → Core match opponent generator",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/result",
core_method: "POST", core_path: "/matches/complete",
notes: "FUT match result submit → Core exactly-once match completion. \
Core requires a match_identity on the body; /matches/result was \
removed because it had no transaction and no idempotency key.",
},
ExactRoute {
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",
notes: "FUT SBC list → Core SBC list",
},
ExactRoute {
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",
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",
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",
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",
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",
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",
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",
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",
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",
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",
notes: "FUT notifications → Core notifications",
},
ExactRoute {
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",
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",
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",
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",
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",
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",
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",
notes: "FUT squad list → Core all squads",
},
ExactRoute {
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",
notes: "FUT trophies → Core achievements",
},
ExactRoute {
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",
notes: "FUT rivals rank → Core division",
},
ExactRoute {
ea_method: "POST", ea_path: "/ut/game/fut/rivals/result",
core_method: "POST", core_path: "/matches/complete",
notes: "FUT rivals match result → Core exactly-once match completion \
(body must carry a match_identity).",
},
ExactRoute {
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",
notes: "FUT objectives group → Core objectives",
},
ExactRoute {
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",
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",
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",
notes: "FUT catalogue items → Core card catalogue",
},
ExactRoute {
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",
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",
notes: "FUT consumables → Core chemistry styles (approximation)",
},
ExactRoute {
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",
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",
notes: "FUT customization → Core settings",
},
ExactRoute {
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",
notes: "FUT active messages → Core notifications (mapped to nearest equivalent)",
},
];
// ── Prefix mappings (dynamic path segments after a known prefix) ───────────────
struct PrefixRoute {
ea_method: &'static str,
ea_prefix: &'static str,
/// Generates the core path given the path suffix after ea_prefix.
core_path_fn: fn(&str) -> String,
notes: &'static str,
}
fn prefix_routes() -> &'static [PrefixRoute] {
&[
// /ut/game/fut/draft/{session_id} → GET /draft/sessions/{session_id}
PrefixRoute {
ea_method: "GET",
ea_prefix: "/ut/game/fut/draft/",
core_path_fn: |suffix| format!("/draft/sessions/{suffix}"),
notes: "FUT draft session → Core draft session",
},
// /ut/game/fut/draft/{id}/pick → POST /draft/sessions/{id}/pick
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/draft/",
core_path_fn: |suffix| {
// suffix is "{id}/pick" or "{id}/abandon"
let parts: Vec<&str> = suffix.splitn(2, '/').collect();
if parts.len() == 2 {
format!("/draft/sessions/{}/{}", parts[0], parts[1])
} else {
format!("/draft/sessions/{suffix}")
}
},
notes: "FUT draft pick/abandon → Core draft action",
},
// /ut/game/fut/champs/{session_id}/result → POST /fut-champs/{session_id}/result
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/champs/",
core_path_fn: |suffix| format!("/fut-champs/{suffix}"),
notes: "FUT Champions match result / claim → Core fut-champs action",
},
// /ut/game/fut/sbc/set/{set_id}/submission → POST /sbc/submit
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/sbc/set/",
core_path_fn: |_| "/sbc/submit".to_string(),
notes: "FUT SBC set submission → Core SBC submit",
},
// /ut/game/fut/trade/{trade_id} (DELETE) → DELETE /market/listings/{trade_id}
PrefixRoute {
ea_method: "DELETE",
ea_prefix: "/ut/game/fut/trade/",
core_path_fn: |suffix| format!("/market/listings/{suffix}"),
notes: "FUT cancel trade listing → Core delete market listing",
},
// /ut/game/fut/item/{owned_id} (DELETE) → DELETE /collection/{owned_id}
PrefixRoute {
ea_method: "DELETE",
ea_prefix: "/ut/game/fut/item/",
core_path_fn: |suffix| format!("/collection/{suffix}"),
notes: "FUT quick-sell item → Core delete owned card",
},
// /ut/game/fut/item/{owned_id} (PUT) → PUT /collection/{owned_id}/chemistry
PrefixRoute {
ea_method: "PUT",
ea_prefix: "/ut/game/fut/item/",
core_path_fn: |suffix| {
// suffix is just the owned_card_id; game sends chemistry style in body
format!("/collection/{suffix}/chemistry")
},
notes: "FUT apply chemistry style → Core update card chemistry",
},
// /ut/game/fut/squad/{id} (GET) → GET /squads/{id}
// Note: must come after exact matches for /squad/active, /squad/0, etc.
PrefixRoute {
ea_method: "GET",
ea_prefix: "/ut/game/fut/squad/",
core_path_fn: |suffix| {
// Skip known non-ID suffixes already handled as exact routes
match suffix {
"active" | "chemistry" | "list" => "/squad".to_string(),
_ => format!("/squads/{suffix}"),
}
},
notes: "FUT squad by ID → Core squad by ID",
},
// /ut/game/fut/squad/{id} (DELETE) → DELETE /squads/{id}
PrefixRoute {
ea_method: "DELETE",
ea_prefix: "/ut/game/fut/squad/",
core_path_fn: |suffix| format!("/squads/{suffix}"),
notes: "FUT delete squad by ID → Core delete squad",
},
// /ut/game/fut/sbc/set/{set_id} (GET) → GET /sbc/{set_id}
PrefixRoute {
ea_method: "GET",
ea_prefix: "/ut/game/fut/sbc/set/",
core_path_fn: |suffix| format!("/sbc/{suffix}"),
notes: "FUT SBC set details → Core individual SBC",
},
// /ut/game/fut/store/pack/{pack_id}/open → POST /packs/open/{pack_id}
PrefixRoute {
ea_method: "POST",
ea_prefix: "/ut/game/fut/store/pack/",
core_path_fn: |suffix| {
// suffix: "{pack_id}/open" or just "{pack_id}"
let id = suffix.trim_end_matches("/open");
format!("/packs/open/{id}")
},
notes: "FUT open pack → Core open pack",
},
]
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call. /// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
/// Returns Some(mapping) if the route is known, None otherwise. /// Returns Some(mapping) if the route is known, None otherwise.
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> { pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
let m = method.to_uppercase(); let m = method.to_uppercase();
let p = path.trim_end_matches('/'); let p = path.trim_end_matches('/');
// These are speculative mappings based on common FUT API patterns. // 1. Exact match
// They will be refined as reverse engineering progresses. for r in EXACT {
let known: &[(&str, &str, CoreMapping)] = &[ if r.ea_method == m && r.ea_path == p {
// Auth return Some(CoreMapping {
( method: r.core_method,
"POST", core_path: r.core_path.to_string(),
"/ut/auth", notes: r.notes,
CoreMapping { });
method: "POST", }
core_path: "/auth/local", }
notes: "FUT auth → Core local auth",
},
),
(
"GET",
"/ut/game/fut/user/settings",
CoreMapping {
method: "GET",
core_path: "/profile",
notes: "FUT settings → Core profile",
},
),
// Club
(
"GET",
"/ut/game/fut/usermassinfo",
CoreMapping {
method: "GET",
core_path: "/club",
notes: "FUT mass info → Core club",
},
),
// Squad
(
"GET",
"/ut/game/fut/squad/active",
CoreMapping {
method: "GET",
core_path: "/squad",
notes: "FUT active squad → Core squad",
},
),
// Packs
(
"GET",
"/ut/game/fut/store/packdetails",
CoreMapping {
method: "GET",
core_path: "/packs",
notes: "FUT pack store → Core pack list",
},
),
// Transfer market (guessed)
(
"GET",
"/ut/game/fut/transfermarket",
CoreMapping {
method: "GET",
core_path: "/market",
notes: "FUT transfer market → Core NPC market",
},
),
];
for (km, kp, mapping) in known { // 2. Prefix match — first prefix that fits wins
if *km == m && *kp == p { for r in prefix_routes() {
return Some(mapping.clone()); if r.ea_method == m {
if let Some(suffix) = p.strip_prefix(r.ea_prefix) {
if !suffix.is_empty() {
let core_path = (r.core_path_fn)(suffix);
return Some(CoreMapping {
method: "GET", // overridden per-route below
core_path,
notes: r.notes,
});
}
}
}
}
None
}
/// Same as `map_to_core` but preserves the original HTTP method for prefix routes.
///
/// The simple `map_to_core` hardcodes "GET" for prefix routes because the method
/// is already known from the match condition. This helper re-derives it.
pub fn map_to_core_with_method(method: &str, path: &str) -> Option<CoreMapping> {
let m = method.to_uppercase();
let p = path.trim_end_matches('/');
for r in EXACT {
if r.ea_method == m && r.ea_path == p {
return Some(CoreMapping {
method: r.core_method,
core_path: r.core_path.to_string(),
notes: r.notes,
});
}
}
for r in prefix_routes() {
if r.ea_method == m {
if let Some(suffix) = p.strip_prefix(r.ea_prefix) {
if !suffix.is_empty() {
return Some(CoreMapping {
method: r.ea_method, // use the matched method
core_path: (r.core_path_fn)(suffix),
notes: r.notes,
});
}
}
} }
} }
@@ -89,7 +574,6 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
} }
/// Return a safe placeholder response for unknown endpoints. /// Return a safe placeholder response for unknown endpoints.
/// This prevents the FIFA 23 client from crashing while we log traffic.
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value { pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
tracing::warn!( tracing::warn!(
"UNKNOWN ENDPOINT: {} {} — returning placeholder", "UNKNOWN ENDPOINT: {} {} — returning placeholder",
@@ -103,3 +587,225 @@ pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
"path": path, "path": path,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_auth_maps() {
let m = map_to_core("POST", "/ut/auth");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/auth/local");
}
#[test]
fn test_known_market_maps() {
let m = map_to_core("GET", "/ut/game/fut/transfermarket");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market");
}
#[test]
fn test_events_maps() {
let m = map_to_core("GET", "/ut/game/fut/events");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/events");
}
#[test]
fn test_squad_put_maps() {
let m = map_to_core("PUT", "/ut/game/fut/squad/active");
assert!(m.is_some());
assert_eq!(m.unwrap().method, "POST");
}
#[test]
fn test_unknown_returns_none() {
assert!(map_to_core("GET", "/ut/game/fut/some/unknown/path").is_none());
}
#[test]
fn test_trailing_slash_trimmed() {
let m = map_to_core("POST", "/ut/auth/");
assert!(m.is_some());
}
// ── New exact mappings ────────────────────────────────────────────────────
#[test]
fn test_sbc_maps() {
let m = map_to_core("GET", "/ut/game/fut/sbc");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/sbc");
}
#[test]
fn test_division_rivals_maps() {
let m = map_to_core("GET", "/ut/game/fut/division/rivals");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/division");
}
#[test]
fn test_fut_champs_start_maps() {
let m = map_to_core("POST", "/ut/game/fut/champs/start");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/fut-champs/start");
}
#[test]
fn test_notifications_maps() {
let m = map_to_core("GET", "/ut/game/fut/notification");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/notifications");
}
#[test]
fn test_settings_put_maps() {
let m = map_to_core("PUT", "/ut/game/fut/user/settings");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/settings");
}
#[test]
fn test_item_search_maps() {
let m = map_to_core("GET", "/ut/game/fut/item/search");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/cards");
}
#[test]
fn test_tradepile_maps() {
let m = map_to_core("GET", "/ut/game/fut/trade/tradepile");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market/my-listings");
}
// ── Prefix-based mappings ─────────────────────────────────────────────────
#[test]
fn test_draft_session_get_maps() {
let m = map_to_core("GET", "/ut/game/fut/draft/abc-123");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123");
}
#[test]
fn test_draft_pick_maps() {
let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/pick");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/pick");
}
#[test]
fn test_draft_abandon_maps() {
let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/abandon");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/abandon");
}
#[test]
fn test_champs_result_maps() {
let m = map_to_core("POST", "/ut/game/fut/champs/sess-456/result");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/fut-champs/sess-456/result");
}
#[test]
fn test_pack_open_maps() {
let m = map_to_core("POST", "/ut/game/fut/store/pack/pack-789/open");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/packs/open/pack-789");
}
#[test]
fn test_quick_sell_maps() {
let m = map_to_core("DELETE", "/ut/game/fut/item/owned-111");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/collection/owned-111");
}
#[test]
fn test_cancel_listing_maps() {
let m = map_to_core("DELETE", "/ut/game/fut/trade/trade-222");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/market/listings/trade-222");
}
#[test]
fn test_total_exact_routes_count() {
assert!(EXACT.len() >= 61, "expected at least 61 exact mappings, got {}", EXACT.len());
}
// ── Phase 22 new mappings ─────────────────────────────────────────────────
#[test]
fn test_squad_list_maps() {
let m = map_to_core("GET", "/ut/game/fut/squad/list");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/squads");
}
#[test]
fn test_squad_by_id_get_maps() {
let m = map_to_core_with_method("GET", "/ut/game/fut/squad/abc-squad-id");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/squads/abc-squad-id");
}
#[test]
fn test_squad_by_id_delete_maps() {
let m = map_to_core_with_method("DELETE", "/ut/game/fut/squad/abc-squad-id");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/squads/abc-squad-id");
}
#[test]
fn test_item_put_chemistry_maps() {
let m = map_to_core_with_method("PUT", "/ut/game/fut/item/owned-999");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/collection/owned-999/chemistry");
}
#[test]
fn test_sbc_set_get_maps() {
let m = map_to_core_with_method("GET", "/ut/game/fut/sbc/set/sbc-123");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/sbc/sbc-123");
}
#[test]
fn test_trophies_maps() {
let m = map_to_core("GET", "/ut/game/fut/trophies");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/achievements");
}
#[test]
fn test_rivals_result_maps() {
let m = map_to_core("POST", "/ut/game/fut/rivals/result");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/matches/complete");
}
#[test]
fn test_catalogue_item_maps() {
let m = map_to_core("GET", "/ut/game/fut/catalogue/item");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/cards");
}
#[test]
fn test_objectives_daily_maps() {
let m = map_to_core("GET", "/ut/game/fut/objectives/daily");
assert!(m.is_some());
assert_eq!(m.unwrap().core_path, "/objectives");
}
#[test]
fn test_active_message_maps() {
let m = map_to_core("GET", "/ut/game/fut/activeMessage");
assert!(m.is_some());
}
}
+82 -14
View File
@@ -6,31 +6,69 @@ use axum::{
}; };
use bytes::Bytes; use bytes::Bytes;
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::Instant,
};
use tokio::sync::broadcast;
use crate::{ use crate::{
capture::{save_capture, CapturedRequest}, capture::{save_capture, CapturedRequest},
config::Config, config::Config,
error::BridgeResult, error::BridgeResult,
mapper::{map_to_core, placeholder_response}, mapper::{map_to_core, placeholder_response},
shaper::shape_response,
}; };
const CAPTURE_DEDUP_SECS: u64 = 1;
#[derive(Clone)] #[derive(Clone)]
pub struct ProxyState { pub struct ProxyState {
pub config: Arc<Config>, pub config: Arc<Config>,
pub http_client: reqwest::Client, pub http_client: reqwest::Client,
/// Broadcast channel for streaming new captures to SSE subscribers.
pub capture_tx: Arc<broadcast::Sender<CapturedRequest>>,
/// Deduplication window: (method+path) → last saved instant.
pub dedup: Arc<Mutex<HashMap<String, Instant>>>,
/// PEM-encoded TLS certificate for download. None when TLS is disabled.
pub cert_pem: Option<Arc<Vec<u8>>>,
} }
impl ProxyState { impl ProxyState {
pub fn new(config: Config) -> Self { pub fn new(config: Config) -> Self {
let (capture_tx, _) = broadcast::channel(256);
Self { Self {
config: Arc::new(config), config: Arc::new(config),
http_client: reqwest::Client::builder() http_client: reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build() .build()
.expect("failed to build HTTP client"), .expect("failed to build HTTP client"),
capture_tx: Arc::new(capture_tx),
dedup: Arc::new(Mutex::new(HashMap::new())),
cert_pem: None,
} }
} }
pub fn with_cert(mut self, cert_pem: Vec<u8>) -> Self {
self.cert_pem = Some(Arc::new(cert_pem));
self
}
}
/// Returns true if this (method, path) pair was already saved within the dedup window.
fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
let key = format!("{method} {path}");
let mut map = dedup.lock().unwrap();
let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
if let Some(last) = map.get(&key) {
if last.elapsed() < threshold {
return true;
}
}
map.insert(key, Instant::now());
false
} }
pub async fn catch_all_handler( pub async fn catch_all_handler(
@@ -48,7 +86,6 @@ pub async fn catch_all_handler(
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string())) .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
.collect(); .collect();
// Extract body via axum's built-in mechanism
let (_parts, body) = req.into_parts(); let (_parts, body) = req.into_parts();
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024) let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
.await .await
@@ -71,7 +108,7 @@ pub async fn catch_all_handler(
); );
let mut capture = let mut capture =
CapturedRequest::new(&method, &path, query.as_deref(), headers, body_str.clone()); CapturedRequest::new(&method, &path, query.as_deref(), headers.clone(), body_str.clone());
let (response_body, status_code): (Value, u16) = let (response_body, status_code): (Value, u16) =
if let Some(mapping) = map_to_core(&method, &path) { if let Some(mapping) = map_to_core(&method, &path) {
@@ -85,12 +122,13 @@ pub async fn catch_all_handler(
match forward_to_core( match forward_to_core(
&state, &state,
mapping.method, mapping.method,
mapping.core_path, &mapping.core_path,
body_str.as_deref(), body_str.as_deref(),
&headers,
) )
.await .await
{ {
Ok((body, status)) => (body, status), Ok((body, status)) => (shape_response(&mapping.core_path, body), status),
Err(e) => { Err(e) => {
tracing::error!("Core request failed: {e}"); tracing::error!("Core request failed: {e}");
(serde_json::json!({ "error": e.to_string() }), 502) (serde_json::json!({ "error": e.to_string() }), 502)
@@ -104,13 +142,22 @@ pub async fn catch_all_handler(
capture = capture.with_response(status_code, Some(response_body.to_string())); capture = capture.with_response(status_code, Some(response_body.to_string()));
let captures_dir = state.config.captures_dir.clone(); // Deduplicate: skip saving if same method+path was saved within 1 second
let capture_clone = capture.clone(); let should_save = !is_duplicate(&state.dedup, &method, &path);
tokio::spawn(async move {
if let Err(e) = save_capture(&captures_dir, &capture_clone) { if should_save {
tracing::warn!("Failed to save capture: {e}"); let captures_dir = state.config.captures_dir.clone();
} let capture_clone = capture.clone();
}); let capture_tx = state.capture_tx.clone();
tokio::spawn(async move {
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
tracing::warn!("Failed to save capture: {e}");
}
// Broadcast to SSE subscribers (ignore send errors — no subscribers is OK)
let _ = capture_tx.send(capture_clone);
});
}
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK); let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
let json_bytes = let json_bytes =
@@ -125,21 +172,42 @@ pub async fn catch_all_handler(
Ok(response) Ok(response)
} }
/// Public alias for use by the replay route in admin.rs.
pub async fn forward_to_core_pub(
state: &ProxyState,
method: &str,
path: &str,
body: Option<&str>,
headers: &[(String, String)],
) -> anyhow::Result<(Value, u16)> {
forward_to_core(state, method, path, body, headers).await
}
async fn forward_to_core( async fn forward_to_core(
state: &ProxyState, state: &ProxyState,
method: &str, method: &str,
path: &str, path: &str,
body: Option<&str>, body: Option<&str>,
incoming_headers: &[(String, String)],
) -> anyhow::Result<(Value, u16)> { ) -> anyhow::Result<(Value, u16)> {
let url = format!("{}{}", state.config.core_url, path); let url = format!("{}{}", state.config.core_url, path);
let builder = match method { let mut builder = match method {
"POST" => state.http_client.post(&url), "POST" => state.http_client.post(&url),
"PUT" => state.http_client.put(&url), "PUT" => state.http_client.put(&url),
"DELETE" => state.http_client.delete(&url), "DELETE" => state.http_client.delete(&url),
_ => state.http_client.get(&url), _ => state.http_client.get(&url),
}; };
let builder = if let Some(b) = body { // Pass through FUT session and anti-phishing headers so Core can log/correlate them.
// These are used in #19 (X-UT-SID) and #20 (X-UT-PHISHING-TOKEN) passthrough.
for (k, v) in incoming_headers {
let lower = k.to_lowercase();
if lower == "x-ut-sid" || lower == "x-ut-phishing-token" || lower == "x-request-id" {
builder = builder.header(k.as_str(), v.as_str());
}
}
builder = if let Some(b) = body {
builder builder
.header("content-type", "application/json") .header("content-type", "application/json")
.body(b.to_string()) .body(b.to_string())
+315 -7
View File
@@ -1,12 +1,26 @@
use axum::{extract::State, Json}; use axum::{
extract::{Path, Query, State},
http::{header, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse, Json, Response,
},
};
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::convert::Infallible;
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState}; use crate::{
capture::load_all_captures,
error::{BridgeError, BridgeResult},
mapper::map_to_core_with_method,
proxy::ProxyState,
};
/// List all captured requests. /// List all captured requests.
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> { pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir) let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
.map_err(crate::error::BridgeError::Internal)?;
let unknown: Vec<_> = captures let unknown: Vec<_> = captures
.iter() .iter()
@@ -20,10 +34,9 @@ pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<
}))) })))
} }
/// List only unknown (unmapped) endpoints. /// List only unknown (unmapped) endpoints, deduplicated by method+path.
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> { pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir) let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
.map_err(crate::error::BridgeError::Internal)?;
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let unknown: Vec<Value> = captures let unknown: Vec<Value> = captures
@@ -49,3 +62,298 @@ pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeRes
"endpoints": unknown, "endpoints": unknown,
}))) })))
} }
/// Wipe all capture files from disk.
pub async fn delete_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
let dir = std::path::Path::new(&state.config.captures_dir);
let mut deleted = 0u64;
if dir.exists() {
for entry in std::fs::read_dir(dir).map_err(BridgeError::Io)? {
let entry = entry.map_err(BridgeError::Io)?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
std::fs::remove_file(&path).map_err(BridgeError::Io)?;
deleted += 1;
}
}
}
Ok(Json(
json!({ "deleted": deleted, "message": "capture folder cleared" }),
))
}
/// Server-sent events stream: pushes each new capture to subscribed clients.
pub async fn get_captures_stream(
State(state): State<ProxyState>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let rx = state.capture_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|result| {
result.ok().and_then(|capture| {
serde_json::to_string(&capture)
.ok()
.map(|json| Ok(Event::default().event("capture").data(json)))
})
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// Endpoint status summary: known/mapped/unknown across all captures.
pub async fn get_endpoint_status(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
use crate::mapper::map_to_core;
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let mut seen: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
for c in &captures {
let key = format!("{} {}", c.method, c.path);
seen.entry(key).or_insert_with(|| {
let status = if c.mapped_to_core.is_some() {
"mapped"
} else if map_to_core(&c.method, &c.path).is_some() {
"known"
} else {
"unknown"
};
json!({
"method": c.method,
"path": c.path,
"status": status,
"mapped_to": c.mapped_to_core,
"first_seen": c.timestamp,
})
});
}
let mut endpoints: Vec<Value> = seen.into_values().collect();
endpoints.sort_by(|a, b| {
a["path"]
.as_str()
.unwrap_or("")
.cmp(b["path"].as_str().unwrap_or(""))
});
Ok(Json(json!({
"total_unique_endpoints": endpoints.len(),
"endpoints": endpoints,
})))
}
/// Query params for the diff endpoint.
#[derive(Deserialize)]
pub struct DiffQuery {
pub a: String,
pub b: String,
}
/// Diff two captures by ID: shows what changed between them.
///
/// Usage: GET /_bridge/captures/diff?a=<uuid>&b=<uuid>
pub async fn get_capture_diff(
State(state): State<ProxyState>,
Query(params): Query<DiffQuery>,
) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
let find = |id: &str| -> Option<&crate::capture::CapturedRequest> {
captures.iter().find(|c| c.id == id)
};
let cap_a = find(&params.a)
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
let cap_b = find(&params.b)
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
// Structural JSON diff of request bodies
let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref());
let resp_diff = diff_json_strings(
cap_a.response_body.as_deref(),
cap_b.response_body.as_deref(),
);
// Header diff: keys present in one but not the other
let headers_a: std::collections::HashSet<String> =
cap_a.headers.iter().map(|(k, _)| k.clone()).collect();
let headers_b: std::collections::HashSet<String> =
cap_b.headers.iter().map(|(k, _)| k.clone()).collect();
let headers_only_in_a: Vec<_> = headers_a.difference(&headers_b).collect();
let headers_only_in_b: Vec<_> = headers_b.difference(&headers_a).collect();
// Headers present in both but with different values
let headers_changed: Vec<Value> = cap_a
.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
}
})
})
.collect();
Ok(Json(json!({
"a": { "id": cap_a.id, "timestamp": cap_a.timestamp, "method": cap_a.method, "path": cap_a.path },
"b": { "id": cap_b.id, "timestamp": cap_b.timestamp, "method": cap_b.method, "path": cap_b.path },
"method_changed": cap_a.method != cap_b.method,
"path_changed": cap_a.path != cap_b.path,
"status_a": cap_a.response_status,
"status_b": cap_b.response_status,
"status_changed": cap_a.response_status != cap_b.response_status,
"headers": {
"only_in_a": headers_only_in_a,
"only_in_b": headers_only_in_b,
"changed": headers_changed,
},
"body_diff": body_diff,
"response_diff": resp_diff,
})))
}
/// Shallow JSON diff of two optional JSON strings.
///
/// If both parse as JSON objects, lists keys that differ or are absent.
/// Falls back to string comparison if either is not valid JSON.
fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
match (a, b) {
(None, None) => json!({ "same": true, "note": "both empty" }),
(Some(_), None) => json!({ "same": false, "note": "b has no body" }),
(None, Some(_)) => json!({ "same": false, "note": "a has no body" }),
(Some(a_str), Some(b_str)) => {
if a_str == b_str {
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();
match (va, vb) {
(Some(ma), Some(mb)) => {
let keys_a: std::collections::HashSet<&str> =
ma.keys().map(|s| s.as_str()).collect();
let keys_b: std::collections::HashSet<&str> =
mb.keys().map(|s| s.as_str()).collect();
let only_in_a: Vec<&str> = keys_a.difference(&keys_b).copied().collect();
let only_in_b: Vec<&str> = keys_b.difference(&keys_a).copied().collect();
let changed: Vec<Value> = keys_a
.intersection(&keys_b)
.filter_map(|k| {
let va = ma.get(*k)?;
let vb = mb.get(*k)?;
if va != vb {
Some(json!({ "key": k, "a": va, "b": vb }))
} else {
None
}
})
.collect();
json!({
"same": false,
"type": "json_object_diff",
"keys_only_in_a": only_in_a,
"keys_only_in_b": only_in_b,
"keys_changed": changed,
})
}
_ => json!({
"same": false,
"type": "string_diff",
"a_len": a_str.len(),
"b_len": b_str.len(),
}),
}
}
}
}
/// Replay a captured request against Core and return the result alongside the original response.
///
/// Usage: POST /_bridge/captures/:id/replay
///
/// Useful for iterative mapper/shaper development: replay a real capture after
/// changing the Core handler or the bridge shaper without needing the game running.
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 capture = captures
.iter()
.find(|c| c.id == capture_id)
.ok_or_else(|| {
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", capture_id))
})?;
let mapping = map_to_core_with_method(&capture.method, &capture.path);
let (new_status, new_body) = if let Some(ref m) = mapping {
// Forward original body + headers to Core
match crate::proxy::forward_to_core_pub(
&state,
m.method,
&m.core_path,
capture.body.as_deref(),
&capture.headers,
)
.await
{
Ok((body, status)) => (
status,
crate::shaper::shape_response(&m.core_path, body),
),
Err(e) => (
502u16,
json!({ "error": format!("core forwarding failed: {e}") }),
),
}
} else {
(
404,
json!({ "error": "no mapping found for this endpoint", "method": capture.method, "path": capture.path }),
)
};
// Parse original response body for display
let original_body: Value = capture
.response_body
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
Ok(Json(json!({
"capture_id": capture_id,
"method": capture.method,
"path": capture.path,
"mapped_to": mapping.as_ref().map(|m| format!("{} {}", m.method, m.core_path)),
"original": {
"status": capture.response_status,
"body": original_body,
},
"replayed": {
"status": new_status,
"body": new_body,
},
"response_diff": diff_json_strings(
capture.response_body.as_deref(),
Some(&new_body.to_string()),
),
})))
}
/// Simple HTML admin dashboard.
pub async fn get_admin_dashboard() -> impl IntoResponse {
let html = include_str!("../admin.html");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(axum::body::Body::from(html))
.unwrap()
}
+149 -1
View File
@@ -1,6 +1,13 @@
use axum::{http::StatusCode, Json}; use axum::{
extract::State,
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
Json,
};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::proxy::ProxyState;
pub async fn get_health() -> (StatusCode, Json<Value>) { pub async fn get_health() -> (StatusCode, Json<Value>) {
( (
StatusCode::OK, StatusCode::OK,
@@ -12,3 +19,144 @@ pub async fn get_health() -> (StatusCode, Json<Value>) {
})), })),
) )
} }
/// Serve the TLS certificate as a downloadable PEM file.
///
/// Users navigate to `https://<bridge-ip>:8443/_bridge/cert.pem` in a browser,
/// download the file, and install it as a trusted CA in their OS or game console.
pub async fn get_cert(State(state): State<ProxyState>) -> Response {
match &state.cert_pem {
Some(pem) => (
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/x-pem-file"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"openfut-ca.pem\"",
),
],
pem.as_ref().clone(),
)
.into_response(),
None => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "TLS is not enabled; no certificate to download" })),
)
.into_response(),
}
}
/// Club management dashboard served at `/_bridge/dashboard`.
///
/// Injects the Core URL so client-side JS can call Core directly without going through the proxy.
pub async fn get_dashboard(State(state): State<ProxyState>) -> Response {
let template = include_str!("../dashboard.html");
let html = template.replace("{{CORE_URL}}", &state.config.core_url);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(axum::body::Body::from(html))
.unwrap()
}
/// HTML setup guide for connecting FIFA 23 to OpenFUT Bridge.
pub async fn get_guide(State(state): State<ProxyState>) -> Html<String> {
let host = state
.config
.listen_addr
.split(':')
.next()
.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 bridge_url = format!("{scheme}://{host}:{port}");
let core_url = &state.config.core_url;
let guide = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenFUT Bridge Setup Guide</title>
<style>
body {{ font-family: system-ui, sans-serif; max-width: 780px; margin: 0 auto; padding: 2rem; background: #0a0a0a; color: #e0e0e0; }}
h1 {{ color: #f5a623; }} h2 {{ color: #a0c4ff; border-bottom: 1px solid #333; padding-bottom: 4px; }}
code {{ background: #1c1c1c; padding: 2px 6px; border-radius: 3px; font-family: monospace; }}
pre {{ background: #1c1c1c; padding: 1rem; border-radius: 6px; overflow-x: auto; }}
.ok {{ color: #5fbb5f; }} .warn {{ color: #f5a623; }}
a {{ color: #a0c4ff; }}
.step {{ margin: 1.2rem 0; padding: 1rem; background: #141414; border-left: 3px solid #f5a623; border-radius: 0 6px 6px 0; }}
</style>
</head>
<body>
<h1>OpenFUT Bridge Setup Guide</h1>
<p><span class="ok">&#x2713;</span> Bridge is running at <code>{bridge_url}</code></p>
<p>Core API: <code>{core_url}</code></p>
<h2>How it works</h2>
<p>OpenFUT Bridge sits between FIFA 23 and EA's servers. It intercepts FUT API calls and
routes supported endpoints to OpenFUT Core (your local offline backend). Unsupported
endpoints return placeholder responses so the game stays stable.</p>
<h2>Step 1 Redirect FIFA 23 traffic</h2>
<div class="step">
<p>Add these entries to your <strong>hosts file</strong> (requires admin/root):</p>
<pre>
# Point FIFA 23 FUT traffic to OpenFUT Bridge
127.0.0.1 fut.ea.com
127.0.0.1 utas.mob.v4.fut.ea.com
</pre>
<ul>
<li>Windows: <code>C:\Windows\System32\drivers\etc\hosts</code></li>
<li>macOS / Linux: <code>/etc/hosts</code></li>
</ul>
</div>
<h2>Step 2 Install the TLS certificate (HTTPS only)</h2>
{cert_section}
<h2>Step 3 Launch FIFA 23</h2>
<div class="step">
<p>Start FIFA 23 and navigate to <strong>Ultimate Team</strong>. The bridge will capture
and proxy FUT API calls. Open <code>{bridge_url}/_bridge/admin</code> in your browser to
watch traffic in real time.</p>
</div>
<h2>Supported endpoints</h2>
<p>See <code><a href="/_bridge/status">/_bridge/status</a></code> for a full list of mapped
and unknown endpoints seen so far.</p>
<h2>Troubleshooting</h2>
<ul>
<li><strong>Game shows "FUT servers unavailable":</strong> make sure the hosts file is saved and DNS is flushed (<code>ipconfig /flushdns</code> on Windows).</li>
<li><strong>Certificate error in browser:</strong> the self-signed cert must be trusted by the OS or the game's certificate store.</li>
<li><strong>Bridge not reaching Core:</strong> check that <code>openfut-core</code> is running and <code>CORE_URL</code> is set correctly (default <code>http://127.0.0.1:3000</code>).</li>
</ul>
<p style="color:#555; font-size:0.85em; margin-top:3rem;">
OpenFUT is an <strong>offline</strong> replacement backend for legitimate FIFA 23 owners.
It does not connect to EA servers or enable online multiplayer.
</p>
</body>
</html>"#,
cert_section = if tls {
format!(
r#"<div class="step">
<p>Download and install the bridge's self-signed CA certificate so FIFA 23 trusts HTTPS connections:</p>
<ol>
<li>Open <a href="/_bridge/cert.pem"><code>{bridge_url}/_bridge/cert.pem</code></a> and save it.</li>
<li><strong>Windows:</strong> double-click the file "Install Certificate" "Local Machine" "Trusted Root Certification Authorities".</li>
<li><strong>macOS:</strong> double-click the file Keychain Access trust "Always".</li>
<li><strong>Linux:</strong> copy to <code>/usr/local/share/ca-certificates/</code> and run <code>update-ca-certificates</code>.</li>
</ol>
</div>"#
)
} else {
"<div class=\"step\"><p class=\"warn\">TLS is disabled — set <code>TLS_ENABLED=true</code> to enable HTTPS (required for FIFA 23 interception).</p></div>".to_string()
}
);
Html(guide)
}
+395
View File
@@ -0,0 +1,395 @@
//! Response shaper: transforms OpenFUT Core responses into the envelope format
//! FIFA 23 expects, based on the Core path that was called.
//!
//! These are best-guess wrappers derived from typical EA FUT API patterns.
//! They will be refined as real traffic captures come in.
use serde_json::{json, Value};
use uuid::Uuid;
/// Shape a Core JSON response into the format FIFA 23 expects for this path.
/// Returns the shaped response or the original unchanged if no shaping is defined.
pub fn shape_response(core_path: &str, core_response: Value) -> Value {
// Exact matches
match core_path {
"/auth/local" => return shape_auth_response(core_response),
"/profile" => return shape_profile_response(core_response),
"/club" => return shape_club_response(core_response),
"/squad" => return shape_squad_response(core_response),
"/market" => return shape_market_response(core_response),
"/market/my-listings" => return shape_my_listings_response(core_response),
"/packs" => return shape_packs_response(core_response),
"/collection" => return shape_collection_response(core_response),
"/cards" => return shape_cards_response(core_response),
"/objectives" => return shape_objectives_response(core_response),
"/notifications" => return shape_notifications_response(core_response),
"/division" => return shape_division_response(core_response),
"/statistics" => return shape_statistics_response(core_response),
"/sbc" => return shape_sbc_response(core_response),
"/fut-champs" => return shape_fut_champs_response(core_response),
"/chemistry-styles" => return shape_chemistry_styles_response(core_response),
"/achievements" => return shape_achievements_response(core_response),
"/squads" => return shape_squads_list_response(core_response),
"/packs/store" => return shape_pack_store_response(core_response),
_ => {}
}
// Prefix matches for parameterised paths
if core_path.starts_with("/packs/open/") {
return shape_pack_open_response(core_response);
}
if core_path.starts_with("/draft/sessions/") {
return shape_draft_response(core_response);
}
if core_path.starts_with("/fut-champs/") {
return shape_fut_champs_response(core_response);
}
if core_path.starts_with("/squads/") {
return shape_squads_list_response(core_response);
}
// Unknown — pass through unchanged
core_response
}
/// Wraps Core auth response in the FUT auth envelope format.
///
/// EA's `/ut/auth` returns a top-level object with a SID, PID, and persona.
/// The `sid` is used as a session token in subsequent `X-UT-SID` headers.
fn shape_auth_response(core: Value) -> Value {
let profile_id = core
.get("profile")
.and_then(|p| p.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let display_name = core
.get("profile")
.and_then(|p| p.get("username"))
.and_then(|v| v.as_str())
.unwrap_or("FUT Player");
let fake_sid = format!("ofut-sid-{}", Uuid::new_v4().simple());
let fake_pid = format!("1{}", &Uuid::new_v4().simple().to_string()[..9]);
let phishing_token = format!("ptkn-{}", Uuid::new_v4().simple());
json!({
"sid": fake_sid,
"pid": {
"pidId": fake_pid,
"email": format!("{}@openfut.local", display_name.to_lowercase().replace(' ', "_")),
"emailStatus": "VERIFIED",
"strength": "STRONG",
"dob": "1990-01-01",
"country": "US",
},
"phishingToken": phishing_token,
"persona": {
"personaId": profile_id,
"displayName": display_name,
"isEmailVerified": true,
},
"userAccountInfo": {
"userAccountInfoSummary": [{
"personaId": profile_id,
"displayName": display_name,
"clubs": [{ "clubType": "FUT", "supported": true }],
}],
},
"openfut": true,
})
}
/// Wraps Core profile response in the FUT user settings envelope.
fn shape_profile_response(core: Value) -> Value {
let profile = core.get("profile").cloned().unwrap_or(core.clone());
json!({
"settings": {
"personaId": profile.get("id").cloned().unwrap_or(json!("unknown")),
"displayName": profile.get("username").cloned().unwrap_or(json!("FUT Player")),
"level": profile.get("level").cloned().unwrap_or(json!(1)),
"xp": profile.get("xp").cloned().unwrap_or(json!(0)),
},
"openfut": true,
"_raw": profile,
})
}
/// Wraps Core club response in the FUT usermassinfo envelope.
fn shape_club_response(core: Value) -> Value {
let club = core.get("club").cloned().unwrap_or(core.clone());
json!({
"pilluserinfo": {
"clubId": club.get("id").cloned().unwrap_or(json!("unknown")),
"credits": club.get("coins").cloned().unwrap_or(json!(0)),
"purchased": 0,
"sold": 0,
},
"userInfo": {
"duplicationInfo": { "isDuplicate": false },
"purchasedPacksInfo": { "premium": 0, "standard": 0 },
},
"openfut": true,
"_raw": club,
})
}
/// Wraps Core squad response in the FUT squad envelope.
fn shape_squad_response(core: Value) -> Value {
let squad = core.get("squad").cloned().unwrap_or(core.clone());
let players = core.get("players").cloned().unwrap_or(json!([]));
json!({
"squad": {
"squadId": squad.get("id").cloned().unwrap_or(json!("unknown")),
"name": squad.get("name").cloned().unwrap_or(json!("My Squad")),
"formation": squad.get("formation").cloned().unwrap_or(json!("4-4-2")),
"players": players,
"rating": 0,
"chemistry": 0,
},
"openfut": true,
})
}
/// 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);
json!({
"auctionInfo": listings,
"bidTokens": {},
"credits": 0,
"currencies": [
{ "name": "COINS", "funds": 0, "finalFunds": 0 },
],
"duplicateItemIdList": [],
"itemData": [],
"total": total,
"openfut": true,
})
}
/// Wraps Core packs response in the FUT pack store envelope.
fn shape_packs_response(core: Value) -> Value {
let packs = core.get("packs").cloned().unwrap_or(json!([]));
json!({
"purchasedPacks": {
"packs": packs,
},
"openfut": true,
})
}
fn shape_my_listings_response(core: Value) -> Value {
let listings = core.get("listings").cloned().unwrap_or(json!([]));
json!({
"tradepile": listings,
"credits": 0,
"openfut": true,
})
}
fn shape_collection_response(core: Value) -> Value {
let items = core.get("collection").cloned().unwrap_or(json!([]));
let total = core.get("total").cloned().unwrap_or(json!(0));
json!({
"itemData": items,
"total": total,
"openfut": true,
})
}
fn shape_cards_response(core: Value) -> Value {
let cards = core.get("cards").cloned().unwrap_or(json!([]));
let total = core.get("total").cloned().unwrap_or(json!(0));
json!({
"items": cards,
"total": total,
"openfut": true,
})
}
fn shape_objectives_response(core: Value) -> Value {
let objectives = core.get("objectives").cloned().unwrap_or(json!([]));
json!({
"objectives": objectives,
"openfut": true,
})
}
fn shape_notifications_response(core: Value) -> Value {
let notifications = core.get("notifications").cloned().unwrap_or(json!([]));
let count = core.get("count").cloned().unwrap_or(json!(0));
json!({
"notifications": notifications,
"count": count,
"openfut": true,
})
}
fn shape_division_response(core: Value) -> Value {
json!({
"divisionRivalsInfo": {
"division": core.get("division").cloned().unwrap_or(json!(5)),
"seasonNumber": core.get("season_number").cloned().unwrap_or(json!(1)),
"points": core.get("season_points").cloned().unwrap_or(json!(0)),
"matchesPlayed": core.get("matches_played").cloned().unwrap_or(json!(0)),
"matchesRemaining": core.get("matches_remaining").cloned().unwrap_or(json!(10)),
"record": core.get("record").cloned().unwrap_or(json!({})),
},
"openfut": true,
"_raw": core,
})
}
fn shape_statistics_response(core: Value) -> Value {
json!({
"stats": core,
"openfut": true,
})
}
fn shape_sbc_response(core: Value) -> Value {
let sbcs = core.get("sbcs").cloned().unwrap_or(json!([]));
json!({
"challenges": sbcs,
"openfut": true,
})
}
fn shape_pack_open_response(core: Value) -> Value {
let cards = core.get("cards").cloned().unwrap_or(json!([]));
json!({
"itemData": cards,
"duplicateItemIdList": [],
"openfut": true,
})
}
fn shape_draft_response(core: Value) -> Value {
json!({
"draft": core,
"openfut": true,
})
}
fn shape_fut_champs_response(core: Value) -> Value {
json!({
"futChampions": core,
"openfut": true,
})
}
fn shape_chemistry_styles_response(core: Value) -> Value {
let styles = core.get("chemistry_styles").cloned().unwrap_or(json!([]));
json!({
"chemistryStyles": styles,
"openfut": true,
})
}
fn shape_achievements_response(core: Value) -> Value {
// FIFA 23 uses a "trophies" envelope with "trophyData" array
let achievements = core.get("achievements").cloned().unwrap_or(json!([]));
let earned = core.get("earned").and_then(|v| v.as_i64()).unwrap_or(0);
let total = core.get("total").and_then(|v| v.as_i64()).unwrap_or(0);
json!({
"trophyData": achievements,
"totalEarned": earned,
"totalAvailable": total,
"openfut": true,
})
}
fn shape_squads_list_response(core: Value) -> Value {
// Wrap squads in EA's squad list envelope
let squads = if core.is_array() {
core
} else {
core.get("squads").cloned().unwrap_or(json!([]))
};
json!({
"squads": squads,
"openfut": true,
})
}
fn shape_pack_store_response(core: Value) -> Value {
let packs = core.get("packs").cloned().unwrap_or(json!([]));
json!({
"purchasablePacks": packs,
"openfut": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shape_auth_response_has_required_fields() {
let core = json!({
"profile": { "id": "prof_001", "username": "TestPlayer" }
});
let shaped = shape_auth_response(core);
assert!(shaped.get("sid").is_some());
assert!(shaped.get("phishingToken").is_some());
assert!(shaped.get("persona").is_some());
assert_eq!(shaped["persona"]["displayName"], "TestPlayer");
assert_eq!(shaped["openfut"], true);
}
#[test]
fn test_shape_response_dispatches_correctly() {
let core = json!({ "profile": { "id": "x", "username": "u" } });
let shaped = shape_response("/auth/local", core);
assert!(shaped.get("sid").is_some());
}
#[test]
fn test_shape_response_passthrough_for_unknown_path() {
let core = json!({ "foo": "bar" });
let shaped = shape_response("/some/unknown/path", core.clone());
assert_eq!(shaped, core);
}
#[test]
fn test_shape_market_response_has_auction_info() {
let core = json!({ "listings": [{ "id": "l1" }], "total": 1 });
let shaped = shape_market_response(core);
assert!(shaped["auctionInfo"].as_array().is_some());
assert_eq!(shaped["total"], 1);
}
#[test]
fn test_shape_achievements_response() {
let core = json!({ "achievements": [{ "id": "first_match" }], "earned": 1, "total": 18 });
let shaped = shape_response("/achievements", core);
assert!(shaped["trophyData"].is_array());
assert_eq!(shaped["totalEarned"], 1);
assert_eq!(shaped["totalAvailable"], 18);
}
#[test]
fn test_shape_squads_list_response() {
let core = json!({ "squads": [{ "id": "sq-1", "name": "My Squad" }] });
let shaped = shape_response("/squads", core);
assert!(shaped["squads"].is_array());
assert_eq!(shaped["squads"].as_array().unwrap().len(), 1);
}
#[test]
fn test_shape_pack_store_response() {
let core = json!({ "packs": [{ "id": "gold-pack" }] });
let shaped = shape_response("/packs/store", core);
assert!(shaped["purchasablePacks"].is_array());
}
}
+44
View File
@@ -0,0 +1,44 @@
use std::sync::Arc;
use tokio_rustls::TlsAcceptor;
/// Generate a self-signed certificate covering localhost and EA FUT hostnames.
/// Returns (cert_pem, key_pem) as byte vectors.
///
/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS
/// trust store, OR certificate validation must be disabled in the game binary.
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
let subject_alt_names = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"fut.ea.com".to_string(),
"utas.mob.v4.fut.ea.com".to_string(),
];
let cert = rcgen::generate_simple_self_signed(subject_alt_names)?;
let cert_pem = cert.serialize_pem()?.into_bytes();
let key_pem = cert.serialize_private_key_pem().into_bytes();
Ok((cert_pem, key_pem))
}
/// Build a TlsAcceptor from PEM-encoded certificate and private key bytes.
pub fn make_tls_acceptor(cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<TlsAcceptor> {
use rustls::{Certificate, PrivateKey, ServerConfig};
let certs: Vec<Certificate> = rustls_pemfile::certs(&mut std::io::Cursor::new(cert_pem))?
.into_iter()
.map(Certificate)
.collect();
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut std::io::Cursor::new(key_pem))?;
if keys.is_empty() {
anyhow::bail!("no PKCS8 private keys found in key PEM");
}
let config = Arc::new(
ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs, PrivateKey(keys.remove(0)))?,
);
Ok(TlsAcceptor::from(config))
}
+229 -1
View File
@@ -1,4 +1,18 @@
use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core}; use axum::{
body::Body,
http::{Request, StatusCode},
};
use openfut_bridge::{
capture::CapturedRequest,
config::Config,
mapper::{map_to_core, placeholder_response},
proxy::ProxyState,
routes,
};
use axum::routing::{any, delete, get, post};
use tower::ServiceExt;
// ── Unit tests ────────────────────────────────────────────────────────────────
#[test] #[test]
fn test_known_endpoint_maps_to_core() { fn test_known_endpoint_maps_to_core() {
@@ -46,3 +60,217 @@ fn test_capture_with_response() {
assert_eq!(capture.response_status, Some(200)); assert_eq!(capture.response_status, Some(200));
assert!(capture.response_body.is_some()); assert!(capture.response_body.is_some());
} }
// ── #24 Placeholder response format ──────────────────────────────────────────
#[test]
fn test_placeholder_response_has_required_fields() {
let resp = placeholder_response("GET", "/ut/game/fut/unknown");
assert_eq!(resp["status"], "ok");
assert!(resp["openfut_note"].is_string());
assert_eq!(resp["method"], "GET");
assert_eq!(resp["path"], "/ut/game/fut/unknown");
}
#[test]
fn test_placeholder_response_for_post() {
let resp = placeholder_response("POST", "/ut/auth/fifa");
assert_eq!(resp["status"], "ok");
assert_eq!(resp["method"], "POST");
}
// ── #25 Full HTTP integration test against bridge ────────────────────────────
fn build_test_app() -> axum::Router {
let cfg = Config {
listen_addr: "127.0.0.1:0".into(),
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
captures_dir: "/tmp/openfut-test-captures".into(),
placeholder_mode: true,
tls_enabled: false,
};
let state = ProxyState::new(cfg);
axum::Router::new()
.route("/_bridge/health", get(routes::health::get_health))
.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/status", get(routes::admin::get_endpoint_status))
.route("/_bridge/captures/:id/replay", post(routes::admin::post_replay_capture))
.fallback(any(openfut_bridge::proxy::catch_all_handler))
.with_state(state)
}
#[tokio::test]
async fn test_bridge_health_endpoint() {
let app = build_test_app();
let resp = app
.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 json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["service"], "openfut-bridge");
}
#[tokio::test]
async fn test_bridge_placeholder_mode_returns_ok() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/ut/game/fut/completely/unknown/endpoint")
.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 json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert!(json["openfut_note"].is_string());
}
#[tokio::test]
async fn test_bridge_captures_endpoint_returns_list() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/captures")
.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 json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["captures"].is_array());
assert!(json["total"].is_number());
}
#[tokio::test]
async fn test_bridge_delete_captures() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/_bridge/captures")
.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 json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["deleted"].is_number());
}
#[tokio::test]
async fn test_bridge_status_endpoint() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/status")
.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 json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["endpoints"].is_array());
}
// ── Phase 13 — Dashboard ──────────────────────────────────────────────────────
#[tokio::test]
async fn test_dashboard_returns_html() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
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 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");
}
#[tokio::test]
async fn test_dashboard_contains_key_sections() {
let app = build_test_app();
let resp = app
.oneshot(
Request::builder()
.uri("/_bridge/dashboard")
.body(Body::empty())
.unwrap(),
)
.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");
assert!(html.contains("tab-collection"), "collection tab missing");
assert!(html.contains("tab-packs"), "packs tab missing");
assert!(html.contains("tab-objectives"), "objectives tab missing");
assert!(html.contains("tab-division"), "division tab missing");
assert!(html.contains("tab-champs"), "fut champs tab missing");
assert!(html.contains("tab-squad"), "squad tab missing");
assert!(html.contains("tab-draft"), "draft tab missing");
assert!(html.contains("tab-market"), "market tab missing");
assert!(html.contains("tab-events"), "events tab missing");
assert!(html.contains("tab-matches"), "matches tab missing");
assert!(html.contains("tab-store"), "pack store tab missing");
assert!(html.contains("tab-sbc"), "sbc tab missing");
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");
}
#[tokio::test]
async fn test_tls_cert_generation() {
let result = openfut_bridge::tls::generate_self_signed_cert();
assert!(result.is_ok(), "cert generation failed: {:?}", result.err());
let (cert_pem, key_pem) = result.unwrap();
assert!(!cert_pem.is_empty());
assert!(!key_pem.is_empty());
// Verify the PEM blocks are well-formed
let cert_str = String::from_utf8(cert_pem).unwrap();
let key_str = String::from_utf8(key_pem).unwrap();
assert!(cert_str.contains("BEGIN CERTIFICATE"));
assert!(key_str.contains("PRIVATE KEY"));
}
#[tokio::test]
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());
}
+196
View File
@@ -0,0 +1,196 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "iced-x86"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
dependencies = [
"lazy_static",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "protossl-scan"
version = "0.1.0"
dependencies = [
"iced-x86",
"memchr",
"windows",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-strings",
"windows-targets",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "protossl-scan"
version = "0.1.0"
edition = "2021"
description = "Task 1: read-only memory scan to confirm FIFA 23 uses EA DirtySDK / ProtoSSL"
[[bin]]
name = "protossl-scan"
path = "src/main.rs"
[dependencies]
# SIMD-accelerated substring search. Orders of magnitude faster than a naive
# byte-by-byte scan when sweeping the game's multi-GB address space.
memchr = "2"
# Pure-Rust x86/x64 disassembler. Lets us read the game's own code around a
# code anchor (clean-room: we only disassemble the binary the user owns).
iced-x86 = "1"
# The official Microsoft `windows` crate gives us safe-ish bindings to the
# Win32 APIs we need. We only turn on the few feature modules we use, to keep
# build times and binary size down.
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Memory",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
] }
# This tool lives inside the openfut-bridge repo but is its OWN crate. Declaring
# an empty [workspace] here makes Cargo treat this directory as a standalone
# workspace root, so it does not try to attach to the openfut-bridge package
# in the parent directory (which would error).
[workspace]
File diff suppressed because it is too large Load Diff