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:
+229
@@ -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())
|
||||||
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
|||||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
* 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.
|
* [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
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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)
|
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():
|
def now():
|
||||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
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):
|
def account_sync_route(h):
|
||||||
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
|
"""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:
|
try:
|
||||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||||
account = activate_account(body)
|
account = activate_account(body)
|
||||||
@@ -209,6 +274,33 @@ def account_sync_route(h):
|
|||||||
return 200, {"account": account, "status": "OK"}
|
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():
|
def current_squad():
|
||||||
"""The squad the client should see: the persisted one (item refs re-embedded
|
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||||
from the club) or the seed ladder squad on first run.
|
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
|
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
|
||||||
# never calls it; launch is blocked unless this succeeds first.
|
# never calls it; launch is blocked unless this succeeds first.
|
||||||
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
|
(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) ----
|
# ---- 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"/item/resource"), lambda m, h: defs_route(h)),
|
||||||
(re.compile(G + r"/defid"), 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:
|
if owned:
|
||||||
packs.append(_pack_body(owned, idx, owned=True))
|
packs.append(_pack_body(owned, idx, owned=True))
|
||||||
if not owned_ids:
|
if not owned_ids:
|
||||||
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
|
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
|
||||||
#
|
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
|
||||||
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
|
# this peer IP is committed here at the first /store/purchasegroup and is
|
||||||
# account owns zero unopened packs (the category is chosen client-side from
|
# immutable for the session thereafter.
|
||||||
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
|
mode = fifa17_empty_mypacks_mode(_fifa17_client_ip(h))
|
||||||
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
|
if mode == FIFA17_MODE_CLEAN:
|
||||||
# guard, so if no `mypacks` group exists the client CRASHES
|
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
|
||||||
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
|
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
|
||||||
# minidump). We therefore MUST emit a `mypacks` group when empty.
|
# dereferencing a null group. (append nothing)
|
||||||
#
|
pass
|
||||||
# state="inactive" avoids the crash but makes the client report the pack
|
else:
|
||||||
# unavailable immediately on Store entry and bounce to the Hub. state="active"
|
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
|
||||||
# keeps the group structurally valid AND lets the Store open normally; the
|
#
|
||||||
# empty tile renders as "0 items" and an explicit open is rejected
|
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
|
||||||
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
|
# account owns zero unopened packs (the category is chosen client-side from
|
||||||
# request and mutates nothing.
|
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
|
||||||
#
|
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
|
||||||
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
|
# guard, so if no `mypacks` group exists the client CRASHES
|
||||||
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
|
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
|
||||||
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
|
# minidump). We therefore MUST emit a `mypacks` group when empty.
|
||||||
# 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
|
# state="inactive" avoids the crash but makes the client report the pack
|
||||||
# zero-pack UX requires a client-side fix -- see
|
# unavailable immediately on Store entry and bounce to the Hub. state="active"
|
||||||
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
|
# keeps the group structurally valid AND lets the Store open normally; the
|
||||||
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
# empty tile renders as "0 items" and an explicit open is rejected
|
||||||
sentinel = {
|
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
|
||||||
"id": 65534,
|
# request and mutates nothing.
|
||||||
"name": "",
|
#
|
||||||
"price": 0,
|
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
|
||||||
"count": 0,
|
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
|
||||||
"gold": True,
|
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
|
||||||
"specialChance": 0.0,
|
# 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
|
||||||
empty = _pack_body(sentinel, 1, owned=True)
|
# zero-pack UX requires a client-side fix -- see
|
||||||
empty["state"] = "active"
|
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
|
||||||
empty["unopened"] = False
|
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
||||||
packs.append(empty)
|
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}
|
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user