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.
This commit is contained in:
@@ -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 <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.
|
||||
Reference in New Issue
Block a user