Files
OpenFUT/openfut-adapter-fifa17
funman300 33300f2ad1 fix(fifa17): serve the match lifecycle instead of proxying it to a dead upstream
"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:

  utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
  utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502

Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.

The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.

`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.

MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.

ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.

THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.

One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.

Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.

Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
2026-08-21 18:00:28 +00:00
..

openfut-adapter-fifa17

The FIFA 17 game adapter. Everything true of FIFA 17 specifically lives here, so that neither OpenFUT Core nor the generic protocol crates have to know about it.

  openfut-protocol-blaze   generic Blaze: Fire2 framing, Heat2/TDF codec
           ▲
  openfut-adapter-fifa17   THIS: command tables, response bodies, dispatch order
           ▲
  OpenFUT Core             game-independent FUT domain (not yet wired)

Status

Surface Port State
Blaze / Fire2 RPC 42130 Implemented, byte-for-byte parity-tested
Redirector (HTTPS + XML) 42127 Python only
Nucleus OAuth stub 42131 Python only
LSX / Origin 4216 Python only
Roster XML 8081 Python only
UTAS / RS4 8099 Python only
POW / EASFC 8094 / 8080 Python only

Nothing here is wired into the running backend. The crate answers frames; it opens no socket, terminates no TLS and owns no runtime. The Python backend remains the live service and the behavioural oracle.

What the adapter owns, and what it must not

Owns: component/command/notification IDs, response body shapes, dispatch ordering, session identity, the fetchClientConfig tables.

Must not own: FUT domain state. Blaze is an auth/session/config protocol — no coins, packs, clubs or squads appear on this wire — so Session holds a session key, a locale, a service name, an auth code and a flag, and that is all. When UTAS is migrated that boundary will need active defending; here it comes free.

Parity

./check-parity.sh          # oracle freshness + byte-for-byte replay
./check-parity.sh --regen  # after an intentional oracle change

fixtures/blaze_transactions.jsonl holds 49 request→response(s) transactions produced by calling the real blaze_responder_v3b.dispatch(). They replay in order against a shared session per connection, so ordering-dependent behaviour is exercised rather than assumed: preAuth captures the locale that later ALOC fields echo, and login sets the auth code getAuthToken returns afterwards.

Comparison is byte-for-byte including frame count and order — a missing post-login notification or a reply where the oracle stays silent fails here.

The suite was mutation-tested: swapping two post-login notifications, flipping one enum deep inside AccountInfo, and hardcoding an address in utas_base()/nucleus_base() were each verified to turn it red. The third initially did not, because the config templating had made those helpers dead code; the table now templates on URL-level tokens so they are the single place a URL shape is defined.

Three behaviours that are easy to get wrong

  • Login answers with four frames, in order: reply, then UserAuthenticated, UserSessionExtendedDataUpdate, UserAdded.
  • An unimplemented RPC still gets an empty reply. Silence makes the client wait for a timeout; an empty reply lets every field fall back to a client-side default and the boot continues.
  • Non-request message types get nothing at all.

No error replies are emitted. msgType 3 exists, but the error-code placement is UNRESOLVED — three clean-room sources disagree between header[14:16], a metadata ERRC, and a payload CNTX/ERRC — so emitting one would be a guess on the wire.

The client config table

fixtures/client_config.json carries 227243 rows per CFID, generated from the Python oracle and templated on {utas_base}, {nucleus_base}, {pow_content_url}, {advertise}, {bind}, {pow_host}. It is reverse-engineered data, not logic, and deriving it mechanically removes a class of transcription typo no reviewer could catch. The generator does not take its own templating on trust: it substitutes real addresses back in and diffs against the oracle for every section before writing the file.

The table must be complete, not representative. The client resolves a per-call key (FUT_RS4_URL_<CALL>) before a per-module one, and any unresolved call falls back to a real, dead EA host — that is what produced "there has been an error connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset was served.

Known defect reproduced deliberately

nucleusConnect and nucleusConnectTrusted are built from the bind address, not the advertised one. On the live split deployment that means the backend tells a client on another machine to reach Nucleus at http://0.0.0.0:42131, which it cannot. Verified against the running container, not inferred.

This is reproduced exactly, because it is what the only proven-working configuration does and changing it would break parity. It also implies the Nucleus stub is not actually reached in the current remote flow. Fixing it is a separate change that needs live validation — see the vault.

Configuration

Nothing is hardcoded. AdapterConfig carries Identity (persona, ids, email, namespace, entitlement group, …) and Endpoints (advertise, bind, POW hosts, telemetry/ticker/QoS ports). Default gives the project's synthetic offline identity on loopback; a remote deployment must override advertise.

Bind and advertise are deliberately distinct: an advertised URL must carry the address the client can reach, which on a two-machine deployment is not the address the server binds.