feat(fifa17): negotiate clean empty My Packs mode

Backend side of the handshake: suppress the synthetic 65534 My-Packs sentinel
ONLY for a session whose client has registered a verified resolver-guard
capability. Additive; the P2 active-sentinel path is retained as the else-branch
and the universal default. Fail-closed everywhere.

- Per-client state keyed by source IP (client_address[0]; the only per-connection
  discriminator in this single-account, stateless backend): _FIFA17_STORE[ip] =
  {resolver, mode}; mode in {None, "sentinel", "clean-v1"}, guarded by a lock.
- New POST /openfut/fifa17/capability endpoint: accepts only
  {"capability":"empty_mypacks_resolver","version":1,...}; unknown capability or
  version => 400 and records nothing (=> sentinel).
- account_sync (the launcher's required per-launch call) resets the per-ip record
  => a new FIFA process starts unfrozen with no inherited capability.
- Store topology is frozen at the FIRST /store/purchasegroup per session:
  clean-v1 iff a v1 capability is registered, else sentinel; immutable thereafter
  (late capability logged + ignored this session; a disappeared capability does
  not un-freeze a clean session). This enforces the SESSION-STABLE invariant.
- store_catalog zero-owned-packs branch: clean-v1 emits NO mypacks group (the
  client guard routes category -1 to Browse); every other case emits the existing
  active 65534 sentinel verbatim. PACK_CATALOG / pack 70 / normal packs / profile
  untouched. FIFA-17 only; not lifted into game-independent Core.
- Tests: full matrix A-J incl. concurrency isolation (two IPs, no global leak) and
  no cross-process capability leak.

Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
This commit is contained in:
funman300
2026-08-13 04:03:37 +00:00
parent 1c396dd562
commit b25761ea31
2 changed files with 374 additions and 38 deletions
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Tests for the FIFA 17 verified-patched-client capability negotiation.
Pins the additive empty-My-Packs switch built on top of the P2 65534 sentinel:
the sentinel is suppressed for a session ONLY when the launcher has registered a
verified resolver capability (v1) for the CURRENT FIFA process, bound to the peer
IP, and the decision is frozen at the first /store/purchasegroup. Every failure /
unknown / late / cross-process case is fail-closed to the active sentinel.
Covers matrix A-J from docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (Task 12/15):
A no-capability, zero packs -> sentinel
B verified v1, zero packs -> clean (no 65534)
C real unopened pack + no capability -> genuine pack, no sentinel
D real unopened pack + capability -> genuine pack, no sentinel
E unsupported version -> endpoint 400 AND mode sentinel
F late capability after sentinel freeze -> stays sentinel
G capability disappears after clean freeze-> stays clean (immutable)
H two concurrent IPs (A verified, B none) -> A clean, B sentinel (no global leak)
I new session via reset clears capability -> fresh unpatched process -> sentinel
J autopatch mismatch => never registers -> sentinel
Standalone unit test in the project style: `python3 test_capability_negotiation.py`.
"""
import importlib
import json
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
SENTINEL_ID = 65534
REAL_PACK_ID = 1
class _H:
"""Minimal request-handler stand-in: peer IP + optional JSON body."""
def __init__(self, ip, body=None):
self.client_address = (ip, 54321)
self._body = json.dumps(body).encode("utf-8") if body is not None else b""
def _ids(catalog):
return [p["id"] for p in catalog["purchase"]]
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
# ---- deterministic pack topology helpers -------------------------------
_orig_visible = utas_server.visible_unopened_packs
def set_zero_packs():
utas_server.visible_unopened_packs = lambda: []
def set_real_pack():
utas_server.visible_unopened_packs = lambda: [REAL_PACK_ID]
def reset_state():
"""Fresh capability store between cases (no cross-case leakage)."""
utas_server._FIFA17_STORE.clear()
def register(ip, version, persona=42, pid=4242):
return utas_server.fifa17_capability_route(_H(ip, {
"capability": "empty_mypacks_resolver",
"version": version,
"personaId": persona,
"fifaPid": pid,
}))
def store(ip):
status, cat = utas_server.store_catalog(_H(ip))
assert status == 200, status
return _ids(cat)
try:
# ---- A. no capability, zero packs -> sentinel ----------------------
reset_state()
set_zero_packs()
ip = "10.0.0.1"
utas_server.fifa17_reset_session(ip)
ids = store(ip)
assert SENTINEL_ID in ids, ids
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL
print("A no-capability zero-packs -> sentinel: OK")
# ---- B. verified v1, zero packs -> clean ---------------------------
reset_state()
set_zero_packs()
ip = "10.0.0.2"
utas_server.fifa17_reset_session(ip)
st, body = register(ip, 1)
assert st == 200 and body == {"status": "OK"}, (st, body)
ids = store(ip)
assert SENTINEL_ID not in ids, ids
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_CLEAN
print("B verified-v1 zero-packs -> clean: OK")
# ---- C. real pack + no capability -> genuine, no sentinel ----------
reset_state()
set_real_pack()
ip = "10.0.0.3"
utas_server.fifa17_reset_session(ip)
ids = store(ip)
assert SENTINEL_ID not in ids, ids
assert REAL_PACK_ID in ids, ids
print("C real-pack no-capability -> genuine, no sentinel: OK")
# ---- D. real pack + capability -> genuine, no sentinel -------------
reset_state()
set_real_pack()
ip = "10.0.0.4"
utas_server.fifa17_reset_session(ip)
register(ip, 1)
ids = store(ip)
assert SENTINEL_ID not in ids, ids
assert REAL_PACK_ID in ids, ids
print("D real-pack capability -> genuine, no sentinel: OK")
# ---- E. unsupported version -> 400 AND mode sentinel ---------------
reset_state()
set_zero_packs()
ip = "10.0.0.5"
utas_server.fifa17_reset_session(ip)
for bad in (2, 99):
st, body = register(ip, bad)
assert st == 400 and "error" in body, (bad, st, body)
# nothing recorded -> resolver stays None
assert utas_server._FIFA17_STORE[ip]["resolver"] is None
ids = store(ip)
assert SENTINEL_ID in ids, ids
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL
# a bad capability string with the right version is also rejected
st, body = utas_server.fifa17_capability_route(_H("10.0.0.55", {
"capability": "something_else", "version": 1}))
assert st == 400, (st, body)
print("E unsupported version -> 400 + sentinel: OK")
# ---- F. late capability after sentinel freeze -> stays sentinel ----
reset_state()
set_zero_packs()
ip = "10.0.0.6"
utas_server.fifa17_reset_session(ip)
ids = store(ip) # freeze: sentinel
assert SENTINEL_ID in ids, ids
register(ip, 1) # arrives late; ignored for session
ids = store(ip)
assert SENTINEL_ID in ids, "late capability must not flip a frozen sentinel"
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL
print("F late capability after sentinel freeze -> sentinel: OK")
# ---- G. capability disappears after clean freeze -> stays clean ----
reset_state()
set_zero_packs()
ip = "10.0.0.7"
utas_server.fifa17_reset_session(ip)
register(ip, 1)
ids = store(ip) # freeze: clean
assert SENTINEL_ID not in ids, ids
utas_server._FIFA17_STORE[ip]["resolver"] = None # capability vanishes
ids = store(ip)
assert SENTINEL_ID not in ids, "frozen clean mode must be immutable"
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_CLEAN
print("G capability disappears after clean freeze -> clean: OK")
# ---- H. two concurrent IPs -> no global leak -----------------------
reset_state()
set_zero_packs()
ip_a, ip_b = "10.0.1.1", "10.0.1.2"
utas_server.fifa17_reset_session(ip_a)
utas_server.fifa17_reset_session(ip_b)
register(ip_a, 1) # A verified, B never registers
ids_a = store(ip_a)
ids_b = store(ip_b)
assert SENTINEL_ID not in ids_a, ids_a
assert SENTINEL_ID in ids_b, ids_b
assert utas_server._FIFA17_STORE[ip_a]["mode"] == utas_server.FIFA17_MODE_CLEAN
assert utas_server._FIFA17_STORE[ip_b]["mode"] == utas_server.FIFA17_MODE_SENTINEL
print("H concurrent IPs (A clean, B sentinel) -> no global leak: OK")
# ---- I. reset clears capability across sessions --------------------
reset_state()
set_zero_packs()
ip = "10.0.2.1"
utas_server.fifa17_reset_session(ip)
register(ip, 1)
ids = store(ip) # session 1: clean
assert SENTINEL_ID not in ids, ids
utas_server.fifa17_reset_session(ip) # relaunch: fresh unpatched process
assert utas_server._FIFA17_STORE[ip] == {"resolver": None, "mode": None}
ids = store(ip) # session 2: no re-register -> sentinel
assert SENTINEL_ID in ids, "capability must not leak across sessions"
print("I new session clears capability -> sentinel: OK")
# ---- J. autopatch mismatch => never registers -> sentinel ----------
reset_state()
set_zero_packs()
ip = "10.0.3.1"
utas_server.fifa17_reset_session(ip) # autopatch verify FAILED: no register
ids = store(ip)
assert SENTINEL_ID in ids, ids
assert utas_server._FIFA17_STORE[ip]["mode"] == utas_server.FIFA17_MODE_SENTINEL
print("J autopatch mismatch (never registers) -> sentinel: OK")
finally:
utas_server.visible_unopened_packs = _orig_visible
print("capability negotiation matrix A-J: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+145 -38
View File
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import copy, datetime, json, os, random, re, sys, http.server
import copy, datetime, json, os, random, re, sys, threading, http.server
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -52,6 +52,65 @@ def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
# ---- FIFA17 empty-My-Packs capability negotiation (per-IP, session-stable) ----
# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is
# suppressed for a session ONLY when the launcher has registered that the CURRENT
# FIFA process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
# Verification is per-FIFA-process; the backend binds it to the peer IP and freezes
# a per-session decision at the first /store/purchasegroup. Fail-closed: any unknown
# / late / absent / wrong-version capability resolves to the active sentinel.
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§7/§9/§11).
FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1
FIFA17_MODE_SENTINEL = "sentinel"
FIFA17_MODE_CLEAN = "clean-v1"
_FIFA17_STORE = {} # ip -> {"resolver": Optional[int], "mode": Optional[str]}
_FIFA17_STORE_LOCK = threading.Lock()
def _fifa17_client_ip(h):
"""Peer IP for the request handler, or None when unavailable (e.g. h is None)."""
try:
return h.client_address[0]
except Exception:
return None
def fifa17_reset_session(ip):
"""Session boundary (/openfut/account/sync): clear capability + unfreeze mode."""
with _FIFA17_STORE_LOCK:
_FIFA17_STORE[ip] = {"resolver": None, "mode": None}
def fifa17_register_capability(ip, version):
"""Register a verified resolver capability for ip. Returns the current mode.
If the session's mode is already frozen, the capability is logged as late and
ignored for this session (mode is immutable after the first store request)."""
with _FIFA17_STORE_LOCK:
rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None})
rec["resolver"] = version
if rec["mode"] is not None:
log("[fifa17-store] capability arrived after mode freeze; ignored for "
"current session (ip %s)" % ip)
return rec["mode"]
def fifa17_empty_mypacks_mode(ip):
"""Resolve (and freeze on first call) the empty-My-Packs mode for ip.
Freeze point = first /store/purchasegroup: clean-v1 iff a matching-version
resolver capability is already registered, else the sentinel fallback."""
with _FIFA17_STORE_LOCK:
rec = _FIFA17_STORE.setdefault(ip, {"resolver": None, "mode": None})
if rec["mode"] is None:
rec["mode"] = (FIFA17_MODE_CLEAN
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
else FIFA17_MODE_SENTINEL)
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
% (ip, rec["mode"]))
return rec["mode"]
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -198,6 +257,12 @@ def auth_body(h=None):
def account_sync_route(h):
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
# Session boundary: each launcher account-sync starts a fresh per-IP FIFA17
# capability session (unfreeze mode + clear any prior capability). A new FIFA
# process must re-verify; nothing leaks across processes.
ip = _fifa17_client_ip(h)
fifa17_reset_session(ip)
log(" ACCOUNT: reset FIFA17 empty-mypacks capability session for ip %s" % ip)
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
account = activate_account(body)
@@ -209,6 +274,33 @@ def account_sync_route(h):
return 200, {"account": account, "status": "OK"}
def fifa17_capability_route(h):
"""POST /openfut/fifa17/capability -- launcher registers a verified resolver
capability for the current FIFA process (bound to the peer IP). Fail-closed:
anything but capability==empty_mypacks_resolver && version==current is a 400
that records NOTHING (the session stays on the sentinel fallback)."""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
return 400, {"error": "unsupported capability"}
if not isinstance(body, dict):
return 400, {"error": "unsupported capability"}
try:
version = int(body.get("version"))
except (TypeError, ValueError):
return 400, {"error": "unsupported capability"}
if (body.get("capability") != "empty_mypacks_resolver"
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
return 400, {"error": "unsupported capability"}
ip = _fifa17_client_ip(h)
fifa17_register_capability(ip, version)
persona = body.get("personaId", "?")
fifa_pid = body.get("fifaPid", "?")
log("[fifa17-store] registered capability empty_mypacks_resolver=%s for %s "
"(persona %s, fifa_pid %s)" % (version, ip, persona, fifa_pid))
return 200, {"status": "OK"}
def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run.
@@ -1203,6 +1295,10 @@ ROUTES = [
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
# never calls it; launch is blocked unless this succeeds first.
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
# Launcher registers a verified per-FIFA-process resolver capability (bound to
# peer IP). Adjacent to account/sync, above the generic /ut routes; FIFA never
# calls it. Fail-closed: absent/late/wrong-version => sentinel (store_catalog).
(re.compile(r"^/openfut/fifa17/capability$"), lambda m, h: fifa17_capability_route(h)),
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
@@ -3426,43 +3522,54 @@ def store_catalog(h):
if owned:
packs.append(_pack_body(owned, idx, owned=True))
if not owned_ids:
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
#
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
# account owns zero unopened packs (the category is chosen client-side from
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
# guard, so if no `mypacks` group exists the client CRASHES
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
# minidump). We therefore MUST emit a `mypacks` group when empty.
#
# state="inactive" avoids the crash but makes the client report the pack
# unavailable immediately on Store entry and bounce to the Hub. state="active"
# keeps the group structurally valid AND lets the Store open normally; the
# empty tile renders as "0 items" and an explicit open is rejected
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
# request and mutates nothing.
#
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
# client behavior, NOT an EA-authentic empty-My-Packs representation, and it
# is FIFA17-specific (do not lift into game-independent Core). A fully clean
# zero-pack UX requires a client-side fix -- see
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "active"
empty["unopened"] = False
packs.append(empty)
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
# this peer IP is committed here at the first /store/purchasegroup and is
# immutable for the session thereafter.
mode = fifa17_empty_mypacks_mode(_fifa17_client_ip(h))
if mode == FIFA17_MODE_CLEAN:
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
# dereferencing a null group. (append nothing)
pass
else:
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
#
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
# account owns zero unopened packs (the category is chosen client-side from
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
# guard, so if no `mypacks` group exists the client CRASHES
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
# minidump). We therefore MUST emit a `mypacks` group when empty.
#
# state="inactive" avoids the crash but makes the client report the pack
# unavailable immediately on Store entry and bounce to the Hub. state="active"
# keeps the group structurally valid AND lets the Store open normally; the
# empty tile renders as "0 items" and an explicit open is rejected
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
# request and mutates nothing.
#
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
# client behavior, NOT an EA-authentic empty-My-Packs representation, and it
# is FIFA17-specific (do not lift into game-independent Core). A fully clean
# zero-pack UX requires a client-side fix -- see
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "active"
empty["unopened"] = False
packs.append(empty)
return 200, {"purchase": packs, "timestamp": 1596326400}