Record the per-IP -> per-session correction: why source-IP-only was unsafe (two FIFA processes share an IP), the authoritative per-login X-UT-SID key with IP and persona as auxiliary, the Capability/StoreMode state machine, the single-use short-TTL launcher->session pending hand-off, activity-based session cleanup, and the documented fail-closed residual for genuinely simultaneous same-(ip,persona) logins. Design history is retained; the per-IP prototype is marked superseded.
16 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.
13. Session binding (hardening — supersedes the per-IP prototype)
History. The first implementation keyed the backend capability/store-mode by
source IP alone (§7 as originally written). That was rejected before deployment:
two FIFA processes that share a source IP — concurrent, or a relaunch — would share
the key, so an unverified process could inherit a verified one's clean-v1
topology and crash on the empty-My-Packs resolver. Source IP is now auxiliary only
(logging, a fail-closed sid/ip sanity check, and the pending hand-off key). This
history is retained deliberately; do not treat per-IP as the design.
Authoritative key = the per-login UTAS session id (X-UT-SID). /ut/auth now
mints a fresh unique SID per login (was a shared constant OPENFUT-SID-…0001); the
client echoes it on every later call, and it is live-confirmed present on real
/store/purchasegroup requests. The SID uniquely identifies one FIFA process/login:
a relaunch re-auths → new SID; two concurrent logins → two SIDs. The legacy constant
is still accepted by the retired security-question gate only, and is never used to
grant clean-v1. A store request whose SID was opened on a different source IP is
fail-closed to sentinel (sid/ip sanity check).
Why not persona alone: the backend is single-account, so personaId cannot
distinguish two sessions, and a relaunch keeps the same persona — persona alone would
leak a prior session's mode. Persona is used only (with IP) to key the pending hand-off.
State machine (per session, keyed by SID)
Capability : Unknown | ResolverV1
StoreMode : Unfrozen | Sentinel | CleanV1
/ut/auth (new SID) : Capability=Unknown, StoreMode=Unfrozen, record {ip,persona}
+ consume any pending (ip,persona) -> Capability=ResolverV1
capability registered : bind to the one live Unfrozen/Unbound session for (ip,persona)
-> Capability=ResolverV1 ; else stage single-use pending ;
else (a session exists but is frozen/ambiguous) -> ignored-late
first /store/purchasegroup : Unfrozen + ResolverV1 -> freeze CleanV1
Unfrozen + otherwise -> freeze Sentinel (consume pending first)
late capability : StoreMode already frozen -> unchanged (ignored-late, not staged)
capability lost/cleared : after a CleanV1 freeze -> stays CleanV1 (mode is cached)
session idle > TTL / reaped: session discarded (a later store with that SID -> Sentinel)
Registration order + pending hand-off
The verified capability is known only after the FIFA process exists, CardsDLL is
loaded, and autopatch confirms the JG bytes — which may land before or after
/ut/auth, but reliably before the user opens the Store. The launcher cannot know
the SID, so its registration is matched to a session by (source_ip, persona) as a
single-use, short-TTL pending (FIFA17_PENDING_TTL = 120s) that is consumed by
exactly one session, at whichever of these happens first for that session: its
/ut/auth (pending predates login), the registration itself (session already live —
bound directly), or its first store request (lazy). If the Store is reached before a
capability binds, the session freezes Sentinel (fail-closed); a later capability
does not change it.
Session cleanup (Task 10)
- creation: at
/ut/auth. - last activity: bumped on every
/store/purchasegroupfor the session. - freeze: first
/store/purchasegroup. - expiry: lazy sweep on every session op removes sessions idle for
FIFA17_SESSION_TTL = 3600sand pendings older thanFIFA17_PENDING_TTL. Explicit Blaze/UTAS teardown is not reliably observable at this handler, so a conservative activity-based TTL is used instead. Reaping only removes expired entries and never affects another live session from the same IP/persona (keyed by distinct SIDs).
Residual limitation (documented, fail-closed)
FIFA carries no launcher-controllable per-process token, so two simultaneous logins
from the same (ip, persona) cannot be disambiguated at the instant a capability is
registered while both are Unfrozen/Unbound. That ambiguous case resolves to
ignored-late → both freeze Sentinel (safe: an unverified process is never granted
clean). The normal one-launcher-per-FIFA and sequential-relaunch flows bind correctly
(proven by matrix K/L/M). This is a UX conservativeness, never a safety hole.