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.
|
||||
* [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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user