fifa17-recon: FUT squad blocker solved + userInfo delivered

The client now issues PUT /squad and the hub renders coins, record and the
squad roster. Three separate root causes, all verified live.

Squad blocker (the long-standing "client never sends PUT /squad"):
  AddPlayerToSquad, GetSquads and SelectSquadById issue ZERO network requests
  (pure local model reads/mutations, FutSquadServiceImpl vtable 0x180233ff0);
  only SaveCurrentSquad writes, and it is unguarded. The client simply needed a
  populated ACTIVE squad model, which arrives via the massinfo `squad` member.
  No response of ours was ever being rejected.

userMassInfo is NOT required to be {}:
  0x180174630 is a FLAT {userInfo, squad, settings, userData} body -- the old
  "wrapper key is user" note was wrong, and the historical freeze was the
  malformed squad member, not the envelope.

clubNameChangeAllowed must be false:
  sending true advertises a club-rename flow whose UI model is never populated;
  the client shows a naming prompt and dies confirming it (ACCESS_VIOLATION
  reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame and no request
  in flight). Isolated by a single-variable run; guarded by a contract check.

Endpoint/schema corrections found in live traffic, invisible to static analysis:
  * GET ut/%s/squad/list is a real endpoint and must return {"squad":[...]},
    not the active-squad object (the /list suffix is appended by the caller, so
    it never appeared in the request table)
  * PUT lands on ut/%s/squad/<id>, not a bare ut/%s/squad
  * userInfo currencies are read as name/funds/finalFunds/active -- there is no
    "value" key, so coins always rendered 0
  * squad-list elements take STRING formation/squadType, not ints
  * the CardsDLL script-API thunk<->name table was off by one (AddPlayerToSquad
    is 0x18004aa70; 0x18004aff0 is GetPotentialChemistry_Club)

FUT_MASSINFO / FUT_USERINFO ladders keep every step of the bisect reproducible.
Contract suite 311 -> 358 checks. Tooling added: PyGhidra harness (Ghidra's
Java/OSGi script path is broken on this box), minidump reader, live code grabber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-03 20:47:16 -07:00
parent 6270c37208
commit 59934b4ef0
16 changed files with 1811 additions and 94 deletions
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Minimal minidump reader: exception code/address + module list, so a FIFA 17
CrashDump can be mapped to <module>+RVA (and thence to a Ghidra address)."""
import struct, sys
p = sys.argv[1]
d = open(p, "rb").read()
assert d[:4] == b"MDMP", "not a minidump: %r" % d[:4]
ver, nstreams, dirrva = struct.unpack_from("<IiI", d, 4)[0], *struct.unpack_from("<II", d, 8)
nstreams, dirrva = struct.unpack_from("<II", d, 8)
streams = {}
for i in range(nstreams):
st, size, rva = struct.unpack_from("<III", d, dirrva + i * 12)
streams.setdefault(st, []).append((size, rva))
print("streams:", sorted(streams))
def mstring(rva):
(ln,) = struct.unpack_from("<I", d, rva)
return d[rva + 4: rva + 4 + ln].decode("utf-16-le", "replace")
mods = []
if 4 in streams:
size, rva = streams[4][0]
(n,) = struct.unpack_from("<I", d, rva)
off = rva + 4
for i in range(n):
base, sz, csum, ts, nrva = struct.unpack_from("<QIIII", d, off)
mods.append((base, sz, mstring(nrva)))
off += 108
print("modules:", len(mods))
exc_addr = None
if 6 in streams:
size, rva = streams[6][0]
tid, _pad = struct.unpack_from("<II", d, rva)
code, flags, recptr, addr, nparams = struct.unpack_from("<IIQQI", d, rva + 8)
exc_addr = addr
NAMES = {0xC0000005: "ACCESS_VIOLATION", 0xC000001D: "ILLEGAL_INSTRUCTION",
0xC0000094: "INT_DIVIDE_BY_ZERO", 0xC0000096: "PRIV_INSTRUCTION",
0x80000003: "BREAKPOINT", 0xC00000FD: "STACK_OVERFLOW",
0xC0000374: "HEAP_CORRUPTION", 0xC0000135: "DLL_NOT_FOUND"}
print("\n=== EXCEPTION ===")
print(" thread : %#x" % tid)
print(" code : %#010x %s" % (code, NAMES.get(code, "?")))
print(" address : %#018x" % addr)
params = [struct.unpack_from("<Q", d, rva + 8 + 32 + i * 8)[0] for i in range(min(nparams, 15))]
print(" params : %s" % [hex(x) for x in params])
if code == 0xC0000005 and len(params) >= 2:
print(" -> %s at %#x" % ({0: "READ from", 1: "WRITE to", 8: "EXECUTE at"}.get(params[0], "access"),
params[1]))
if exc_addr is not None:
hit = [m for m in mods if m[0] <= exc_addr < m[0] + m[1]]
print("\n=== FAULTING MODULE ===")
if hit:
base, sz, name = hit[0]
short = name.split("\\")[-1]
print(" %s base=%#x size=%#x" % (short, base, sz))
print(" RVA = %#x" % (exc_addr - base))
print(" ghidra (PE base 0x180000000) = %#x" % (0x180000000 + (exc_addr - base)))
else:
print(" address %#x is in NO loaded module (bad indirect call / corrupt ptr)" % exc_addr)
print("\n=== modules of interest ===")
for base, sz, name in mods:
s = name.split("\\")[-1].lower()
if any(k in s for k in ("cards", "fifa", "dbdata", "core", "game")):
print(" %-28s base=%#014x size=%#x" % (name.split("\\")[-1], base, sz))
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Pseudo-backtrace from a minidump: walk the faulting thread's stack and report
every qword that points into a loaded module's code (i.e. plausible return
addresses), innermost first."""
import struct, sys
p = sys.argv[1]
d = open(p, "rb").read()
nstreams, dirrva = struct.unpack_from("<II", d, 8)
streams = {}
for i in range(nstreams):
st, size, rva = struct.unpack_from("<III", d, dirrva + i * 12)
streams.setdefault(st, []).append((size, rva))
def mstring(rva):
(ln,) = struct.unpack_from("<I", d, rva)
return d[rva + 4: rva + 4 + ln].decode("utf-16-le", "replace")
mods = []
size, rva = streams[4][0]
(n,) = struct.unpack_from("<I", d, rva)
off = rva + 4
for i in range(n):
base, sz, csum, ts, nrva = struct.unpack_from("<QIIII", d, off)
mods.append((base, sz, mstring(nrva).split("\\")[-1]))
off += 108
mods.sort()
# faulting thread + exception address
size, rva = streams[6][0]
tid, _ = struct.unpack_from("<II", d, rva)
code, flags, recptr, exc_addr, nparams = struct.unpack_from("<IIQQI", d, rva + 8)
def whose(a):
for base, sz, name in mods:
if base <= a < base + sz:
return name, a - base
return None, 0
# thread list
size, rva = streams[3][0]
(nthreads,) = struct.unpack_from("<I", d, rva)
off = rva + 4
target = None
for i in range(nthreads):
t_id, susp, pcls, prio, teb, stk_start, stk_size, stk_rva, ctx_size, ctx_rva = \
struct.unpack_from("<IIIIQQIIII", d, off)
if t_id == tid:
target = (stk_start, stk_size, stk_rva, ctx_size, ctx_rva)
off += 48
print("faulting thread %#x exception at %s+%#x" % (tid, *whose(exc_addr)))
if not target:
print("no stack captured for the faulting thread"); sys.exit(0)
stk_start, stk_size, stk_rva, ctx_size, ctx_rva = target
print("stack %#x..%#x (%d bytes)\n" % (stk_start, stk_start + stk_size, stk_size))
# CONTEXT_AMD64: Rsp at offset 0x98, Rip at 0xF8 (RUNTIME layout)
if ctx_size >= 0x100:
rsp = struct.unpack_from("<Q", d, ctx_rva + 0x98)[0]
rip = struct.unpack_from("<Q", d, ctx_rva + 0xF8)[0]
print("RSP=%#x RIP=%#x (%s+%#x)\n" % (rsp, rip, *whose(rip)))
else:
rsp = stk_start
print("=== plausible return addresses (innermost first) ===")
seen, out = set(), []
start = max(rsp - stk_start, 0)
for o in range(int(start), stk_size - 8, 8):
(v,) = struct.unpack_from("<Q", d, stk_rva + o)
name, off2 = whose(v)
if name is None:
continue
key = (name, off2)
if key in seen:
continue
seen.add(key)
out.append((stk_start + o, name, off2))
for i, (sa, name, off2) in enumerate(out[:45]):
tag = ""
if "cards" in name.lower():
tag = " <-- CardsDLL ghidra %#x" % (0x180000000 + off2)
print(" [%2d] %#x %s+%#x%s" % (i, sa, name, off2, tag))
+35
View File
@@ -152,6 +152,41 @@ def make_squad(step="s1"):
return squad, {"itemData": club_items}
def squad_rating(squad):
"""Squad rating = mean of the rated players actually placed (0 when the squad
is empty, e.g. the s1 zero-resolve ladder step)."""
ratings = [(pl.get("itemData") or {}).get("rating", 0)
for pl in squad.get("players", [])]
ratings = [r for r in ratings if isinstance(r, int) and r > 0]
return sum(ratings) // len(ratings) if ratings else 0
def squad_summary(squad):
"""One FutSquadList element (deser 0x180141fc0, verified 2026-08-03).
Exactly six atoms are recognised; everything else is SKIP'd:
rating 0x274 int (scalar getter 0x1801c79d0)
chemistry 0x81 int (scalar getter 0x1801c79d0)
formation 0x12b STRING (string getter 0x1801c7aa0 -> enum conv 0x180166590)
id 0x15c int
squadName 0x2d3 STRING
squadType 0x2d6 STRING (string getter 0x1801c7aa0 -> enum conv 0x1801668e0)
NOTE: formation/squadType are STRINGS here, exactly as in the full-squad parser
0x18013d1f0 -- they go through the same 0x1801c7aa0 + converter pair. (The
rebuild plan's "<int>" for those two was wrong; feeding ints to a string getter
is the classic type-mismatch freeze at 0x1801c7f1a.)
"""
return {
"rating": squad_rating(squad),
"chemistry": int(squad.get("chemistry", 0)),
"formation": squad.get("formation", "f442"),
"id": int(squad.get("id", 0)),
"squadName": squad.get("squadName", "OpenFUT"),
"squadType": squad.get("squadType", "REGULAR_SQUAD"),
}
# Selected at import time from the environment (default s1 = the zero-resolve test).
STEP = os.environ.get("FUT_SQUAD_STEP", "s1")
SQUAD, CLUB = make_squad(STEP)
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Summarise /tmp/utas_server.log since the last MARKER line: one row per request
(verb, path, status), plus the squad/massinfo detail lines. Keeps the transcript
small instead of dumping the raw log."""
import re, sys, collections
LOG = "/tmp/utas_server.log"
raw = open(LOG, errors="replace").read().splitlines()
# start after the last marker (or the last server banner)
start = 0
for i, l in enumerate(raw):
if "=== MARKER" in l or "=== utas_server" in l:
start = i
lines = raw[start:]
REQ = re.compile(r"^\[[\d:]+\] (GET|PUT|POST|DELETE|HEAD|PATCH) (\S+)")
RESP = re.compile(r"^\[[\d:]+\] -> (\d+) (.*)")
rows, counts, pending = [], collections.Counter(), None
notes = []
for l in lines:
m = REQ.match(l)
if m:
pending = (m.group(1), m.group(2).split("?")[0], m.group(2))
continue
m = RESP.match(l)
if m and pending:
rows.append((pending[0], pending[1], m.group(1), len(m.group(2))))
counts[(pending[0], pending[1])] += 1
pending = None
continue
if "UNMAPPED" in l or "SQUAD:" in l or "STORE:" in l or "MARKET:" in l or "ITEM:" in l:
notes.append(l.strip())
print(f"{len(rows)} requests since marker\n")
print("verb path n")
for (v, p), n in counts.most_common():
print(f"{v:6} {p:48} {n}")
squad = [r for r in rows if "/squad" in r[1]]
print(f"\n--- squad family ({len(squad)}) ---")
for r in squad:
print(" ", r[0], r[1], "->", r[2], f"({r[3]}b)")
# the live URL is /squad/<id> (e.g. /squad/0), not a bare /squad -- match on the
# segment, not endswith, or the headline result reads as a false negative.
print("\nPUT /squad seen:", any(r[0] == "PUT" and "/squad" in r[1] for r in rows))
mi = [r for r in rows if "userMassInfo" in r[1]]
print("userMassInfo requests:", len(mi), [r[2] for r in mi])
if notes:
print("\n--- notes ---")
for n in notes[-25:]:
print(" ", n)
last = rows[-6:]
print("\n--- last 6 requests (where it stopped) ---")
for r in last:
print(" ", r[0], r[1], "->", r[2])
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""OpenFUT Ghidra helper: opens the analysed cardsdll.dll program once and exposes
decompile / xref / vtable helpers, so each RE question is a small python file
instead of a JVM restart + OSGi compile.
Usage: ghidra_env.py <query.py> -- runs <query.py> with the helpers in scope
(see tools/ghidra_queries/ for worked examples)
WHY PYGHIDRA: this box's Ghidra 12.1.2 cannot compile .java scripts at all --
analyzeHeadless -postScript Foo.java dies with "Failed to get OSGi bundle
containing script" for EVERY script, including ones that ran before (it is the
in-process OSGi/javac path that is broken, not the scripts). PyGhidra bypasses it.
Setup once:
python3 -m venv gvenv
gvenv/bin/pip install --no-index \
--find-links /opt/ghidra/Ghidra/Features/PyGhidra/pypkg/dist pyghidra
gvenv/bin/python ghidra_env.py <query.py>
Two traps this file already works around:
* open_program(..., nested_project_location=False) -- otherwise pyghidra creates
a NEW empty project at <loc>/<name>/ and re-imports (losing the analysis).
* os._exit(0) at the end -- JVM teardown under jpype deadlocks forever.
* read_bytes() uses a Java byte[]; passing a Python bytearray to Memory.getBytes
silently reads NOTHING and every scan comes back with 0 hits.
"""
import os, sys
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/opt/ghidra")
import pyghidra
pyghidra.start(verbose=False)
from ghidra.app.decompiler import DecompInterface # noqa: E402
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
DLL = "/tmp/fut/cardsdll.dll"
PROJ_DIR, PROJ = "/tmp/ghidra_fut", "cardsdll"
# nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the
# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/.
_ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ,
analyze=False, program_name="cardsdll.dll",
nested_project_location=False)
flat = _ctx.__enter__()
prog = flat.getCurrentProgram()
mon = ConsoleTaskMonitor()
fm = prog.getFunctionManager()
listing = prog.getListing()
mem = prog.getMemory()
refs = prog.getReferenceManager()
_dec = DecompInterface()
_dec.openProgram(prog)
def addr(a):
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
def func(a):
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
def dec(a, timeout=180):
"""Decompiled C for the function containing address a."""
f = func(a)
if f is None:
return "// no function at %#x" % int(a)
r = _dec.decompileFunction(f, timeout, mon)
if r is None or not r.decompileCompleted():
return "// decompile failed for %s" % f.getName()
return str(r.getDecompiledFunction().getC())
def xrefs_to(a):
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
out = []
it = refs.getReferencesTo(addr(a))
while it.hasNext():
r = it.next()
f = fm.getFunctionContaining(r.getFromAddress())
out.append((int(r.getFromAddress().getOffset()), str(r.getReferenceType()),
f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
return out
def qword(a):
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
def dword(a):
return mem.getInt(addr(a)) & 0xFFFFFFFF
import jpype # noqa: E402
_JBYTE = jpype.JArray(jpype.JByte)
def read_bytes(a, n):
"""Bulk read n bytes at a. MUST use a Java byte[] -- passing a Python
bytearray to Memory.getBytes silently reads nothing (this bug quietly
zeroed several earlier scans)."""
buf = _JBYTE(int(n))
got = mem.getBytes(addr(a), buf)
return bytes((int(x) & 0xFF) for x in buf[:got])
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
hits = []
for b in mem.getBlocks():
if b.getName() not in blocks or not b.isInitialized():
continue
s = int(b.getStart().getOffset())
size = int(b.getEnd().getOffset()) - s + 1
off = 0
chunk = 1 << 20
while off < size:
ln = min(chunk, size - off)
try:
data = read_bytes(s + off, ln)
except Exception:
off += ln
continue
i = data.find(pattern)
while i != -1:
hits.append(s + off + i)
i = data.find(pattern, i + 1)
off += ln - (len(pattern) - 1) if ln == chunk else ln
return hits
def rd_str(a, maxlen=200):
b = bytearray()
p = int(a)
for _ in range(maxlen):
c = mem.getByte(addr(p)) & 0xFF
if c == 0:
break
b.append(c)
p += 1
return b.decode("utf-8", "replace")
def vtable(a, n=64):
"""[(slot_offset, target_addr, function_name)] reading n qwords at a."""
out = []
for i in range(n):
try:
t = qword(int(a) + i * 8)
except Exception:
break
f = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None
out.append((i * 8, t, f.getName() if f else ""))
return out
def fname(a):
f = func(a)
return f.getName() if f else "?"
def callees(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCalledFunctions(mon)}) if f else []
def callers(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCallingFunctions(mon)}) if f else []
if __name__ == "__main__":
if len(sys.argv) > 1:
g = dict(globals())
g["__name__"] = "__main__"
exec(open(sys.argv[1]).read(), g)
else:
print("loaded:", prog.getName(), fm.getFunctionCount(), "functions")
sys.stdout.flush()
# JVM teardown deadlocks under jpype here -- skip it, all output is flushed.
os._exit(0)
@@ -0,0 +1,65 @@
# Q16: FutComponentServicesImpl::FutSquadServiceImpl -- get its ctor + vtable and
# decompile SaveCurrentSquad(+0x50) / AddPlayerToSquad(+0x168) for real.
out = open("/tmp/ghidra_fut/squad_impl.txt", "w")
P = lambda *a: print(*a, file=out)
P("=== all FutComponentServicesImpl::* service classes ===")
for a in find_all(b"FutComponentServicesImpl::", blocks=(".rdata", ".data")):
P(" %#x %s" % (a, rd_str(a, 90)))
P("\n=== ctor/factory FUN_180189be0 ===")
P(dec(0x180189be0)[:3000])
NAME = None
for a in find_all(b"FutComponentServicesImpl::FutSquadServiceImpl", blocks=(".rdata", ".data")):
NAME = a
P("\nclass-name string @ %#x; xrefs:" % NAME)
for frm, typ, fn, ent in xrefs_to(NAME):
P(" %#x %s in %s @ %#x" % (frm, typ, fn, ent))
# The ctor writes the vtable into the object. Find vtables whose slot count is
# large and that live near other Fut service vtables; verify by checking that
# +0x50 / +0x168 / +0x198 / +0x1b8 / +0x1c8 are all real functions.
P("\n=== candidate FutSquadServiceImpl vtables (>=58 slots) ===")
TXT_LO, TXT_HI = 0x180001000, 0x1801e4fff
def is_fn(v):
return TXT_LO <= v <= TXT_HI and fm.getFunctionAt(addr(v)) is not None
p, cur, runs = 0x1801e5000, None, []
while p < 0x2891f0 + 0x180000000:
try:
v = qword(p)
except Exception:
v = 0
if is_fn(v):
if cur is None:
cur = [p, 0]
cur[1] += 1
else:
if cur and cur[1] >= 58:
runs.append(tuple(cur))
cur = None
p += 8
if cur and cur[1] >= 58:
runs.append(tuple(cur))
for s, n in runs:
nm = rd_str(s + n * 8, 60)
P(" vtable %#x %d slots trailing=%r" % (s, n, nm[:45]))
P("\n=== slot decompiles for every candidate ===")
for s, n in runs:
nm = rd_str(s + n * 8, 60)
P("\n##### vtable %#x (%d slots, %r) #####" % (s, n, nm[:40]))
for off, tag in ((0x50, "SaveCurrentSquad"), (0x168, "AddPlayerToSquad")):
if off // 8 >= n:
continue
t = qword(s + off)
f = fm.getFunctionAt(addr(t))
P("--- +%#05x %s -> %#x %s ---" % (off, tag, t, f.getName() if f else ""))
P(dec(t)[:3000])
out.close()
print("wrote squad_impl.txt")
@@ -0,0 +1,36 @@
# Q17: FutSquadServiceImpl vtable = 0x180233ff0 (written by ctor FUN_180189be0).
# Dump it and decompile the placement/save slots.
out = open("/tmp/ghidra_fut/squad_svc.txt", "w")
P = lambda *a: print(*a, file=out)
VT = 0x180233FF0
SL = {0x50: "SaveCurrentSquad", 0xb8: "pre-save getter", 0x168: "AddPlayerToSquad",
0x198: "GetSquadList", 0x1b8: "GetSquads", 0x1c8: "SelectSquadById"}
P("=== FutSquadServiceImpl vtable @ %#x ===" % VT)
n = 0x60
for i in range(n):
a = VT + i * 8
try:
t = qword(a)
except Exception:
break
f = fm.getFunctionContaining(addr(t)) if 0x180001000 <= t <= 0x1801e4fff else None
if f is None and not (0x180001000 <= t <= 0x1801e4fff):
P(" +%#05x %#x <end of vtable>" % (i * 8, t))
break
P(" +%#05x -> %#x %-18s %s" % (i * 8, t, f.getName() if f else "(no fn)",
SL.get(i * 8, "")))
P("\n=== decompiles ===")
for off in (0x50, 0xb8, 0x168, 0x198, 0x1b8, 0x1c8):
try:
t = qword(VT + off)
except Exception:
continue
f = fm.getFunctionContaining(addr(t))
P("\n########## +%#05x %s -> %#x %s ##########"
% (off, SL.get(off, ""), t, f.getName() if f else ""))
P(dec(t))
out.close()
print("wrote squad_svc.txt")
@@ -0,0 +1,43 @@
# Q7: build the definitive FUT script-API map: script name -> thunk -> service
# vtable slot, by parsing the 24-byte {?, fnptr, name} records the registrar
# FUN_18004a3a0 writes, then joining with the slot each thunk calls.
import re
out = open("/tmp/ghidra_fut/fut_api_map.txt", "w")
P = lambda *a: print(*a, file=out)
c = dec(0x18004a3a0, 300)
# records appear as: <lhs> = FUN_xxxxxxxxx; <lhs> = "Name";
pairs = re.findall(r'=\s*(FUN_[0-9a-f]+);\s*\n\s*\w+\s*=\s*"([^"]+)"', c)
P("=== registrar records: %d ===" % len(pairs))
SLOT = re.compile(r"\*\(code \*\*\)\(lVar\d+ \+ (0x[0-9a-f]+|\d+)\)")
rows = []
for fnname, script in pairs:
ent = int(fnname.replace("FUN_", ""), 16)
body = dec(ent, 60)
slots = sorted(set(int(s, 16) if s.startswith("0x") else int(s)
for s in SLOT.findall(body)))
nargs = len(re.findall(r"FUN_18019fb[45]0\(", body))
ret = "FUN_18019fc00(" in body or "FUN_18019fbf0(" in body
rows.append((slots[0] if slots else -1, script, ent, nargs, ret, body))
P("\n%-34s %-8s %-14s %s" % ("script name", "slot", "thunk", "args ret"))
for slot, script, ent, nargs, ret, _ in sorted(rows):
P("%-34s %-8s %#-14x %d %s" % (script, hex(slot) if slot >= 0 else "-", ent,
nargs, "Y" if ret else ""))
P("\n=== thunks with NO service call (pure local/query) ===")
for slot, script, ent, nargs, ret, body in sorted(rows):
if slot < 0:
P("--- %s @ %#x ---" % (script, ent))
P(body)
WANT = ("AddPlayer", "SaveCurrentSquad", "SaveSquad", "RemovePlayer",
"GetCurrentSquadID", "GetCurrentSquadData", "SetPlayer", "SwapPlayer")
P("\n=== decompiles of the placement/save path ===")
for slot, script, ent, nargs, ret, body in sorted(rows):
if any(w.lower() in script.lower() for w in WANT):
P("--- %s slot=%s @ %#x ---" % (script, hex(slot) if slot >= 0 else "-", ent))
P(body)
out.close()
print("wrote fut_api_map.txt; records:", len(pairs))
@@ -0,0 +1,64 @@
# Q9: FUN_18004c180 builds the squad-service object registered into DAT_1802dfd18.
# Recover its vtable, then decompile AddPlayerToSquad(+0xc8) and SaveCurrentSquad
# (+0x28) -- the two slots that decide whether PUT /squad is ever issued.
out = open("/tmp/ghidra_fut/squad_service.txt", "w")
P = lambda *a: print(*a, file=out)
P("=== factory FUN_18004c180 ===")
P(dec(0x18004c180))
# find vtable pointers written by the factory
f = func(0x18004c180)
cands = {}
for a in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(a)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801e5000 <= t <= 0x1802891ff:
cands[t] = cands.get(t, 0) + 1
P("\n=== vtable candidates referenced by the factory ===")
best = None
for t, n in sorted(cands.items()):
try:
fns = [fm.getFunctionAt(addr(qword(t + i * 8))) for i in range(6)]
except Exception:
continue
nf = sum(1 for x in fns if x)
if nf >= 5:
P(" %#x (%d/6 fnptrs, %d refs)" % (t, nf, n))
if best is None:
best = t
SLOTS = {0x20: "SaveSquad", 0x28: "SaveCurrentSquad", 0x30: "RemovePlayer",
0x40: "GetCurrentSquadID?", 0x48: "GetCurrentSquadChemistry",
0x58: "GetCurrentSquadData", 0x80: "GetSquadList", 0x88: "GetSquads",
0x90: "SelectSquadById", 0xa8: "IsCardInSquad", 0xb8: "GetSquadLineup",
0xc0: "LoadActiveSquad", 0xc8: "AddPlayerToSquad"}
for t in sorted(cands):
try:
fns = [fm.getFunctionAt(addr(qword(t + i * 8))) for i in range(6)]
except Exception:
continue
if sum(1 for x in fns if x) < 5:
continue
P("\n=== VTABLE %#x ===" % t)
for off, tgt, name in vtable(t, 40):
tag = SLOTS.get(off, "")
P(" +%#04x -> %#x %-16s %s" % (off, tgt, name, tag))
P("\n--- key slot decompiles ---")
for off in (0x28, 0xc8, 0x88, 0x80, 0x90, 0xc0):
try:
tgt = qword(t + off)
except Exception:
continue
if not fm.getFunctionAt(addr(tgt)):
continue
P("### +%#04x %s -> %#x" % (off, SLOTS.get(off, ""), tgt))
P(dec(tgt))
break
out.close()
print("wrote squad_service.txt")
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Wait for FIFA17.exe, then dump + disassemble the unpacked code around a VA.
FIFA17.exe is packed on disk but Wine maps it flat at 0x140000000 and it unpacks
at load, so the only way to read the real instructions is from a LIVE process
(/proc/<pid>/mem, needs ptrace_scope=0 -- openfut-fut.sh's root_arm does that).
The game does NOT need to be at the crash point; the code is mapped as soon as
the module is up.
Usage: grab_crash_code.py [va_hex] [nbytes_before] [nbytes_after]
Default VA is the 2026-08-03 create-club crash site FIFA17.exe+0x71b8651.
"""
import glob, os, sys, time
VA = int(sys.argv[1], 16) if len(sys.argv) > 1 else 0x1471B8651
BEFORE = int(sys.argv[2]) if len(sys.argv) > 2 else 0xC0
AFTER = int(sys.argv[3]) if len(sys.argv) > 3 else 0x60
OUT = "/tmp/crash_code.txt"
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.split("/")[-1])
except Exception:
pass
return None
print("waiting for FIFA17.exe (launch the game; no need to reach the crash)...",
flush=True)
pid = None
while pid is None:
pid = find_pid()
if pid is None:
time.sleep(2)
print("pid=%d, reading %#x" % (pid, VA), flush=True)
# give the unpacker a moment after process start
time.sleep(5)
start = VA - BEFORE
with open("/proc/%d/mem" % pid, "rb") as f:
f.seek(start)
data = f.read(BEFORE + AFTER)
lines = ["pid=%d window %#x..%#x (%d bytes)" % (pid, start, start + len(data), len(data)),
"raw: " + data.hex()]
try:
import capstone
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
md.detail = False
# align: disassemble from several offsets, keep the run that lands exactly on VA
best = None
for skip in range(0, 16):
ins = list(md.disasm(data[skip:], start + skip))
if any(i.address == VA for i in ins):
if best is None or len(ins) > len(best[1]):
best = (skip, ins)
if best:
for i in best[1]:
mark = " <<<<< FAULT (read from 0x0)" if i.address == VA else ""
lines.append(" %#x %-10s %s%s" % (i.address, i.mnemonic, i.op_str, mark))
else:
lines.append("could not align a disassembly onto the fault VA")
except ImportError:
lines.append("(capstone not installed; raw bytes above)")
open(OUT, "w").write("\n".join(lines) + "\n")
print("\n".join(lines))
print("\nwrote " + OUT)
+94 -5
View File
@@ -18,6 +18,7 @@ import json, sys, urllib.request
BASE = "http://127.0.0.1:8099"
G = "/ut/game/fifa17"
V2 = "/ut/v2/game/fifa17"
PERSONA_ID = 33068179 # blaze LoginResponse SESS.BUID / PDTL.PID
_fail = []
_pass = 0
@@ -123,11 +124,98 @@ def test_squad_boot():
check("squad slot itemData is object-or-null", it is None or is_obj(it), repr(it))
def test_massinfo_empty():
# GetUserMassInfo (deser 0x180174630) MUST stay {} -- any populated userInfo/squad
# desyncs the parser -> freeze (CARD_SYSTEM.md). Guard against accidental population.
def test_squad_list_shape():
# userInfo.squadList (atom 0x2d4) goes through FUN_180142260 -- the same parser as
# the FutSquadList response -- so it must be an OBJECT with a "squad" ARRAY, never
# a bare array. Each element (0x180141fc0): rating/chemistry/id INT,
# formation/squadName/squadType STRING (string getter 0x1801c7aa0 + enum conv).
d = _get(G + "/user")
ui = d.get("userInfo", {})
check("user.userInfo is object", is_obj(ui), repr(type(ui)))
# Every STRING atom 0x18013ec10 consumes must be present and non-empty: a null
# string pointer in this record is what the 2026-08-03 create-club crash read.
for k in ("clubName", "clubAbbr", "established", "accountCreatedPlatformName"):
check(f"userInfo.{k} is non-empty string", is_str(ui.get(k)) and ui.get(k), repr(ui.get(k)))
# actives is optional (omitted by FUT_USERINFO=min); when present it must be an
# array of at most 5 item refs (0x18013ec10 stops storing past index 4).
act = ui.get("actives")
check("userInfo.actives absent or array", act is None or is_arr(act), repr(act))
check("userInfo.actives <= 5", len(act or []) <= 5)
# coins/record are what the hub renders: currencies elements are read by
# FUN_180138bd0 as name/funds/finalFunds/active -- "value" is NOT a key it knows.
coins = next((c for c in ui.get("currencies", []) if c.get("name") == "coins"), None)
check("userInfo has coins currency", coins is not None, repr(ui.get("currencies")))
if coins:
check("userInfo coins.funds is number", is_num(coins.get("funds")), repr(coins))
check("userInfo coins.finalFunds is number", is_num(coins.get("finalFunds")), repr(coins))
check("userInfo coins uses funds not value", "value" not in coins, repr(coins))
for k in ("won", "draw", "loss"):
check(f"userInfo.{k} is number", is_num(ui.get(k)), repr(ui.get(k)))
# REGRESSION GUARD: clubNameChangeAllowed=true is the isolated root cause of the
# 2026-08-03 create-club crash (identical field set, only this bool flipped, 4/4
# crash vs no crash). It may be absent, but it must never be true.
check("clubNameChangeAllowed is not true", ui.get("clubNameChangeAllowed") is not True,
repr(ui.get("clubNameChangeAllowed")))
# squadList is OPTIONAL (FUT_USERINFO ladder) -- but if present it must be an
# object with a squad array, never a bare array.
sl = ui.get("squadList")
check("userInfo.squadList absent or object", sl is None or is_obj(sl), repr(sl))
if is_obj(sl):
check("squadList.squad is array", is_arr(sl.get("squad")), repr(sl.get("squad")))
for e in sl.get("squad", []):
check("squadList elem is object", is_obj(e), repr(e))
if not is_obj(e):
continue
for k in ("rating", "chemistry", "id"):
check(f"squadList.{k} is number", is_num(e.get(k)), repr(e.get(k)))
for k in ("formation", "squadName", "squadType"):
check(f"squadList.{k} is string", is_str(e.get(k)), repr(e.get(k)))
def test_squad_list_endpoint():
# GET ut/%s/squad/list is the real FutSquadList URL (live-observed 2026-08-03).
# Its parser 0x180142260 recognises ONLY squad(0x2cd), so the body MUST be
# {"squad":[...]}; returning the active-squad object here yields "MY SQUADS: 0".
d = _get(G + "/squad/list")
check("squad/list is object", is_obj(d), repr(type(d)))
check("squad/list has squad array", is_arr(d.get("squad")), repr(d)[:120])
check("squad/list is NOT the active-squad object", "players" not in d, repr(list(d)))
for e in d.get("squad", []):
for k in ("rating", "chemistry", "id"):
check(f"squad/list elem {k} is number", is_num(e.get(k)), repr(e.get(k)))
for k in ("formation", "squadName", "squadType"):
check(f"squad/list elem {k} is string", is_str(e.get(k)), repr(e.get(k)))
def test_massinfo_shape():
# GetUserMassInfo (deser 0x180174630) is a FLAT object -- no "user" wrapper.
# Populated as of 2026-08-03 (see FUT_RESPONSE_REBUILD_PLAN.md S7): userInfo,
# squad, settings, userData. Every member must keep its reversed type or the
# SAX reader desyncs -> busy-loop freeze at 0x1801c7f1a.
d = _get(G + "/userMassInfo")
check("userMassInfo is empty {}", d == {}, repr(d))
check("massinfo is object", is_obj(d), repr(type(d)))
check("massinfo has no 'user' wrapper", "user" not in d, repr(list(d)))
if not d:
return # FUT_MASSINFO=empty bisect mode
for k in ("userInfo", "squad", "settings", "userData"):
if k in d:
check(f"massinfo.{k} is object", is_obj(d[k]), repr(type(d.get(k))))
sq = d.get("squad")
if is_obj(sq):
# squad(0x2cd) -> LoadActiveSquad parser 0x18013d1f0, same schema as GET /squad
check("massinfo.squad.players is array", is_arr(sq.get("players")), repr(type(sq.get("players"))))
check("massinfo.squad.formation is string", is_str(sq.get("formation")), repr(sq.get("formation")))
check("massinfo.squad.squadType is string", is_str(sq.get("squadType")), repr(sq.get("squadType")))
check("massinfo.squad.custom is string", is_str(sq.get("custom")), repr(type(sq.get("custom"))))
check("massinfo.squad.actives is array", is_arr(sq.get("actives")), repr(type(sq.get("actives"))))
check("massinfo.squad.manager is array", is_arr(sq.get("manager")), repr(type(sq.get("manager"))))
check("massinfo.squad.kicktakers is array", is_arr(sq.get("kicktakers")), repr(type(sq.get("kicktakers"))))
# personaId MUST equal the logged-in persona (0x18014659c) or SquadLoad
# discards our squad and builds a throwaway one.
check("massinfo.squad.personaId == PERSONA_ID", sq.get("personaId") == PERSONA_ID, repr(sq.get("personaId")))
if is_obj(d.get("settings")):
check("massinfo.settings.configs is array", is_arr(d["settings"].get("configs")),
repr(type(d["settings"].get("configs"))))
def test_club_items():
@@ -138,7 +226,8 @@ def test_club_items():
def main():
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
test_auction_record_shape, test_squad_boot, test_massinfo_empty, test_club_items]
test_auction_record_shape, test_squad_boot, test_squad_list_shape,
test_squad_list_endpoint, test_massinfo_shape, test_club_items]
try:
_get(G + "/user/credits")
except Exception as e:
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Diff the atoms the userInfo deserializer (0x18013ec10) can consume against the
keys utas_server.user_info() actually sends, and flag STRING-typed fields we omit
(a NULL string pointer is exactly the crash class seen in FUN_180084f90)."""
import re, sys, os
sys.path.insert(0, "/home/alex/Documents/OpenFUT/fifa17-recon/tools")
os.environ.setdefault("FUT_PROFILE", "/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"4cf26d25-8cee-4db3-ad9e-9fd1838020eb/scratchpad/diffprof.json")
atoms = {}
for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"):
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
atoms[int(p[0])] = p[2]
src = open("/tmp/ghidra_fut/userinfo.txt").read()
# Every comparison against the key-id variable, plus switch cases.
ids = set()
for m in re.finditer(r"iVar\d+ == (0x[0-9a-f]+|\d+)", src):
ids.add(int(m.group(1), 0))
for m in re.finditer(r"case (0x[0-9a-f]+|\d+):", src):
ids.add(int(m.group(1), 0))
for m in re.finditer(r"caseD_([0-9a-f]+)", src):
ids.add(int(m.group(1), 16))
# getter used per id -> type. 0x1801c7aa0 = STRING, 0x1801c79d0 = int,
# 0x1801c7a40/0x1801c7b40 = bool/other scalars.
GET = {"1801c7aa0": "str", "1801c79d0": "int"}
typed = {}
for m in re.finditer(r"(?:iVar\d+ == |case )(0x[0-9a-f]+|\d+)\)?:?\s*\{?\s*\n((?:.*\n){0,6})", src):
try:
i = int(m.group(1), 0)
except ValueError:
continue
blk = m.group(2)
for g, t in GET.items():
if g in blk:
typed[i] = t
break
from utas_server import user_info # noqa: E402
sent = set(user_info().keys())
known = {i: atoms.get(i, "?") for i in sorted(ids) if i in atoms}
print("userInfo deser consumes %d named atoms; user_info() sends %d keys\n"
% (len(known), len(sent)))
missing = [(i, n, typed.get(i, "")) for i, n in known.items() if n not in sent]
extra = sorted(sent - set(known.values()))
print("=== parsed by the client but NOT sent by us (%d) ===" % len(missing))
for i, n, t in sorted(missing, key=lambda x: (x[2] != "str", x[1])):
print(" %-32s atom %#-6x %s" % (n, i, ("<-- STRING" if t == "str" else t)))
print("\n=== we send but the deser does not name (harmless SKIPs) ===")
print(" " + ", ".join(extra))
+282 -66
View File
@@ -14,7 +14,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
import datetime, json, os, re, sys, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST # 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
ADDR = ("127.0.0.1", 8099)
@@ -44,52 +44,190 @@ def auth_body():
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run.
personaId is FORCED to PERSONA_ID: SquadLoad compares it against the logged-in
persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0] branch and
builds a throwaway squad instead of adopting ours.
"""
saved = STORE.active_squad()
# Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
# what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers
# and squadName present on reload instead of silently dropping them.
sq = dict(SQUAD)
if saved:
sq.update(STORE.reconstruct_squad(saved))
sq["personaId"] = PERSONA_ID
sq.setdefault("id", 0)
return sq
def squad_list_body(squad=None):
"""FutSquadListServerResponse body (deser 0x180172140 -> value parser
0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY
of squad summaries. This is the same parser userInfo.squadList goes through."""
return {"squad": [squad_summary(squad or current_squad())]}
# Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE
# the userInfo record -- they reach into the global model singleton FUN_18011a830:
# squadList(0x2d4) -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model
# unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks)
# Every other member just fills a scalar/string slot in the record. The 2026-08-03
# crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never
# actually reached the client before (massinfo was {} and /user is not called at
# boot) -- so these two newly-exercised branches are the prime suspects, and both
# are optional for coins/record. Ladder, cheapest-first:
# min = ONLY what coins/record need | safe = + the rest of the scalars
# roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both
# `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only
# `unopenedPacks` remains untested, hence `full` is not the default.
#
# ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE.
# Two runs sent the IDENTICAL field set and differed only in that one bool:
# true -> client shows the FUT club-name prompt, then dies confirming it
# (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs)
# false -> no prompt at all, hub loads, coins + record render
# Sending true advertises "a club-name change is available", pushing the client into
# a naming flow whose UI model we never populate. Neither side-effecting member
# (squadList / unopenedPacks) was involved -- both were already omitted in the
# crashing run. Keep this false unless the rename flow is actually implemented.
_UI = os.environ.get("FUT_USERINFO", "roster")
def user_info():
# Deserializer 0x18013EC10; every member optional (unknown key ids are
# skipped via 0x180135FF0), so {} also parses.
return {
# Now that massinfo is populated, this record actually reaches the hub -- read
# the live profile instead of the old hardcoded placeholders.
p = STORE.profile()
rec = p.get("record", {})
info = {
"personaId": PERSONA_ID,
"clubName": "OpenFUT", "clubAbbr": "OFC", "established": "2026",
"clubNameChangeAllowed": True,
"currencies": [{"name": "coins", "value": 15000},
{"name": "points", "value": 0}],
"won": 0, "draw": 0, "loss": 0,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False,
"feature": {"trade": True},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
"actives": [], "squadList": [],
"clubName": p.get("clubName", "OpenFUT"), "clubAbbr": p.get("clubAbbr", "OFC"),
"established": p.get("established", "2026"),
# accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
# 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
# established 0x110 were already covered). Sending it is correct, but note
# it was NOT the cause of the create-club crash -- that was still identical
# with this field present. Value matches auth's nucleusPersonaPlatform.
"accountCreatedPlatformName": "pc",
# The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
# funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key,
# so the old {"name","value"} pairs were parsed as 0 and the hub showed 0
# coins. The caller then matches name against "coins"/"points" literally.
"currencies": [
{"name": "coins", "funds": STORE.coins(),
"finalFunds": STORE.coins(), "active": True},
{"name": "points", "funds": p.get("points", 0),
"finalFunds": p.get("points", 0), "active": True},
],
# record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record
"won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0),
}
# Everything below is optional for coins/record. `min` stops here.
if _UI != "min":
info.update({
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. MUST stay False:
# True is the isolated root cause of the create-club crash (see _UI).
"clubNameChangeAllowed": False,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False, # 0x262 -> bool at +0x68
"feature": {"trade": True},
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
# actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed
# ladder squad has none yet -> empty array (arrays never desync).
"actives": (current_squad().get("actives") or [])[:5],
})
# ---- the two side-effecting members, off by default (see _UI above) --------
if _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}
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).
# Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the
# roster empty. NOT required for the ACTIVE squad -- that comes from the
# massinfo `squad` member, which is already proven working.
info["squadList"] = squad_list_body()
return info
# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member. The member
# NAME is never compared, but the nesting level is required.
USER_GET = {"userInfo": user_info()}
def user_get():
return {"userInfo": user_info()}
# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
USER_POST = {"login": True, "userData": user_info(),
"squad": {}, "starterPack": {}, "bonusPacks": []}
def user_post():
# squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so
# serve the real schema-correct squad rather than {} -- a create-club response
# carrying an empty squad leaves the client with a 0-slot squad model, which is
# precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c).
return {"login": True, "userData": user_info(),
"squad": current_squad(), "starterPack": {}, "bonusPacks": []}
# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2).
SETTINGS = {"configs": []}
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser 0x180174630).
# ANY content here (userInfo AND/OR squad) DESYNCS CardsDLL's massinfo parser ->
# infinite tokenizer spin (busy-loop freeze at 0x1801c7f1a). Proven: {} reaches
# the hub; {userInfo,...} and {...,squad,...} both freeze. The userInfo sub-deser
# 0x18013ec10 mis-consumes some field in user_info(). So keep userMassInfo EMPTY
# (hub-reaching) and deliver club/squad via their OWN endpoints (/user, /club,
# /squad) whose parsers we know work. Select via env FUT_MASSINFO (empty|userinfo|full).
_MI = os.environ.get("FUT_MASSINFO", "empty")
if _MI == "full":
MASSINFO = {"userInfo": user_info(), "squad": SQUAD,
"settings": {"configs": []}, "userData": {}}
elif _MI == "userinfo":
MASSINFO = {"userInfo": user_info(), "settings": {"configs": []}, "userData": {}}
else:
MASSINFO = {} # proven hub-reaching
# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser FUN_180174630).
#
# CORRECTED 2026-08-03 (supersedes the old "MUST BE {}" note). Full decompile of
# 0x180174630 (/tmp/ghidra_fut/massinfo.txt): the body is a FLAT object whose
# top-level keys dispatch to
# userInfo(0x370) -> 0x18013ec10 squad(0x2cd) -> 0x18013d1f0
# settings(0x2bf) -> 0x18013c6d0 userData(0x36d) -> 0x180142470
# clubUser(0x91), errors(0x10c), loanPlayers(0x19a), loanPlayerClientData(0x199),
# pileSizeClientData(0x227); everything else SKIP'd via 0x180135ff0.
# There is NO "user" wrapper (the old note was wrong -- "user" only appears nested
# inside clubUser). The prologue (2 x NextToken before the key loop) is identical
# to 0x18014cc60, the proven-flat CreateUser parser.
#
# The historical freeze is now attributed to the SQUAD member: it was fed a squad
# object built before the exact schema was known (0x18013d1f0 was only reversed on
# 2026-08-03). Every field of user_info() type-checks against 0x18013ec10, and the
# one structurally wrong field -- squadList as a bare array -- is SKIP-safe rather
# than spin-inducing. So massinfo is now served POPULATED (like EA does), which is
# what delivers the squad roster to the client at boot.
#
# LIVE STATUS 2026-08-03: `full` PARSES fine (no freeze -- the client processed it
# and issued the next request), but the client then enters FUT club-creation and
# dies confirming the club name: reproducible ACCESS_VIOLATION reading 0x0 at
# FIFA17.exe+0x71b8651 (3/3 runs). That crash site is
# mov rax,[r9+0x28] ; mov r9,[rax] <- r9->field_28 is NULL, unguarded
# in FIFA17.exe's own UI code -- no CardsDLL frame on the stack and NO request is
# sent when it happens, so it is not a response being rejected. Bisecting which
# massinfo member triggers it, one relaunch per value:
# BISECT RESULT: `squad` alone reaches the hub AND made the client issue
# PUT /squad/0 (blocker solved) -- so the crash is in the `userInfo` member.
# `full` is restored now that userInfo omits its two side-effecting members by
# default (see _UI): that is what puts coins/record back on the hub.
# INSTANT FALLBACK if the crash returns: FUT_MASSINFO=squad (known-good).
_MI = os.environ.get("FUT_MASSINFO", "full")
def massinfo():
if _MI == "empty":
return {} # old known-hub-reaching body
if _MI == "squad":
return {"squad": current_squad()}
if _MI == "userinfo":
return {"userInfo": user_info()}
if _MI == "settings":
return {"settings": SETTINGS}
return {"userInfo": user_info(), # squadList -> roster singleton
"squad": current_squad(), # personaId == PERSONA_ID
"settings": SETTINGS,
"userData": {}}
# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
@@ -145,12 +283,37 @@ def defs_route(h):
return 200, {"itemData": [item_def(i) for i in ids]}
def item_route(h):
# PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal
# screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0,
# "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry
# bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00).
# Returning [] here makes FIFA think the move failed -> kicks to main menu.
# GET stays defs_route (ViewCards / ConsumablesSearch / loan lists).
if h.command == "PUT":
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
req = body.get("itemData")
if isinstance(req, list):
moved = STORE.move_items(req)
if moved:
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
# Same shape as the proven-parseable GET itemData (no extra keys):
# adding an unexpected key (e.g. chemistry) desynced the parser
# and made FIFA report "failed to send to club" then log out.
return 200, {"itemData": moved}
return 200, {"itemData": []}
return defs_route(h)
G = r"/ut/game/[^/]+"
ROUTES = [
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
(re.compile(G + r"/item(\?|$)"), lambda m, h: defs_route(h)),
(re.compile(G + r"/item(\?|$)"), lambda m, h: item_route(h)),
# ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ----
(re.compile(r"/store/purchasegroup"), lambda m, h: store_catalog(h)),
(re.compile(r"/store/transaction"), lambda m, h: store_buy(h)),
@@ -170,18 +333,29 @@ ROUTES = [
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
# ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ----
# The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard
# freeze at boot). Re-enable only after the exact shape is reversed. The forged
# data still lives in fut_seed.py + MASSINFO/squad_route below (unrouted).
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
# /user/list + /user/accountinfo stay {} (nothing in them is load-bearing yet).
# /user, /squad and /userMassInfo serve real data again -- the squad object
# matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies.
(re.compile(G + r"/user/list"), lambda m, h: (200, {})),
(re.compile(G + r"/user/accountinfo"), lambda m, h: (200, {})),
(re.compile(G + r"/user$|" + G + r"/user\?"), lambda m, h: user_route(h)),
# LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list`
# -- the request-table strings only showed ut/%s/squad, so the static conclusion
# "there is NO separate squad-list URL" (REBUILD_PLAN S1b) was WRONG. This must
# precede the generic /squad route, which was swallowing it and returning the
# full active-squad object; the list parser 0x180142260 recognises ONLY
# squad(0x2cd) and skipped every one of those keys -> "MY SQUADS: 0".
(re.compile(G + r"/squad/list"), lambda m, h: (200, squad_list_body())),
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
# PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an
# UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct
# -- routed explicitly so it stops showing up as an unmapped hit in the log.
(re.compile(G + r"/match/reset"), lambda m, h: (200, {})),
(re.compile(G + r"/hub"), lambda m, h: (200, {})),
# STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}.
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)),
# Populated massinfo (see massinfo() above): userInfo + squad + settings.
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, massinfo())),
(re.compile(G + r"/season"), lambda m, h: (200, {})),
# ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §)
# tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
@@ -201,70 +375,84 @@ ROUTES = [
def user_route(h):
if h.command == "POST":
return 200, USER_POST
return 200, user_post()
if NEW_USER:
# accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
return 404, {}
return 200, USER_GET
return 200, user_get()
# ut/%s/squad carries the WHOLE squad family (request table 0x18021dfc0): GET =
# LoadActiveSquad AND GetSquadList/GetSquads, PUT = SaveCurrentSquad/SaveSquad.
# Load-vs-List is not resolvable statically (one binding row each, 0x18027cb70 /
# 0x18027be28) -- but the two parsers are mutually SKIP-tolerant: the squad-object
# parser 0x18013d1f0 does not recognise "squad"(0x2cd), and the list parser
# 0x180142260 recognises ONLY "squad". So a MERGED body satisfies both. Kept behind
# FUT_SQUAD_LIST=merged for now (GET /squad is boot-critical; default keeps the
# proven plain-squad body so a live test isolates one change at a time).
_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off")
def squad_route(h):
# GET = LoadActiveSquad, PUT = updateActiveSquad. Persist the squad the user
# builds so it survives relaunches. Never return {} (empty body resets the 23
# slots, 0x18013d1f0).
# PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
# exactly ONE key, id(0x15c) -> reply {"id": <squadId>}, NOT an echo of the
# squad (the echo's extra keys were merely SKIP'd, but the id was only present
# by luck of the client's own body).
if h.command == "PUT":
try:
sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
except Exception:
sq = None
if isinstance(sq, dict) and sq.get("players"):
sq.setdefault("id", 0)
STORE.save_squad(sq)
return 200, sq
saved = STORE.active_squad()
return 200, (STORE.reconstruct_squad(saved) if saved else SQUAD)
log(" SQUAD: saved squad id=%s (%d slots)" % (sq["id"], len(sq["players"])))
return 200, {"id": sq["id"]}
# Malformed/empty PUT: still answer with the id we hold, never {} -- the
# save handler needs the key and an empty body reads as squadId 0.
return 200, {"id": current_squad().get("id", 0)}
# GET = LoadActiveSquad: the full squad object, directly (no wrapper). Never
# return {} -- an empty body resets the 23 slots (0x18013d1f0).
sq = current_squad()
if _SQUAD_LIST_MODE == "merged":
sq = dict(sq, **squad_list_body(sq))
return 200, sq
# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
def store_catalog(h):
# GET store/purchasegroup/all. Root key MUST be "purchase" (atom 608, array);
# pack identity is "id" (int16, NOT packId); price is a "currencies" array of
# {name,funds,finalFunds}; display name is "description". (wf a245577b —
# {purchaseGroups:...} + packId/price were all unknown atoms => empty => "not
# available".) quantity:0 => unlimited.
# Parse format is verified correct ("purchase" array, per-pack 0x18013af30).
# The store still rejected minimal packs -> a pack must be COMPLETE to count as
# valid: content info + BOTH a coins price (currencies) and a FIFA-Points price
# (extPrice {finalPrice,originalPrice} -> {"mtx":N}).
packs = []
idx = 1
for p in PACK_CATALOG:
gold = p["gold"]
mtx = max(1, p["price"] // 100)
packs.append({
# assetId (atom 0x23) is the REAL pack identity the deser 0x18013af30
# reads (ENDPOINT_MAP store §). id/packType/quantity/saleType/isPremium
# are all unknown atoms -> SKIP (harmless no-ops, kept for readability).
"assetId": p["id"],
"id": p["id"],
"packType": "GOLD" if gold else "BRONZE",
"description": p["name"],
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": False,
"saleType": "PERMANENT",
"sortPriority": p["id"] - 100,
"sortPriority": idx,
"currencies": [{"name": "coins", "funds": p["price"], "finalFunds": p["price"]}],
# extPrice inner keys are amount(0x1b)/currency(0xc4), NOT mtx (skipped).
"extPrice": {"finalPrice": {"amount": mtx, "currency": "fifapoints"},
"originalPrice": {"amount": mtx, "currency": "fifapoints"}},
"extPrice": {"finalPrice": {"amount": mtx, "currency": "mtx"},
"originalPrice": {"amount": mtx, "currency": "mtx"}},
"packContentInfo": {
"bronzeQuantity": 0 if gold else p["count"],
"silverQuantity": 0,
"goldQuantity": p["count"] if gold else 0,
"rareQuantity": 1 if gold else 0,
"rareQuantity": p["count"] if gold else 0,
"itemQuantity": p["count"],
"unopened": False,
},
})
idx += 1
return 200, {"purchase": packs, "timestamp": 1596326400}
@@ -297,6 +485,34 @@ def store_buy(h):
def purchased_items(h):
# POST = FutPurchaseItemsServerResponse: the BUY itself. Live ground truth: FIFA
# sends {"packId":1,"useCredits":1,"usePreOrder":0,"currency":"COINS"} then
# immediately polls GET /purchased/items for the awarded cards. Open the pack
# here (debit coins, award items). Response mirrors CardsDLL's serializer
# (0x180126900): packId, firstPartyStoreId, groupName, productId,
# purchasePackType -- unknown keys are skipped, so extra fields are harmless.
# GET = FutGetPurchasedItemsServerResponse: the awarded items from the last buy.
if h.command == "POST":
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
pid = body.get("packId")
pack = pack_by_id(pid) if isinstance(pid, int) else None
if pack is None:
return 200, {"itemData": STORE.last_pack()}
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"])
if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
% (pack["name"], len(items), STORE.coins()))
return 200, {
"packId": pid,
"firstPartyStoreId": 0,
"groupName": "fifa17",
"productId": str(pack["id"]),
"purchasePackType": "GOLD" if pack["gold"] else "BRONZE",
}
return 200, {"itemData": STORE.last_pack()}