From 16771b0b33562007013bbb5eea20c92e295269ee Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 04:30:08 +0000 Subject: [PATCH] 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//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. --- scripts/client-error-string.py | 113 +++++++++++++++++++++++++++++++ scripts/hub-diff-prod-staging.py | 62 +++++++++++++++++ scripts/hub-dump.py | 56 +++++++++++++++ 3 files changed, 231 insertions(+) create mode 100755 scripts/client-error-string.py create mode 100755 scripts/hub-diff-prod-staging.py create mode 100755 scripts/hub-dump.py diff --git a/scripts/client-error-string.py b/scripts/client-error-string.py new file mode 100755 index 0000000..d8739d6 --- /dev/null +++ b/scripts/client-error-string.py @@ -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//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()) diff --git a/scripts/hub-diff-prod-staging.py b/scripts/hub-diff-prod-staging.py new file mode 100755 index 0000000..ca3575c --- /dev/null +++ b/scripts/hub-diff-prod-staging.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Diff hub-route SHAPES between production (known-good) and staging. + +Values legitimately differ (different clubs, different piles). What must not differ is +structure: a key production sends that staging omits, or a type/null mismatch, is a +candidate cause for the client rejecting FUT bootstrap. +""" +import json +import os + +PROD, STAG = "/tmp/hub_prod", "/tmp/hub_stag" + + +def t(v): + return "null" if v is None else type(v).__name__ + + +def shape(obj, prefix="", depth=0): + """path -> type, descending into the FIRST element of lists (representative).""" + out = {} + if depth > 6: + return out + if isinstance(obj, dict): + for k, v in obj.items(): + p = f"{prefix}.{k}" if prefix else k + out[p] = t(v) + out.update(shape(v, p, depth + 1)) + elif isinstance(obj, list): + out[(prefix or "") + "[]"] = "list" + if obj: + out.update(shape(obj[0], f"{prefix}[0]", depth + 1)) + return out + + +total = 0 +for name in sorted(os.listdir(PROD)): + pf, sf = os.path.join(PROD, name), os.path.join(STAG, name) + if not os.path.exists(sf): + print(f"{name}: MISSING on staging") + continue + p = json.load(open(pf)) + s = json.load(open(sf)) + ps, ss = shape(p.get("body")), shape(s.get("body")) + missing = [k for k in ps if k not in ss] + types = [(k, ps[k], ss[k]) for k in ps if k in ss and ps[k] != ss[k]] + nulls = [k for k in ps if k in ss and ss[k] == "null" and ps[k] != "null"] + if p.get("status") != s.get("status"): + print(f"\n### {name}: STATUS prod={p.get('status')} staging={s.get('status')}") + total += 1 + if missing or types: + print(f"\n### {name}") + for k in missing: + print(f" MISSING on staging: {k:<44} prod_type={ps[k]}") + for k, a, b in types: + mark = " <-- NULL" if b == "null" else "" + print(f" TYPE {k:<48} prod={a:<7} staging={b}{mark}") + total += len(missing) + len(types) + +print(f"\n=== {total} structural difference(s) across {len(os.listdir(PROD))} routes ===") +if total == 0: + print("Every hub route matches production's shape. The bootstrap failure is not a") + print("missing/mistyped field in these responses.") diff --git a/scripts/hub-dump.py b/scripts/hub-dump.py new file mode 100755 index 0000000..d6bb109 --- /dev/null +++ b/scripts/hub-dump.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Dump every FUT-hub route from one UTAS to a directory, for prod-vs-staging shape diffing. + +Usage: dump_hub.py +""" +import json +import os +import socket +import sys + +ROUTES = { + "user_accountinfo": "/ut/game/fifa17/user/accountinfo", + "squad_active": "/ut/game/fifa17/squad/active", + "squad_0": "/ut/game/fifa17/squad/0", + "club": "/ut/game/fifa17/club?count=20", + "club_stats_staff": "/ut/game/fifa17/club/stats/staff", + "club_stats_club": "/ut/game/fifa17/club/stats/club", + "clientdata_store": "/ut/game/fifa17/clientdata/store", + "purchased_items": "/ut/game/fifa17/purchased/items", + "tradepile": "/ut/game/fifa17/tradePile", + "tradepile_counts": "/ut/game/fifa17/tradePile/counts", + "watchlist": "/ut/game/fifa17/watchList", + "trade_status": "/ut/game/fifa17/trade/status", + "usermassinfo": "/ut/game/fifa17/userMassInfo", + "settings": "/ut/game/fifa17/settings", +} + +port, outdir = int(sys.argv[1]), sys.argv[2] +os.makedirs(outdir, exist_ok=True) + +for name, path in ROUTES.items(): + try: + s = socket.create_connection(("127.0.0.1", port), timeout=25) + s.sendall((f"GET {path} HTTP/1.1\r\nHost: x\r\n" + "X-OpenFUT-Game: fifa17\r\nConnection: close\r\n\r\n").encode()) + buf = b"" + while True: + c = s.recv(65536) + if not c: + break + buf += c + s.close() + head, _, body = buf.partition(b"\r\n\r\n") + status = head.split(b" ")[1].decode() + rec = {"status": status} + try: + rec["body"] = json.loads(body) + except Exception: + rec["body"] = None + rec["raw"] = body[:200].decode(errors="replace") + with open(os.path.join(outdir, f"{name}.json"), "w") as f: + json.dump(rec, f) + n = len(json.dumps(rec.get("body"))) if rec.get("body") is not None else 0 + print(f" {status} {name:<18} {n} bytes") + except Exception as e: + print(f" ERR {name:<18} {type(e).__name__}: {e}")