test(scripts): recover the client's error text, and diff hub shapes against production

Three diagnostics from chasing a FUT error that four server-side fixes failed to
resolve, kept because the technique generalises.

client-error-string.py recovers FIFA's on-screen message from /proc/<pid>/mem,
read-only, scanning ASCII and UTF-16LE (FIFA UI strings are wide). This ended the
guessing: the dialog reads "An error occurred downloading the FUT Squad Update.
Please try again." -- a CONTENT DOWNLOAD failure, not the player's lineup. Every
squad fix before it was aimed at the wrong subsystem, because "squad update" in FIFA
means the roster update, and the server-side symptom (a squad the client would not
accept) was consistent with both readings. When the server says 200 and the client
says no, the client's own words are the cheapest evidence available and should have
been the FIRST thing recovered, not the fifth.

hub-dump.py + hub-diff-prod-staging.py diff every hub route between production
(known-good, same client accepts it) and staging, comparing key presence and JSON
types rather than values, since values legitimately differ. Result: 0 structural
differences across 14 routes, which retired the whole "a missing field breaks
bootstrap" line of investigation in one run instead of one restart at a time.

Also ruled out with evidence: cert gates ARE patched (autopatch logs
"pid 56298: PATCHED cert gates", and the gate bytes read back as the patched
patterns); the roster server serves the FUT Squad Update fine (TLS1.2
AES256-GCM-SHA384, HTTP/1.0 200, application/xml) once probed with
ALL:@SECLEVEL=0 -- a default modern context gets SSLV3_ALERT_HANDSHAKE_FAILURE and
would have been a false alarm; production and staging Blaze advertise identical
roster/POW hosts; the Blaze session is healthy and answering PINGs; and the squad
round-trips exactly through PUT/GET.
This commit is contained in:
funman300
2026-08-18 04:30:08 +00:00
parent 022634704a
commit 16771b0b33
3 changed files with 231 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Recover FIFA's on-screen error text from the live client, read-only.
Diagnosing this squad error from the server side has failed repeatedly: the host
answers 200/ok for every request, the squad round-trips exactly, and its shape now
matches production's known-good squad field for field. So the message the client is
actually showing is the missing evidence.
Scans readable regions of /proc/<pid>/mem for candidate substrings in both ASCII and
UTF-16LE (FIFA UI strings are typically wide), and prints the surrounding text so the
full sentence and any error code come out. Opens the memory O_RDONLY and only ever
pread()s -- it cannot perturb the process.
"""
import os
import re
import sys
NEEDLES = [b"quad Update", b"quad update", b"QUAD_UPDATE", b"quad_update",
b"pdate your squad", b"pdating squad", b"quad Management",
b"nable to update", b"FUT_ERR", b"SQUAD_ERR"]
MAX_REGION = 96 * 1024 * 1024 # skip absurd regions; the UI heap is not that big
CONTEXT = 140
def pid_of(name="FIFA17.exe"):
for d in os.listdir("/proc"):
if not d.isdigit():
continue
try:
if open(f"/proc/{d}/comm").read().strip() == name:
return int(d)
except OSError:
continue
return None
def wide(b):
"""UTF-16LE form of an ASCII needle."""
return b"".join(bytes([c, 0]) for c in b)
def regions(pid):
out = []
for line in open(f"/proc/{pid}/maps"):
parts = line.split()
if len(parts) < 2 or "r" not in parts[1]:
continue
lo, _, hi = parts[0].partition("-")
lo, hi = int(lo, 16), int(hi, 16)
size = hi - lo
if 0 < size <= MAX_REGION:
out.append((lo, size, parts[-1] if len(parts) > 5 else ""))
return out
def render(buf, pos, is_wide):
lo = max(0, pos - CONTEXT)
hi = min(len(buf), pos + CONTEXT)
chunk = buf[lo:hi]
if is_wide:
try:
txt = chunk.decode("utf-16le", errors="replace")
except Exception:
txt = repr(chunk)
else:
txt = chunk.decode("latin-1", errors="replace")
txt = re.sub(r"[^\x20-\x7e]+", " ", txt)
return re.sub(r"\s{2,}", " ", txt).strip()
def main():
pid = pid_of()
if not pid:
print("FIFA17.exe not running")
return 1
print(f"scanning pid {pid}")
targets = [(n, False) for n in NEEDLES] + [(wide(n), True) for n in NEEDLES]
hits, scanned = [], 0
fd = os.open(f"/proc/{pid}/mem", os.O_RDONLY)
try:
for lo, size, path in regions(pid):
try:
buf = os.pread(fd, size, lo)
except OSError:
continue
scanned += size
for needle, is_wide in targets:
start = 0
while True:
p = buf.find(needle, start)
if p < 0:
break
hits.append((lo + p, is_wide, render(buf, p, is_wide), path))
start = p + 1
if len(hits) > 60:
break
finally:
os.close(fd)
print(f"scanned {scanned // (1024*1024)} MiB, {len(hits)} hit(s)\n")
seen = set()
for addr, is_wide, txt, path in hits:
key = txt[:110]
if key in seen:
continue
seen.add(key)
kind = "utf16" if is_wide else "ascii"
print(f"0x{addr:x} [{kind}] {path}")
print(f" {txt}\n")
return 0
if __name__ == "__main__":
sys.exit(main())