00d85aa6e4
Record the overnight launcher-lineage reconciliation (mergeca7ce26retaining both feat/launcher-arming13339c1and feat/sbc-hook-tracing 958ff24; only src/process.rs conflict, resolved keep-deleted), the deferred superproject gitlink bump (blocked by uncommitted openfut-hook WIP overlapping the merged hook content), the validated deployment-candidate commit tuple + local build artifacts, and the controlled A/B/C deployment sequence. Production stays P2 active-sentinel until the A/B passes.
359 lines
20 KiB
Markdown
359 lines
20 KiB
Markdown
# 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.
|