RETRACTION FIRST. The previous commit added itemType to club items on the theory
that it gated ingestion, because player/staff sent it and were resident while
kit/badge/stadium omitted it and were not. Relaunched the client with itemType on
all three: ?type=kit answered total=2 emitted=2, and still no cardtype-7 record.
The correlation was an artefact of the control. Measured read-only over
/proc/PID/mem with full coverage (3605 MiB, nothing skipped): the "resident"
players and staff were all SQUAD members, which arrive via userMassInfo. Players
that appear in /club?type=player but NOT in userMassInfo are not resident either -
0 records for 6 of 6 sampled, 5 with no byte match at all, out of 1966 served.
Residency tracks the ROUTE. /club?type= responses never enter the persistent card
collection, and no value of itemType changes that. itemType is kept as wire
fidelity (every real EA item carries it) and relabelled; its doc no longer claims
to fix anything. The diagnostic KIT_PROBE is removed - it could only have tested
shape hypotheses that this result makes moot.
RECOVERED from the unpacked CardsDLL, no archive extraction, no instrumentation:
Packed kit id, both directions present and agreeing:
id = (teamid << 14) | (year ? (year-1800) << 5 : 0) | kittype
so a kit is addressed by the triple (teamid, year, kittype).
FUN_180033770 answers ONLY for team 130000 - 0x1800d8ab0 is literally
`mov $0x1fbd0,%eax ; ret`. Every other team id falls through to the engine's
catalogue kits, which are the lockable ones.
sub_180033430 writes the tile: NAME = "HOME_SIDE"/"AWAY_SIDE", TYPE = the
localised Kit_type_0 / Kit_type_1 / Kit_type_historical, and LOCKED (always
value 0, never 1). If the queried triple matches NEITHER active triple it
writes NOTHING - which is exactly why one tile rendered "undefined". A missing
write, not a bad string. There is no Kit_type_2.
FUN_1800d73d0 selector 2/3 does `setne dil ; add $0x65,%edi` then compares
itemState: active home = 101, active away = 102, derived arithmetically and
independent of the enum table. year at +0xba is movzbl - a byte INDEX.
Above all of it: FUT_GET_MATCH_KITS_DP (0x7565) handler FUN_1800be6a0 gates on
`cmpb $0x1,0x152(%r14)` and returns early otherwise. KITS_AVAILABLE IS
ctx+0x152. Constructor zeroes it; the only setter is case index 6 (message
0x757a) of the jump table at 0x1800c00d4. Live value is 0, so no kit list is
ever built. 0x757a has no name in CardsDLL and that is bounded, not sloppy: the
registration run ends at 0x7575 with the epilogue immediately after, and 70
other ids resolve from the same table as the positive control.
Tables (audit_fifa17_kits.py, full-table counts): category 2/3/5 -> engine kit
type 0/1/2 with 0 counterexamples against 54/166/145 discriminating keys; the id
band is NOT home/away (band 63 holds 740 home AND 88 third).
Vault: "Kit Selector Data Path.md". cargo test 429 passed 0 failed across the two
crates; clippy -D warnings clean; fmt clean.
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.