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
247 lines
12 KiB
Markdown
247 lines
12 KiB
Markdown
# 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:
|
|
|
|
1. **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>` and
|
|
`FUT_RS4_URL_<CALL>` point the FUT API at `127.0.0.1:8099`; `FIFA_POW_URL` points
|
|
the EASFC layer at `127.0.0.1:8094`. **No root, no DNS games.**
|
|
2. **`/etc/hosts`** — for hosts baked into the binary (`easw.easports.com`,
|
|
`gosredirector.ea.com`).
|
|
3. **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 `lea` points at *the header*, not the
|
|
text. Lookups must use `name_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 claimed `FutMoveCard` parses
|
|
`chemistry` (it does not); it called seven store pack fields "skipped no-ops" (all are
|
|
parsed); it gave price-object keys as `amount`/`currency` (the parsers read
|
|
`externalPriceId`). **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
|
|
|
|
1. **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?
|
|
2. **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.
|
|
3. **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.
|