e06fd57211
Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
(nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.
Launcher: bump gitlink to c277213 (shareholder-grade redesign + live account panel).
Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
209 lines
10 KiB
Markdown
209 lines
10 KiB
Markdown
# FIFA 17 FUT Match Lifecycle
|
|
|
|
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
|
|
the backend/responder side. Consolidates knowledge previously scattered across
|
|
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
|
|
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
|
|
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
|
|
|
|
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
|
|
**HYPOTHESIS** (reasoned, not yet live-confirmed).
|
|
|
|
> ## Status caveat (read first)
|
|
> **No football match has ever started or completed in FIFA against this stack.**
|
|
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
|
|
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
|
|
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
|
|
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
|
|
> [Open questions](#open-questions--blockers)).
|
|
|
|
## Scope
|
|
|
|
This documents the **UTAS responder handshake + economy reward** for a match — the
|
|
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
|
|
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
|
|
which remains unverified.
|
|
|
|
## The loop: a four-call state machine
|
|
|
|
CardsDLL issues six match RPCs; four form the playable loop. All share the base
|
|
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
|
|
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> Created: POST /match (no matchId)
|
|
Created --> Ready: POST|PUT /match/ready {matchId}
|
|
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
|
|
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
|
|
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
|
|
```
|
|
|
|
### Call classifier (`_match_call`, utas_server.py:3325)
|
|
|
|
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
|
|
|
|
1. path ends `/match/end` → **END** (routed as `FutDestroyMatch` regardless of verb).
|
|
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
|
|
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
|
|
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
|
|
allocate a new match.
|
|
3. path ends `/match/ready` → **READY** (`FutMatchReady`).
|
|
4. otherwise → **CREATE** (`FutCreateMatch`).
|
|
|
|
## Per-call contracts
|
|
|
|
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
|
|
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
|
|
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
|
|
(SKIP-safe).
|
|
|
|
Response (`match_route`, utas_server.py:3399):
|
|
```json
|
|
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
|
|
```
|
|
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
|
|
`test_match_lifecycle.py:51`).
|
|
|
|
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
|
|
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
|
|
response parser has two scalar members + one nested member.
|
|
|
|
Response (`match_ready_body`, utas_server.py:3353):
|
|
```json
|
|
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
|
|
```
|
|
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
|
|
logged-in user's persona.
|
|
- The parser also has a nested `items` member (the opponent squad). It is **omitted
|
|
deliberately** until the opponent-squad item contract is recovered from a live
|
|
capture; unrecognized/absent members are skip-safe. **This omission is one of the
|
|
two blockers.**
|
|
|
|
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
|
|
CardsDLL: `FutPlayGame` — no request deserializer.
|
|
|
|
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
|
|
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
|
|
keepalive/transition ack.
|
|
|
|
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
|
|
CardsDLL: `FutDestroyMatch` @ `0x180121b60` — **the rewards call**. Routed as
|
|
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
|
|
|
|
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
|
|
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
|
|
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
|
|
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
|
|
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
|
|
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
|
|
|
|
Request fields that matter:
|
|
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
|
|
comparison is NOT how the client reports the outcome. Nine values, mapped to a
|
|
win/draw/loss bucket:
|
|
|
|
| endReason | bucket |
|
|
|---|---|
|
|
| `WIN`, `DNF_WIN` | won |
|
|
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
|
|
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
|
|
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
|
|
|
|
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
|
|
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
|
|
nothing may require them. Used only as a fallback outcome probe if `endReason` is
|
|
absent.
|
|
|
|
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
|
|
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
|
|
```json
|
|
{
|
|
"allCoins": <post-credit balance:int>,
|
|
"matchCoins": <per-result coins:int>,
|
|
"seasonCoins": 0,
|
|
"tournamentCoins": 0,
|
|
"boostConis": 0, // EA's typo — exact key required
|
|
"participationAward": <int>,
|
|
"teamOfTournamentWinner": false,
|
|
"gameModeAward": { "coins": <total award:int> }
|
|
}
|
|
```
|
|
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
|
|
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
|
|
> top-level `"coins"` was silently skipped and never reached the client — the one
|
|
> field most obviously named "the reward" was the one going nowhere.
|
|
|
|
Effect (persisted to the active FUT save): credit coins; increment the matching
|
|
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
|
|
`test_match_lifecycle.py:74-81`).
|
|
|
|
## Reward policy
|
|
|
|
Env-tunable defaults (FUT-plausible, **not reversed** — `economy_policy.rs:20-36`,
|
|
`utas_server.py:3201-3206`):
|
|
|
|
| Result | Match coins | Participation | Total |
|
|
|---|---|---|---|
|
|
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
|
|
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
|
|
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
|
|
|
|
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
|
|
|
|
## Ownership split (Rust vs Python)
|
|
|
|
The match loop is **partially migrated**. Only the coin-crediting END leg is
|
|
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
|
|
served by the Python oracle.
|
|
|
|
| Call | Owner | Where |
|
|
|---|---|---|
|
|
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
|
|
| READY | Python | `utas_server.py::match_route` |
|
|
| PLAY | Python | `utas_server.py::match_route` |
|
|
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd` → `handle_match_end` → `build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
|
|
|
|
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
|
|
by the economy authority barrier ahead of the general classifier — so it can never
|
|
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
|
|
through the single Core economy transaction (`grant_reward`); W/D/L record + match
|
|
count still live in the Python save until CREATE/READY/PLAY migrate.
|
|
|
|
## Test coverage
|
|
|
|
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
|
|
live server): drives CREATE→READY→PLAY→END and asserts:
|
|
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
|
|
- READY returns exactly `{matchId, opponentPersonaId:0}`.
|
|
- PLAY returns `{}` and does **not** advance `nextItemId`.
|
|
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
|
|
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
|
|
|
|
## Open questions / blockers
|
|
|
|
1. **READY `items` contract (opponent squad)** — the nested `items` member of
|
|
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
|
|
client requires it present/non-empty to enter a match is unknown. **Blocker.**
|
|
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
|
|
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
|
|
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
|
|
The first live match is expected to reveal whether the static read was complete
|
|
(every match body is logged for exactly this reason).
|
|
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
|
|
here and unverified.
|
|
|
|
## Sources
|
|
|
|
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
|
|
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
|
|
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
|
|
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
|
|
descriptor block (3183-3197).
|
|
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
|
|
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
|
|
`build_match_reward_body`.
|
|
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
|
|
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
|
|
`06 Agent Memory/{Project State,Known Issues}.md`.
|