a93a8bcdd7
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
298 lines
14 KiB
Markdown
298 lines
14 KiB
Markdown
# 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.
|