fix(fifa17): keep empty My Packs group client-safe
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".
Root cause (bug 6c): FIFA 17's Store/Scaleform path resolves the `mypacks` category even with zero unopened packs (category chosen client-side via 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 an absent group crashes the client (CardsDLL+0x14882, [NULL+0x48], minidump-confirmed). An inactive placeholder avoids the crash but makes the Store report the pack unavailable on entry and bounce to the Hub; an active placeholder lets the Store open normally.
65534 stays economy-safe: pack_by_id() returns None, so store_buy()/purchased_items() cannot open it or grant items/coins, and grant_unopened_pack() rejects it. Explicit selection is rejected client-side ("This pack is no longer available") and sends no backend request. This is a FIFA-17 client-compatibility shim (P2), not an EA-authentic representation, confined to the FIFA-17 backend (not OpenFUT Core). A clean zero-pack UX needs a client-side fix (docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md).
Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
This commit is contained in:
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for the empty-My-Packs FIFA 17 compatibility workaround (bug 6c).
|
||||
|
||||
Pins the behavior store_catalog() now depends on:
|
||||
- unopenedPackIds == [] -> exactly one synthetic active `mypacks` placeholder id 65534
|
||||
- unopenedPackIds == [70] -> no synthetic placeholder; the genuine owned pack is shown
|
||||
- synthetic id 65534 stays economy-safe (non-resolvable, non-openable, non-granting)
|
||||
- normal store packs (1/5/6/7) are untouched by the empty-state behavior
|
||||
|
||||
See docs/evidence/STORE_TILE_6C.md and FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
||||
Standalone unit test in the project style: `python3 test_empty_mypacks.py`.
|
||||
"""
|
||||
import importlib
|
||||
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
|
||||
|
||||
|
||||
def _set_unopened(fut_store, ids):
|
||||
"""Deterministically set the active profile's owned unopened packs."""
|
||||
p = fut_store.STORE.load()
|
||||
p["unopenedPackIds"] = list(ids)
|
||||
fut_store.STORE._save()
|
||||
|
||||
|
||||
def _mypacks(catalog):
|
||||
return [p for p in catalog["purchase"]
|
||||
if (p.get("displayGroup") or {}).get("value") == "mypacks"]
|
||||
|
||||
|
||||
def _fake_request(command, body):
|
||||
class _H:
|
||||
pass
|
||||
h = _H()
|
||||
h.command = command
|
||||
h._body = body
|
||||
return h
|
||||
|
||||
|
||||
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"})
|
||||
catalog_ids = [p["id"] for p in fut_store.PACK_CATALOG]
|
||||
|
||||
# ---- A. Empty unopened packs -> one active synthetic 65534 placeholder ----
|
||||
_set_unopened(fut_store, [])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
status, cat = utas_server.store_catalog(None)
|
||||
assert status == 200
|
||||
myp = _mypacks(cat)
|
||||
assert len(myp) == 1, "expected exactly one mypacks entry, got %r" % myp
|
||||
s = myp[0]
|
||||
assert s["id"] == SENTINEL_ID, s
|
||||
assert s["state"] == "active", s # the P2 fix: active, not inactive
|
||||
assert (s.get("displayGroup") or {}).get("value") == "mypacks", s
|
||||
assert SENTINEL_ID not in catalog_ids, "65534 must not be in PACK_CATALOG"
|
||||
assert fut_store.pack_by_id(SENTINEL_ID) is None
|
||||
print("A empty-state active placeholder: PASS")
|
||||
|
||||
# ---- D (empty half). Normal packs untouched in empty state ----
|
||||
norm = {p["id"]: p for p in cat["purchase"] if p["id"] in (1, 5, 6, 7)}
|
||||
assert set(norm) == {1, 5, 6, 7}, sorted(norm)
|
||||
assert all(norm[i]["state"] == "active" for i in norm), norm
|
||||
assert norm[1]["packType"] == "BRONZE" and norm[1]["description"] == "Bronze Pack"
|
||||
|
||||
# ---- B. Non-empty unopened packs -> NO synthetic; genuine owned pack shown ----
|
||||
_set_unopened(fut_store, [70])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
status, cat = utas_server.store_catalog(None)
|
||||
assert status == 200
|
||||
ids = [p["id"] for p in cat["purchase"]]
|
||||
assert SENTINEL_ID not in ids, "synthetic placeholder must be suppressed when a pack exists"
|
||||
myp = _mypacks(cat)
|
||||
assert len(myp) == 1 and myp[0]["id"] == 70, myp
|
||||
assert myp[0]["state"] == "active" and myp[0]["unopened"] is True, myp[0]
|
||||
# normal packs still intact alongside the owned pack
|
||||
assert {1, 5, 6, 7}.issubset(set(ids)), sorted(ids)
|
||||
print("B non-empty-state genuine pack: PASS")
|
||||
|
||||
# ---- C. Economy safety of the synthetic placeholder ----
|
||||
_set_unopened(fut_store, [])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
coins0 = fut_store.STORE.coins()
|
||||
items0 = len(fut_store.STORE.items())
|
||||
next0 = fut_store.STORE.load()["nextItemId"]
|
||||
|
||||
assert fut_store.pack_by_id(SENTINEL_ID) is None
|
||||
|
||||
# store_buy: a confirmed-buy transaction for 65534 must be a no-op {}
|
||||
status, body = utas_server.store_buy(
|
||||
_fake_request("PUT", b'{"packId":65534,"state":"TRANSACTIONCREATED"}'))
|
||||
assert status == 200 and body == {}, (status, body)
|
||||
|
||||
# purchased_items: POST buy for 65534 must not open/grant anything
|
||||
status, body = utas_server.purchased_items(
|
||||
_fake_request("POST", b'{"packId":65534,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'))
|
||||
assert status == 200, (status, body)
|
||||
assert "createPackResponse" not in body, body
|
||||
|
||||
# 65534 cannot enter the owned-pack pile (not a catalog pack)
|
||||
assert fut_store.STORE.grant_unopened_pack(SENTINEL_ID) is False
|
||||
assert SENTINEL_ID not in fut_store.STORE.unopened_packs()
|
||||
|
||||
# nothing mutated
|
||||
assert fut_store.STORE.coins() == coins0, (fut_store.STORE.coins(), coins0)
|
||||
assert len(fut_store.STORE.items()) == items0
|
||||
assert fut_store.STORE.load()["nextItemId"] == next0
|
||||
assert not any(i.get("id") == SENTINEL_ID or i.get("resourceId") == SENTINEL_ID
|
||||
for i in fut_store.STORE.items())
|
||||
print("C economy safety (65534 non-openable / non-granting): PASS")
|
||||
|
||||
print("empty My Packs compatibility: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -3426,12 +3426,31 @@ def store_catalog(h):
|
||||
if owned:
|
||||
packs.append(_pack_body(owned, idx, owned=True))
|
||||
if not owned_ids:
|
||||
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
|
||||
# renders rows. If the group is absent FIFA falls back to Bronze and
|
||||
# shows the empty-category dialog over the wrong tab. Retain an inactive
|
||||
# zero-item sentinel so the destination resolves, while state != active
|
||||
# keeps it out of the visible row list. Its id is deliberately absent
|
||||
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
|
||||
# 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": "",
|
||||
@@ -3441,7 +3460,7 @@ def store_catalog(h):
|
||||
"specialChance": 0.0,
|
||||
}
|
||||
empty = _pack_body(sentinel, 1, owned=True)
|
||||
empty["state"] = "inactive"
|
||||
empty["state"] = "active"
|
||||
empty["unopened"] = False
|
||||
packs.append(empty)
|
||||
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||
|
||||
Reference in New Issue
Block a user