# 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 -> 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//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 launcher `LogBuffer` line-by-line as `[autopatch] `. There is no socket / named pipe / status-file readback. `--launcher-pid` is 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-HTTP `POST /openfut/account/sync` on `openfut_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.** `SID` is a fixed module constant shared by all clients (utas_server.py:32); account identity is one global `ACCOUNT` singleton. There is **no per-session identity** in requests. The only per-connection discriminator available at every handler is `self.client_address[0]` (peer IP), currently unused. Server is `ThreadingHTTPServer` (utas_server.py:3784); module is import-safe (server under `if __name__ == "__main__"`). - **No bridge/proxy in the FIFA-17 path.** FIFA reaches the Python backend's published `:8099` directly (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).** 1. **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). 2. Status file in `$XDG_RUNTIME_DIR` keyed by launcher-pid+FIFA-pid+version+timestamp — works but needs explicit staleness handling and cleanup; more moving parts. 3. 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/capability` next 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, current **`1`**. - **VERIFIED (v1) means, for THIS FIFA process:** the CardsDLL tested build was recognised AND the live bytes at RVA `0x14858` are `7f 0f` (`JG`) **after autopatch enforcement** — i.e. `guarded_action` returned `"patch"` (was `75 0f`, written, re-read as `7f 0f`) **or** `"noop"` (already `7f 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//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= ``` Any non-verified terminal state emits an explicit, non-advertising status line, e.g.: ``` [store-guard] guard status=UNSUPPORTED_BUILD fifa_pid= (no capability advertised) ``` ## 5. Launcher per-process capability state ```rust pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option } ``` - 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: ```json {"capability":"empty_mypacks_resolver","version":1,"personaId":,"fifaPid":} ``` - **Binding key = source IP** (`self.client_address[0]`). The registration arrives from the client machine's IP; FIFA's `/store/purchasegroup` requests arrive from the **same** IP (same machine). `personaId`/`fifaPid` are 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/sync` from `ip` sets `{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/capability` from `ip` sets `resolver = version`. If `mode` is already frozen, it is logged as late and **ignored for this session**. - **Freeze point = first `/store/purchasegroup`** from `ip` (§9): if `mode is None`, set `mode = "clean-v1"` iff `resolver == 1` else `"sentinel"`, and log once. Thereafter `mode` is 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: # 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/purchasegroup` for the session. - **freeze:** first `/store/purchasegroup`. - **expiry:** lazy sweep on every session op removes sessions idle for `FIFA17_SESSION_TTL = 3600s` and pendings older than `FIFA17_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. --- ## 14. Deployment candidate & controlled A/B (overnight reconciliation 2026-08-13) **Launcher lineage reconciliation.** The two divergent launcher histories (merge base `87241ac`) were reconciled by a real merge — **not** a rebase/squash/rewrite — in a clean worktree: - `feat/launcher-arming` `13339c1` (client arming + FIFA-17 capability reporting) - `feat/sbc-hook-tracing` `958ff24` (openfut-hook SBC request tracing / RE probes) Merged commit **`ca7ce26`** on branch `integration/fifa17-launcher-capability-sbc` retains **both** ancestors (`git merge-base --is-ancestor` true for both `958ff24` and `13339c1`). The only conflict was `src/process.rs` (launcher-arming deleted it + dropped `mod process`; SBC only incidentally tidied it) — resolved **keep-deleted** (orphan module; the SBC feature lives entirely in `openfut-hook/*`). The two features are in disjoint crates/processes (launcher-crate Rust host vs `openfut-hook` Windows DLL) and share no stdout readers, child handles, or lifecycle — no integration code was needed. **Gitlink status — DEFERRED (morning blocker).** The superproject gitlink still records the pre-reconciliation `958ff24`. It was **not** bumped to `ca7ce26` because the live submodule checkout carries uncommitted `openfut-hook/*` WIP that overlaps the merged hook content; a non-destructive `git checkout ca7ce26` is refused ("local changes would be overwritten"), and no `-f`/`reset`/`clean` is permitted. The user must first reconcile that WIP against the merged `openfut-hook`, then the gitlink can bump. Preservation artifact: `/tmp/openfut-launcher-overnight-tracked.patch` (sha256 `8e65de2c…`). **Validated deployment-candidate tuple** (reproducible from git except the deferred gitlink): ``` superproject HEAD a82407c (backend per-session + docs) backend guard b0d5e04 fix(fifa17): guard missing store category resolution client proof fc29c2e docs(fifa17): record no-sentinel client resolver proof autopatch report 1c396dd feat(fifa17): report verified client patch capability backend negotiate b25761e feat(fifa17): negotiate clean empty My Packs mode session binding 805d754 fix(fifa17): isolate patched-client capability per session launcher merged HEAD ca7ce26 merge: reconcile launcher capability and SBC tracing (ancestors 13339c1 capability + 958ff24 SBC) launcher gitlink (super) 958ff24 <-- to become ca7ce26 once WIP reconciled ``` Local build artifacts (NOT deployed): launcher `target/release/openfut-launcher` (sha256 `a390c61d…`); backend image `openfut-fut-backend:candidate-overnight` (`84d280be…`, ships `utas_server.py` `33e0ef3…`). Live `:dev` image and the running container were left untouched. ### Controlled A/B sequence (execute only in a later authorized deploy task) **A — patched client:** fresh FIFA process → autopatch verifies the JG guard → launcher parses the verified line and registers → `/ut/auth` mints a fresh `X-UT-SID` → capability binds to that SID → first `/store/purchasegroup` freezes `clean-v1` → backend omits 65534 → Store opens on Browse Packs, no crash. **B — unpatched client, same machine/IP, NEW session:** new `X-UT-SID`, no verified capability → first store freezes `sentinel` → backend emits active 65534 → no crash. Proves same-IP isolation + fail-closed fallback. **C — failed patch (optional):** autopatch reports `UNSUPPORTED_BUILD`/`VERIFY_FAILED` → launcher never registers → `sentinel`. Production remains the P2 active-sentinel universal default until this A/B passes.