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
12 KiB
OpenFUT — project handoff
Self-contained briefing for an assistant with no access to this repo or machine. Everything needed to understand the project and reason about its open problems is here.
1. What this is
OpenFUT is a clean-room, fully offline re-implementation of the server backend for FIFA 17 Ultimate Team (FUT). EA's servers for FIFA 17 are long dead. The goal is to make the retail game's FUT mode fully playable again — open packs, build squads, use the transfer market, play matches and earn rewards — by emulating every server the client talks to, on localhost.
Nothing is decompiled into the project. The game's binaries are read to learn the wire format (which JSON keys, of which types, each response must contain), and the servers are written from scratch in Python against that spec.
The client is unmodified retail FIFA 17 running under Wine/Proton on Linux.
2. Architecture — four independent servers
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 over TCP. Local Origin client emulation.
│ Login, entitlements, persona.
├─ Blaze :42127 redirector (TLS) → :42130 game server, :42131 nucleus
│ EA's binary "Fire2/TDF" RPC protocol. Session, auth,
│ and — critically — the CLIENT-CONFIG STORE.
├─ UTAS / RS4 :8099 The FUT REST API. JSON over HTTP. ~45 endpoints.
│ Club, squads, packs, market, matches. The bulk of it.
└─ POW / EASFC :8094 (+ :8080 content) A third HTTP API, discovered late.
Online status bar, level, EASFC credits, catalogue.
Plus two helpers: a roster server (:8081) serving a roster-update XML the FUT loading screen blocks on, and autopatch, which patches the running process's ProtoSSL certificate verification so the client accepts our self-signed TLS.
How the client is redirected
Three mechanisms, in decreasing order of preference:
- Blaze client-config keys — the cleanest. Blaze serves a key/value config store
and the client reads its own service URLs from it.
FUT_RS4_APIURL_<MODULE>andFUT_RS4_URL_<CALL>point the FUT API at127.0.0.1:8099;FIFA_POW_URLpoints the EASFC layer at127.0.0.1:8094. No root, no DNS games. /etc/hosts— for hosts baked into the binary (easw.easports.com,gosredirector.ea.com).- iptables DNAT — for a hardcoded IP (
159.153.51.20→ the Blaze redirector).
3. 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 (an "atom"). This has three consequences that dominate the project:
Atoms. Every JSON key name maps to a 16-bit atom id via FNV-1a. There is a recovered table of ~900 (id → name). A response is really "which atoms does deserializer X read, and of what type".
Type fidelity is fatal. Feeding a scalar where the parser expects an object or array
does not error — it desyncs the token reader and the game hard-freezes in a busy loop
at 0x1801c7f1a. This is the single most common way to break the game, and it has bitten
this project repeatedly. Arrays must be arrays; nested objects must be objects.
Unknown keys are usually skipped safely — most deserializers route an unrecognised
atom to a value-skip handler (FUN_180135ff0), so extra fields are inert. But not
always: at least one deserializer (FutMoveCard, 0x180128600) has no skip handler
at all, so any unexpected key leaves its value unconsumed and desyncs the reader.
Working method
For any endpoint: find the response class's deserializer, extract the atom ids it compares against, map them to names, note the getter used for each (int / string / bool / nested), and build the minimal body. Omit nested members unless their shape is known — omission is skip-safe, a wrong shape freezes the game.
4. What works today (live-verified)
| Area | Status |
|---|---|
| Boot to the FUT hub | ✅ |
| Club identity, coins, W/D/L record | ✅ |
| Active squad — renders 11 real players, chemistry links | ✅ |
Squad building — PUT /squad/<id> fires, persists across relaunch |
✅ |
| Squad roster ("MY SQUADS") | ✅ |
| Transfer market — browse, bid, buy-now, sell, watchlist | ✅ |
| Packs — buy, cards land in club | ✅ (via a workaround, see §5) |
| Quick sell — destroys cards, credits coins | ✅ |
| Match loop — create/ready/play/destroy + coin rewards | ✅ implemented, never played in-game |
| Online/EASFC status bar (no "servers unreachable") | ✅ when enabled |
| Seasons / tournaments / leaderboards / champions | ⚠️ routed with reversed schemas, never live-tested |
Current save state: 99 club items, 8,400 coins, 0-0-0 record.
Design convention: every risky change ships behind an environment flag, default set
to whatever is live-proven (FUT_MASSINFO, FUT_USERINFO, FUT_MODES,
FUT_PACK_AUTOCLUB, FUT_POW, FUT_STORE_GROUPS, …). This exists because two working
screens were broken by shipping "corrections" on by default.
5. Open problems
5a. "Send to Club" kills the FUT session — UNSOLVED
Opening a pack shows the cards correctly. Choosing Send to Club results in:
"We are sorry but there has been an error connecting to FIFA 17 Ultimate Team. You will be returned to the FIFA 17 Main Menu."
The client then POSTs ut/delete/auth (logout) about a second later. The cards do
move correctly server-side every time — only the acknowledgement is rejected.
Eliminated by live test:
| Hypothesis | Result |
|---|---|
Missing chemistry field |
Failed without it too |
| Extra keys + no skip handler in the deserializer | Real finding; sending only the parsed key still failed |
| The response body at all | A bare {} also fails |
| A network failure ("error connecting") | Zero non-loopback connections during a failure — that string is FIFA's generic session-error text |
| Missing per-call service URLs | 146 were genuinely missing and are now served; unchanged |
| The POW layer saturating HTTP | Same failure with POW fully disabled |
| The reveal screen's exit path | "Quick Sell All" from the same screen works |
| The club not being loaded | Loading MY CLUB first, then moving, still failed |
The key asymmetry: quick sell (POST ut/delete/<sku>/item) and move
(PUT ut/<sku>/item) both receive an identical bare {} from the same server, with the
same headers and status. Quick sell succeeds; move kills the session. Whatever decides
this is client-side state, not the wire.
Workaround in place: pack contents are deposited straight into the club at open time and the pending pile is kept empty, so the client is never offered a move. Packs are fully usable; the cost is that the reveal screen shows nothing to assign.
5b. "MY CLUB" counter always reads 0 — UNSOLVED
The hub tab bar shows MY CLUB 0 despite the club holding 99 items.
Eliminated by live test:
- Not the item list — the user opened MY CLUB, the client fetched the club and displayed all 99 players correctly, and the counter still read 0.
- Not
pileSizeClientData— the massinfo member that carries pile sizes as{"entries":[{"key":int,"value":int}]}. A probe sent 16 entries with uniquely identifiable values; the counter stayed 0. - Not lazy loading — the club endpoint was fetched 6 times that session.
Unexplored contrast: the ACTIVE SQUAD tab in the same bar correctly shows 11/23.
So some counters work. Whatever differs between that one and the club one is likely the
answer.
5c. Store tiles render "unknown" — DIAGNOSED, NOT FIXED
Pack tiles show unknown with zero item counts. The "unknown" string is an
unconditional default in a string constructor — the field simply never gets written.
The store renders display groups, and displayGroup is parsed recursively by the same
element parser. Sending it populated froze the store (the type-desync busy loop), so
it is behind a flag, default off. Doing it properly needs the group's own field set worked
out rather than a self-referential copy of the pack.
6. Notable reverse-engineering findings
- Class → deserializer resolution. A response class's name literal is preceded by a
4-byte header, and the constructing factory's
leapoints at the header, not the text. Lookups must usename_address - 4. Six attempts failed on this off-by-four; four of them returned zero results and nearly got recorded as "this class has no deserializer". - The request-template table is a floor, not a ceiling. Several real endpoints are
built by the caller appending a suffix and therefore never appear in the binary's URL
table:
squad/list,user/club,club/stats/*,clientdata/<key>. Only live traffic reveals them. This has caught the project three separate times. - The documentation lies. The project's own
ENDPOINT_MAP.md(~1,360 lines, ~100 reversed structs) has been wrong repeatedly: it claimedFutMoveCardparseschemistry(it does not); it called seven store pack fields "skipped no-ops" (all are parsed); it gave price-object keys asamount/currency(the parsers readexternalPriceId). Verify against the decompiler before relying on any row. - The online layer was hiding in an unpacked DLL. "EA FC servers are unreachable" comes from a third HTTP API implemented in a loose, unpacked, string-rich library — not the packed executable, and not any layer previously emulated. It is redirectable purely through the Blaze config store.
- The main executable is Denuvo-packed. Its code exists only in a live process. Live
memory is readable via
/proc/PID/mem(the PE is mapped flat), which is how a crash site was disassembled. Any logic living there cannot be reversed statically.
7. Tooling built
- A PyGhidra harness with helpers for decompiling, xrefs, vtables, byte scanning and class→deserializer resolution. (Ghidra's Java/OSGi scripting is broken on this machine; PyGhidra bypasses it entirely.)
- A minidump reader — exception record, fault-time registers, module map, and a stack walk that recovers a usable backtrace.
- A live code grabber that reads and disassembles unpacked code out of a running process.
- A read-only live probe pattern for polling client model state while playing.
- Two test suites: 380 live contract checks (freeze-safety: asserts every response field's type against the reversed schema) and 51 pure unit checks for match rewards.
- A traffic-replay audit that diffs current responses against a known-good session — this is what proves a change did not alter what the client sees.
8. Documentation in-repo
| File | Contents |
|---|---|
ENDPOINT_MAP.md |
~100 reversed response structs, atoms, types, freeze risks |
FUT_RESPONSE_REBUILD_PLAN.md |
Squad family, massinfo, the service-layer call graph |
REBUILD_RESEARCH.md |
Complete API surface, gap analysis, POW layer, and every eliminated hypothesis with its evidence |
CARD_SYSTEM.md |
How card identity resolves locally from the game's own database |
REPACK_INTEL.md |
Origin/LSX emulation notes |
9. Where help would be most valuable
- The move-vs-quick-sell asymmetry (§5a). Two sibling endpoints, identical responses, one works. What client-side precondition could a "move item between piles" operation have that a "discard item" operation does not — a loaded destination collection, a known pile capacity, a valid target index?
- The MY CLUB counter (§5b). Given the client demonstrably has the items and renders them, what else could a tab counter read from? Note that a sibling counter in the same bar works correctly.
- Whether either is fixable server-side at all, or whether the honest answer is that the deciding logic lives in the packed executable and only live instrumentation can settle it.
Useful framing: this project's failures have almost always come from proposing a fix before testing the assumption under it. Hypotheses that come with a cheap way to disconfirm them are worth far more than plausible ones.