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
+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}