diff --git a/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md b/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md new file mode 100644 index 0000000..2c17394 --- /dev/null +++ b/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md @@ -0,0 +1,228 @@ +# 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.