Files
OpenFUT/docs/MATCH_LIFECYCLE.md
T
funman300 e06fd57211 feat(host): migrate non-economy UTAS routes to Rust + launcher redesign
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.
2026-08-17 16:04:20 +00:00

10 KiB

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).

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).

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/endEND (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/readyREADY (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):

{"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):

{"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 @ 0x180121b60the 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:

{
  "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 reversedeconomy_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::MatchEndhandle_match_endbuild_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.