# 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_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

```rust
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:

```json
{"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 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.
