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}")