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.
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 keepFUT_MODESoff 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):
- path ends
/match/end→ END (routed asFutDestroyMatchregardless of verb). - body has integer
matchIdon the bare/matchpath → PLAY (FutPlayGame). CREATE and PLAY shareut/<sku>/match; the presence of an intmatchIdis the only discriminator — this is why a bare/matchcarryingmatchIdmust NOT allocate a new match. - path ends
/match/ready→ READY (FutMatchReady). - 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>}
opponentPersonaIddefaults to0— a neutral placeholder, never the logged-in user's persona.- The parser also has a nested
itemsmember (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_economyonPOST /ut/delete/game/<sku>/match— EA/CardsDLL tunnels DELETE-semantics ops through the/ut/delete/game/prefix (FutDestroyMatch=DELETE ut/%s/match/{id}). Python'smatch_routeadditionally 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/gameform, and a/ut/game/.../match/endwould 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_WINwon DRAW,DNF_DRAW,NO_CONTESTdraw LOSS,DNF_LOSS,DNF,QUITloss (missing/unknown) draw (neutral fallback — credits without inventing a win) -
myMatchStats/opponentMatchStats— literal-keyed objects, 15 int fields each, first isgoals. Omitted by the client whenendReasonisDNF/QUIT, so nothing may require them. Used only as a fallback outcome probe ifendReasonis 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 insidegameModeAward, 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+ advancesnextItemIdby 1. - READY returns exactly
{matchId, opponentPersonaId:0}. - PLAY returns
{}and does not advancenextItemId. - END credits
MATCH_COINS["won"] + MATCH_PARTICIPATION, persistsrecord == {won:1,draw:0,loss:0}andmatchesPlayed == 1.
Open questions / blockers
- READY
itemscontract (opponent squad) — the nesteditemsmember ofFutMatchReadyServerResponseis unserved pending a live capture. Whether the client requires it present/non-empty to enter a match is unknown. Blocker. - Client mode-entry gate — reaching a match from the FUT hub UI is unverified;
FUT_MODESstays off by default (the season/tournament routes return{}). - 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).
- 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.