Created
March 19, 2026 14:48
-
-
Save vargo/bbd5a272dca55679ad0fa60eac9eed05 to your computer and use it in GitHub Desktop.
Arc Raiders (game) python script for crash report analysis (test on linux, might work on others). Usage: `python arc_crash_analyser.py {full-path-to-crashdir-see-code-DEFAULT_CRASH_ROOT}`
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| DISCLAIMER: | |
| I used an AI coding assistent to quickly cook this up and applied a few tweaks. | |
| It seems to work (linux), but won't guarantee there aren't any bugs, nor will I provide support. | |
| Arc Raiders / UE5 Crash Folder Analyser | |
| ======================================== | |
| Scans all crash folders under a given path, parses: | |
| - CrashContext.runtime-xml | |
| - UEMinidump.dmp (minidump binary) | |
| - Breadcrumbs_RHIThread_0.txt (if present) | |
| Prints a condensed per-crash summary and an aggregate statistics table. | |
| Usage: | |
| python3 arc_crash_analyser.py [crash_root] | |
| Default crash_root (snap Steam install): | |
| ~/snap/steam/common/.local/share/Steam/steamapps/compatdata/1808500/pfx/ | |
| drive_c/users/steamuser/AppData/Local/PioneerGame/Saved/Crashes | |
| """ | |
| import os | |
| import sys | |
| import struct | |
| import json | |
| import re | |
| import xml.etree.ElementTree as ET | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from collections import defaultdict, Counter | |
| # ───────────────────────────────────────────── | |
| # DEFAULT CRASH ROOT | |
| # ───────────────────────────────────────────── | |
| # ~/snap/steam/common/.local/share/Steam/steamapps/compatdata/1808500/pfx/drive_c/users/steamuser/AppData/Local/PioneerGame/Saved/Crashes | |
| DEFAULT_CRASH_ROOT = Path.home() / ( | |
| "/home/<USER>/snap/steam/common/.local/share/Steam/steamapps/compatdata" | |
| "/1808500/pfx/drive_c/users/steamuser/AppData/Local" | |
| "/PioneerGame/Saved/Crashes" | |
| ) | |
| # ───────────────────────────────────────────── | |
| # COLOURS (disabled when not a tty) | |
| # ───────────────────────────────────────────── | |
| USE_COLOR = sys.stdout.isatty() | |
| def c(code, text): | |
| return f"\033[{code}m{text}\033[0m" if USE_COLOR else text | |
| RED = lambda t: c("31;1", t) | |
| YELLOW = lambda t: c("33;1", t) | |
| GREEN = lambda t: c("32;1", t) | |
| CYAN = lambda t: c("36;1", t) | |
| BOLD = lambda t: c("1", t) | |
| DIM = lambda t: c("2", t) | |
| # ───────────────────────────────────────────── | |
| # WINDOWS FILETIME → datetime | |
| # ───────────────────────────────────────────── | |
| _WIN_EPOCH = datetime(1601, 1, 1) | |
| def filetime_to_dt(ft: int) -> datetime: | |
| return _WIN_EPOCH + timedelta(microseconds=ft // 10) | |
| # ───────────────────────────────────────────── | |
| # EXCEPTION CODE LOOKUP | |
| # ───────────────────────────────────────────── | |
| EXC_CODES = { | |
| 0xC0000005: "ACCESS_VIOLATION", | |
| 0xC0000094: "INT_DIVIDE_BY_ZERO", | |
| 0xC00000FD: "STACK_OVERFLOW", | |
| 0x80000003: "BREAKPOINT", | |
| 0xC0000096: "PRIV_INSTRUCTION", | |
| 0xC000001D: "ILLEGAL_INSTRUCTION", | |
| 0xC0000409: "STACK_BUFFER_OVERRUN", | |
| 0xC0000374: "HEAP_CORRUPTION", | |
| } | |
| ACCESS_TYPES = {0: "READ", 1: "WRITE", 8: "DEP/EXECUTE"} | |
| # ───────────────────────────────────────────── | |
| # MINIDUMP PARSER | |
| # ───────────────────────────────────────────── | |
| def parse_minidump(path: Path) -> dict: | |
| """ | |
| Returns a dict with keys: | |
| timestamp, num_threads, crashed_thread_id, exc_code, exc_name, | |
| exc_address, access_type, faulting_address, rip_offset, | |
| modules (list of dicts with name/base/size) | |
| """ | |
| result = {} | |
| try: | |
| data = path.read_bytes() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| if data[:4] != b"MDMP": | |
| return {"error": "not a minidump"} | |
| # Header | |
| num_streams = struct.unpack_from("<I", data, 8)[0] | |
| stream_rva = struct.unpack_from("<I", data, 12)[0] | |
| result["timestamp"] = struct.unpack_from("<I", data, 20)[0] | |
| # Stream directory | |
| streams = {} | |
| for i in range(num_streams): | |
| off = stream_rva + i * 12 | |
| stype, ssize, srva = struct.unpack_from("<III", data, off) | |
| streams[stype] = (ssize, srva) | |
| def rq(off): return struct.unpack_from("<Q", data, off)[0] | |
| def ri(off): return struct.unpack_from("<I", data, off)[0] | |
| def rh(off): return struct.unpack_from("<H", data, off)[0] | |
| # ── Exception stream (type 6) ────────────────────────────────── | |
| if 6 in streams: | |
| _, erva = streams[6] | |
| thread_id = ri(erva) | |
| result["crashed_thread_id"] = thread_id | |
| exc_off = erva + 8 | |
| exc_code = ri(exc_off) | |
| exc_addr = rq(exc_off + 16) | |
| num_p = ri(exc_off + 32) | |
| params = [rq(exc_off + 40 + k * 8) for k in range(min(num_p, 15))] | |
| result["exc_code"] = exc_code | |
| result["exc_name"] = EXC_CODES.get(exc_code, f"{exc_code:#010x}") | |
| result["exc_address"] = exc_addr | |
| result["access_type"] = ACCESS_TYPES.get(params[0], str(params[0])) if params else "N/A" | |
| result["faulting_address"] = params[1] if len(params) > 1 else None | |
| # RIP offset from PioneerGame base | |
| image_base = 0x140000000 | |
| if image_base <= exc_addr < image_base + 0x10000000: | |
| result["rip_offset"] = exc_addr - image_base | |
| else: | |
| result["rip_offset"] = None | |
| # ── Thread list stream (type 3) ──────────────────────────────── | |
| if 3 in streams: | |
| _, trva = streams[3] | |
| num_threads = ri(trva) | |
| result["num_threads"] = num_threads | |
| # ── Module list stream (type 4) ──────────────────────────────── | |
| modules = [] | |
| if 4 in streams: | |
| _, mrva = streams[4] | |
| num_mods = ri(mrva) | |
| for i in range(num_mods): | |
| off = mrva + 4 + i * 108 | |
| base = rq(off) | |
| size = ri(off + 8) | |
| name_rva = ri(off + 20) | |
| if name_rva > 0 and name_rva < len(data): | |
| slen = ri(name_rva) | |
| raw = data[name_rva + 4 : name_rva + 4 + slen] | |
| try: | |
| name = raw.decode("utf-16-le").rstrip("\x00") | |
| except Exception: | |
| name = "<decode error>" | |
| else: | |
| name = "<none>" | |
| short = name.split("\\")[-1] if "\\" in name else name | |
| modules.append({"name": short, "base": base, "size_kb": size // 1024}) | |
| result["modules"] = modules | |
| return result | |
| # ───────────────────────────────────────────── | |
| # CRASHCONTEXT PARSER | |
| # ───────────────────────────────────────────── | |
| def parse_crash_context(path: Path) -> dict: | |
| result = {} | |
| try: | |
| tree = ET.parse(path) | |
| root = tree.getroot() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| rt = root.find("RuntimeProperties") | |
| if rt is None: | |
| return {"error": "no RuntimeProperties"} | |
| def get(tag, default=""): | |
| el = rt.find(tag) | |
| return el.text.strip() if el is not None and el.text else default | |
| result["crash_guid"] = get("CrashGUID") | |
| result["crash_type"] = get("CrashType") | |
| result["error_message"] = get("ErrorMessage") | |
| result["engine_version"] = get("EngineVersion") | |
| result["build_version"] = get("BuildVersion") | |
| result["game_state"] = get("GameStateName") | |
| result["seconds_running"] = get("SecondsSinceStart") | |
| result["is_oom"] = get("MemoryStats.bIsOOM") | |
| result["is_assert"] = get("IsAssert") | |
| result["is_ensure"] = get("IsEnsure") | |
| result["is_exit_requested"] = get("IsRequestingExit") | |
| result["cpu"] = get("Misc.CPUBrand") | |
| result["gpu"] = get("Misc.PrimaryGPUBrand") | |
| result["os"] = get("Misc.OSVersionMajor") | |
| result["time_of_crash_raw"] = get("TimeOfCrash") | |
| result["callstack_hash"] = get("PCallStack") | |
| result["callstack_full"] = get("PCallStackFull") | |
| result["gpu_breadcrumbs"] = get("GPUBreadCrumbs") | |
| result["num_cores"] = get("Misc.NumberOfCores") | |
| result["map_name"] = "" | |
| # Extract map name from Sentry breadcrumbs in GameData | |
| gd = root.find("GameData") | |
| if gd is not None: | |
| sentry_el = gd.find("__sentry") | |
| if sentry_el is not None and sentry_el.text: | |
| # Unescape HTML entities | |
| raw = sentry_el.text.replace(""", '"').replace("&", "&") | |
| try: | |
| sentry = json.loads(raw) | |
| tags = sentry.get("tags", {}) | |
| result["map_name"] = tags.get("mapname", "") | |
| result["is_oom_tag"] = tags.get("memory.IsOOM", "") | |
| result["peak_ram_gb"] = tags.get("memory.PeakUsedPhysical", "") | |
| result["avail_ram_gb"] = tags.get("memory.AvailablePhysical", "") | |
| result["gpu_driver"] = tags.get("gpu.UserDriverVersion", "") | |
| result["wine_version"] = tags.get("Wine.Version", "") | |
| result["settings_quality"] = tags.get("settings.overallquality", "") | |
| result["settings_rtxgi"] = tags.get("settings.rtxgi", "") | |
| result["settings_api"] = tags.get("settings.graphicsapi", "") | |
| result["is_pso"] = tags.get("IsCompilingPSOs", "") | |
| # Breadcrumb trail (map transitions) | |
| crumbs = sentry.get("breadcrumbs", []) | |
| result["breadcrumbs"] = [ | |
| {"msg": b.get("message",""), "ts": b.get("timestamp",0)} | |
| for b in crumbs | |
| ] | |
| except Exception: | |
| pass | |
| # Thread list — find crashed thread name | |
| threads = rt.find("Threads") | |
| result["crashed_thread_name"] = "Unknown" | |
| if threads is not None: | |
| for t in threads.findall("Thread"): | |
| crashed_el = t.find("IsCrashed") | |
| if crashed_el is not None and crashed_el.text == "true": | |
| name_el = t.find("ThreadName") | |
| if name_el is not None: | |
| result["crashed_thread_name"] = name_el.text or "Unknown" | |
| break | |
| # RHI info from EngineData | |
| ed = root.find("EngineData") | |
| if ed is not None: | |
| def eget(tag, default=""): | |
| el = ed.find(tag) | |
| return el.text.strip() if el is not None and el.text else default | |
| result["rhi_name"] = eget("RHI.RHIName") | |
| result["gpu_adapter"] = eget("RHI.AdapterName") | |
| result["driver_version"] = eget("RHI.UserDriverVersion") | |
| result["driver_date"] = eget("RHI.DriverDate") | |
| result["feature_level"] = eget("RHI.FeatureLevel") | |
| result["dred_enabled"] = eget("RHI.DRED") | |
| result["aftermath"] = eget("RHI.Aftermath") | |
| result["breadcrumbs_rhi"] = eget("RHI.Breadcrumbs") | |
| return result | |
| # ───────────────────────────────────────────── | |
| # BREADCRUMBS FILE PARSER | |
| # ───────────────────────────────────────────── | |
| def parse_breadcrumbs(path: Path) -> str: | |
| try: | |
| text = path.read_text(errors="replace").strip() | |
| return text if text else "(empty)" | |
| except Exception as e: | |
| return f"(read error: {e})" | |
| # ───────────────────────────────────────────── | |
| # INTERESTING MODULE FILTER | |
| # ───────────────────────────────────────────── | |
| INTERESTING_KEYWORDS = [ | |
| "dlss", "nvngx", "nvidia", "eac", "easyantiCheat", | |
| "physx", "xess", "fsr", "streamline", "sl_", | |
| "discord", "cerebro", "aftermath", "nvapi", | |
| ] | |
| def flag_modules(modules: list) -> list: | |
| flagged = [] | |
| for m in modules: | |
| low = m["name"].lower() | |
| if any(k in low for k in INTERESTING_KEYWORDS): | |
| flagged.append(m) | |
| return flagged | |
| # ───────────────────────────────────────────── | |
| # CALLSTACK PRETTY PRINTER | |
| # ───────────────────────────────────────────── | |
| def format_callstack(raw: str) -> list[str]: | |
| """Parse PCallStackFull into readable lines.""" | |
| if not raw: | |
| return [] | |
| frames = [] | |
| for token in raw.split(): | |
| if token.startswith("PioneerGame") or token.startswith("kernel") or token.startswith("ntdll"): | |
| frames.append(token) | |
| elif token.startswith("0x") and frames: | |
| frames[-1] += f" @ {token}" | |
| elif token == "+" and frames: | |
| frames[-1] += " +" | |
| else: | |
| if frames: | |
| frames[-1] += f" {token}" | |
| else: | |
| frames.append(token) | |
| # Rejoin properly | |
| result = [] | |
| combined = raw | |
| # Simple split by known module names | |
| parts = re.split(r'(?=(?:PioneerGame|kernel32|ntdll|user32)\s)', combined) | |
| for p in parts: | |
| p = p.strip() | |
| if p: | |
| result.append(p) | |
| return result | |
| # ───────────────────────────────────────────── | |
| # SINGLE CRASH FOLDER PROCESSOR | |
| # ───────────────────────────────────────────── | |
| def process_crash_folder(folder: Path) -> dict: | |
| """Returns merged info dict for one crash folder.""" | |
| info = {"folder": folder.name, "folder_path": str(folder)} | |
| ctx_file = folder / "CrashContext.runtime-xml" | |
| dmp_file = folder / "UEMinidump.dmp" | |
| bc_file = folder / "Breadcrumbs_RHIThread_0.txt" | |
| info["has_context"] = ctx_file.exists() | |
| info["has_minidump"] = dmp_file.exists() | |
| info["has_breadcrumbs"] = bc_file.exists() | |
| if info["has_context"]: | |
| info.update(parse_crash_context(ctx_file)) | |
| if info["has_minidump"]: | |
| dmp = parse_minidump(dmp_file) | |
| info["dmp"] = dmp | |
| if info["has_breadcrumbs"]: | |
| info["breadcrumbs_rhi_file"] = parse_breadcrumbs(bc_file) | |
| # Parse folder mtime as fallback timestamp | |
| info["folder_mtime"] = datetime.fromtimestamp(folder.stat().st_mtime) | |
| return info | |
| # ───────────────────────────────────────────── | |
| # FORMAT A SINGLE CRASH REPORT | |
| # ───────────────────────────────────────────── | |
| def format_crash(info: dict, index: int, total: int) -> str: | |
| lines = [] | |
| sep = "─" * 72 | |
| sep2 = "━" * 72 | |
| crash_type = info.get("crash_type", "?") | |
| ct_color = RED(crash_type) if crash_type == "Crash" else YELLOW(crash_type) | |
| # Time | |
| time_str = "?" | |
| toc_raw = info.get("time_of_crash_raw", "") | |
| if toc_raw and toc_raw.isdigit(): | |
| try: | |
| time_str = filetime_to_dt(int(toc_raw)).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| except Exception: | |
| time_str = toc_raw | |
| elif "folder_mtime" in info: | |
| time_str = info["folder_mtime"].strftime("%Y-%m-%d %H:%M:%S (folder mtime)") | |
| lines.append(BOLD(sep2)) | |
| lines.append(BOLD(f" CRASH {index}/{total} [{ct_color}] {time_str}")) | |
| lines.append(BOLD(sep2)) | |
| lines.append(f" Folder : {DIM(info['folder'])}") | |
| lines.append(f" GUID : {info.get('crash_guid', '?')}") | |
| lines.append(f" Build : {info.get('build_version', '?')} / Engine: {info.get('engine_version','?')}") | |
| secs = info.get("seconds_running", "") | |
| if secs: | |
| m, s = divmod(int(secs), 60) | |
| lines.append(f" Uptime : {m}m {s}s ({secs}s)") | |
| lines.append("") | |
| # ── Crash details ── | |
| lines.append(CYAN(" ── Crash ──────────────────────────────────────────────────────")) | |
| lines.append(f" Error : {RED(info.get('error_message','?'))}") | |
| lines.append(f" Thread : {info.get('crashed_thread_name','?')}") | |
| dmp = info.get("dmp", {}) | |
| if dmp and not dmp.get("error"): | |
| exc_name = dmp.get("exc_name", "?") | |
| exc_addr = dmp.get("exc_address", 0) | |
| fault_addr = dmp.get("faulting_address") | |
| access = dmp.get("access_type", "?") | |
| rip_off = dmp.get("rip_offset") | |
| lines.append(f" Exception: {RED(exc_name)}") | |
| lines.append(f" Exc Addr : {exc_addr:#018x}" + (f" (PioneerGame +{rip_off:#010x})" if rip_off else "")) | |
| if fault_addr is not None: | |
| lines.append(f" Fault At : {fault_addr:#018x} ({access})") | |
| lines.append(f" Threads : {dmp.get('num_threads','?')} total") | |
| elif dmp.get("error"): | |
| lines.append(f" Dump : {YELLOW('(parse error: ' + dmp['error'] + ')')}") | |
| else: | |
| lines.append(f" Dump : {DIM('(no minidump)')}") | |
| lines.append(f" Callstack: {info.get('callstack_hash','?')}") | |
| cs_raw = info.get("callstack_full", "") | |
| if cs_raw: | |
| frames = format_callstack(cs_raw) | |
| for i, f in enumerate(frames[:8]): | |
| prefix = " !! " if i == 0 else f" {i:2d} " | |
| color = RED if i == 0 else DIM | |
| lines.append(color(prefix + f)) | |
| if len(frames) > 8: | |
| lines.append(DIM(f" ... ({len(frames)-8} more frames)")) | |
| lines.append("") | |
| # ── Game state ── | |
| lines.append(CYAN(" ── Game State ─────────────────────────────────────────────────")) | |
| lines.append(f" Map : {info.get('map_name','?')}") | |
| lines.append(f" State : {info.get('game_state','?')}") | |
| lines.append(f" GPU BC : {info.get('gpu_breadcrumbs','?')}") | |
| lines.append(f" OOM : {info.get('is_oom','?')} / {info.get('is_oom_tag','?')}") | |
| lines.append(f" Exit req : {info.get('is_exit_requested','?')}") | |
| lines.append(f" PSO comp : {info.get('is_pso','?')}") | |
| # Breadcrumb trail (map transitions) | |
| crumbs = info.get("breadcrumbs", []) | |
| if crumbs: | |
| lines.append(" Map trail:") | |
| for b in crumbs[-6:]: | |
| ts = datetime.fromtimestamp(b["ts"]).strftime("%H:%M:%S") if b["ts"] else "?" | |
| lines.append(DIM(f" [{ts}] {b['msg']}")) | |
| lines.append("") | |
| # ── Hardware / Driver ── | |
| lines.append(CYAN(" ── Hardware & Driver ──────────────────────────────────────────")) | |
| lines.append(f" CPU : {info.get('cpu','?')}") | |
| lines.append(f" GPU : {info.get('gpu_adapter', info.get('gpu','?'))}") | |
| lines.append(f" Driver : {info.get('driver_version', info.get('gpu_driver','?'))} ({info.get('driver_date','?')})") | |
| lines.append(f" RHI : {info.get('rhi_name','?')} FeatureLevel={info.get('feature_level','?')}") | |
| lines.append(f" Wine : {info.get('wine_version','?')}") | |
| lines.append(f" RAM peak : {info.get('peak_ram_gb','?')} GB / free={info.get('avail_ram_gb','?')} GB") | |
| lines.append("") | |
| # ── Settings ── | |
| lines.append(CYAN(" ── Graphics Settings ──────────────────────────────────────────")) | |
| lines.append(f" Quality : {info.get('settings_quality','?')}") | |
| lines.append(f" RTX GI : {YELLOW(info.get('settings_rtxgi','?'))}") | |
| lines.append(f" API : {info.get('settings_api','?')}") | |
| lines.append(f" Aftermath: {info.get('aftermath','?')} DRED={info.get('dred_enabled','?')}") | |
| lines.append(f" Breadcrbs: {info.get('breadcrumbs_rhi','?')}") | |
| lines.append("") | |
| # ── Interesting modules ── | |
| mods = dmp.get("modules", []) if dmp else [] | |
| flagged = flag_modules(mods) | |
| if flagged: | |
| lines.append(CYAN(" ── Notable Modules ────────────────────────────────────────────")) | |
| for m in sorted(flagged, key=lambda x: x["size_kb"], reverse=True): | |
| size_str = f"{m['size_kb']:,} KB" | |
| warning = "" | |
| low = m["name"].lower() | |
| if "dlssd" in low: | |
| warning = RED(" ← DLSS Ray Reconstruction (experimental!)") | |
| elif "dlss" in low and "dlssg" not in low: | |
| warning = YELLOW(" ← DLSS SR") | |
| elif "discord" in low: | |
| warning = YELLOW(" ← Discord SDK") | |
| elif "aftermath" in low: | |
| warning = DIM(" ← GPU diagnostics (disabled)") | |
| lines.append(f" {m['name']:<40} {size_str:>10}{warning}") | |
| lines.append("") | |
| # ── RHI breadcrumbs file ── | |
| bc_content = info.get("breadcrumbs_rhi_file") | |
| if bc_content: | |
| lines.append(CYAN(" ── Breadcrumbs_RHIThread_0.txt ────────────────────────────────")) | |
| for bl in bc_content.splitlines()[:20]: | |
| lines.append(f" {bl}") | |
| lines.append("") | |
| return "\n".join(lines) | |
| # ───────────────────────────────────────────── | |
| # AGGREGATE STATS | |
| # ───────────────────────────────────────────── | |
| def print_aggregate(crashes: list[dict]) -> None: | |
| print(BOLD("━" * 72)) | |
| print(BOLD(" AGGREGATE STATISTICS")) | |
| print(BOLD("━" * 72)) | |
| total = len(crashes) | |
| print(f" Total crash folders analysed : {BOLD(str(total))}") | |
| # Crash types | |
| types = Counter(c.get("crash_type","?") for c in crashes) | |
| print(f" Crash types : " + ", ".join(f"{k}={v}" for k,v in types.items())) | |
| # Unique callstack hashes | |
| hashes = [c.get("callstack_hash","") for c in crashes if c.get("callstack_hash")] | |
| unique_hashes = set(hashes) | |
| print(f" Unique callstack hashes : {len(unique_hashes)}") | |
| hash_counts = Counter(hashes) | |
| for h, cnt in hash_counts.most_common(5): | |
| print(f" {cnt:3}× {h}") | |
| # Crash times and intervals | |
| times = [] | |
| for c in crashes: | |
| toc = c.get("time_of_crash_raw","") | |
| if toc and toc.isdigit(): | |
| try: | |
| times.append(filetime_to_dt(int(toc))) | |
| except Exception: | |
| pass | |
| times.sort() | |
| if len(times) >= 2: | |
| intervals = [(times[i+1]-times[i]).total_seconds() for i in range(len(times)-1)] | |
| avg_interval = sum(intervals)/len(intervals) | |
| min_interval = min(intervals) | |
| max_interval = max(intervals) | |
| print(f" Time between crashes : avg={avg_interval/60:.1f}m min={min_interval:.0f}s max={max_interval/60:.1f}m") | |
| # Uptime at crash | |
| uptimes = [] | |
| for c in crashes: | |
| s = c.get("seconds_running","") | |
| if s and s.isdigit(): | |
| uptimes.append(int(s)) | |
| if uptimes: | |
| avg_up = sum(uptimes)/len(uptimes) | |
| print(f" Seconds running at crash : avg={avg_up:.0f}s ({avg_up/60:.1f}m) min={min(uptimes)}s max={max(uptimes)}s") | |
| # Maps | |
| maps = Counter(c.get("map_name","?") for c in crashes if c.get("map_name")) | |
| if maps: | |
| print(f" Maps at crash time : " + ", ".join(f"{k}={v}" for k,v in maps.most_common())) | |
| # Game states | |
| states = Counter(c.get("game_state","?") for c in crashes if c.get("game_state")) | |
| if states: | |
| print(f" Game states : " + ", ".join(f"{k}={v}" for k,v in states.most_common())) | |
| # Faulting addresses | |
| fault_addrs = Counter() | |
| rip_offsets = Counter() | |
| for c in crashes: | |
| dmp = c.get("dmp", {}) | |
| fa = dmp.get("faulting_address") | |
| ro = dmp.get("rip_offset") | |
| if fa is not None: | |
| fault_addrs[f"{fa:#018x}"] += 1 | |
| if ro is not None: | |
| rip_offsets[f"+{ro:#010x}"] += 1 | |
| if fault_addrs: | |
| print(f" Faulting addresses : " + ", ".join(f"{k}={v}" for k,v in fault_addrs.most_common())) | |
| if rip_offsets: | |
| print(f" Crash instruction (RIP offs) : " + ", ".join(f"{k}={v}" for k,v in rip_offsets.most_common())) | |
| # RTX GI settings | |
| rtxgi = Counter(c.get("settings_rtxgi","?") for c in crashes if c.get("settings_rtxgi")) | |
| if rtxgi: | |
| print(f" RTX GI quality at crash : " + ", ".join(f"{k}={v}" for k,v in rtxgi.most_common())) | |
| # OOM | |
| ooms = sum(1 for c in crashes if c.get("is_oom_tag") == "True") | |
| print(f" OOM crashes : {ooms}/{total}") | |
| # Minidump presence | |
| has_dmp = sum(1 for c in crashes if c.get("has_minidump")) | |
| has_bc = sum(1 for c in crashes if c.get("has_breadcrumbs")) | |
| print(f" Crashes with minidump : {has_dmp}/{total}") | |
| print(f" Crashes with RHI breadcrumbs : {has_bc}/{total}") | |
| print(BOLD("━" * 72)) | |
| print() | |
| # ───────────────────────────────────────────── | |
| # MAIN | |
| # ───────────────────────────────────────────── | |
| def main(): | |
| crash_root = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_CRASH_ROOT | |
| if not crash_root.exists(): | |
| print(RED(f"ERROR: Crash root not found: {crash_root}")) | |
| print(f"Usage: {sys.argv[0]} [crash_root_path]") | |
| sys.exit(1) | |
| # Find all crash folders (UECC-Windows-* pattern) | |
| folders = sorted( | |
| [f for f in crash_root.iterdir() if f.is_dir() and f.name.startswith("UECC-")], | |
| key=lambda f: f.stat().st_mtime | |
| ) | |
| if not folders: | |
| print(YELLOW(f"No crash folders found under {crash_root}")) | |
| sys.exit(0) | |
| print(BOLD(f"\n Arc Raiders / UE5 Crash Analyser")) | |
| print(DIM(f" Root: {crash_root}")) | |
| print(DIM(f" Found {len(folders)} crash folder(s)\n")) | |
| all_crashes = [] | |
| for i, folder in enumerate(folders, 1): | |
| info = process_crash_folder(folder) | |
| all_crashes.append(info) | |
| print(format_crash(info, i, len(folders))) | |
| print_aggregate(all_crashes) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment