# 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//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//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//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": , "reportIdEnabled": false, "id": } ``` Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN, `test_match_lifecycle.py:51`). ### READY — `POST|PUT /ut//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": , "opponentPersonaId": } ``` - `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//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//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//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//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": , "matchCoins": , "seasonCoins": 0, "tournamentCoins": 0, "boostConis": 0, // EA's typo — exact key required "participationAward": , "teamOfTournamentWinner": false, "gameModeAward": { "coins": } } ``` > **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//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//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`.