fifa17-recon: reconcile authoritative tools with running backend (B)

- Add 8 files present in docker/fifa17-python/tools but missing from the
  top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in
  the server's docker tree; byte-identical to the running image).
- Preserve newer responder work already matching the running container:
  utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND),
  blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py,
  test_fut_contract.py, fifa17-hook-m1.sh.
- Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime
  registries). Local tree is now a strict superset of B with all shared
  files byte-identical.
This commit is contained in:
funman300
2026-08-10 17:08:06 -07:00
parent 622a774f6a
commit 8cba70dc90
44 changed files with 1742 additions and 84 deletions
Regular → Executable
+10 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches """Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches.""" the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
import glob, time, struct import glob, time, struct, sys
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches # Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
import glob, time, os import glob, time, os
@@ -24,7 +24,7 @@ STORE_PATCHES = {
0x1800175aa: NOP2, 0x1800175aa: NOP2,
} }
LOG="/tmp/autopatch.log" LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
def log(m): def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}" line=f"[{time.strftime('%H:%M:%S')}] {m}"
@@ -55,8 +55,16 @@ def wr(pid,va,b):
patched=set() patched=set()
store_patched=set() store_patched=set()
launcher_pid = None
if "--launcher-pid" in sys.argv:
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
log("=== AUTOPATCH watching for FIFA17.exe ===") log("=== AUTOPATCH watching for FIFA17.exe ===")
while True: while True:
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
break
for pid in find_pids(): for pid in find_pids():
if pid not in patched: if pid not in patched:
try: try:
+12 -3
View File
@@ -526,6 +526,7 @@ OSDK_TICKER = []
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject # Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK. # http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081" ROSTER_HOST = "127.0.0.1:8081"
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [ OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0 ("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
@@ -562,7 +563,6 @@ IDENTITY_PARAMS = [
# FUT_POW=1 ./openfut-fut.sh restart # FUT_POW=1 ./openfut-fut.sh restart
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback. # and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094") POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes") _POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
OSDK_POW = [ OSDK_POW = [
("FIFA_POW_URL", "http://%s/" % POW_HOST), ("FIFA_POW_URL", "http://%s/" % POW_HOST),
@@ -571,6 +571,14 @@ OSDK_POW = [
("POW_IS_ON", "1"), ("POW_IS_ON", "1"),
] if _POW_ON else [] ] if _POW_ON else []
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
# POW API remains opt-in through FUT_POW/POW_IS_ON.
FUT_CONTENT_CONFIG = [
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
]
CLIENT_CONFIGS = { CLIENT_CONFIGS = {
"BlazeSDK": None, # built dynamically, see below "BlazeSDK": None, # built dynamically, see below
"netres": OSDK_NETRES, # CFID (verified @0x143962be0) "netres": OSDK_NETRES, # CFID (verified @0x143962be0)
@@ -741,8 +749,9 @@ def client_config_for(cfid: str) -> list:
# this is a no-op by default. (Putting the keys ONLY under a hypothetical # this is a no-op by default. (Putting the keys ONLY under a hypothetical
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.) # "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
if cfid == "BlazeSDK": if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW) return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW) return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
+ FUT_CONTENT_CONFIG + OSDK_POW)
def fetch_config_response_fields(cfid: str) -> "OrderedDict": def fetch_config_response_fields(cfid: str) -> "OrderedDict":
+18 -4
View File
@@ -158,6 +158,7 @@ launch() {
local trace_enabled=0 local trace_enabled=0
local request_trace_enabled=0 local request_trace_enabled=0
local notifier_trace_enabled=0 local notifier_trace_enabled=0
local commit_enabled=0
case "$mode" in case "$mode" in
baseline) baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] || [[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -176,6 +177,15 @@ launch() {
request_trace_enabled=1 request_trace_enabled=1
notifier_trace_enabled=1 notifier_trace_enabled=1
;; ;;
commit)
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1
notifier_trace_enabled=1
commit_enabled=1
;;
*) die "unknown launch mode: $mode" ;; *) die "unknown launch mode: $mode" ;;
esac esac
need_file "$deployed_dll" need_file "$deployed_dll"
@@ -192,12 +202,12 @@ launch() {
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] || [[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
die "deployed version.dll does not match the staged M1 artifact" die "deployed version.dll does not match the staged M1 artifact"
command -v umu-run >/dev/null || die "umu-run is required" command -v umu-run >/dev/null || die "umu-run is required"
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_COMMIT OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do
[[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch" [[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch"
done done
mkdir -p "${wine_prefix}/dosdevices" mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:" ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; every mutation feature disabled); log=/tmp/fifa17-hook-m1-launch.log" note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir" cd "$game_dir"
env \ env \
GAMEID=fifa17 \ GAMEID=fifa17 \
@@ -209,7 +219,7 @@ launch() {
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \ OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \ OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH=0 \ OPENFUT_SBC_DISPATCH=0 \
OPENFUT_SBC_COMMIT=0 \ OPENFUT_SBC_COMMIT="$commit_enabled" \
OPENFUT_SBC_ARM_ONLY=0 \ OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \ OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -217,7 +227,7 @@ launch() {
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace] Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
inspect Read-only PE/hash/export preflight (default). inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect. build Cross-build the inert FIFA17 hook, then run inspect.
@@ -232,6 +242,9 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
launch-trace launch-trace
Start the single M3 passive factory/deserializer trace; requires: Start the single M3 passive factory/deserializer trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-commit
Trace and arm the SBC cache only after a validated native parse; requires:
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
Optional path overrides: Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR, OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -247,6 +260,7 @@ case "${1:-inspect}" in
launch) launch baseline ;; launch) launch baseline ;;
launch-resolve) launch resolve ;; launch-resolve) launch resolve ;;
launch-trace) launch trace ;; launch-trace) launch trace ;;
launch-commit) launch commit ;;
-h|--help|help) usage ;; -h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;; *) usage >&2; die "unknown command: $1" ;;
esac esac
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Launcher-to-server active-account selection for the single-player stack."""
import json
import os
from fut_account import ACCOUNT
from fut_store import STORE, profile_path_for
def _existing_identity(persona_id):
path = profile_path_for(persona_id)
try:
with open(path) as f:
profile = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(profile, dict):
return {}
return {
"club_name": profile.get("clubName"),
"club_abbr": profile.get("clubAbbr"),
"established": profile.get("established"),
"pow_level": profile.get("powLevel"),
"pow_exp": profile.get("powExp"),
"pow_exp_max": profile.get("powExpMax"),
"pow_funds": profile.get("powFunds"),
"pow_funds_cap": profile.get("powFundsCap"),
}
def activate(payload):
"""Select/create one persistent profile and publish it to all responders."""
if not isinstance(payload, dict):
raise ValueError("account payload must be an object")
try:
persona_id = int(payload.get("personaId"))
except (TypeError, ValueError):
raise ValueError("personaId must be a positive integer") from None
persona_name = payload.get("personaName")
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
raise ValueError("personaId must be positive and personaName must not be empty")
values = _existing_identity(persona_id)
values.update(persona_id=persona_id, persona_name=persona_name.strip())
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
("established", "established"), ("squadName", "squad_name"),
("level", "pow_level"), ("experience", "pow_exp"),
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
("accountFundsCap", "pow_funds_cap")):
if payload.get(wire) not in (None, ""):
values[field] = payload[wire]
ACCOUNT.replace(values)
ACCOUNT.set_online_profile()
ACCOUNT.save()
profile = STORE.select_account(persona_id)
STORE.ensure_security_question()
return {
"personaId": ACCOUNT.persona_id,
"personaName": ACCOUNT.persona_name,
"clubName": ACCOUNT.club_name,
"clubAbbr": ACCOUNT.club_abbr,
"level": ACCOUNT.pow_level,
"experience": ACCOUNT.pow_exp,
"experienceMax": ACCOUNT.pow_exp_max,
"accountFunds": ACCOUNT.pow_funds,
"accountFundsCap": ACCOUNT.pow_funds_cap,
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
"coins": profile.get("coins", 0),
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
}
+113 -10
View File
@@ -226,6 +226,56 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
# the club showing different numbers for the same card. # the club showing different numbers for the same card.
SPECIAL_CARD_TYPES = {
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
# stable identities; the client resolves the footballer from the low 24 bits.
"TOTW": (3, 1, 2, 34),
"PURPLE": (4, 2, 3, 7),
"TOTY": (5, 3, 6, 3),
"RECORD_BREAKER": (6, 4, 5, 2),
"TOTS": (11, 5, 5, 7),
"OTW": (21, 6, 2, 14),
"HALLOWEEN": (22, 7, 3, 8),
"MOVEMBER": (23, 8, 3, 8),
"SBC": (24, 9, 4, 17),
}
def choose_special_type(player, rng=None):
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
import random
rng = rng or random
rating = player[1]
eligible = []
for name, spec in SPECIAL_CARD_TYPES.items():
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
continue
if name == "TOTS" and rating < 75:
continue
eligible.append((name, spec[3]))
names, weights = zip(*eligible)
return rng.choices(names, weights=weights, k=1)[0]
def player_item(item_id, player, special=False):
"""Build a base or named FIFA 17 special revision from a pool row.
`special=True` remains supported and chooses a weighted eligible family;
callers and tests may also pass an explicit name such as ``"TOTY"``.
"""
asset, rating, pos, nation, league, team, attrs = player
if special:
special_name = choose_special_type(player) if special is True else special
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
rating = min(99, rating + boost)
attrs = [min(99, value + boost) for value in attrs]
else:
rareflag, version = 1, 0
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
version=version, rareflag=rareflag)
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS # FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
# the same number the server pays. # the same number the server pays.
# #
@@ -293,6 +343,10 @@ def _new_profile():
"purchased": [], # unassigned/pending items from opened packs "purchased": [], # unassigned/pending items from opened packs
"squads": [], # saved squads (raw squad objects from PUT /squad) "squads": [], # saved squads (raw squad objects from PUT /squad)
"packsOpened": 0, "packsOpened": 0,
# Owned reward packs are separate from purchased items. Pack 70 is a
# one-time migration grant used to bring the retail My Packs flow online.
"unopenedPackIds": [70],
"unopenedSeeded": True,
} }
@@ -311,6 +365,10 @@ class Store:
self._p = _new_profile() self._p = _new_profile()
self._sync_identity() self._sync_identity()
self._save() self._save()
if not self._p.get("unopenedSeeded"):
self._p.setdefault("unopenedPackIds", []).append(70)
self._p["unopenedSeeded"] = True
self._save()
self._sync_identity() self._sync_identity()
return self._p return self._p
@@ -513,6 +571,32 @@ class Store:
sq = self.load()["squads"] sq = self.load()["squads"]
return sq[0] if sq else None return sq[0] if sq else None
def unopened_packs(self):
"""Owned reward-pack template IDs, including repeated grants."""
return list(self.load().get("unopenedPackIds", []))
def consume_unopened_pack(self, pack_id):
"""Atomically consume one owned instance of a reward pack."""
with _LOCK:
p = self.load()
owned = p.setdefault("unopenedPackIds", [])
try:
owned.remove(pack_id)
except ValueError:
return False
self._save()
return True
def grant_unopened_pack(self, pack_id):
"""Persist one additional owned reward-pack instance."""
if pack_by_id(pack_id) is None:
return False
with _LOCK:
p = self.load()
p.setdefault("unopenedPackIds", []).append(pack_id)
self._save()
return True
def reconstruct_squad(self, squad): def reconstruct_squad(self, squad):
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>} """FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
(a reference). Re-embed the FULL club item by id so the squad reloads with (a reference). Re-embed the FULL club item by id so the squad reloads with
@@ -557,7 +641,8 @@ class Store:
return i return i
def open_pack(self, price, count, gold=True, tiers=None): def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
players_only=False):
"""Deduct `price` coins, generate `count` player items from the pool, and """Deduct `price` coins, generate `count` player items from the pool, and
place them in the PENDING purchased pile (unassigned). They are NOT owned place them in the PENDING purchased pile (unassigned). They are NOT owned
club items until moved there via FutMoveCard (PUT /item). Returns None if club items until moved there via FutMoveCard (PUT /item). Returns None if
@@ -579,19 +664,31 @@ class Store:
# fixed number so it scales from a 5-card bronze to an 11-card premium. # fixed number so it scales from a 5-card bronze to an 11-card premium.
n_extra = 0 n_extra = 0
extras = [] extras = []
if PACK_MIX and count >= 5: if PACK_MIX and not players_only and count >= 5:
n_extra = max(1, count // 4) n_extra = max(1, count // 4)
extras = _pack_extras(n_extra, self) extras = _pack_extras(n_extra, self)
n_extra = len(extras) n_extra = len(extras)
n_players = max(1, count - n_extra) n_players = max(1, count - n_extra)
if tiers: if tiers:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers))) # Draw each tier independently but reject duplicate asset IDs inside
for _ in range(n_players)] # one pack. The real pool is large enough that this normally succeeds
# on the first attempt; the cap makes malformed tiny test pools safe.
picks = []
used_assets = set()
for _ in range(n_players):
tier_pool = fut_cards.pool_for(random.choice(tiers))
available = [p for p in tier_pool if p[0] not in used_assets]
pick = random.choice(available or tier_pool)
picks.append(pick)
used_assets.add(pick[0])
else: else:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(n_players)] picks = random.sample(pool, min(n_players, len(pool)))
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at) while len(picks) < n_players:
for (a, r, p, n, lg, tm, at) in picks] picks.append(random.choice(pool))
items = [player_item(self.new_item_id(), pick,
special=random.random() < special_chance)
for pick in picks]
items += extras items += extras
random.shuffle(items) random.shuffle(items)
with _LOCK: with _LOCK:
@@ -677,11 +774,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [
# no silver or bronze players at all, so all three packs were identical in practice. # no silver or bronze players at all, so all three packs were identical in practice.
PACK_CATALOG = [ PACK_CATALOG = [
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False, {"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
"tiers": ["bronze"] * 8 + ["silver"] * 2}, "tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True, {"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4}, "tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True, {"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"tiers": ["gold"] * 9 + ["silver"] * 1}, "tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True},
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True, "ownedOnly": True},
] ]
@@ -0,0 +1,25 @@
"""Trace FutPurchaseDraftModeServerResponse beyond its known seven-int parser."""
cls = "FutPurchaseDraftModeServerResponse"
print("CLASS", cls, class_deser(cls))
seen = set()
for deser, vt, factory in class_deser(cls):
print("\nVTABLE", hex(vt), "FACTORY", hex(factory), "DESER", hex(deser))
print(vtable(vt, 32))
for target in [factory, deser] + [t for _, t, name in vtable(vt, 32) if name]:
if target in seen:
continue
seen.add(target)
print("\n===", hex(target), fname(target), "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
for target in (0x18014C090, 0x18014C260, 0x18014C820, 0x18014C8A0):
if target in seen:
continue
print("\n=== CANDIDATE", hex(target), fname(target), "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
print("QUERY_DONE")
@@ -0,0 +1,22 @@
"""Bind the live draft-purchase URI builder to one of its two response factories."""
for literal in (
"purchase/mode/",
"/purchase/mode/",
"draft",
"ut/%s/draft/mode",
"FutPurchaseDraftModeServerResponse",
):
print("\nLITERAL", repr(literal))
for hit in find_all(literal.encode() + b"\x00"):
print(hex(hit), rd_str(hit), xrefs_to(hit)[:100])
for target in (0x180224EF8, 0x1802262F0, 0x18014C090, 0x180150260):
print("\nTARGET", hex(target), fname(target))
print("XREFS", xrefs_to(target)[:200])
for frm, typ, fn, ent in xrefs_to(target):
if ent:
print("\nOWNER", hex(ent), fn)
print(dec(ent, 300))
print("QUERY_DONE")
@@ -0,0 +1,16 @@
"""Dump both request vtables sharing FutPurchaseDraftModeServerResponse."""
for vt in (0x180226300, 0x180224F08):
print("\nREQUEST_VTABLE", hex(vt))
rows = vtable(vt, 40)
print(rows)
seen = set()
for off, target, name in rows:
if not name or target in seen:
continue
seen.add(target)
print("\n=== SLOT", hex(off), hex(target), name, "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
print("QUERY_DONE")
@@ -0,0 +1,23 @@
"""Recover the JSON element shape consumed by the array-root draft response."""
targets = (
0x180138BD0, # helper called once per array element
0x180150310, # array-root FutPurchaseDraftModeServerResponse parser
)
seen = set()
for target in targets:
print("\n=== TARGET", hex(target), fname(target), "===")
print(dec(target, 500))
print("XREFS", xrefs_to(target)[:150])
# Include direct callees so small string/value accessors used by the helper
# are visible without broad, noisy whole-program searching.
for callee, name in callees(target):
if callee in seen:
continue
seen.add(callee)
print("\n--- CALLEE", hex(callee), name, "---")
print(dec(callee, 250))
print("QUERY_DONE")
@@ -0,0 +1,17 @@
"""Trace active draft-state enum literals and the current-state response consumers."""
for literal in ("DRAFTSQUAD_ON", "DRAFTSQUAD_OFF", "DRAFT_SQUAD", "squadState",
"stateParam1", "stateParam2", "roundsInfo"):
print("\n=== LITERAL", literal, "===")
for hit in find_all(literal.encode() + b"\x00", (".rdata", ".data")):
print("HIT", hex(hit), "XREFS", xrefs_to(hit)[:100])
for _frm, _typ, _name, entry in xrefs_to(hit):
print("\n--- XREF FUNCTION", hex(entry), fname(entry), "---")
print(dec(entry, 500))
for target in (0x180147070,):
print("\n=== STATE DESERIALIZER", hex(target), fname(target), "===")
print(dec(target, 500))
print("CALLERS", callers(target))
print("QUERY_DONE")
@@ -0,0 +1,21 @@
"""Resolve draft-state atom IDs to their authoritative wire strings."""
ATOM_TABLE = 0x1802D2760
def atom_name(index):
pointer = qword(ATOM_TABLE + index * 8)
return rd_str(pointer, 96)
groups = {
"squadState values": (0x1AC, 0x6A, 0x9C, 0x12C, 0x169, 0x225, 0x23E, 0x277, 0x278),
"stateParam1 values": (0x169, 0x1AA, 0x22D),
"entranceCriteria keys": (0x96, 0xDF, 0x241),
"top-level keys": (0x108, 0x13B, 0x293, 0x2CD, 0x2D5, 0x2EE, 0x2EF),
}
for group, indices in groups.items():
print("\n===", group, "===")
for index in indices:
print(hex(index), repr(atom_name(index)))
print("QUERY_DONE")
@@ -0,0 +1,50 @@
"""Resolve the concrete owner behind request+0x08 for the SBC category request.
q_md_sbc_9 proved generic slot +0x88 (0x1801631e0) invokes:
owner = *(request + 8)
owner.vtable[+0x18](owner, parsed_response, 0)
Work backwards from the category request constructor and its callers to identify who
supplies request+8, then map candidate owner vtables and their +0x18 consumers.
"""
import traceback
try:
def show(a, label):
f = func(a)
print("\n=== %s %#x %s ===" % (label, a, f.getName() if f else "?"))
print(dec(a))
ctor = 0x18017a7c0
show(ctor, "category request constructor")
print("\n=== ctor callers ===")
for ent, name in callers(ctor):
print(" %#x %s" % (ent, name))
show(ent, "ctor caller")
print("\n=== ctor xrefs ===")
for frm, typ, name, ent in xrefs_to(ctor):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
# The request base constructor is usually visible as the first direct call in
# the category constructor. Dump every direct callee so request+8 initialization
# can be distinguished from URI/tag setup.
print("\n=== constructor direct callees ===")
for target, name in callees(ctor):
print(" %#x %s" % (target, name))
show(target, "ctor callee")
# Ghidra did not create a function at the traced +0x90 thunk. Print its raw
# instructions and nearby containing-function identity without assuming a body.
print("\n=== raw callback thunk at 0x180154830 ===")
ad = addr(0x180154830)
for _ in range(48):
ins = listing.getInstructionAt(ad)
if ins is None:
print(" %s <not disassembled>" % ad)
ad = ad.add(1)
continue
print(" %s %s" % (ad, ins))
ad = ins.getNext().getAddress() if ins.getNext() else ad.add(ins.getLength())
except Exception:
traceback.print_exc()
@@ -0,0 +1,34 @@
"""Trace the FUT-root constructor's third argument, inherited by every request at +8.
The category request lives at FUT root +0x4140 (qword index 0x828). Its base ctor
stores the root constructor's param_3 at request+8, making that object the receiver
of owner.vtable[+0x18](owner, parsed_response, 0).
"""
import traceback
try:
root_ctor = 0x18010cdc0
print("=== root ctor callers ===")
for ent, name in callers(root_ctor):
print("\n--- %#x %s ---" % (ent, name))
print(dec(ent))
print("\n=== root ctor xrefs ===")
for frm, typ, name, ent in xrefs_to(root_ctor):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
if ent:
print(dec(ent))
# Static singleton slot and root vtables provide adjacent factory/type metadata.
for site in (0x1802e6398, 0x18021c2a0, 0x18021cda8, 0x18021cdb8):
print("\n=== qwords around %#x ===" % site)
for i in range(-8, 16):
p = site + i * 8
try:
value = qword(p)
except Exception:
continue
print(" [%#x] = %#x %s" % (p, value, fname(value)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""Map the category success notifier already instrumented at 0x18017aa80.
The checkpoint hook can passively record ctx+0x88 and the +0x58..+0x60 handler
vector. Establish where this notifier sits relative to request ownership transfer and
whether it is the concrete receiver-side publication path we need to observe live.
"""
import traceback
try:
target = 0x18017aa80
print("=== notifier 0x18017aa80 ===")
print(dec(target))
print("\n=== notifier callers ===")
for ent, name in callers(target):
print(" %#x %s" % (ent, name))
print(dec(ent))
print("\n=== notifier xrefs ===")
for frm, typ, name, ent in xrefs_to(target):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
# Adjacent category request methods often expose the notifier through a vtable
# or callback descriptor; inspect nearby functions and data references.
for a in (0x18017aa80, 0x18017aaf0, 0x18017ab80, 0x18017b1c0):
f = func(a)
print("\n=== %#x %s ===" % (a, f.getName() if f else "?"))
print(dec(a))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Map the sole live category-notifier listener into CardsDLL.
Live capture 2026-08-07:
listener object 0x4216ca48
listener vtable 0x6ffffc20d6d0
vtable +0x08 0x6ffffc1e577a
CardsDLL slide 0x6ffe7c020000
static method 0x1801c577a
"""
TARGET = 0x1801C577A
VTABLE = 0x1801ED6D0
print("=== live notifier listener method ===")
target_function = func(TARGET)
if target_function is None:
print("no Ghidra function at %#x" % TARGET)
print("raw PE decoding: jmp [0x1801e5200], imported CRT _purecall")
else:
print("containing function:", target_function.getName(),
hex(int(target_function.getEntryPoint().getOffset())))
print(dec(TARGET))
print("\n=== listener vtable ===")
for slot, target, name in vtable(VTABLE, 12):
print("%+#04x %#x %s" % (slot, target, name))
print("\n=== method callers/xrefs ===")
print("callers:", callers(TARGET) if target_function is not None else [])
for row in xrefs_to(TARGET):
print(row)
@@ -0,0 +1,29 @@
"""Find concrete siblings of the live notifier listener's abstract vtable."""
import struct
VTABLE = 0x1801ED6D0
DTOR = 0x180018EF0
PURECALL_THUNK = 0x1801C577A
print("=== exact vtable references ===")
for row in xrefs_to(VTABLE):
print(row)
print("\n=== vtables sharing the live listener destructor ===")
for hit in find_all(struct.pack("<Q", DTOR), blocks=(".rdata", ".data")):
try:
slots = [qword(hit + i * 8) for i in range(12)]
except Exception:
continue
# Require the same broad interface shape: destructor in slot 0 and at least
# one CardsDLL code pointer after it. This filters incidental data matches.
if slots[0] != DTOR or not any(0x180000000 <= x < 0x1801E5000 for x in slots[1:]):
continue
print("vtable=%#x slot8=%#x %s" %
(hit, slots[1], "PURE" if slots[1] == PURECALL_THUNK else "CONCRETE"))
for i, target in enumerate(slots):
print(" +%#04x %#x %s" % (i * 8, target, fname(target)))
refs_here = xrefs_to(hit)
if refs_here:
print(" refs:", refs_here)
@@ -0,0 +1,31 @@
"""Locate event 0x753c users and category-listener registration/removal paths."""
import struct
EVENT = 0x753C
NOTIFIER = 0x18017AA80
print("=== immediate/data occurrences of event 0x753c ===")
seen = set()
for hit in find_all(struct.pack("<I", EVENT)):
print("hit", hex(hit))
owner = func(hit)
if owner is not None:
entry = int(owner.getEntryPoint().getOffset())
print(" containing", hex(entry), owner.getName())
seen.add(entry)
for row in xrefs_to(hit):
print(" ", row)
if row[3]:
seen.add(row[3])
print("\n=== decompile functions referencing event literal ===")
for entry in sorted(seen):
print("\n--- %#x %s ---" % (entry, fname(entry)))
print(dec(entry))
print("\n=== category request ctor/dtor and notifier neighborhood ===")
for target in (0x18017A7C0, 0x18017AA10, NOTIFIER, 0x18017AAF0, 0x18017B1C0):
print("\n--- %#x %s ---" % (target, fname(target)))
print("callers", callers(target))
print("xrefs", xrefs_to(target))
@@ -0,0 +1,16 @@
"""Resolve the SBC controller and its 0x756c refresh registration/dispatch contract."""
TARGETS = (
(0x1800B5260, "SBC controller allocation/ctor neighborhood"),
(0x1800B53F0, "SBC controller constructor"),
(0x1800B5760, "SBC service/controller constructor"),
(0x1800B5E00, "SBC tile builder"),
(0x1801A4A70, "event registration"),
(0x1801A4CD0, "event dispatch"),
)
for target, label in TARGETS:
print("\n=== %s %#x %s ===" % (label, target, fname(target)))
print(dec(target))
print("callers", callers(target))
print("xrefs", xrefs_to(target))
@@ -0,0 +1,10 @@
"""Decompile the concrete SBC controller event-listener vtable."""
VTABLE = 0x18020A888
print("=== SBC controller event subobject vtable ===")
for off in range(0, 0x80, 8):
target = qword(VTABLE + off)
print("\nslot +%#x -> %#x %s" % (off, target, fname(target)))
if 0x180001000 <= target < 0x180200000:
print(dec(target, 180))
print("callers", callers(target)[:30])
@@ -0,0 +1,7 @@
"""Follow the SBC category-completion continuation registered by event 0x753c."""
for target in (0x1800B8950, 0x1800B89D0, 0x1800B8C30, 0x1800BA460, 0x1800B7090):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300))
print("callers", callers(target)[:50])
print("xrefs", xrefs_to(target)[:50])
@@ -0,0 +1,10 @@
"""Resolve manager +0xe0 used to schedule the ServerErrSets continuation."""
for target in (0x180009C80, 0x1800D7170, 0x180154830, 0x1801631E0):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300))
print("xrefs", xrefs_to(target)[:80])
print("\n=== candidate manager vtables referencing category request callbacks ===")
for target in (0x1800B8950, 0x18017AA80, 0x18017B2B0):
print(hex(target), xrefs_to(target)[:100])
@@ -0,0 +1,21 @@
"""Find completion callbacks that test the same status field at response+0x1c."""
patterns = (
bytes.fromhex("83 7a 1c 00"), # cmp dword ptr [rdx+1c],0
bytes.fromhex("83 79 1c 00"), # cmp dword ptr [rcx+1c],0
bytes.fromhex("83 78 1c 00"), # cmp dword ptr [rax+1c],0
)
seen = set()
for pattern in patterns:
print("\npattern", pattern.hex())
for hit in find_all(pattern):
f = func(hit)
if f is None:
continue
entry = int(f.getEntryPoint().getOffset())
if entry in seen:
continue
seen.add(entry)
print("\n=== hit %#x function %#x %s ===" % (hit, entry, f.getName()))
print(dec(f, 180)[:5000])
@@ -0,0 +1,14 @@
"""Map the live category response object's vtable and status-bearing base class."""
VTABLE = 0x18022E5B0
print("=== live category response vtable ===")
print("vtable xrefs", xrefs_to(VTABLE)[:100])
for off in range(0, 0x100, 8):
target = qword(VTABLE + off)
print("slot +%#x -> %#x %s" % (off, target, fname(target)))
if 0x180001000 <= target < 0x180200000 and off < 0x60:
print(dec(target, 120)[:3000])
print("\n=== direct references to vtable entries/address ===")
for a in range(VTABLE - 0x20, VTABLE + 0x20, 8):
print(hex(a), xrefs_to(a)[:40])
@@ -0,0 +1,22 @@
"""Find static assignments/usages of completion status 999 (0x3e7)."""
patterns = []
for modrm in (0x40, 0x41, 0x42, 0x43, 0x46, 0x47, 0x80, 0x81, 0x82, 0x83, 0x86, 0x87):
patterns.append(bytes((0xC7, modrm, 0x1C, 0xE7, 0x03, 0x00, 0x00)))
patterns.extend((bytes.fromhex("b8 e7 03 00 00"), bytes.fromhex("b9 e7 03 00 00"),
bytes.fromhex("ba e7 03 00 00"), bytes.fromhex("41 b8 e7 03 00 00")))
seen = set()
for pattern in patterns:
for hit in find_all(pattern):
f = func(hit)
entry = int(f.getEntryPoint().getOffset()) if f else 0
key = (entry, hit)
if key in seen:
continue
seen.add(key)
print("\n=== pattern %s hit %#x function %#x %s ===" %
(pattern.hex(), hit, entry, f.getName() if f else "?"))
if f:
print(dec(f, 240)[:10000])
print("callers", callers(f)[:80])
@@ -0,0 +1,8 @@
"""Trace callers of the HTTP/FUT status mapper returning 999."""
for target in (0x1801844C0, 0x180163120, 0x180165050, 0x180165CC0,
0x18016C060, 0x180184A90):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300)[:18000])
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,26 @@
"""Decompile the transport-result conversion and SBC response base methods."""
TARGETS = (
0x180184420,
0x1801844C0,
0x180184A90,
0x180163120,
0x1801631E0,
0x180165050,
0x180165CC0,
0x18016C060,
0x18016C110,
0x18016C950,
0x18016CA40,
0x18016CAC0,
0x18016CB20,
0x18016CB90,
0x18016CBE0,
0x18016CCA0,
0x18016D230,
)
for address in TARGETS:
print("\n===== %#x %s =====" % (address, fname(address)))
print(dec(address, 60))
@@ -0,0 +1,31 @@
"""Enumerate CardsDLL instructions that write a dword-like value to object +0x1c.
This is intentionally a read-only listing query. It finds explicit memory writes whose
rendered destination operand contains displacement 0x1c, then groups them by function.
"""
listing = prog.getListing()
seen = set()
for insn in listing.getInstructions(True):
text = insn.toString().lower()
if "0x1c" not in text and "+1ch" not in text:
continue
refs = insn.getReferencesFrom()
has_write = any(ref.getReferenceType().isWrite() for ref in refs)
# Register-relative memory writes do not always produce a Ghidra reference, so retain
# the common write mnemonics and require the first rendered operand to contain +0x1c.
mnemonic = insn.getMnemonicString().lower()
dst = insn.getDefaultOperandRepresentation(0).lower()
if "0x1c" not in dst and "+1ch" not in dst:
continue
if not has_write and mnemonic not in ("mov", "movzx", "and", "or", "xor", "inc", "dec"):
continue
owner = func(int(insn.getAddress().getOffset()))
entry = int(owner.getEntryPoint().getOffset()) if owner else 0
key = (entry, int(insn.getAddress().getOffset()))
if key in seen:
continue
seen.add(key)
print("%#x function=%#x %s :: %s" %
(key[1], entry, owner.getName() if owner else "?", insn.toString()))
@@ -0,0 +1,7 @@
"""Inspect the two additional CardsDLL functions with explicit dword writes to +0x1c."""
for target in (0x180171970, 0x1801790A0):
print("\n===== %#x %s =====" % (target, fname(target)))
print(dec(target, 180))
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,61 @@
"""Continue the SBC response handoff analysis after the 2026-08-07 passive trace.
Proven live boundary:
request +0x80 factory -> response 0x18022e5b0
response +0x08 -> 0x18017b2b0 returns true
request +0x90 -> parsed response callback returns normally
request +0x88 -> ownership transfer returns normally
The next unknown is the receiving owner's virtual +0x18 consumer called by
0x1801631e0. Recover the concrete receiver, its vtable, and downstream publication.
"""
import traceback
try:
def dump_function(a, label):
f = func(a)
print("\n=== %s @%#x (%s) ===" % (label, a, f.getName() if f else "?"))
if f:
print("entry=%s body=%s" % (f.getEntryPoint(), f.getBody()))
print(dec(a))
def dump_instructions(a, before=0, count=80):
f = func(a)
print("\n=== instructions around %#x ===" % a)
if not f:
return
rows = []
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins:
rows.append(ins)
pivot = next((i for i, ins in enumerate(rows)
if int(ins.getAddress().getOffset()) >= a), 0)
for ins in rows[max(0, pivot-before):pivot+count]:
print(" %s %s" % (ins.getAddress(), ins))
dump_function(0x1801631e0, "post-request ownership handoff / owner consumer")
dump_instructions(0x1801631e0, count=120)
print("\n=== callers/xrefs of 0x1801631e0 ===")
for ent, name in callers(0x1801631e0):
print(" caller %#x %s" % (ent, name))
print(dec(ent))
for frm, typ, name, ent in xrefs_to(0x1801631e0):
print(" xref from=%#x type=%s fn=%s entry=%#x" %
(frm, typ, name, ent))
request_vtable = 0x18022e5c0
print("\n=== category request vtable %#x ===" % request_vtable)
for off, target, name in vtable(request_vtable, 40):
print(" +%#04x -> %#x %s" % (off, target, name))
for slot, label in ((0x80, "typed factory"),
(0x88, "ownership transfer"),
(0x90, "completion callback")):
target = qword(request_vtable + slot)
dump_function(target, "request %s slot +%#x" % (label, slot))
dump_instructions(target, count=100)
except Exception:
traceback.print_exc()
@@ -0,0 +1,30 @@
"""Find indirect calls to service-interface slot +0xe0 and compare contracts."""
PATTERNS = (
bytes.fromhex("ff 90 e0 00 00 00"),
bytes.fromhex("ff 91 e0 00 00 00"),
bytes.fromhex("ff 92 e0 00 00 00"),
bytes.fromhex("ff 93 e0 00 00 00"),
bytes.fromhex("ff 96 e0 00 00 00"),
bytes.fromhex("ff 97 e0 00 00 00"),
bytes.fromhex("41 ff 90 e0 00 00 00"),
bytes.fromhex("41 ff 91 e0 00 00 00"),
bytes.fromhex("41 ff 92 e0 00 00 00"),
bytes.fromhex("41 ff 93 e0 00 00 00"),
)
seen = set()
for pattern in PATTERNS:
for hit in find_all(pattern, blocks=(".text",)):
owner = func(hit)
if owner is None:
continue
entry = int(owner.getEntryPoint().getOffset())
if entry in seen:
continue
seen.add(entry)
print("\n===== call %#x function %#x %s =====" %
(hit, entry, owner.getName()))
print(dec(owner, 120)[:12000])
print("callees", callees(owner)[:80])
@@ -0,0 +1,27 @@
"""Map the FIFA response-registry primitives surrounding state-3 completion."""
TARGETS = (
0x145336C50,
0x145336E60,
0x1453370B0,
0x1453371B0,
0x145337B20,
0x1453388A0,
0x145338950,
0x145339650,
0x1453396A0,
0x145339B10,
0x145374E10,
0x145375070,
0x145376200,
0x145376270,
0x1453762E0,
0x145376360,
)
for target in TARGETS:
print("\n===== %#x %s =====" % (target, fname(target)))
print(dec(target, 180)[:16000])
print("callers", callers(target)[:120])
print("callees", callees(target)[:120])
@@ -0,0 +1,16 @@
"""Analyze the unpacked FIFA17 SBC completion route offline."""
TARGETS = (
(0x146B805B0, "SBC request owner thunk"),
(0x145374E80, "generic request-state dispatcher"),
(0x145376270, "state-3 completion handler"),
(0x1453388A0, "response registry lookup (manager mode 1)"),
(0x145338950, "response registry lookup (manager mode 2)"),
(0x146162F50, "completion broadcast invoked on lookup miss"),
)
for target, label in TARGETS:
print("\n=== %s %#x %s ===" % (label, target, fname(target)))
print(dec(target, 300))
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,17 @@
"""Resolve the two data tables that reference the SBC request-owner thunk."""
THUNK = 0x146B805B0
REFERENCES = (0x14366D3E0, 0x14381B5D0)
print("thunk bytes", read_bytes(THUNK, 32).hex(" "))
for reference in REFERENCES:
print("\n=== reference %#x ===" % reference)
print("raw", read_bytes(reference - 0x40, 0x90).hex(" "))
for slot in range(reference - 0x40, reference + 0x48, 8):
target = qword(slot)
print("%#x rel=%+#x -> %#x %s xrefs=%s" %
(slot, slot - reference, target, fname(target), xrefs_to(slot)[:8]))
if func(target) is not None:
print(dec(target, 60)[:5000])
+26 -7
View File
@@ -162,6 +162,22 @@ def log(*a):
print("[lsx]", *a, flush=True) print("[lsx]", *a, flush=True)
_SECRET_ATTR_RE = re.compile(
r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"')
_AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"')
_CHALLENGE_ATTR_RE = re.compile(r'(?i)\b(response)="[^"]*"')
def safe_xml_for_log(xml):
"""Redact credential-bearing LSX attributes from ordinary diagnostics."""
safe = _SECRET_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), xml)
if "<AuthCode " in safe:
safe = _AUTH_CODE_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), safe)
if "<ChallengeAccepted " in safe:
safe = _CHALLENGE_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), safe)
return safe
# ---------------------------------------------------------------- crypto # ---------------------------------------------------------------- crypto
# (verbatim from v1 -- verified end-to-end by decrypting captured # (verbatim from v1 -- verified end-to-end by decrypting captured
# captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.) # captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
@@ -389,8 +405,7 @@ def build_reply(mid, req_name, attrs, conn, recipient=""):
conn.stop_events = True conn.stop_events = True
log("*** GetAuthCode ISSUED ***") log("*** GetAuthCode ISSUED ***")
log(f" ClientId={client_id!r} Scope={scope!r}") log(f" ClientId={client_id!r} Scope={scope!r}")
log(f" code={code} -- this must arrive as Blaze " log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)")
f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
return resp(mid, return resp(mid,
f'AuthCode value="{code}" Code="{code}" Return="{code}"') f'AuthCode value="{code}" Code="{code}" Return="{code}"')
@@ -489,8 +504,7 @@ def serve(sock, addr):
client_resp = mr.group(1) if mr else "" client_resp = mr.group(1) if mr else ""
h = challenge_response(client_key, client_resp) h = challenge_response(client_key, client_resp)
conn.key = derive_session_key(h) conn.key = derive_session_key(h)
log(f"client key={client_key} response={h[:16]}... " log("handshake accepted; session crypto initialized")
f"session_key={conn.key.hex()}")
# 3. plaintext ChallengeAccepted # 3. plaintext ChallengeAccepted
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS")) conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
@@ -525,7 +539,7 @@ def serve(sock, addr):
continue continue
mm = REQ_RE.search(xml) mm = REQ_RE.search(xml)
if not mm: if not mm:
log("<<", xml) log("<<", safe_xml_for_log(xml))
continue continue
mid, name, rest = mm.group(1), mm.group(2), mm.group(3) mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
attrs = dict(ATTR_RE.findall(rest)) attrs = dict(ATTR_RE.findall(rest))
@@ -533,7 +547,7 @@ def serve(sock, addr):
recip = rm.group(1) if rm else "" recip = rm.group(1) if rm else ""
reply = build_reply(mid, name, attrs, conn, recip) reply = build_reply(mid, name, attrs, conn, recip)
log(f"<< id={mid} {name} recipient={recip!r} {attrs}") log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
log(f">> {reply}") log(">>", safe_xml_for_log(reply))
conn.send_enc(reply) conn.send_enc(reply)
why = PUSH_AFTER.get(name) why = PUSH_AFTER.get(name)
@@ -591,7 +605,12 @@ def selftest():
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0) # 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
# actually reads; Code=/Return= are legacy padding. # actually reads; Code=/Return= are legacy padding.
assert '<AuthCode value=' in r, r assert '<AuthCode value=' in r, r
print("[ok] GetAuthCode ->", r) redacted = safe_xml_for_log(
'<AuthCode value="secret" Code="secret" Return="secret"/>')
assert "secret" not in redacted and redacted.count("[REDACTED]") == 3, redacted
status = safe_xml_for_log('<ErrorSuccess Code="0" Description=""/>')
assert 'Code="0"' in status, status
print("[ok] GetAuthCode response shape and log redaction")
print("[ok] selftest passed") print("[ok] selftest passed")
+23
View File
@@ -65,6 +65,18 @@ MODE = os.environ.get("POW_MODE", "serve")
API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094") API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094")
CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080") CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080")
# CardsDLL's store-description localizer accepts an empty translation catalogue;
# transport/XML success is the gate. It discovers individual <trans-unit> records
# when present, so keep a standards-shaped empty XLIFF document rather than invent
# labels for server content we do not yet expose.
STOREPACK_DESCRIPTIONS_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2">
<file source-language="en_us" datatype="plaintext" original="storepackdescriptions">
<body />
</file>
</xliff>
"""
def _split(hostport, default_port): def _split(hostport, default_port):
host, _, port = hostport.partition(":") host, _, port = hostport.partition(":")
@@ -296,6 +308,17 @@ class _Handler(http.server.BaseHTTPRequestHandler):
log(" body: %s" % body[:65536].decode("utf-8", "replace")) log(" body: %s" % body[:65536].decode("utf-8", "replace"))
if self.kind == "content": if self.kind == "content":
content_path = self.path.split("?", 1)[0]
if content_path.rstrip("/") == "/fut/packs/loc/storepackdescriptions.en_us.xml":
raw = STOREPACK_DESCRIPTIONS_XML
self.send_response(200)
self.send_header("Content-Type", "application/xml; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(raw)
log(" -> 200 storepack descriptions XML (%d bytes)" % len(raw))
return
# Art assets (.dds/.png). We have none; 404 is the honest answer and is # Art assets (.dds/.png). We have none; 404 is the honest answer and is
# what a missing-asset CDN would return. Logged so we learn what art the # what a missing-asset CDN would return. Logged so we learn what art the
# client wants before deciding to synthesise any. # client wants before deciding to synthesise any.
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Regression tests for launcher-selected persistent FIFA 17 accounts."""
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)
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)
a = fut_accounts.activate({
"personaId": 111001, "personaName": "TEST_A",
"level": 12, "experience": 345, "experienceMax": 1000,
"accountFunds": 50, "accountFundsCap": 100000,
})
assert a["personaId"] == 111001
assert a["level"] == 12
assert fut_store.STORE.coins() == 15000
assert fut_store.STORE.unopened_packs() == [70]
fut_store.STORE.spend(400)
fut_store.STORE.consume_unopened_pack(70)
b = fut_accounts.activate({"personaId": 222002, "personaName": "TEST_B"})
assert b["personaId"] == 222002
assert fut_store.STORE.coins() == 15000
assert fut_store.STORE.unopened_packs() == [70]
a2 = fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
assert a2["personaId"] == 111001
assert fut_store.STORE.coins() == 14600
assert fut_store.STORE.unopened_packs() == []
assert a2["level"] == 12
assert a2["accountFunds"] == 50
active = json.load(open(os.environ["FUT_ACCOUNT_PATH"]))
assert active["persona_id"] == 111001
assert active["persona_name"] == "TEST_A"
# A second process-like Account instance must observe an atomic active
# account file replacement rather than retaining its first loaded value.
observer = fut_account.Account(os.environ["FUT_ACCOUNT_PATH"])
assert observer.persona_id == 111001
fut_accounts.activate({"personaId": 222002, "personaName": "TEST_B"})
assert observer.persona_id == 222002
class Purchase:
command = "POST"
_body = b'{"packId":6,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'
utas_server._OPENED_PACK_GRACE.clear()
status, _ = utas_server.purchased_items(Purchase())
assert status == 200
assert utas_server._OPENED_PACK_GRACE == [6]
status, catalog = utas_server.store_catalog(None)
assert status == 200
grace = [p for p in catalog["purchase"]
if p.get("id") == 6 and p.get("unopened")]
assert len(grace) == 1
assert grace[0]["state"] == "active"
assert grace[0]["displayGroup"]["value"] == "mypacks"
print("account profile isolation: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Regression: autopatch logging is per-launcher/user writable.
The watcher is run with a definitely-absent launcher PID, so it writes its
startup/ownership-exit diagnostics and terminates without touching FIFA.
"""
import os
import pathlib
import subprocess
import sys
import tempfile
def main():
script = pathlib.Path(__file__).with_name("autopatch.py")
with tempfile.TemporaryDirectory(prefix="openfut-autopatch-test-") as root:
log_path = pathlib.Path(root) / "autopatch.log"
env = os.environ.copy()
env["OPENFUT_AUTOPATCH_LOG"] = str(log_path)
result = subprocess.run(
[sys.executable, str(script), "--launcher-pid", "999999999"],
env=env,
text=True,
capture_output=True,
timeout=5,
)
assert result.returncode == 0, result.stderr or result.stdout
assert log_path.is_file(), "OPENFUT_AUTOPATCH_LOG was ignored"
text = log_path.read_text()
assert "watching for FIFA17.exe" in text
assert "launcher pid 999999999 exited" in text
print("autopatch writable-log override: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9 -3
View File
@@ -104,11 +104,17 @@ def test_store_catalog():
check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase")))) check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase"))))
for p in d.get("purchase", []): for p in d.get("purchase", []):
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId"))) check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
check("pack.currencies is array (coin price)", is_arr(p.get("currencies")))
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo"))) check("pack.packContentInfo is object", is_obj(p.get("packContentInfo")))
check("pack.extPrice is object", is_obj(p.get("extPrice"))) if not p.get("unopened"):
check("store pack currencies is array (coin price)",
is_arr(p.get("currencies")))
check("store pack extPrice is object", is_obj(p.get("extPrice")))
ep = p.get("extPrice", {}) ep = p.get("extPrice", {})
check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice"))) check("store extPrice.finalPrice is object",
is_obj(ep.get("finalPrice")))
else:
check("owned pack omits purchase currencies", "currencies" not in p)
check("owned pack omits external purchase price", "extPrice" not in p)
def test_market_bodies(): def test_market_bodies():
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Regression for the FIFA 17 FUT hub offline-Seasons summary.
CardsDLL's hub parser at 0x180139610 recognizes offlineSeason (atom 0x1ec)
and passes its object to 0x18013c3a0. That nested parser recognizes the
string-valued divisionId, gamesPlayed, points, totalGames, and
progressDataVersion fields. Without offlineSeason, the Single Player Season
UI rejects the otherwise-successful GetHubData response before /season is sent.
"""
import os
import pathlib
import sys
import tempfile
TOOLS = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS))
def main():
with tempfile.TemporaryDirectory(prefix="openfut-hub-season-test-") as state:
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
os.environ["FUT_MODES"] = "1"
import utas_server
body = utas_server.hub_data()
assert isinstance(body, dict), "hub response root must be an object"
summary = body.get("offlineSeason")
assert isinstance(summary, dict), "hub.offlineSeason must be an object"
expected = {"divisionId", "gamesPlayed", "points", "totalGames",
"progressDataVersion"}
assert set(summary) == expected, repr(summary)
for key in expected:
assert isinstance(summary[key], str), "%s must be a string: %r" % (key, summary[key])
assert summary["divisionId"] == "10", repr(summary)
assert summary["gamesPlayed"] == "0", repr(summary)
assert summary["points"] == "0", repr(summary)
if __name__ == "__main__":
main()
print("PASS: FUT hub includes the recovered offline-Seasons summary")
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Isolated account-scoped regression for the FUT match HTTP lifecycle.
Drives CREATE -> READY -> PLAY -> END through match_route using a temporary
profile root. No live profile or server is touched.
"""
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)
class Request:
def __init__(self, path, body, command="POST"):
self.path = path
self.command = command
self._body = json.dumps(body).encode("utf-8")
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)
persona_id = 909001
fut_accounts.activate({"personaId": persona_id, "personaName": "MATCH_TEST"})
initial = fut_store.STORE.load()
initial_coins = initial["coins"]
initial_next_id = initial["nextItemId"]
status, created = utas_server.match_route(
Request("/ut/game/fifa17/match", {}))
assert status == 200
match_id = created["id"]
assert created["reportIdEnabled"] is False
assert fut_store.STORE.load()["nextItemId"] == initial_next_id + 1
status, ready = utas_server.match_route(
Request("/ut/game/fifa17/match/ready", {"matchId": match_id}))
assert status == 200
assert ready == {"matchId": match_id, "opponentPersonaId": 0}
next_id_before_play = fut_store.STORE.load()["nextItemId"]
status, played = utas_server.match_route(
Request("/ut/game/fifa17/match", {"matchId": match_id}))
assert status == 200
assert played == {}
assert fut_store.STORE.load()["nextItemId"] == next_id_before_play
status, ended = utas_server.match_route(Request(
"/ut/game/fifa17/match/end",
{"matchId": match_id, "endReason": "WIN",
"myMatchStats": {"goals": 2},
"opponentMatchStats": {"goals": 1}},
))
assert status == 200
expected_reward = (utas_server.MATCH_COINS["won"]
+ utas_server.MATCH_PARTICIPATION)
assert ended["allCoins"] == initial_coins + expected_reward
profile_path = os.path.join(state, "accounts", str(persona_id),
"fifa17_profile.json")
persisted = json.load(open(profile_path, encoding="utf-8"))
assert persisted["coins"] == initial_coins + expected_reward
assert persisted["record"] == {"won": 1, "draw": 0, "loss": 0}
assert persisted["matchesPlayed"] == 1
print("match lifecycle persistence: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Regression tests for FIFA 17's account-scoped phishing/security gate."""
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)
DEVICE_ID = "1" * 32
TRANSFORMED_ANSWER = "a" * 32 # sanitized replay value, not a real answer
class Request:
def __init__(self, method, path, sid=None):
self.command = method
self.path = path
self.headers = {"X-UT-SID": sid} if sid is not None else {}
self._body = b""
def request(utas_server, method, suffix, sid=None):
sid = utas_server.SID if sid is None else sid
h = Request(method, "/ut/game/fifa17/phishing/" + suffix, sid)
return utas_server.security_question_route(h)
def profile(state, persona_id):
path = os.path.join(state, "accounts", str(persona_id), "fifa17_profile.json")
with open(path) as f:
return json.load(f)
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)
# New/missing state: launcher account selection initializes one account only.
fut_accounts.activate({"personaId": 771001, "personaName": "SEC_A"})
p = profile(state, 771001)
assert p["securityQuestion"] == {"version": 1, "verified": True}
# Actual trusted-device response fields parsed by CardsDLL 0x18012a170.
code, body = request(
utas_server, "GET", "trusteddevice?deviceId=" + DEVICE_ID)
assert code == 200
assert body == {
"changed": False,
"exists": True,
"locked": False,
"trusted": True,
}
# Existing initialized state survives a fresh Store instance/process view.
reopened = fut_store.Store(fut_store.profile_path_for(771001))
assert reopened.profile()["securityQuestion"] == {
"version": 1, "verified": True}
# FIFA's observed repeat-session request: POST, empty body, opaque 32-hex
# deviceId and transformed answer in the query string. The answer is accepted
# for OpenFUT compatibility but never persisted.
code, body = request(
utas_server,
"POST",
"validate?deviceId=%s&answer=%s" % (DEVICE_ID, TRANSFORMED_ANSWER),
)
assert (code, body) == (200, {})
saved = profile(state, 771001)
assert TRANSFORMED_ANSWER not in json.dumps(saved)
# Question lookup uses the three fields parsed by CardsDLL 0x180129850.
code, body = request(
utas_server, "GET", "question?deviceId=" + DEVICE_ID)
assert code == 200
assert set(body) == {"question", "attempts", "recoverAttempts"}
assert all(isinstance(body[k], int) for k in body)
# Malformed values/methods and missing sessions fail explicitly.
code, _ = request(utas_server, "POST", "validate?deviceId=bad&answer=bad")
assert code == 400
code, _ = request(
utas_server, "DELETE", "trusteddevice?deviceId=" + DEVICE_ID)
assert code == 405
h = Request(
"GET", "/ut/game/fifa17/phishing/trusteddevice?deviceId=" + DEVICE_ID)
code, _ = utas_server.security_question_route(h)
assert code == 400
# Ordinary request logging must redact answer query values.
raw_path = "/ut/game/fifa17/phishing/validate?deviceId=%s&answer=%s" % (
DEVICE_ID, TRANSFORMED_ANSWER)
safe_path = utas_server.safe_request_path(raw_path)
assert TRANSFORMED_ANSWER not in safe_path
assert "answer=%5BREDACTED%5D" in safe_path
# Multiple profiles receive independent persisted state; selecting B must not
# alter A's initialized record.
fut_accounts.activate({"personaId": 771002, "personaName": "SEC_B"})
assert profile(state, 771002)["securityQuestion"] == {
"version": 1, "verified": True}
assert profile(state, 771001)["securityQuestion"] == {
"version": 1, "verified": True}
# Legacy profile with the field removed is repaired once and persisted.
b_path = os.path.join(state, "accounts", "771002", "fifa17_profile.json")
b = profile(state, 771002)
b.pop("securityQuestion")
with open(b_path, "w") as f:
json.dump(b, f)
fut_store.STORE._p = None
code, body = request(
utas_server, "GET", "trusteddevice?deviceId=" + DEVICE_ID)
assert code == 200 and body["exists"] and body["trusted"]
assert profile(state, 771002)["securityQuestion"]["verified"] is True
print("security-question compatibility: PASS")
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Regression for the FIFA 17 tournament-list response wrapper.
CardsDLL's endpoint response parser at 0x18016b220 accepts an OBJECT root,
recognizes only tournament (atom 0x328), then opens its ARRAY and invokes the
element parser at 0x180169ef0. A bare array therefore parses as no tournament
list at all.
"""
import os
import pathlib
import sys
import tempfile
TOOLS = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS))
def main():
with tempfile.TemporaryDirectory(prefix="openfut-tournament-test-") as state:
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import utas_server
body = utas_server.tournament_list()
assert isinstance(body, dict), "tournament response root must be an object"
body_dict = dict(body)
assert set(body_dict) == {"tournament"}, repr(body_dict)
tournaments = body_dict["tournament"]
assert isinstance(tournaments, list), "tournament must be an array"
assert tournaments, "the offline tournament catalog must not be empty"
assert all(isinstance(entry, dict) for entry in tournaments)
if __name__ == "__main__":
main()
print("PASS: tournament response uses the recovered object/array wrapper")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Regression: ordinary UTAS diagnostics never expose session credentials."""
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)
CANARY = "OPENFUT_UTAS_CANARY_SECRET"
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["FUT_LOG"] = os.path.join(state, "utas.log")
os.environ.pop("FUT_PROFILE", None)
import utas_server
importlib.reload(utas_server)
for name in ("X-UT-SID", "Authorization", "Cookie", "Set-Cookie"):
rendered = utas_server.safe_header_for_log(name, CANARY)
assert rendered == "[REDACTED]", (name, rendered)
assert CANARY not in rendered
assert utas_server.safe_header_for_log("Content-Type", "application/json") == "application/json"
assert utas_server.safe_header_for_log("X-Request-Id", "status-0") == "status-0"
print("UTAS header redaction: PASS")
if __name__ == "__main__":
main()
+345 -54
View File
@@ -11,11 +11,13 @@ 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 datetime, json, os, re, sys, http.server import copy, datetime, json, os, random, re, sys, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room) from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_item
import fut_cards
import fut_staff
from fut_account import ACCOUNT, validate_club # identity + club, single source from fut_account import ACCOUNT, validate_club # identity + club, single source
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the # FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
@@ -37,6 +39,16 @@ SID = "OPENFUT-SID-0000000000000001"
# Flip to True once you want to exercise the create-club path instead. # Flip to True once you want to exercise the create-club path instead.
NEW_USER = False NEW_USER = False
# An owned pack is consumed persistently when opened, but FIFA's reveal controller
# still returns to the My Packs group after all items are assigned/sold. Keep the
# just-opened catalogue record visible until the next hub request so that group is
# not deleted underneath a live UI controller.
_OPENED_PACK_GRACE = []
def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
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")
@@ -285,10 +297,16 @@ def user_info():
"actives": (current_squad().get("actives") or [])[:5], "actives": (current_squad().get("actives") or [])[:5],
}) })
# ---- the two side-effecting members, off by default (see _UI above) -------- # ---- the two side-effecting members, off by default (see _UI above) --------
if _UI in ("packs", "full"): # Ownership counters must reflect persistent inventory only. The catalogue
# grace row prevents StoreFront from deleting a group beneath its live reveal
# controller, but the pack was already consumed and must not remain in FIFA's
# cached unopened-pack count.
unopened_count = len(STORE.unopened_packs())
if unopened_count or _UI in ("packs", "full"):
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks # unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered). # (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0} info["unopenedPacks"] = {"preOrderPacks": 0,
"recoveredPacks": unopened_count}
if _UI in ("roster", "full"): if _UI in ("roster", "full"):
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it # squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen). # fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
@@ -936,13 +954,30 @@ def quick_sell_route(h):
except Exception: except Exception:
body = {} body = {}
ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)] ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)]
# Live FIFA 17 bulk serializer FUN_180126f40 emits the singular atom
# `itemId` containing an array of int64 handles. Keep itemIds as a tolerant
# alias for old replay fixtures, but never rely on it for the retail client.
if not ids and isinstance(body.get("itemId"), list):
ids = body["itemId"]
if not ids and isinstance(body.get("itemIds"), list): if not ids and isinstance(body.get("itemIds"), list):
ids = body["itemIds"] ids = body["itemIds"]
ids = [int(i) for i in ids if isinstance(i, int) and i > 0]
sellable_before = {it.get("id") for it in STORE.purchased() + STORE.items()}
sold, coins = STORE.quick_sell(ids) sold, coins = STORE.quick_sell(ids)
if sold: if sold:
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)" log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
% (sold, coins, STORE.coins())) % (sold, coins, STORE.coins()))
return 200, {} else:
log(" QUICKSELL: no requested ids were found; balance unchanged at %d"
% STORE.coins())
# FutDiscardCardServerResponse. `totalCredits` is the absolute post-sale
# wallet balance, not the sale delta. Only echo accounted-for IDs; duplicate
# or stale request handles must not be removed from the client model twice.
sold_ids = list(dict.fromkeys(iid for iid in ids if iid in sellable_before))
return 200, {
"items": [{"id": iid} for iid in sold_ids],
"totalCredits": STORE.coins(),
}
def _move_ack(req, moved): def _move_ack(req, moved):
@@ -1116,6 +1151,19 @@ ROUTES = [
# is composed by appending a suffix, so it is invisible to the request-template # is composed by appending a suffix, so it is invisible to the request-template
# table, and the generic /squad route below was swallowing it. MUST precede it. # table, and the generic /squad route below was swallowing it. MUST precede it.
(re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)), (re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)),
# Live-composed Draft URL, likewise invisible in the static request templates.
# It must precede generic /squad or squad_route answers {"id":0}, leaving the
# formation carousel empty even though FORMATION_DRAFT was accepted.
(re.compile(G + r"/squad/mode/\d+/draft/choices/formation"),
lambda m, h: draft_formation_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/captain"),
lambda m, h: draft_captain_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/player"),
lambda m, h: draft_player_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/manager"),
lambda m, h: draft_manager_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choose"),
lambda m, h: draft_choose_route(h)),
(re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)), (re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)),
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
@@ -1286,6 +1334,10 @@ def hub_data():
showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
listings are 'selling'; none are 'sold'. count == selling == number of listings.""" listings are 'selling'; none are 'sold'. count == selling == number of listings."""
if _OPENED_PACK_GRACE:
log(" STORE: retiring %d opened-pack grace entry at hub"
% len(_OPENED_PACK_GRACE))
_OPENED_PACK_GRACE.clear()
if not HUBDATA: if not HUBDATA:
return {} return {}
players = len([i for i in STORE.items() if _is_player(i)]) players = len([i for i in STORE.items() if _is_player(i)])
@@ -2522,25 +2574,214 @@ def sbc_tag_route(h):
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if # Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
# this turns out to be wrong. # this turns out to be wrong.
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1" DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"
# Modes that successfully passed the entry-purchase response in this server process.
# Keep this volatile until the complete draft lifecycle (including abandon/rewards)
# is implemented; persisting a half-built draft would make recovery harder.
_DRAFT_SESSIONS = {}
def _draft_squad(session):
"""A Draft-owned squad model, separate from STORE's regular active squad."""
squad = copy.deepcopy(SQUAD)
squad.update({
"id": 0,
"personaId": ACCOUNT.persona_id,
"squadName": "My Draft",
"formation": session.get("formation", "f442"),
"squadType": "DRAFT_SQUAD",
"chemistry": 0,
"starRating": 0,
"captain": 0,
"manager": ([{
"id": session["manager"].get("id", 0),
"itemData": copy.deepcopy(session["manager"]),
"dream": False,
}] if session.get("manager") else []),
})
# SQUAD is currently the empty, schema-proven seed, but explicitly stripping
# itemData prevents a future seed-mode change from leaking the regular XI here.
selected = session.get("selected", {})
squad["players"] = []
for index in range(23):
player = {"index": index, "kitNumber": 0}
if index in selected:
player["itemData"] = copy.deepcopy(selected[index])
squad["players"].append(player)
captain_slot = session.get("captain_slot")
if captain_slot in selected:
squad["captain"] = selected[captain_slot].get("id", 0)
return squad
def draft_state_route(h): def draft_state_route(h):
if not DRAFT_STATE: if not DRAFT_STATE:
return squad_route(h) return squad_route(h)
m = re.search(r"[?&]mode=([^&]+)", h.path)
mode = m.group(1) if m else ""
session = _DRAFT_SESSIONS.get(mode)
purchased = session is not None
return 200, [{ return 200, [{
"squadState": "INVALID", # 0x2d5 STRING enum # Atom-table-backed enum consumed by FUN_180147070. After a successful
# entry purchase FIFA's next legitimate stage is formation selection.
"squadState": session.get("stage", "FORMATION_DRAFT") if purchased else "INVALID",
"stateParam1": "INVALID", # STRING "stateParam1": "INVALID", # STRING
"stateParam2": "0", # STRING (the int getter also accepts it) "stateParam2": "0", # STRING (the int getter also accepts it)
"gamesWonCurrentMatch": 0, # INT "gamesWonCurrentMatch": 0, # INT
"roundsInfo": [], # array of the 7-scalar element; empty is safe "roundsInfo": [], # array of the 7-scalar element; empty is safe
**({"squad": _draft_squad(session)} if purchased else {}),
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe. # entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
}] }]
def draft_formation_choices_route(h):
"""Return the first Draft round: a formation carousel.
FutGetDraftChoicesServerResponse (FUN_18014f2d0) consumes an object root with
choices[] records. Formation records use only index + formation; itemData is
reserved for later player/manager rounds.
"""
log(" DRAFT: serving formation choices")
return 200, {
"positionid": 0,
"tier": 1,
"choices": [
{"index": 0, "formation": "f442"},
{"index": 1, "formation": "f433"},
],
}
def draft_captain_choices_route(h):
"""Offer five known-good player cards for the captain round."""
candidates = [player for player in fut_cards.POOL if player[1] >= 84]
cards = [player_item(800000000 + index, player, special=random.random() < 0.20)
for index, player in enumerate(random.sample(candidates, 5))]
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "CAPTAIN_DRAFT"})
session["pending_choices"] = cards
log(" DRAFT: serving %d captain choices" % len(cards))
return 200, {
"positionid": 0,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
def draft_player_choices_route(h):
"""Offer a player round for the slot requested by the Draft UI."""
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
position_id = int(body.get("positionId", body.get("positionid", 0)))
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "PLAYER_DRAFT"})
selected_assets = {
card.get("assetId") for card in session.get("selected", {}).values()
}
def draft_manager_choices_route(h):
"""Offer five verified FIFA 17 managercards for the final Draft round."""
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "MANAGER_DRAFT"})
manager_ids = random.sample(fut_staff.STARTER_MANAGERS, 5)
cards = [fut_staff.manager_item(800200000 + index, carddbid)
for index, carddbid in enumerate(manager_ids)]
session["stage"] = "MANAGER_DRAFT"
session["pending_choices"] = cards
session["pending_position"] = 23
log(" DRAFT: serving %d manager choices" % len(cards))
return 200, {
"positionid": 23,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
# Prefer the requested slot's broad position family. If FIFA sends only a
# numeric squad slot (as observed), the client still enforces chemistry/fit;
# offering varied high-quality players is safer than inventing a slot map for
# every formation. Five unique assets are guaranteed per carousel.
candidates = [player for player in fut_cards.POOL
if player[1] >= 75 and player[0] not in selected_assets]
picks = random.sample(candidates, 5)
draft_seq = session.get("draft_item_seq", 0)
cards = [player_item(800100000 + draft_seq + index, player,
special=random.random() < 0.12)
for index, player in enumerate(picks)]
session["draft_item_seq"] = draft_seq + len(cards)
session["pending_choices"] = cards
session["pending_position"] = position_id
log(" DRAFT: serving %d player choices for slot %d"
% (len(cards), position_id))
return 200, {
"positionid": position_id,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
def draft_choose_route(h):
"""Acknowledge a pick and advance the volatile Draft state machine."""
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
m = re.search(r"/squad/mode/(\d+)/draft/choose", h.path)
mode_id = int(m.group(1)) if m else 0
mode = "SINGLE_PLAYER" if mode_id == 1 else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "FORMATION_DRAFT"})
if session.get("stage") == "FORMATION_DRAFT":
formations = ("f442", "f433")
choice = body.get("choiceIndex", 0)
session["formation"] = formations[choice] if choice in range(len(formations)) else "f442"
session["stage"] = "CAPTAIN_DRAFT"
log(" DRAFT: chose formation %s; advancing to captain"
% session["formation"])
elif session.get("stage") in ("CAPTAIN_DRAFT", "PLAYER_DRAFT", "MANAGER_DRAFT"):
choice_index = int(body.get("choiceIndex", 0))
position_id = int(body.get("positionId", session.get("pending_position", 0)))
choices = session.get("pending_choices", [])
if 0 <= choice_index < len(choices):
session.setdefault("selected", {})[position_id] = copy.deepcopy(
choices[choice_index]
)
if session.get("stage") == "CAPTAIN_DRAFT":
session["captain_slot"] = position_id
session["stage"] = "PLAYER_DRAFT"
log(" DRAFT: chose captain for slot %d; advancing to players"
% position_id)
elif session.get("stage") == "MANAGER_DRAFT":
session["manager"] = copy.deepcopy(choices[choice_index])
session["stage"] = "COMPLETED_DRAFT"
log(" DRAFT: chose manager; draft squad is complete")
else:
log(" DRAFT: chose player for slot %d" % position_id)
else:
log(" DRAFT: rejected out-of-range choice %d at slot %d"
% (choice_index, position_id))
else:
log(" DRAFT: acknowledged pick at stage %s body=%s"
% (session.get("stage"), json.dumps(body)))
# FutPickDraftChoiceServerResponse uses the generic no-field response parser.
return 200, {}
# ---- Draft entry purchase ---------------------------------------------------- # ---- Draft entry purchase ----------------------------------------------------
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0} # POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns # -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
# the fresh draft session summary. # (1 for SINGLE_PLAYER), not the entry price.
# #
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the # LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
# client CRASHED immediately after. Sequence, from the log: # client CRASHED immediately after. Sequence, from the log:
@@ -2552,33 +2793,22 @@ def draft_state_route(h):
# unimplemented call, which is the outcome a correct fix is supposed to have. # unimplemented call, which is the outcome a correct fix is supposed to have.
# #
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two # WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
# structures reference the class name. Resolved this session, and the doc's note about # structures reference the class name. Live behaviour plus the request vtable resolves
# the second one is wrong: # the POST to the second variant:
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root # 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly # (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
# the seven scalar ints below. THIS IS THE RESPONSE PARSER. # scalar ints. This is a distinct response using the same class name.
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root # 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a # (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
# response root at all. It parses ENTRANCE CRITERIA: each element's # parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
# name is strcmp'd against the literals "COINS", "POINTS" and # with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name # 0x180226300 selects factory 0x180150260 for the live purchase POST.
# string because it is the fee sub-object, not an alternate envelope.
# #
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An # LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
# object-root parser handed {} parses benignly and leaves defaults; an array-root # 200), issue no follow-up request, and spin at high CPU. That is the array parser's
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it # exact EOF-loop signature. The response below therefore uses the required array root.
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran, # No coins are deducted yet: the emulator has not served a verified entrance price,
# and the failure is downstream of an empty-but-valid parse. That is consistent with # and the path's mode id must not be mistaken for a price.
# 0x18014c260 and inconsistent with 0x180150310.
#
# All seven members are scalar ints and the skip handler is present, so unknown keys
# are inert and there is no freeze surface here.
#
# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the
# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state,
# so there is no fee to charge yet. Charging a guessed amount would be inventing an
# economy rule; when entranceCriteria is served the price becomes real and this is the
# place to take it.
# #
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a # DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
# confirmed crash, so there is no working state being protected. # confirmed crash, so there is no working state being protected.
@@ -2593,18 +2823,18 @@ def draft_purchase_route(h):
# calling .group() on the first argument. Doing that raised AttributeError, # calling .group() on the first argument. Doing that raised AttributeError,
# which killed the connection outright -- strictly worse than the {} it replaced. # which killed the connection outright -- strictly worse than the {} it replaced.
m = re.search(r"/purchase/mode/(\d+)/draft", h.path) m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
price = int(m.group(1)) if m else 0 mode = int(m.group(1)) if m else 0
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria " mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
"served yet, so the client posted its own price)" % price) _DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
return 200, { coins = STORE.coins()
"championEventId": 0, points = STORE.profile().get("points", 0)
"expectedTierLevel": 1, log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
"gamesPlayed": 0, "(entry fee not deducted until entrance criteria are verified)" % mode)
"gamesRemaining": 4, # a draft run is 4 rounds return 200, [
"rank": 0, {"name": "COINS", "funds": coins, "finalFunds": coins},
"score": 0, {"name": "POINTS", "funds": points, "finalFunds": points},
"tierLevel": 1, {"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
} ]
def champion_route(h): def champion_route(h):
@@ -2866,7 +3096,7 @@ def _probe_final_funds(p):
return p["price"] return p["price"]
def _pack_body(p, idx): def _pack_body(p, idx, owned=False):
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30). """One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
@@ -2922,10 +3152,20 @@ def _pack_body(p, idx):
"goldQuantity": p["count"] if gold else 0, "goldQuantity": p["count"] if gold else 0,
"rareQuantity": p["count"] if gold else 0, "rareQuantity": p["count"] if gold else 0,
"itemQuantity": p["count"], "itemQuantity": p["count"],
"unopened": False,
}, },
# This is a top-level BOOL in the 0x158-byte pack record. Nesting it in
# packContentInfo (the old code) is skip-safe but completely inert.
"unopened": bool(owned),
} }
if STORE_DISPLAYGROUP: if owned:
# Reward packs are opened with usePreOrder=1 and have no purchase path.
# Leaving zero-value coin/mtx objects attached makes the My Packs tile
# format an unavailable payment label as the literal "undefined".
body.pop("currencies", None)
body.pop("extPrice", None)
if owned:
body["displayGroup"] = {"value": "mypacks", "priority": idx}
elif STORE_DISPLAYGROUP:
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT -- # THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the # it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
# recursion the old notes assumed does not exist and there was never anything # recursion the old notes assumed does not exist and there was never anything
@@ -2955,7 +3195,19 @@ def _pack_body(p, idx):
# than a caption, the store goes from ugly-but-working to unusable. Default OFF # than a caption, the store goes from ugly-but-working to unusable. Default OFF
# for exactly that reason, and the live test buys a pack to prove the buy path # for exactly that reason, and the live test buys a pack to prove the buy path
# still works. # still works.
body["displayGroup"] = {"value": p["name"]} # FIFA 17's StoreFront does not treat this value as an arbitrary caption.
# It resolves exactly six hard-coded category tokens: mypacks, points,
# bronze, silver, gold and special (FUN_180014580/FUN_180014df0). Pack
# titles here create unsupported pseudo-categories and make GOTO_STORE_MYPACK
# initially land on the all-groups screen. Keep ordinary packs in the
# client's canonical categories; `description` remains the per-pack title.
if p.get("specialChance", 0.0) >= 1.0:
category = "special"
elif gold:
category = "gold"
else:
category = "bronze"
body["displayGroup"] = {"value": category}
# FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05: # FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
# sending displayGroup did switch the store to a grouped render path, all three # sending displayGroup did switch the store to a grouped render path, all three
# packs collapsed into ONE group, and drilling into any of the three group tiles # packs collapsed into ONE group, and drilling into any of the three group tiles
@@ -2997,7 +3249,32 @@ def store_catalog(h):
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one. 0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
""" """
packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)] normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]
packs = [_pack_body(p, idx) for idx, p in enumerate(normal, start=1)]
owned_ids = visible_unopened_packs()
for idx, pack_id in enumerate(owned_ids, start=1):
owned = pack_by_id(pack_id)
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.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "inactive"
empty["unopened"] = False
packs.append(empty)
return 200, {"purchase": packs, "timestamp": 1596326400} return 200, {"purchase": packs, "timestamp": 1596326400}
@@ -3015,10 +3292,11 @@ def store_buy(h):
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int): if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
return 200, {} # not a confirmed buy return 200, {} # not a confirmed buy
pack = pack_by_id(pid) pack = pack_by_id(pid)
if not pack: if not pack or pack.get("ownedOnly"):
return 200, {} return 200, {}
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"], items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers")) pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None: if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins())) log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins()))
@@ -3047,8 +3325,14 @@ def purchased_items(h):
pack = pack_by_id(pid) if isinstance(pid, int) else None pack = pack_by_id(pid) if isinstance(pid, int) else None
if pack is None: if pack is None:
return 200, {"itemData": STORE.last_pack()} return 200, {"itemData": STORE.last_pack()}
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
log(" STORE: rejected unopened pack %s; no owned instance" % pid)
return 200, {"itemData": STORE.last_pack()}
if pack.get("ownedOnly"):
_OPENED_PACK_GRACE.append(pid)
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"], items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers")) pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None: if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d" log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
@@ -3072,13 +3356,20 @@ def credits_route(h):
# The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50, # The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b. # atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
c = STORE.coins() c = STORE.coins()
return 200, { body = {
"credits": c, "credits": c,
"currencies": [ "currencies": [
{"name": "coins", "funds": c, "finalFunds": c}, {"name": "coins", "funds": c, "finalFunds": c},
{"name": "points", "funds": 0, "finalFunds": 0}, {"name": "points", "funds": 0, "finalFunds": 0},
], ],
} }
# Do not count _OPENED_PACK_GRACE here: it is a UI-lifetime catalogue shim,
# not an owned pack. Reporting it would leave the My Packs badge stuck at 1.
unopened_count = len(STORE.unopened_packs())
if unopened_count:
body["unopenedPacks"] = {"preOrderPacks": 0,
"recoveredPacks": unopened_count}
return 200, body
# ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ---------------- # ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ----------------