Design + cross-component contract for verified patched-client capability negotiation: architecture inventory, transport choice (autopatch stdout -> launcher, sibling /openfut/fifa17/capability endpoint), the versioned capability and its VERIFIED semantics, autopatch verification states, launcher per-process state, source-IP binding, the session-stable freeze point, the additive store switch, the trust model (local preservation, not attestation), the fail-closed matrix, and P2 retention.
12 KiB
FIFA 17 — verified patched-client capability negotiation
Goal: let the FIFA 17 backend suppress the synthetic My-Packs sentinel (id 65534)
only when the current FIFA process has positively verified that the CardsDLL
resolver guard is active (JNZ→JG at RVA 0x14858). Unpatched / unsupported /
unknown / failed-patch clients keep receiving the existing P2 active sentinel.
Core principle: the capability is not "this launcher supports the patch"; it is "the resolver guard was verified in this particular FIFA process."
This document is the design + the cross-component contract. It is deliberately
additive: the P2 active-sentinel path (docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md)
remains the default and the universal fallback.
1. Architecture inventory (as-built, verified by reading the code)
Data flow today (launch of one FIFA process):
LauncherApp::launch_game (openfut-launcher/src/app.rs:450)
-> account_sync::sync POST /openfut/account/sync (:8099) [REQUIRED; launch is gated on it]
-> ensure_local_services() spawn LSX, then autopatch.py --launcher-pid <launcher_pid>
-> game_launch::launch umu-run FIFA17.exe (grandchild; launcher never learns FIFA PID)
FIFA process
-> autopatch.py self-discovers FIFA by comm=='FIFA17.exe'; patches /proc/<pid>/mem each tick
-> FIFA -> backend POST /ut/auth (login) ; GET /store/purchasegroup ; ... (:8099)
Facts that shape the design:
- Launcher ↔ autopatch IPC = one-way stdout only.
local_services::spawn(openfut-launcher/src/local_services.rs:279-296) pipes autopatch stdout/stderr into the launcherLogBufferline-by-line as[autopatch] <line>. There is no socket / named pipe / status-file readback.--launcher-pidis the launcher's own pid (local_services.rs:66), used for liveness, not to identify FIFA. - Launcher ↔ backend = exactly one control call:
account_sync::sync(openfut-launcher/src/account_sync.rs:43) — a tiny stdlib-HTTPPOST /openfut/account/synconopenfut_account_sync_port(default 8099), sent once per launch, before FIFA starts, and launch is blocked unless it succeeds (utas_server.py:1204). This is the reliable per-FIFA-process session boundary. - Backend is single-account, stateless-per-request, threaded.
SIDis a fixed module constant shared by all clients (utas_server.py:32); account identity is one globalACCOUNTsingleton. There is no per-session identity in requests. The only per-connection discriminator available at every handler isself.client_address[0](peer IP), currently unused. Server isThreadingHTTPServer(utas_server.py:3784); module is import-safe (server underif __name__ == "__main__"). - No bridge/proxy in the FIFA-17 path. FIFA reaches the Python backend's
published
:8099directly (client-side DNAT/hosts redirect); the openfut-bridge is legacy FIFA-23. Docker's iptables DNAT preserves the source IP for external LAN clients. The launcher and FIFA run on the same client machine, so the backend observes them under the same peer IP regardless of NAT.
2. Capability transport — options and choice
Ranked against the as-built architecture:
autopatch → launcher (chosen: structured stdout line).
- Structured stdout line (CHOSEN). Reuses the existing one-way pipe the launcher already reads. It is live (only the current autopatch child's stdout), inherently child-bound, and carries zero stale-file risk — a previous launch's capability cannot leak because nothing is persisted. Smallest possible change. Format is a machine-readable token (§4).
- Status file in
$XDG_RUNTIME_DIRkeyed by launcher-pid+FIFA-pid+version+timestamp — works but needs explicit staleness handling and cleanup; more moving parts. - Unix-domain socket — most capable but overkill; there is no bidirectional need.
launcher → backend (chosen: sibling HTTP endpoint on the account-sync port).
- A. Existing session-init channel (CHOSEN). Add
POST /openfut/fifa17/capabilitynext to the existing/openfut/account/sync(same port 8099, same tiny stdlib-HTTP client). It cannot ride inside account_sync because the capability is only known after autopatch verifies (which happens after account_sync + FIFA start), so it is a separate, later call — but on the same proven transport. - B. Blaze/login metadata — rejected: no OpenFUT-owned field is available without risking a field FIFA depends on, and Blaze runs in a separate responder.
- C. New local IPC + backend side-channel — unnecessary; A already exists.
- D. Server-wide "assume patched" config — dev/testing fallback only; cannot distinguish patched vs unpatched clients, so never the production mechanism.
3. The capability (name + version + VERIFIED semantics)
- Name:
fifa17.empty_mypacks_resolver, integer version, current1. - VERIFIED (v1) means, for THIS FIFA process: the CardsDLL tested build was
recognised AND the live bytes at RVA
0x14858are7f 0f(JG) after autopatch enforcement — i.e.guarded_actionreturned"patch"(was75 0f, written, re-read as7f 0f) or"noop"(already7f 0f). - It explicitly does NOT mean any of: "autopatch.py contains the guard code", "the launcher build is new enough", or "a config flag is set". The signal represents observed runtime enforcement on the specific process, nothing less.
4. autopatch verification state + emitted line
Per-FIFA-pid guard status (fail-closed; never loosens the existing byte guard):
| state | meaning |
|---|---|
NOT_ATTEMPTED |
CardsDLL not yet mapped / guard not evaluated for this pid |
VERIFIED |
live bytes == 7f 0f after enforcement (from patch or noop) |
UNSUPPORTED_BUILD |
live bytes are neither the known original nor patched (guarded_action → skip) |
WRITE_FAILED |
/proc/<pid>/mem write raised |
VERIFY_FAILED |
post-write re-read != 7f 0f |
Only VERIFIED advertises capability. On transition to VERIFIED, autopatch emits
once per FIFA pid on stdout:
[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
Any non-verified terminal state emits an explicit, non-advertising status line, e.g.:
[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=<pid> (no capability advertised)
5. Launcher per-process capability state
pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option<u32> }
- Starts UNKNOWN (
None) at each launch. - Becomes
Some(1)when the launcher parses a valid capability line from the current autopatch child's stdout (parse_capability_line). - Discarded when autopatch stops / FIFA exits / launcher exits / next launch. It is never persisted and never reused for a later FIFA process — staleness is structurally impossible.
On first Some(v), the launcher registers the capability with the backend (§6) once.
6. Launcher → backend registration + binding
POST /openfut/fifa17/capability (port = openfut_account_sync_port, 8099), body:
{"capability":"empty_mypacks_resolver","version":1,"personaId":<id>,"fifaPid":<pid>}
- Binding key = source IP (
self.client_address[0]). The registration arrives from the client machine's IP; FIFA's/store/purchasegrouprequests arrive from the same IP (same machine).personaId/fifaPidare for logging only (the backend is single-account, so persona cannot discriminate clients). - Concurrency: distinct client machines → distinct peer IPs → independent decisions (no global state). Two FIFA processes on one machine share an IP — an accepted limitation (the backend is single-account anyway); documented in §Trust.
7. Backend session-stable decision
Per-IP record (guarded by a lock; threaded server):
_FIFA17_STORE[ip] = {"resolver": Option[int], "mode": Option[str]} # mode: None|"sentinel"|"clean-v1"
- Reset (session boundary):
/openfut/account/syncfromipsets{resolver: None, mode: None}. This is the launcher's required per-launch call, so every new FIFA process starts from a clean, unfrozen record — no cross-process leak. - Register:
/openfut/fifa17/capabilityfromipsetsresolver = version. Ifmodeis already frozen, it is logged as late and ignored for this session. - Freeze point = first
/store/purchasegroupfromip(§9): ifmode is None, setmode = "clean-v1"iffresolver == 1else"sentinel", and log once. Thereaftermodeis immutable for the session. - Default / fail-closed: an IP with no record (no account-sync, no capability),
an unknown resolver version, a late capability, or a disappeared capability all
resolve to (or remain)
"sentinel".
8. Freeze point rationale
Freeze at first /store/purchasegroup, not at login/account-sync. account-sync
fires before FIFA starts and before autopatch can verify, so freezing there would
always be sentinel. First Store request is the earliest moment at which a genuine
capability can already be registered (autopatch verifies at process start; the user
opens the Store later), while still being a single, well-defined topology commit for
the session. Once Store topology is served, it must not change (the F3 experiment
proved a mid-session flip can leave a stale positive ordinal that crashes even the
sign-only guard — see FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART III/IV and the
SESSION-STABLE invariant).
9. Store behaviour (additive switch)
At store_catalog, only the zero-owned-packs branch changes:
if not owned_ids:
if fifa17_empty_mypacks_mode(client_ip) == "clean-v1":
pass # patched client: emit NO mypacks group; guard routes -1 to Browse
else:
<append active 65534 sentinel exactly as today> # P2 fallback (unchanged)
Untouched: real owned-pack rendering, PACK_CATALOG, pack 70, normal packs 1/5/6/7,
profile state, all store env flags. Default remains sentinel. This lives in the FIFA-17
Python backend only — never in game-independent OpenFUT Core.
| client | zero packs | real unopened pack |
|---|---|---|
| verified v1 | no sentinel (clean) | genuine My Packs, no sentinel |
| no / unknown capability | active 65534 sentinel | genuine My Packs, no sentinel |
10. Trust model (Task 14)
This is not anti-cheat / attestation. OpenFUT assumes the user controls the launcher/client machine and the server is a private preservation environment. The verification exists to prevent accidents: a stale capability, an unsupported CardsDLL build, a failed autopatch, the wrong process, or an unpatched client receiving no sentinel and crashing. No signatures / PKI / remote attestation.
Isolation across distinct client machines relies on the backend observing distinct peer IPs (source-IP-preserving publish; Docker's default for external LAN via iptables DNAT). Two FIFA processes on one machine cannot be distinguished by IP — accepted, since the backend is single-account. The single-client production case is unaffected by NAT because launcher and FIFA share one IP.
11. Fail-closed matrix (Task 15) — every failure ⇒ sentinel
autopatch missing / not run · guard UNSUPPORTED_BUILD / WRITE_FAILED /
VERIFY_FAILED · launcher cannot parse the line · registration POST fails ·
account-sync never called · unknown capability version · capability arrives after
freeze · capability disappears after a sentinel freeze — all resolve to the active
65534 sentinel. Asserted by tests (matrix A–J) and this document.
12. P2 retained (Task 16)
The active-sentinel implementation is not removed. It is the else-branch of the switch and the universal default for unpatched clients, unsupported builds, failed patches, unknown launchers, and late capabilities. The clean path is purely additive.