Files
OpenFUT/docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
T
funman300 d4c3811665 docs(fifa17): document patched-client store negotiation
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.
2026-08-13 04:03:37 +00:00

12 KiB
Raw Blame History

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 launcher LogBuffer line-by-line as [autopatch] <line>. 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_actionskip)
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/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:
        <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 AJ) 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.