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
"""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."""
import glob, time, struct
import glob, time, struct, sys
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
import glob, time, os
@@ -24,7 +24,7 @@ STORE_PATCHES = {
0x1800175aa: NOP2,
}
LOG="/tmp/autopatch.log"
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}"
@@ -55,8 +55,16 @@ def wr(pid,va,b):
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 ===")
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():
if pid not in patched:
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
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081"
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
@@ -562,7 +563,6 @@ IDENTITY_PARAMS = [
# FUT_POW=1 ./openfut-fut.sh restart
# 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_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")
OSDK_POW = [
("FIFA_POW_URL", "http://%s/" % POW_HOST),
@@ -571,6 +571,14 @@ OSDK_POW = [
("POW_IS_ON", "1"),
] 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 = {
"BlazeSDK": None, # built dynamically, see below
"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
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
return sorted((CLIENT_CONFIGS.get(cfid) or []) + 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
+ FUT_CONTENT_CONFIG + OSDK_POW)
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
+18 -4
View File
@@ -158,6 +158,7 @@ launch() {
local trace_enabled=0
local request_trace_enabled=0
local notifier_trace_enabled=0
local commit_enabled=0
case "$mode" in
baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -176,6 +177,15 @@ launch() {
request_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" ;;
esac
need_file "$deployed_dll"
@@ -192,12 +202,12 @@ launch() {
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
die "deployed version.dll does not match the staged M1 artifact"
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"
done
mkdir -p "${wine_prefix}/dosdevices"
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"
env \
GAMEID=fifa17 \
@@ -209,7 +219,7 @@ launch() {
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH=0 \
OPENFUT_SBC_COMMIT=0 \
OPENFUT_SBC_COMMIT="$commit_enabled" \
OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -217,7 +227,7 @@ launch() {
usage() {
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).
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
Start the single M3 passive factory/deserializer trace; requires:
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:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -247,6 +260,7 @@ case "${1:-inspect}" in
launch) launch baseline ;;
launch-resolve) launch resolve ;;
launch-trace) launch trace ;;
launch-commit) launch commit ;;
-h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;;
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.
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
# the same number the server pays.
#
@@ -293,6 +343,10 @@ def _new_profile():
"purchased": [], # unassigned/pending items from opened packs
"squads": [], # saved squads (raw squad objects from PUT /squad)
"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._sync_identity()
self._save()
if not self._p.get("unopenedSeeded"):
self._p.setdefault("unopenedPackIds", []).append(70)
self._p["unopenedSeeded"] = True
self._save()
self._sync_identity()
return self._p
@@ -513,6 +571,32 @@ class Store:
sq = self.load()["squads"]
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):
"""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
@@ -557,7 +641,8 @@ class Store:
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
place them in the PENDING purchased pile (unassigned). They are NOT owned
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.
n_extra = 0
extras = []
if PACK_MIX and count >= 5:
if PACK_MIX and not players_only and count >= 5:
n_extra = max(1, count // 4)
extras = _pack_extras(n_extra, self)
n_extra = len(extras)
n_players = max(1, count - n_extra)
if tiers:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
for _ in range(n_players)]
# Draw each tier independently but reject duplicate asset IDs inside
# 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:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(n_players)]
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
for (a, r, p, n, lg, tm, at) in picks]
picks = random.sample(pool, min(n_players, len(pool)))
while len(picks) < n_players:
picks.append(random.choice(pool))
items = [player_item(self.new_item_id(), pick,
special=random.random() < special_chance)
for pick in picks]
items += extras
random.shuffle(items)
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.
PACK_CATALOG = [
{"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,
"tiers": ["gold"] * 6 + ["silver"] * 4},
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
{"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)
_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
# (verbatim from v1 -- verified end-to-end by decrypting captured
# 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
log("*** GetAuthCode ISSUED ***")
log(f" ClientId={client_id!r} Scope={scope!r}")
log(f" code={code} -- this must arrive as Blaze "
f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)")
return resp(mid,
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
@@ -489,8 +504,7 @@ def serve(sock, addr):
client_resp = mr.group(1) if mr else ""
h = challenge_response(client_key, client_resp)
conn.key = derive_session_key(h)
log(f"client key={client_key} response={h[:16]}... "
f"session_key={conn.key.hex()}")
log("handshake accepted; session crypto initialized")
# 3. plaintext ChallengeAccepted
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
@@ -525,7 +539,7 @@ def serve(sock, addr):
continue
mm = REQ_RE.search(xml)
if not mm:
log("<<", xml)
log("<<", safe_xml_for_log(xml))
continue
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
attrs = dict(ATTR_RE.findall(rest))
@@ -533,7 +547,7 @@ def serve(sock, addr):
recip = rm.group(1) if rm else ""
reply = build_reply(mid, name, attrs, conn, recip)
log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
log(f">> {reply}")
log(">>", safe_xml_for_log(reply))
conn.send_enc(reply)
why = PUSH_AFTER.get(name)
@@ -591,7 +605,12 @@ def selftest():
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
# actually reads; Code=/Return= are legacy padding.
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")
+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")
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):
host, _, port = hostport.partition(":")
@@ -296,6 +308,17 @@ class _Handler(http.server.BaseHTTPRequestHandler):
log(" body: %s" % body[:65536].decode("utf-8", "replace"))
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
# what a missing-asset CDN would return. Logged so we learn what art the
# 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())
+10 -4
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"))))
for p in d.get("purchase", []):
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.extPrice is object", is_obj(p.get("extPrice")))
ep = p.get("extPrice", {})
check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice")))
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", {})
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():
@@ -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.
* [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__)))
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
# 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.
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():
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],
})
# ---- 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
# (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"):
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
@@ -936,13 +954,30 @@ def quick_sell_route(h):
except Exception:
body = {}
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):
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)
if sold:
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
% (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):
@@ -1116,6 +1151,19 @@ ROUTES = [
# 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.
(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"/squad"), lambda m, h: squad_route(h)),
(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
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
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:
return {}
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
# this turns out to be wrong.
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):
if not DRAFT_STATE:
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, [{
"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
"stateParam2": "0", # STRING (the int getter also accepts it)
"gamesWonCurrentMatch": 0, # INT
"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.
}]
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 ----------------------------------------------------
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns
# the fresh draft session summary.
# POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
# (1 for SINGLE_PLAYER), not the entry price.
#
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
# 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.
#
# 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
# the second one is wrong:
# structures reference the class name. Live behaviour plus the request vtable resolves
# the POST to the second variant:
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly
# the seven scalar ints below. THIS IS THE RESPONSE PARSER.
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
# scalar ints. This is a distinct response using the same class name.
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a
# response root at all. It parses ENTRANCE CRITERIA: each element's
# name is strcmp'd against the literals "COINS", "POINTS" and
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name
# string because it is the fee sub-object, not an alternate envelope.
# (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
# parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
# with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
# 0x180226300 selects factory 0x180150260 for the live purchase POST.
#
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An
# object-root parser handed {} parses benignly and leaves defaults; an array-root
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran,
# and the failure is downstream of an empty-but-valid parse. That is consistent with
# 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.
# LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
# 200), issue no follow-up request, and spin at high CPU. That is the array parser's
# exact EOF-loop signature. The response below therefore uses the required array root.
# No coins are deducted yet: the emulator has not served a verified entrance price,
# and the path's mode id must not be mistaken for a price.
#
# 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.
@@ -2593,18 +2823,18 @@ def draft_purchase_route(h):
# calling .group() on the first argument. Doing that raised AttributeError,
# which killed the connection outright -- strictly worse than the {} it replaced.
m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
price = int(m.group(1)) if m else 0
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria "
"served yet, so the client posted its own price)" % price)
return 200, {
"championEventId": 0,
"expectedTierLevel": 1,
"gamesPlayed": 0,
"gamesRemaining": 4, # a draft run is 4 rounds
"rank": 0,
"score": 0,
"tierLevel": 1,
}
mode = int(m.group(1)) if m else 0
mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
_DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
coins = STORE.coins()
points = STORE.profile().get("points", 0)
log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
"(entry fee not deducted until entrance criteria are verified)" % mode)
return 200, [
{"name": "COINS", "funds": coins, "finalFunds": coins},
{"name": "POINTS", "funds": points, "finalFunds": points},
{"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
]
def champion_route(h):
@@ -2866,7 +3096,7 @@ def _probe_final_funds(p):
return p["price"]
def _pack_body(p, idx):
def _pack_body(p, idx, owned=False):
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
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,
"rareQuantity": p["count"] if gold else 0,
"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 --
# 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
@@ -2955,7 +3195,19 @@ def _pack_body(p, idx):
# 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
# 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:
# 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
@@ -2997,7 +3249,32 @@ def store_catalog(h):
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.
"""
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}
@@ -3015,10 +3292,11 @@ def store_buy(h):
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
return 200, {} # not a confirmed buy
pack = pack_by_id(pid)
if not pack:
if not pack or pack.get("ownedOnly"):
return 200, {}
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:
return 461, {"reason": "insufficient_coins", "credits": 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
if pack is None:
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"],
pack.get("tiers"))
pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
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,
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
c = STORE.coins()
return 200, {
body = {
"credits": c,
"currencies": [
{"name": "coins", "funds": c, "finalFunds": c},
{"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 §) ----------------