Skip to content

Instantly share code, notes, and snippets.

@vargo
Created March 19, 2026 14:48
Show Gist options
  • Select an option

  • Save vargo/bbd5a272dca55679ad0fa60eac9eed05 to your computer and use it in GitHub Desktop.

Select an option

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}`
#!/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("&quot;", '"').replace("&amp;", "&")
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