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:
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user