FIFA refuses to kick off with "your player or managers contracts have expired".
The club had no manager, and could not have had one: `/club?type=manager` (the
token the STAFF tab actually sends) was rejected by the host, and staff items
were counted and dropped by the adapter instead of being shaped.
The squad's manager reference is a red herring worth recording. It points at
wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH
(cardsubtypeid 8), not a manager. The client's own club/stats agrees:
staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has
never owned a manager, so one is MINTED rather than restored.
Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an
instruction-level reversal of the item parser and the managercards merge that
justifies every key by its record offset, and CARD_SYSTEM.md records it
confirmed live on 2026-08-05 (ten managers rendered with correct flags, league
names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that
key set and nothing else:
* `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the
client's merge never writes, so the server is their only source — they are the
flag, the league badge and both halves of manager chemistry. Coaches get
neither, because the four coach tables have no nation/league/team column and
emitting zeroes there would be invention.
* `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff
mask (players are the only masked family), so `version` must stay 0 or the
lookup misses — silently, since the manager branch has no else-arm.
* `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge
and are then read by the card view-model; `assetId`/`rating`/`rareflag` are
omitted because the merge overwrites them from the client's own tables. A
staff card is therefore never routed through `shape_item`.
Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4`
— the client's own discriminator, and its own stats model counts a manager
INSIDE the staff total with staffManager as a bucket within it. A parallel
`ContentKind::Manager` would have been a second source of truth for a fact the
subtype already carries, and would have silently under-counted club/stats.
`squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the
oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and
feeding that deserializer the wrong container type freezes the SAX reader. The
contract reaches the client through the CardsDb record registered from the
/club envelope, which is a find-or-insert and therefore accumulates.
TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look
like no assignment at all:
1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the
assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None —
indistinguishable from "no manager". Now reads `id`, and a present-but-
unreadable manager is an error rather than a silent absence. The projection
also now warns when an assignment cannot be resolved to an owned instance,
which is the documented "Core drops an owned card with no CardDefinition from
/collection without erroring" trap.
2. `Route::WatchList` was produced by NO classifier arm, so its handler was
unreachable and every `watchList` request fell through to Passthrough — the
same defect class as `season/list`. Against a stack whose Python upstream is
deliberately dead this 502'd. This was failing
`sbc_survives_complete_core_and_host_restart` at HEAD before this change.
The manager itself is seeded from the client's own tables, never invented:
managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis
Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the
dominant league in the restored squad (12 of 23), so the chemistry pairing is
the correct one rather than an arbitrary pick.
Verified live against the restored club: /club?type=manager and ?type=staff both
return 4 items (the minted manager plus the 3 coaches the profile already owned
and could never see), the manager carries contract 7 with nation/league/team,
coaches correctly carry none of the three, squad.manager resolves to the same
wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib +
36 host_test + all economy suites green.
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 227–243 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.