Files
OpenFUT/OPENFUT_PROJECT_REPORT.md
T
funman300 a93a8bcdd7 fifa17-recon: decouple contract suite from the Python implementation
test_fut_contract.py no longer imports ACCOUNT from fut_account. The expected
persona comes from FUT_TEST_PERSONA_ID and the target from FUT_TEST_BASE, so the
suite now imports nothing but stdlib and talks to a server at a URL.

That is what lets these 380 checks certify ANY implementation of the reversed
spec, a future Rust openfut-core included, without replaying the reverse
engineering. The original reason for reading ACCOUNT still holds and is preserved
in the comment: suite and server must not each hold a private copy of the
constant, or the identity-consistency checks would only prove two copies matched.

Also adds pileSizeClientData(0x227) behind FUT_PILESIZES (default off). A probe
run with 16 uniquely-valued entries did NOT move the MY CLUB counter, so that
member is eliminated as its source; the code is kept for the record and flagged
off.

Docs: OPENFUT_PROJECT_REPORT.md and OPENFUT_HANDOFF.md. The report now separates
"built but untested" from "never requested by the client" -- the server log records
User-Agent, and splitting real client traffic (ProtoHttp) from this project's own
probes shows /season, /tournament, /champion, /match, /clubUser and /user/list are
at ZERO client requests. /clubUser (0 client, 93 probe) and /user/list (0 client,
180 probe) are the starkest: work was done on both assuming the client wanted them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 10:31:37 -07:00

14 KiB

OpenFUT — Project Report

Goal: make FIFA 17 Ultimate Team fully playable offline, forever, by re-implementing every server the game talks to.

Status: FUT boots, loads, and is playable. Packs, squads, the transfer market, coins and progression all work. Two cosmetic/flow problems remain open.

Timeline: 2026-06-25 → 2026-08-04 · 44 commits · ~12,900 lines of Python across 39 tools · ~3,600 lines of reverse-engineering documentation.


1. What the project is

EA shut down FIFA 17's servers years ago, which kills Ultimate Team — the mode is entirely server-driven. Your club, squads, packs, market and progression all live server-side, so without a backend the mode is dead even though the game still installs and runs.

OpenFUT replaces that backend with local servers. The game is unmodified retail FIFA 17 running under Wine/Proton on Linux; nothing is patched into the game except a single runtime tweak so it accepts our TLS certificate.

This is clean-room work. No EA code is copied or redistributed. The game's own binaries are read to learn the wire format — which JSON keys, of which types, each response must carry — and the servers are written from scratch against that specification.


2. Project history

The project changed target twice before finding its footing. That arc matters, because each pivot was driven by hitting a hard wall.

Phase 1 — FIFA 23 (June 2026). Began as an offline FUT backend for FIFA 23: a Rust core (openfut-core, Axum + SQLite), a protocol bridge (openfut-bridge), and a GUI launcher. All three built and passed tests. The architecture was sound but the client never got far enough to exercise it.

Phase 2 — the FIFA 23 wall. FIFA 23 refused to go online at all. Extensive reverse engineering of the connection state machine, live-memory probing, and forcing the "go online" gate directly all failed — the client's internal coherence checks were the wall, not any single flag. Documented as a dead end rather than fought.

Phase 3 — the FIFA 17 pivot (late July). FIFA 17 turned out to be a far better target: its network library is unprotected and fully symboled, exposing 979 RPC names. The insight was to crack FIFA 17 first and port the understanding back.

That worked, quickly:

  • TLS pinning defeated — the client's certificate verification is patched at runtime in memory, so a self-signed cert is accepted.
  • Origin/LSX emulation — the local Origin client protocol was reverse engineered, including the repack's own crypto layer, beating "log in to Origin" and "title version outdated".
  • Blaze cracked end to end — EA's binary RPC protocol: redirector, second-hop handshake, and the encoded pre-auth exchange. This was the big one.
  • Full FUT API mapped — ~100 response structures reverse engineered to field level.

Phase 4 — building the FUT backend (August). With the protocol understood, the work became making FUT actually play: card rendering, packs, the store, the transfer market, squads, and match rewards. This is where the project stands.


3. Architecture

FIFA 17 does not talk to one backend. It talks to four, on different protocols, and all four must be satisfied in sequence before FUT loads.

FIFA 17 (Wine/Proton)
  │
  ├─ LSX / Origin     :4216    XML/TCP — local Origin client emulation
  │                            login, entitlements, persona
  ├─ Blaze            :42127 redirector (TLS) → :42130 game, :42131 nucleus
  │                            EA's binary Fire2/TDF RPC
  │                            session, auth, and the CLIENT-CONFIG STORE
  ├─ UTAS / RS4       :8099    the FUT REST API — JSON/HTTP, ~45 endpoints
  │                            club, squads, packs, market, matches
  └─ POW / EASFC      :8094    a third HTTP API (+ :8080 content)
                               online status, level, credits, catalogue

Plus roster (:8081), serving an XML file the FUT loading screen blocks on, and autopatch, which patches certificate verification in the running process.

Redirection, in order of preference

  1. Blaze client-config keys — the client reads its own service URLs from a key/value store that Blaze serves. Pointing FUT and EASFC at localhost needs no root and no DNS manipulation. This is the clean mechanism and most redirection uses it.
  2. /etc/hosts — for hostnames baked into the binary.
  3. iptables DNAT — for one hardcoded IP address.

4. The wire format — and why it is unforgiving

FUT responses are JSON, but the client does not use a general JSON object model. Each response class has a hand-written SAX-style deserializer that walks tokens and dispatches on a hashed key id ("atom"). Three consequences dominate the project:

Atoms. Every JSON key maps to a 16-bit id via FNV-1a. A recovered table of ~900 id→name pairs is the Rosetta stone. A response spec is really "which atoms does this deserializer read, of what type".

Type fidelity is fatal. A scalar where an object or array is expected does not error — it desyncs the reader and hard-freezes the game in a busy loop. This is the primary failure mode of the entire project.

Unknown keys are usually skipped — but not always. Most deserializers route unrecognised atoms to a skip handler, making extra fields inert. At least one does not, so any unexpected key desyncs it.

Working method: locate the deserializer, extract its atom set and per-atom getter types, build the minimal body, and omit nested members whose shape isn't known — omission is safe, a wrong shape freezes the game.


5. What works

Capability State
Boot: Origin → Blaze → FUT hub
Club identity, coins, W/D/L record
Active squad — 11 real players, ratings, chemistry
Squad building — saves and survives relaunch
Squad roster ("MY SQUADS")
Transfer market — browse, bid, buy-now, list, watchlist
Store — buy packs
Packs — cards land in the club via workaround (§6a)
Quick sell — credits coins
Match loop — create/ready/play/destroy + rewards ⚠️ built, never requested by the client (see below)
Online/EASFC status bar when enabled
Seasons, tournaments, leaderboards, champions ⚠️ routed, never requested by the client (see below)
Card identity (names, faces, ratings) resolves from the game's own local database

"Untested" is two different things, and the difference matters

The server log records the User-Agent of every request. The real client identifies as ProtoHttp; this project's own curl and Python probes do not. Separating them shows that several endpoints previously filed as "built but untested" have in fact never been requested by the game at all, and everything recorded against them was self-inflicted traffic:

endpoint client requests project probes
/leaderboards/options 5 1
/clientdata/userHubData 13 2
/user/accountinfo 23 49
/season, /season/user 0 2
/tournament, /tournament/user 0 3
/leaderboards (bare) 0 2
/champion 0 2
/match 0 2
/clubUser 0 93
/user/list 0 180
/sbs (SBC), /draft/mode 0 0

/clubUser and /user/list are the starkest: 273 requests between them, none from the game. Work was done on both on the assumption the client wanted them.

A zero in the client column does not mean the client never wants that endpoint. In most cases it means nobody has navigated to that part of the game yet. It does mean no claim about those endpoints has been tested against the client, and any analysis that does not apply this filter is misleading by default.

Requirement: every capture and analysis tool in this project should apply the User-Agent split by default rather than as an afterthought.

Design convention. Every risky change ships behind an environment flag whose default is whatever is live-proven. This exists because shipping "corrections" on by default broke two working screens — once freezing the store outright.


6. Open problems

6a. "Send to Club" ends the FUT session

Opening a pack displays the cards correctly. Choosing Send to Club produces "there has been an error connecting to FIFA 17 Ultimate Team" and a logout. The cards do move correctly server-side; only the acknowledgement is rejected.

Eliminated by live test: the response body (a bare {} fails identically), a missing field, unknown-key desync, a network failure (zero external connections during a failure — that error string is FIFA's generic session error), missing service URLs, the online layer, the reveal screen's exit path, and the club not being loaded.

The asymmetry: quick sell and move receive an identical bare {} from the same server. Quick sell succeeds. Move ends the session. The deciding factor is client-side state, not the wire.

Workaround: pack contents are deposited straight into the club at open time, so the client is never offered a move. Packs are fully usable; the reveal screen just doesn't offer assignment.

6b. "MY CLUB" counter reads 0

The hub shows MY CLUB 0 despite 99 items. Not the item list (the client displays all 99), not the pile-size data (a 16-entry probe changed nothing), not lazy loading (fetched 6 times). Unexplored: the neighbouring ACTIVE SQUAD counter works correctly — the difference between them is likely the answer.

6c. Store tiles read "unknown"

"unknown" is an unconditional default in a string constructor — the field is never written. The store renders display groups, and that member is parsed recursively by the same parser; sending it populated froze the store, so it is flagged off pending a correct group schema.

6d. Large parts of the game have never been opened

Distinct from 6a to 6c, which are things that misbehave. Per the User-Agent table in section 5, entire modes have never issued a single client request: Seasons, Tournaments, FUT Champions, Draft, SBC, and the match loop itself. Their endpoints are routed and their schemas are reversed, but no claim about any of them has been tested against the game.

This is not a bug list. It is unmeasured surface, and it is the cheapest information available to the project because most of it costs nothing but navigating menus. It is recorded here because "routed from a reversed schema" reads like a stronger claim than it is, and section 7 warns that this project's notes have described things differently from what is true.

A multi-agent investigation into 6a and 6b is currently running.


7. Notable findings

  • Class → deserializer resolution. A response class's name literal is preceded by a 4-byte header and the factory points at the header. Six attempts failed on that off-by-four; four returned nothing and were nearly recorded as "no deserializer exists".
  • The URL table is a floor, not a ceiling. Several real endpoints are built by appending a suffix at the call site and never appear in the binary's template table. Only live traffic reveals them — this caught the project three separate times.
  • The project's own documentation has been wrong repeatedly — fields described as inert turned out to be parsed, and documented key names didn't match the parsers. Verify against the decompiler, not the notes.
  • The online layer was hiding in plain sight. "EA FC servers unreachable" comes from a third HTTP API in a loose, unpacked, string-rich library — not the protected executable, and not any previously emulated layer. Redirectable purely by config.
  • The main executable is Denuvo-packed, so its code exists only in a live process. Live memory is readable, which is how a crash site was disassembled — but logic living there cannot be reverse engineered statically.

8. Tooling and quality

  • PyGhidra harness with decompile / xref / vtable / byte-scan / class-resolution helpers (Ghidra's own Java scripting is broken on this machine).
  • Minidump reader — exception record, fault-time registers, module map, stack walk.
  • Live code grabber — reads and disassembles unpacked code from a running process.
  • Read-only live model probes for watching client state while playing.
  • Test suites: 380 live contract checks (type/freeze safety per reversed schema) plus 51 pure unit checks. Both green.
  • Traffic-replay audit — diffs current responses against a known-good session to prove a change didn't alter what the client sees.
  • ~3,600 lines of RE documentation across six files, including every eliminated hypothesis with its supporting evidence.

9. Roadmap

Immediate (free, no code): play a match — the reward loop is built and unit-tested but has never run in-game. Enable the game-mode endpoints and see whether four more modes light up.

Near term: close the two open problems, most likely via live instrumentation rather than more static analysis. Implement SBC and Draft (schemas already recovered). Fix the store display groups properly.

Longer term: the stated destination is porting this into the Rust openfut-core behind a FIFA-17 bridge. Everything currently lives in Python prototypes; the documentation is now good enough to write the port against.


10. Honest assessment

What went well. The FIFA 17 pivot was the decisive call — recognising that an unprotected binary was worth more than persisting against a hardened one. Blaze, Origin and the FUT API were all cracked end to end. The freeze-safety test suite has repeatedly caught regressions before they reached the game.

What went badly. Progress has been slowest where fixes were proposed before the assumption under them was tested. Both open problems absorbed many attempts built on plausible but unverified theories; several were disproved in a single measurement that could have been taken first. Two working screens were broken by shipping unverified "corrections" on by default — which is precisely why the flag convention exists now.

The most reliable technique has been comparing a working case against a failing one: diffing live traffic against a known-good session, and contrasting a succeeding endpoint with its failing sibling. That has produced more answers than any amount of decompilation.