Files
onion-mc/analysis/client-perf/harness/bench.py
T
logan 4fa9a6ab81 feat(bench): automate B7 shader profiles, wire B12b trials, settle 45s
- gen_shader_profiles.py + shader-profiles/: Iris per-pack option files
  resolved from each pack's profile.* lines (Iris has no profile key)
- bench.py: flag rows shader_not_loaded when Iris falls back to vanilla
  rendering (analyze.py treats it as invalid)
- matrix: shader_options session key, trial jar globs pinned to the
  downloaded Modrinth builds, post-join settle 45 s (B0 v1 showed rep 1
  still warming at 10 s)
2026-10-16 23:34:52 -04:00

1461 lines
55 KiB
Python

#!/usr/bin/env python3
"""Client-perf bench harness v2: no input injection, one launch per config session.
bench.py boot --n 3 [--variant C1] [--join] # cold-launch reliability + startup time
bench.py session --variant C1 --plan s1,s2,s3 --reps 3 [--label B2] [--dry-run]
bench.py disarm # remove a leftover autoplan datapack
bench.py restore # undo a crashed session's changes
bench.py meta # print the environment snapshot
`session` applies the variant, pins options.txt, writes a per-session datapack whose
`#minecraft:load` hook waits for the player and then drives every scene x rep itself,
printing `BENCH_MARK warm|start|end <scene> <rep>` via /say. The harness only tails
latest.log for those markers, watches for crashes, kills the client when the plan is
done, slices the MangoHud CSV per window and appends one JSON row per window to
~/onion-bench/results/session.jsonl. Stdlib only; see README.md.
"""
import argparse
import datetime as dt
import fnmatch
import hashlib
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import zipfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
PROPOSED = HERE.parent / "proposed-configs"
BENCH = Path.home() / "onion-bench"
PRISM = BENCH / "prism"
INSTANCE = "OnionBench"
INST_DIR = PRISM / "instances" / INSTANCE
GAME = INST_DIR / ".minecraft"
WORLD = "onionbench"
MANGOLOGS = BENCH / "mangologs"
RESULTS = BENCH / "results"
PLAN_PACK = "onion-bench-autoplan"
MACHINE = "proxy-9900X-7800XT"
PINS = [
("onboardAccessibility", "false"),
("pauseOnLostFocus", "false"),
("tutorialStep", "none"),
("narrator", "0"),
("skipMultiplayerWarning", "true"),
("joinedFirstServer", "true"),
("maxFps", "260"),
("enableVsync", "false"),
]
# Reset before every variant apply so resource-pack state never leaks between sessions.
# options.txt had neither key on 2026-10-16, so this is the NeoForge default.
RESOURCE_PACK_BASELINE = [
("resourcePacks", '["vanilla","mod_resources"]'),
("incompatibleResourcePacks", "[]"),
]
PENDING = ".harness-pending.json" # crash-safe restore journal, in GAME
BACKUP_DIR = ".harness-backup" # file backups referenced by the journal, in GAME
DGPU_DEVICE = "0x747e" # RX 7800 XT
DH_RENDER_SECTION, DH_RENDER_KEY = "client.advanced.debugging", "rendererMode"
# BENCH-MATRIX §3: one session runs its scenes in this order (S1 first).
SCENE_ORDER = ["s1", "s2", "s2s", "s3", "s4", "s5"]
SCENE_FN = {"s1": "bench:s1", "s2": "bench:s2", "s2s": "bench:s2", "s3": "bench:s3",
"s4": "bench:s4", "s5": "bench:s1"}
S5_PREWAIT, S5_CAPTURE = 10, 120 # S5: at S1, wait 10 s, tour; all 120 s count
# BENCH-MATRIX §1 JVM sets (applied to instance.cfg only when --jvm is given).
JVM_SETS = {
"J0": "-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 "
"-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:G1NewSizePercent=30 "
"-XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M -XX:G1ReservePercent=20 "
"-XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 "
"-XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 "
"-XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 "
"-XX:+PerfDisableSharedMem -XX:MaxTenuringThreshold=1",
"J1": "-XX:+UseG1GC -XX:MaxGCPauseMillis=37 -XX:G1HeapRegionSize=16M "
"-XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=23 -XX:G1ReservePercent=20 "
"-XX:G1MixedGCCountTarget=3 -XX:G1HeapWastePercent=20 "
"-XX:InitiatingHeapOccupancyPercent=10 -XX:G1RSetUpdatingPauseTimePercent=0 "
"-XX:MaxTenuringThreshold=1 -XX:GCTimeRatio=99 -XX:+DisableExplicitGC "
"-XX:+PerfDisableSharedMem",
"J2": "-XX:+UseZGC -XX:+ZGenerational -XX:+DisableExplicitGC -XX:+PerfDisableSharedMem",
"J3": "-XX:+UseShenandoahGC -XX:+DisableExplicitGC -XX:+PerfDisableSharedMem",
}
LOG_TS = re.compile(r"^\[(\d{2})([A-Za-z]{3})(\d{4}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})\]")
MONTHS = {m: i + 1 for i, m in enumerate(
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])}
MARK_RE = re.compile(r"BENCH_MARK (warm|start|end) (\w+) (\d+)\b")
DONE_RE = re.compile(r"BENCH_DONE (\w+)")
GAME_TOOK_RE = re.compile(r"Game took ([\d.]+) seconds to start")
JOINED_RE = re.compile(r"\]: \S+ joined the game")
ATLAS_RE = re.compile(r"Created: (\d+x\d+x\d+) minecraft:textures/atlas/blocks\.png-atlas")
CRASH_RES = [
("mod_instance", re.compile(r"Failed to create mod instance")),
("fatal", re.compile(r"\[[^\]]*/FATAL\]")),
("broken_mod_state", re.compile(r"Cowardly refusing")),
("crash_report", re.compile(r"---- Minecraft Crash Report ----|Crash report saved to")),
]
BACKUP_RE = re.compile(r"Server Backup started|backup starting|Saving chunks|Autosav",
re.IGNORECASE)
def log(msg):
print(f"[bench] {msg}", flush=True)
# ---------------------------------------------------------------- pure helpers (tested)
def log_time(line):
"""Wall-clock epoch of a Minecraft log line ('[15Oct2026 15:25:53.147] ...'), or None."""
m = LOG_TS.match(line)
if not m or m.group(2) not in MONTHS:
return None
d, mon, y, hh, mm, ss, ms = m.groups()
t = dt.datetime(int(y), MONTHS[mon], int(d), int(hh), int(mm), int(ss))
return time.mktime(t.timetuple()) + int(ms) / 1000.0
def parse_markers(lines):
"""Return ({(kind, scene, rep): epoch}, done_epoch). First occurrence wins (the
server line precedes the client [CHAT] echo)."""
marks, done = {}, None
for line in lines:
m = MARK_RE.search(line)
if m:
key = (m.group(1), m.group(2), int(m.group(3)))
t = log_time(line)
if t is not None and key not in marks:
marks[key] = t
continue
if done is None and DONE_RE.search(line):
done = log_time(line)
return marks, done
def classify_line(line):
"""Crash class for one log line, or None."""
for name, rx in CRASH_RES:
if rx.search(line):
return name
return None
def scan_events(lines):
"""Startup facts from log lines: game_took_s, title/joined epochs, atlas, crash."""
out = {"game_took_s": None, "title_t": None, "joined_t": None, "atlas": None,
"crash": None, "crash_line": None}
for line in lines:
if out["game_took_s"] is None:
m = GAME_TOOK_RE.search(line)
if m:
out["game_took_s"] = float(m.group(1))
out["title_t"] = log_time(line)
if out["joined_t"] is None and JOINED_RE.search(line):
out["joined_t"] = log_time(line)
if out["atlas"] is None:
m = ATLAS_RE.search(line)
if m:
out["atlas"] = m.group(1)
if out["crash"] is None:
c = classify_line(line)
if c:
out["crash"], out["crash_line"] = c, line.strip()[:300]
return out
def backup_times(lines):
return [t for t in (log_time(l) for l in lines if BACKUP_RE.search(l)) if t is not None]
def parse_mangohud_csv(path):
"""Return (meta, rows). meta = the spec line (os/cpu/gpu/...) as a dict; rows =
[(elapsed_s, frametime_ms, fps)]. The column header is located by name."""
lines = Path(path).read_text(errors="replace").splitlines()
hdr_i = None
for i, line in enumerate(lines[:10]):
cols = line.split(",")
if "frametime" in cols and "elapsed" in cols:
hdr_i = i
break
if hdr_i is None:
raise ValueError(f"no frametime/elapsed header in {path}")
meta = {}
if hdr_i >= 2:
meta = dict(zip(lines[hdr_i - 2].split(","), lines[hdr_i - 1].split(",")))
cols = lines[hdr_i].split(",")
e_i, ft_i, fps_i = cols.index("elapsed"), cols.index("frametime"), cols.index("fps")
rows = []
for line in lines[hdr_i + 1:]:
parts = line.split(",")
if len(parts) != len(cols):
continue
try:
rows.append((float(parts[e_i]) / 1e9, float(parts[ft_i]), float(parts[fps_i])))
except ValueError:
continue
return meta, rows
def csv_name_time(path):
"""Epoch from MangoHud's '<proc>_YYYY-MM-DD_HH-MM-SS.csv' name (local time)."""
m = re.search(r"_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.csv$", str(path))
if not m:
return None
return time.mktime(dt.datetime(*map(int, m.groups())).timetuple())
def file_birth(path):
"""(epoch, method) for when the file was created; falls back to the name stamp."""
st = os.stat(path)
bt = getattr(st, "st_birthtime", None)
if bt:
return float(bt), "st_birthtime"
try:
out = subprocess.run(["stat", "-c", "%.9W", str(path)], capture_output=True,
text=True, timeout=5).stdout.strip()
if out and out not in ("0", "-") and float(out) > 0:
return float(out), "stat-W"
except (OSError, ValueError, subprocess.SubprocessError):
pass
nt = csv_name_time(path)
if nt is not None:
return nt, "filename"
return st.st_mtime, "mtime"
def pct(values, p):
"""Linear-interpolated percentile of an unsorted list, p in [0, 1]."""
if not values:
return float("nan")
s = sorted(values)
k = (len(s) - 1) * p
lo, hi = int(k), min(int(k) + 1, len(s) - 1)
return s[lo] + (s[hi] - s[lo]) * (k - lo)
def window_metrics(rows, origin, t_start, t_end):
"""Metrics of rows whose wall time (origin + elapsed_s) is in [t_start, t_end]."""
ft, fps = [], []
for e, frametime, f in rows:
wall = origin + e
if t_start <= wall <= t_end:
ft.append(frametime)
fps.append(f)
if not ft:
return None
n = len(ft)
k = max(1, int(n * 0.01))
worst_ft = sorted(ft, reverse=True)[:k]
worst_fps = sorted(fps)[:k]
mean_ft = sum(ft) / n
return {
"n": n,
"p50_ms": round(pct(ft, 0.50), 3),
"p95_ms": round(pct(ft, 0.95), 3),
"p99_ms": round(pct(ft, 0.99), 3),
"peak_ms": round(max(ft), 3),
"avg_fps": round(1000.0 / mean_ft, 2) if mean_ft > 0 else None,
"one_low_fps": round(1000.0 / (sum(worst_ft) / k), 2),
"one_low_fps_legacy": round(sum(worst_fps) / k, 2),
"coverage_s": round(min(t_end, origin + rows[-1][0]) - max(t_start, origin + rows[0][0]), 2),
}
def validity_flags(m, duration_s, refresh_rates=(60.0,), backups=(), t_start=0.0, t_end=0.0):
"""Warning flags for one window. m may be None (no frames)."""
flags = []
if m is None:
return ["no_data"]
for hz in set([60.0, *refresh_rates]):
if hz > 0 and abs(m["p50_ms"] - 1000.0 / hz) <= 0.03 * (1000.0 / hz):
flags.append("vsync_suspect")
break
expected = 0.5 * duration_s * (m["avg_fps"] or 0)
if m["n"] < 500 or m["n"] < expected:
flags.append("low_n")
if any(t_start - 5 <= b <= t_end for b in backups):
flags.append("backup_in_window")
return flags
def build_timeline(plan, reps, warmup_s, capture_s, sid):
"""Datapack timeline: list of (commands, delay_ticks_after). Pure; tested."""
tl = []
for scene in plan:
for rep in range(1, reps + 1):
go = f"execute as @a[limit=1] at @s run function {SCENE_FN[scene]}"
if scene == "s5":
tl.append(([go, f"say BENCH_MARK warm s5 {rep}"], S5_PREWAIT * 20))
tl.append(([f"execute as @a[limit=1] at @s run function bench:tour_start",
f"say BENCH_MARK start s5 {rep}"], S5_CAPTURE * 20))
tl.append(([f"say BENCH_MARK end s5 {rep}", "function bench:tour_stop"], 0))
continue
pre = [go]
if scene == "s2s":
pre.append("function bench:storm_on")
tl.append((pre + [f"say BENCH_MARK warm {scene} {rep}"], max(1, warmup_s * 20)))
tl.append(([f"say BENCH_MARK start {scene} {rep}"], capture_s * 20))
post = [f"say BENCH_MARK end {scene} {rep}"]
if scene == "s2s":
post.append("function bench:storm_off")
tl.append((post, 0))
tl.append(([f"say BENCH_DONE {sid}"], 0))
# merge zero-delay steps into the next one (schedule needs > 0 ticks)
merged, buf = [], []
for cmds, delay in tl:
buf.extend(cmds)
if delay > 0:
merged.append((buf, delay))
buf = []
if buf:
merged.append((buf, 0))
return merged
def plan_seconds(plan, reps, warmup_s, capture_s):
total = 0
for scene in plan:
total += reps * ((S5_PREWAIT + S5_CAPTURE) if scene == "s5" else (warmup_s + capture_s))
return total
def generate_datapack(sid, plan, reps, warmup_s, capture_s, settle_s):
"""Return {relative path: text} for the per-session autoplan datapack."""
ns = f"bench:autoplan/{sid}"
holder = f"#{sid}"
files = {
"pack.mcmeta": json.dumps({"pack": {
"pack_format": 48,
"description": f"Onion bench autoplan {sid} (generated by bench.py; "
f"deleted at session end)"}}) + "\n",
"data/minecraft/tags/function/load.json":
json.dumps({"values": [f"{ns}/load"]}) + "\n",
f"data/bench/function/autoplan/{sid}/load.mcfunction":
"# armed by bench.py for one session; the pack is removed afterwards\n"
"scoreboard objectives add bench_plan dummy\n"
f"execute unless score {holder} bench_plan matches 1.. run "
f"schedule function {ns}/poll 20t\n",
f"data/bench/function/autoplan/{sid}/poll.mcfunction":
f"execute if entity @a run function {ns}/begin\n"
f"execute unless entity @a run schedule function {ns}/poll 20t\n",
f"data/bench/function/autoplan/{sid}/begin.mcfunction":
f"scoreboard players set {holder} bench_plan 1\n"
# spectator: elevated scene cameras must not fall (S4 measured the sea floor)
"gamemode spectator @a\n"
"time set 6000\n"
"weather clear\n"
"function bench:storm_off\n"
"function bench:tour_stop\n"
f"say BENCH_PLAN {sid} begin\n"
f"schedule function {ns}/step0 {max(1, settle_s * 20)}t\n",
}
tl = build_timeline(plan, reps, warmup_s, capture_s, sid)
for i, (cmds, delay) in enumerate(tl):
body = list(cmds)
if delay > 0 and i + 1 < len(tl):
body.append(f"schedule function {ns}/step{i + 1} {delay}t")
files[f"data/bench/function/autoplan/{sid}/step{i}.mcfunction"] = "\n".join(body) + "\n"
return files
def merge_pins(text, pins=PINS):
"""Apply key:value pins to options.txt text, keeping every other line."""
want = dict(pins)
out, seen = [], set()
for line in text.splitlines():
k = line.split(":", 1)[0]
if k in want and ":" in line:
line = f"{k}:{want[k]}"
seen.add(k)
out.append(line)
out += [f"{k}:{v}" for k, v in pins if k not in seen]
return "\n".join(out) + "\n"
# ---------------------------------------------------------------- environment
def bench_env():
uid = os.getuid()
run = Path(f"/run/user/{uid}")
env = dict(os.environ)
env.setdefault("DISPLAY", ":1")
if not env.get("XAUTHORITY"):
cands = sorted(run.glob("xauth_*"), key=lambda p: p.stat().st_mtime)
if cands:
env["XAUTHORITY"] = str(cands[-1])
env.setdefault("XDG_RUNTIME_DIR", str(run))
local_bin = str(Path.home() / ".local/bin")
if local_bin not in env.get("PATH", "").split(":"):
env["PATH"] = local_bin + ":" + env.get("PATH", "")
return env
def sha256_file(p):
h = hashlib.sha256()
with open(p, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def tree_hash(root, skip=lambda rel: False):
h = hashlib.sha256()
n = 0
for p in sorted(Path(root).rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(root).as_posix()
if skip(rel):
continue
h.update(rel.encode() + b"\0" + sha256_file(p).encode() + b"\n")
n += 1
return h.hexdigest(), n
def config_skip(rel):
"""Runtime junk that changes without a config change (spark buffers, backups)."""
return rel.endswith((".bak", ".tmp", ".jfr", ".lock", ".log")) or "/tmp" in "/" + rel
def mods_hash(mods_dir):
h = hashlib.sha256()
files = sorted(p for p in Path(mods_dir).glob("*") if p.is_file())
for p in files:
h.update(f"{p.name}\0{p.stat().st_size}\n".encode())
return h.hexdigest(), len(files)
def read_text(p, default=None):
try:
return Path(p).read_text().strip()
except OSError:
return default
def read_cfg(path):
out = {}
for line in (read_text(path, "") or "").splitlines():
if "=" in line and not line.startswith("["):
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
def meminfo_available_mb():
for line in (read_text("/proc/meminfo", "") or "").splitlines():
if line.startswith("MemAvailable:"):
return int(line.split()[1]) // 1024
return None
def proc_cmdline(pid):
try:
return Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
except OSError:
return []
def java_procs():
"""[(pid, [args])] for every process whose comm is java."""
out = []
for d in Path("/proc").iterdir():
if not d.name.isdigit():
continue
try:
if (d / "comm").read_text().strip() != "java":
continue
except OSError:
continue
args = [a.decode(errors="replace") for a in proc_cmdline(d.name) if a]
out.append((int(d.name), args))
return out
def client_pids():
"""Java PIDs of the bench client only. Never the dedicated server (unix_args.txt)."""
pids = []
for pid, args in java_procs():
joined = " ".join(args)
if f"instances/{INSTANCE}" in joined and "unix_args.txt" not in joined:
pids.append(pid)
return pids
def server_procs():
return [pid for pid, args in java_procs()
if any("unix_args.txt" in a for a in args)
and not any(f"instances/{INSTANCE}" in a for a in args)]
def cpu_ticks(pid):
try:
f = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
return int(f[11]) + int(f[12]) # utime + stime
except (OSError, IndexError, ValueError):
return None
def server_cpu_pct(pids, sample=1.0):
if not pids:
return None
hz = os.sysconf("SC_CLK_TCK")
a = {p: cpu_ticks(p) for p in pids}
time.sleep(sample)
b = {p: cpu_ticks(p) for p in pids}
tot = sum((b[p] - a[p]) for p in pids if a[p] is not None and b[p] is not None)
return round(100.0 * tot / hz / sample, 1)
def gpu_info():
cards = []
for dev in sorted(Path("/sys/class/drm").glob("card[0-9]*/device")):
if "-" in dev.parent.name:
continue
info = {"card": dev.parent.name, "device": read_text(dev / "device"),
"dpm_level": read_text(dev / "power_dpm_force_performance_level")}
sclk = read_text(dev / "pp_dpm_sclk", "") or ""
cur = [l for l in sclk.splitlines() if l.rstrip().endswith("*")]
info["sclk"] = cur[0].split(":", 1)[1].strip(" *") if cur else None
cards.append(info)
return cards
def refresh_rates(env):
try:
out = subprocess.run(["xrandr", "--current"], env=env, capture_output=True,
text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return []
rates = []
for line in out.splitlines():
for tok in line.split():
if "*" in tok:
try:
rates.append(float(tok.strip("*+")))
except ValueError:
pass
return sorted(set(rates))
def git_state():
try:
sha = subprocess.run(["git", "-C", str(REPO), "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=10).stdout.strip()
dirty = bool(subprocess.run(["git", "-C", str(REPO), "status", "--porcelain"],
capture_output=True, text=True, timeout=30).stdout.strip())
return sha, dirty
except (OSError, subprocess.SubprocessError):
return None, None
def java_info():
inst = read_cfg(INST_DIR / "instance.cfg")
glob_cfg = read_cfg(PRISM / "prismlauncher.cfg")
java = inst.get("JavaPath") if inst.get("OverrideJavaLocation") == "true" else glob_cfg.get("JavaPath")
if inst.get("OverrideJavaArgs") == "true":
jvm_args = inst.get("JvmArgs", "")
else:
jvm_args = glob_cfg.get("JvmArgs", "")
mem = inst if inst.get("OverrideMemory") == "true" else glob_cfg
version = None
if java:
try:
r = subprocess.run([java, "-version"], capture_output=True, text=True, timeout=20)
version = (r.stderr or r.stdout).splitlines()[0] if (r.stderr or r.stdout) else None
except (OSError, subprocess.SubprocessError):
pass
return {"java_path": java, "java_version": version, "jvm_args_cfg": jvm_args,
"max_mem_mb": mem.get("MaxMemAlloc") or inst.get("MaxMemAlloc"),
"wrapper": inst.get("WrapperCommand")}
def snapshot(env, fast=False):
sha, dirty = git_state()
srv = server_procs()
mh, mc = mods_hash(GAME / "mods") if (GAME / "mods").is_dir() else (None, 0)
lvl = GAME / "saves" / WORLD / "level.dat"
return {
"machine": MACHINE,
"display": DISPLAY_MODE["mode"],
"res": "%dx%d" % DISPLAY_MODE["res"],
"git_sha": sha, "git_dirty": dirty,
"harness_sha": sha256_file(Path(__file__))[:16],
"mods_hash": mh, "mods_count": mc,
**java_info(),
"kernel": os.uname().release,
"cpu_governor": read_text("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"),
"gpus": gpu_info(),
"dri_prime": env.get("DRI_PRIME"),
"mem_available_mb": meminfo_available_mb(),
"loadavg": list(os.getloadavg()),
"server_running": bool(srv),
"server_cpu_pct": None if fast else server_cpu_pct(srv),
"level_dat_sha": sha256_file(lvl)[:16] if lvl.exists() else None,
"refresh_rates": refresh_rates(env),
}
# ---------------------------------------------------------------- launch / monitor
class LogTail:
"""Incremental reader of latest.log; survives the file being (re)created."""
def __init__(self, path):
self.path, self.ino, self.pos, self.buf, self.lines = Path(path), None, 0, "", []
def poll(self):
try:
st = self.path.stat()
except FileNotFoundError:
return []
if st.st_ino != self.ino or st.st_size < self.pos:
self.ino, self.pos, self.buf = st.st_ino, 0, ""
with open(self.path, "r", errors="replace") as fh:
fh.seek(self.pos)
data = fh.read()
self.pos = fh.tell()
data = self.buf + data
parts = data.split("\n")
self.buf = parts.pop()
self.lines.extend(parts)
return parts
def crash_files():
names = set()
d = GAME / "crash-reports"
if d.is_dir():
names |= {p.name for p in d.iterdir()}
names |= {p.name for p in GAME.glob("hs_err_pid*.log")}
return names
DISPLAY_MODE = {"mode": "gamescope-headless", "res": (1920, 1080)}
def display_prefix(mode, res):
"""gamescope wrapper argv for a display mode (empty for the desktop)."""
if mode == "desktop":
return []
w, h = res
cmd = ["gamescope", "-W", str(w), "-H", str(h), "-w", str(w), "-h", str(h),
"--force-windows-fullscreen", "--immediate-flips", "-r", "1000", "-o", "1000"]
if mode == "gamescope-headless":
cmd += ["--backend", "headless"]
return cmd + ["--"]
def launch(env, world):
if (GAME / "logs" / "latest.log").exists():
(GAME / "logs" / "latest.log").unlink()
env = dict(env)
if DISPLAY_MODE["mode"] != "desktop":
env.setdefault("WAYLAND_DISPLAY", "wayland-0")
cmd = ["setsid", *display_prefix(DISPLAY_MODE["mode"], DISPLAY_MODE["res"]),
"prismlauncher", "-d", str(PRISM), "--launch", INSTANCE]
if world:
cmd += ["--world", world]
return subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def kill_client(prism):
"""TERM then KILL the bench client's java PIDs, then our own Prism child."""
for sig, wait in ((signal.SIGTERM, 10), (signal.SIGKILL, 5)):
pids = client_pids()
for pid in pids:
try:
os.kill(pid, sig)
except ProcessLookupError:
pass
t0 = time.time()
while client_pids() and time.time() - t0 < wait:
time.sleep(0.5)
if not client_pids():
break
if prism is not None and prism.poll() is None:
prism.terminate()
try:
prism.wait(5)
except subprocess.TimeoutExpired:
prism.kill()
def monitor(tail, prism, before_crash, until, timeout, on_line=None):
"""Poll until until(state) is true, a crash, an exit, or timeout.
Returns (outcome, detail, elapsed_s)."""
t0 = time.time()
seen_java = False
while True:
for line in tail.poll():
c = classify_line(line)
if c:
return "crash", f"{c}: {line.strip()[:200]}", time.time() - t0
if on_line:
on_line(line)
if until():
return "ok", None, time.time() - t0
new = crash_files() - before_crash
if new:
return "crash", "new crash file: " + ", ".join(sorted(new)), time.time() - t0
pids = client_pids()
seen_java = seen_java or bool(pids)
if seen_java and not pids:
return "crash", "client process exited", time.time() - t0
if not seen_java and prism.poll() is not None and time.time() - t0 > 60:
return "crash", "launcher exited without starting java", time.time() - t0
if time.time() - t0 > timeout:
return "timeout", f"no completion within {timeout}s", time.time() - t0
time.sleep(1.0)
def archive_log(tag):
src = GAME / "logs" / "latest.log"
if src.exists():
dst = RESULTS / "logs" / f"{tag}.log"
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst)
return str(dst)
return None
def append_jsonl(path, row):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a") as fh:
fh.write(json.dumps(row, sort_keys=True) + "\n")
def preflight(kill_stale):
pids = client_pids()
if pids:
if not kill_stale:
sys.exit(f"bench client already running (pids {pids}); close it or pass --kill-stale")
kill_client(None)
def apply_variant(vid):
r = subprocess.run([sys.executable, str(PROPOSED / "apply_variant.py"), vid, str(GAME)],
capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f"apply_variant {vid} failed:\n{r.stdout}{r.stderr}")
return r.stdout.splitlines()
def apply_pins():
opts = GAME / "options.txt"
opts.write_text(merge_pins(opts.read_text() if opts.exists() else ""))
def apply_jvm(jvm, heap_mb):
"""Set JvmArgs / MaxMemAlloc in the bench instance.cfg (bench instance only)."""
cfg = INST_DIR / "instance.cfg"
orig = INST_DIR / "instance.cfg.harness-orig"
if not orig.exists():
shutil.copyfile(cfg, orig)
lines = cfg.read_text().splitlines()
want = {}
if jvm:
want.update({"JvmArgs": JVM_SETS[jvm], "OverrideJavaArgs": "true"})
if heap_mb:
want.update({"MaxMemAlloc": str(heap_mb), "OverrideMemory": "true"})
out, seen = [], set()
for line in lines:
k = line.split("=", 1)[0]
if k in want:
line = f"{k}={want[k]}"
seen.add(k)
out.append(line)
out += [f"{k}={v}" for k, v in want.items() if k not in seen]
cfg.write_text("\n".join(out) + "\n")
def plan_dir():
return GAME / "saves" / WORLD / "datapacks" / PLAN_PACK
def disarm():
d = plan_dir()
if d.exists():
shutil.rmtree(d)
return True
return False
def write_datapack(files):
disarm()
d = plan_dir()
for rel, text in files.items():
p = d / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text)
# ---------------------------------------------------------------- journaled session mutations
class Journal:
"""Write-ahead restore journal (GAME/.harness-pending.json). Every entry is written
before the mutation it undoes, so restore_pending() works after a hard kill."""
def __init__(self, game, sid=None):
self.game, self.sid, self.entries = Path(game), sid, []
self.path, self.bdir = self.game / PENDING, self.game / BACKUP_DIR
@classmethod
def begin(cls, game, sid):
j = cls(game, sid)
if j.path.exists():
raise RuntimeError(f"{j.path} exists: run `bench.py restore` first")
j._write()
return j
def _write(self):
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.write_text(json.dumps({"session": self.sid, "entries": self.entries}, indent=1))
os.replace(tmp, self.path)
def record(self, entry):
self.entries.append(entry)
self._write()
def backup_file(self, rel):
"""Journal the current content (or absence) of GAME/rel; first backup wins."""
rel = Path(rel).as_posix()
if any(e["op"] == "file" and e["path"] == rel for e in self.entries):
return
src, backup = self.game / rel, None
if src.is_file():
self.bdir.mkdir(exist_ok=True)
backup = f"{len(self.entries)}-{src.name}"
shutil.copy2(src, self.bdir / backup)
self.record({"op": "file", "path": rel, "backup": backup})
def pending_exists(game=None):
return (Path(game or GAME) / PENDING).exists()
def _undo(game, e, bdir):
op = e["op"]
if op == "file":
dst = game / e["path"]
if e["backup"] is None:
if dst.exists():
dst.unlink()
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bdir / e["backup"], dst)
elif op == "rename":
src, dst = game / e["src"], game / e["dst"]
if dst.exists() and not src.exists():
os.rename(dst, src)
elif op in ("added", "tree"):
p = game / e["path"]
if p.is_dir():
shutil.rmtree(p)
elif p.exists():
p.unlink()
elif op == "world":
path, bak, ext = game / e["path"], game / e["backup"], game / e["extract"]
if ext.exists():
shutil.rmtree(ext)
if bak.exists():
if path.exists():
shutil.rmtree(path)
os.rename(bak, path)
elif not e["had_world"] and path.exists():
shutil.rmtree(path)
else:
raise ValueError(f"unknown journal op {op!r}")
def restore_pending(game=None):
"""Undo every journaled mutation (newest first). Returns [(entry, error)] for the
entries that failed; those stay in the journal, otherwise journal + backups go."""
game = Path(game or GAME)
path, bdir = game / PENDING, game / BACKUP_DIR
if not path.exists():
return []
data = json.loads(path.read_text())
failed = []
for e in reversed(data.get("entries", [])):
try:
_undo(game, e, bdir)
except (OSError, ValueError, KeyError) as exc:
failed.append((e, f"{type(exc).__name__}: {exc}"))
if failed:
data["entries"] = [e for e, _ in reversed(failed)]
path.write_text(json.dumps(data, indent=1))
else:
path.unlink()
if bdir.exists():
shutil.rmtree(bdir)
return failed
def prop_escape(v):
"""java.util.Properties value escaping (what Iris' store() writes)."""
out = "".join("\\" + c if c in "\\=:#!" else c for c in v)
return "\\" + out if out.startswith(" ") else out
def set_properties(text, want):
"""Set key=value pairs in .properties text, keeping comments and other keys."""
out, seen = [], set()
for line in text.splitlines():
s = line.lstrip()
if s and s[0] not in "#!":
k = re.split(r"\s*[=:]\s*|\s+", s, maxsplit=1)[0]
if k in want:
line = f"{k}={prop_escape(want[k])}"
seen.add(k)
out.append(line)
out += [f"{k}={prop_escape(v)}" for k, v in want.items() if k not in seen]
return "\n".join(out) + "\n"
def apply_shader(game, journal, shader, options_file=None):
"""Iris on with shaderpacks/<shader>, or off. Iris' per-pack options file
shaderpacks/<shader>.txt is journaled too, so edits made during the run revert."""
game = Path(game)
cfg = game / "config" / "iris.properties"
journal.backup_file("config/iris.properties")
on = shader != "off"
text = cfg.read_text() if cfg.exists() else ""
cfg.parent.mkdir(parents=True, exist_ok=True)
cfg.write_text(set_properties(text, {"enableShaders": "true" if on else "false",
"shaderPack": shader if on else ""}))
if on:
rel = f"shaderpacks/{shader}.txt"
journal.backup_file(rel)
if options_file:
shutil.copyfile(options_file, game / rel)
def set_dh_rendering(text, on):
"""Set [client.advanced.debugging] rendererMode (what DH's GUI-only
quickEnableRendering toggles): DEFAULT or DISABLED."""
out, section, hit = [], None, False
for line in text.splitlines():
m = re.match(r"^\s*\[([^\]]+)\]\s*$", line)
if m:
section = m.group(1).strip()
elif section == DH_RENDER_SECTION:
m = re.match(rf"^(\s*){DH_RENDER_KEY}\s*=", line)
if m:
line = f'{m.group(1)}{DH_RENDER_KEY} = "{"DEFAULT" if on else "DISABLED"}"'
hit = True
out.append(line)
if not hit:
raise ValueError(f"no {DH_RENDER_KEY} under [{DH_RENDER_SECTION}] in DistantHorizons.toml")
return "\n".join(out) + "\n"
def apply_dh(game, journal, on):
p = Path(game) / "config" / "DistantHorizons.toml"
new = set_dh_rendering(p.read_text(), on) # raises before journaling if the key is gone
journal.backup_file("config/DistantHorizons.toml")
p.write_text(new)
def split_list(s):
return [x.strip() for x in (s or "").split(",") if x.strip()]
def resolve_disable(game, globs):
"""Jar names in GAME/mods matching any glob (case-insensitive). Every glob must hit."""
jars = sorted(p.name for p in (Path(game) / "mods").glob("*.jar"))
hits, empty = [], []
for g in globs:
m = [j for j in jars if fnmatch.fnmatchcase(j.lower(), g.lower())]
if not m:
empty.append(g)
hits += [j for j in m if j not in hits]
if empty:
raise ValueError(f"--disable-mods glob(s) matched no jar in mods/: {empty}")
return hits
def check_add_mods(game, paths):
out = []
for s in paths:
p = Path(s).expanduser()
if not p.is_file() or p.suffix != ".jar":
raise ValueError(f"--add-mods: {p} is not a .jar file")
if (Path(game) / "mods" / p.name).exists():
raise ValueError(f"--add-mods: mods/{p.name} already exists")
out.append(p)
return out
def disable_mods(game, journal, names):
for n in names:
src, dst = f"mods/{n}", f"mods/{n}.disabled"
if (Path(game) / dst).exists():
raise ValueError(f"{dst} already exists")
journal.record({"op": "rename", "src": src, "dst": dst})
os.rename(Path(game) / src, Path(game) / dst)
def add_mods(game, journal, paths):
for p in paths:
journal.record({"op": "added", "path": f"mods/{p.name}"})
shutil.copy2(p, Path(game) / "mods" / p.name)
def zip_world_root(zip_path):
"""Member prefix of the world inside the zip ('' or 'top/'); rejects unsafe paths."""
with zipfile.ZipFile(zip_path) as z:
names = z.namelist()
for n in names:
if n.startswith(("/", "\\")) or ".." in Path(n).parts:
raise ValueError(f"unsafe member path in {zip_path}: {n}")
if "level.dat" in names:
return ""
tops = {n.split("/", 1)[0] for n in names if n.strip("/")}
if len(tops) == 1:
top = tops.pop()
if f"{top}/level.dat" in names:
return top + "/"
raise ValueError(f"{zip_path}: no level.dat at the root or under a single top directory")
def swap_world(game, journal, zip_path, world=WORLD):
"""Replace saves/<world> with the zip's world (original kept as
<world>.harness-bak until restore). Returns the zip's sha256."""
prefix = zip_world_root(zip_path)
saves = Path(game) / "saves"
path, bak, ext = saves / world, saves / f"{world}.harness-bak", saves / f".{world}.harness-extract"
for p in (bak, ext):
if p.exists():
raise ValueError(f"{p} exists (unrestored earlier swap?)")
digest = sha256_file(zip_path)
journal.record({"op": "world", "path": f"saves/{world}", "backup": f"saves/{bak.name}",
"extract": f"saves/{ext.name}", "had_world": path.exists()})
if path.exists():
os.rename(path, bak)
with zipfile.ZipFile(zip_path) as z:
z.extractall(ext)
os.rename(ext / prefix if prefix else ext, path)
if ext.exists():
shutil.rmtree(ext)
return digest
def reset_resource_packs(game):
opts = Path(game) / "options.txt"
opts.write_text(merge_pins(opts.read_text() if opts.exists() else "",
RESOURCE_PACK_BASELINE))
def options_values(game, keys):
opts = Path(game) / "options.txt"
vals = {}
for line in (opts.read_text().splitlines() if opts.exists() else []):
k, _, v = line.partition(":")
if k in keys:
vals[k] = v
return vals
SHADER_FAIL_RE = re.compile(
r"Falling back to normal rendering without shaders|Failed to compile shaders|"
r"Failed to create shader rendering pipeline|Failed to load the shaderpack|"
r"Shaders are disabled because|is not valid! Can't load it")
def shader_flags(shader, lines):
"""['shader_not_loaded'] when a shader was requested but Iris didn't load it."""
if not shader or shader == "off":
return []
ok = any("Using shaderpack: " in ln for ln in lines)
bad = any(SHADER_FAIL_RE.search(ln) for ln in lines)
return ["shader_not_loaded"] if bad or not ok else []
def env_flags(gpus, server_running):
"""Warn flags for uncontrolled noise sources."""
flags = []
if any(g.get("device") == DGPU_DEVICE and g.get("dpm_level") != "high" for g in gpus):
flags.append("gpu_dpm_auto")
if server_running:
flags.append("server_running")
return flags
def effective_jvm_flags(pids):
for pid in pids:
args = [a.decode(errors="replace") for a in proc_cmdline(pid) if a]
return [a for a in args if a.startswith(("-X", "-XX"))]
return None
def pick_csvs(t_launch):
"""MangoHud CSVs born at/after launch, newest first, with (birth, method)."""
out = []
for p in MANGOLOGS.glob("*.csv"):
if p.name.endswith("_summary.csv"):
continue
birth, method = file_birth(p)
if birth >= t_launch - 2:
out.append((birth, method, p))
return sorted(out, reverse=True)
# ---------------------------------------------------------------- subcommands
def refuse_pending():
if pending_exists():
sys.exit(f"{GAME / PENDING} exists: a previous session did not restore the "
f"instance. Run `bench.py restore` first.")
def cmd_boot(a):
refuse_pending()
env = bench_env()
preflight(a.kill_stale)
disarm()
if a.variant:
apply_variant(a.variant)
apply_pins()
meta = snapshot(env)
variant = read_text(GAME / "BENCH_VARIANT.txt")
counts = {}
for i in range(1, a.n + 1):
before = crash_files()
t_launch = time.time()
prism = launch(env, WORLD if a.join else None)
tail = LogTail(GAME / "logs" / "latest.log")
target = JOINED_RE if a.join else GAME_TOOK_RE
hit = {"v": False}
def on_line(line):
if target.search(line):
hit["v"] = True
try:
outcome, detail, _ = monitor(tail, prism, before, lambda: hit["v"], a.timeout,
on_line)
time.sleep(2)
tail.poll()
finally:
kill_client(prism)
ev = scan_events(tail.lines)
tag = f"boot-{time.strftime('%Y%m%d-%H%M%S', time.localtime(t_launch))}-{i}"
row = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(t_launch)),
"kind": "boot", "label": a.label, "launch": i, "of": a.n, "join": a.join,
"variant": variant, "outcome": outcome, "detail": detail,
"startup_s": ev["game_took_s"],
"title_s": round(ev["title_t"] - t_launch, 2) if ev["title_t"] else None,
"join_s": round(ev["joined_t"] - t_launch, 2) if ev["joined_t"] else None,
"atlas": ev["atlas"], "log": archive_log(tag), **meta,
"mem_available_after_mb": meminfo_available_mb(),
}
append_jsonl(RESULTS / "boot.jsonl", row)
counts[outcome] = counts.get(outcome, 0) + 1
log(f"launch {i}/{a.n}: {outcome} startup={row['startup_s']}s title={row['title_s']}s "
f"join={row['join_s']}s {detail or ''}")
time.sleep(a.cooldown)
log("totals: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
return 0 if counts.get("ok", 0) == a.n else 1
def session_plan(a):
plan = [s.strip().lower() for s in a.plan.split(",") if s.strip()]
bad = [s for s in plan if s not in SCENE_FN]
if bad:
sys.exit(f"unknown/unsupported scene(s) {bad}; s6 (GUI) is manual. Use {SCENE_ORDER}")
if len(set(plan)) != len(plan):
sys.exit(f"duplicate scene in plan {plan}; use --reps")
if not a.any_order and plan != sorted(plan, key=SCENE_ORDER.index):
sys.exit(f"plan {plan} breaks the S1->S6 session order (BENCH-MATRIX §3); "
f"pass --any-order to override")
return plan
def cmd_session(a):
plan = session_plan(a)
sid = "p" + time.strftime("%Y%m%d%H%M%S")
files = generate_datapack(sid, plan, a.reps, a.warmup, a.capture, a.settle)
budget = plan_seconds(plan, a.reps, a.warmup, a.capture)
timeout = a.timeout or int(a.boot_timeout + a.settle + budget * 1.25 + 120)
if not (PROPOSED / "out" / a.variant).is_dir():
sys.exit(f"variant {a.variant} not in {PROPOSED / 'out'} - run build.py first")
# validate every instance change before touching anything
try:
if a.shader != "off" and not (GAME / "shaderpacks" / a.shader).exists():
raise ValueError(f"--shader {a.shader}: not in {GAME / 'shaderpacks'}")
if a.shader_options and (a.shader == "off" or not Path(a.shader_options).is_file()):
raise ValueError("--shader-options needs --shader NAME and an existing file")
to_disable = resolve_disable(GAME, split_list(a.disable_mods))
to_add = check_add_mods(GAME, split_list(a.add_mods))
if a.world_zip:
zip_world_root(a.world_zip)
except (ValueError, OSError, zipfile.BadZipFile) as e:
sys.exit(str(e))
if a.dry_run:
log(f"dry run: session {sid}, variant {a.variant}, plan {plan} x{a.reps}, "
f"est. scene time {budget}s, timeout {timeout}s")
log(f"would pin options.txt: {', '.join(f'{k}:{v}' for k, v in PINS)}")
if a.jvm or a.heap:
log(f"would set instance.cfg: jvm={a.jvm} heap={a.heap}")
log(f"would reset options.txt: {dict(RESOURCE_PACK_BASELINE)}")
log(f"would set shader={a.shader} options={a.shader_options} dh={a.dh} "
f"disable={to_disable} add={[p.name for p in to_add]} world_zip={a.world_zip}")
if pending_exists():
log(f"WARNING: {GAME / PENDING} exists; a real run would refuse to start")
log(f"would write datapack {plan_dir()}:")
for rel, text in files.items():
print(f"--- {PLAN_PACK}/{rel}")
print(text, end="")
return 0
if budget + a.settle + 120 > 1700:
log("WARNING: plan exceeds ~28 min; mangohud-local log_duration=1800 will truncate the CSV")
refuse_pending()
env = bench_env()
preflight(a.kill_stale)
# SIGTERM (e.g. a matrix runner timeout) unwinds through the restore below
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
journal = Journal.begin(GAME, sid)
prism = None
state = {"done": False}
try:
reset_resource_packs(GAME)
apply_out = apply_variant(a.variant)
apply_pins()
if a.jvm or a.heap:
apply_jvm(a.jvm, a.heap)
apply_shader(GAME, journal, a.shader, a.shader_options)
apply_dh(GAME, journal, a.dh == "on")
disable_mods(GAME, journal, to_disable)
add_mods(GAME, journal, to_add)
world_sha = swap_world(GAME, journal, a.world_zip) if a.world_zip else None
cfg_hash, cfg_n = tree_hash(GAME / "config", config_skip)
opt_hash = sha256_file(GAME / "options.txt")
packs = options_values(GAME, dict(RESOURCE_PACK_BASELINE))
meta = snapshot(env)
journal.record({"op": "tree", "path": plan_dir().relative_to(GAME).as_posix()})
write_datapack(files)
before = crash_files()
t_launch = time.time()
log(f"session {sid}: {a.variant} {plan} x{a.reps}, timeout {timeout}s")
prism = launch(env, WORLD)
tail = LogTail(GAME / "logs" / "latest.log")
outcome, detail = _run_session(a, tail, prism, before, timeout, state)
finally:
if prism is not None:
kill_client(prism)
failed = restore_pending(GAME)
for e, err in failed:
log(f"RESTORE FAILED {e}: {err}")
if failed:
log(f"journal kept at {GAME / PENDING}; fix and run `bench.py restore`")
jvm_flags = state.get("jvm_flags")
return _record_session(a, sid, plan, tail, t_launch, outcome, detail, jvm_flags,
cfg_hash, cfg_n, opt_hash, apply_out, meta, packs, world_sha,
to_disable, to_add, failed)
def _run_session(a, tail, prism, before, timeout, state):
"""Monitor the running client until BENCH_DONE / crash / exit / timeout."""
def on_line(line):
m = MARK_RE.search(line)
if m and "[CHAT]" not in line:
log(f" {m.group(1):5s} {m.group(2)} rep {m.group(3)}")
if DONE_RE.search(line):
state["done"] = True
state["jvm_flags"] = None
def until():
if state["jvm_flags"] is None:
state["jvm_flags"] = effective_jvm_flags(client_pids())
return state["done"]
outcome, detail, _ = monitor(tail, prism, before, until, timeout, on_line)
time.sleep(2) # let MangoHud flush the tail of the capture
tail.poll()
return outcome, detail
def _record_session(a, sid, plan, tail, t_launch, outcome, detail, jvm_flags, cfg_hash,
cfg_n, opt_hash, apply_out, meta, packs, world_sha, to_disable, to_add,
restore_failed):
mem_after = meminfo_available_mb()
lines = tail.lines
ev = scan_events(lines)
marks, _ = parse_markers(lines)
backups = backup_times(lines)
log_path = archive_log(f"session-{sid}")
csvs = pick_csvs(t_launch)
parsed = []
for birth, method, p in csvs:
try:
cmeta, rows = parse_mangohud_csv(p)
except (OSError, ValueError) as e:
log(f"skip {p.name}: {e}")
continue
if rows:
parsed.append((birth, method, p, cmeta, rows))
if not parsed:
log("WARNING: no MangoHud CSV created after launch")
base = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(t_launch)),
"kind": "window", "session": sid, "label": a.label, "variant": a.variant,
"jvm_set": a.jvm, "heap_mb": a.heap, "jvm_flags": jvm_flags,
"config_hash": cfg_hash[:16], "config_files": cfg_n, "options_hash": opt_hash[:16],
"apply_output": apply_out,
"shader": a.shader, "shader_options": a.shader_options,
"shader_options_sha256": sha256_file(a.shader_options) if a.shader_options else None,
"dh": a.dh, "disabled_mods": to_disable, "added_mods": [p.name for p in to_add],
"world_zip": a.world_zip, "world_zip_sha256": world_sha,
"resource_packs": packs.get("resourcePacks"),
"incompatible_resource_packs": packs.get("incompatibleResourcePacks"),
"restore_errors": [err for _, err in restore_failed],
"session_outcome": outcome, "session_detail": detail,
"startup_s": ev["game_took_s"],
"join_s": round(ev["joined_t"] - t_launch, 2) if ev["joined_t"] else None,
"atlas": ev["atlas"], "log": log_path, **meta, "mem_available_after_mb": mem_after,
}
noise = env_flags(meta["gpus"], meta["server_running"]) + shader_flags(a.shader, lines)
if restore_failed:
noise.append("restore_failed")
n_ok = 0
for scene in plan:
warmup = 0 if scene == "s5" else a.warmup
capture = S5_CAPTURE if scene == "s5" else a.capture
for rep in range(1, a.reps + 1):
ts, te = marks.get(("start", scene, rep)), marks.get(("end", scene, rep))
row = dict(base, scene=scene, rep=rep, warmup_s=warmup, capture_s=capture,
t_start=ts, t_end=te)
if ts is None or te is None:
row.update(metrics=None, flags=[{"crash": "crashed", "timeout": "timeout"}
.get(outcome, "no_markers"), *noise])
append_jsonl(RESULTS / "session.jsonl", row)
log(f"{scene} rep {rep}: no complete window ({outcome})")
continue
m, src = None, None
for birth, method, p, cmeta, rows in parsed: # newest first
if birth <= ts and birth + rows[-1][0] >= te - 0.5:
m, src = window_metrics(rows, birth, ts, te), (birth, method, p, cmeta)
break
if m is None and parsed:
birth, method, p, cmeta, rows = parsed[0]
m, src = window_metrics(rows, birth, ts, te), (birth, method, p, cmeta)
flags = validity_flags(m, te - ts, meta["refresh_rates"], backups, ts, te)
if outcome != "ok":
flags.append("session_" + outcome) # warn: window itself completed
flags += noise
row.update(metrics=m, flags=flags)
if src:
row.update(csv=src[2].name, align=src[1], csv_gpu=src[3].get("gpu", "").strip(),
csv_birth=round(src[0], 3))
append_jsonl(RESULTS / "session.jsonl", row)
if m:
n_ok += 1
log(f"{scene} rep {rep}: n={m['n']} p50={m['p50_ms']} p99={m['p99_ms']} "
f"1%low={m['one_low_fps']} avg={m['avg_fps']} {','.join(flags)}")
log(f"session {sid} {outcome}: {n_ok} windows recorded -> {RESULTS / 'session.jsonl'}")
return 0 if outcome == "ok" and not restore_failed else 1
def cmd_disarm(a):
log("removed " + str(plan_dir()) if disarm() else "nothing armed")
return 0
def cmd_restore(a):
if client_pids():
sys.exit("bench client is running; close it before restoring")
if not pending_exists():
log("nothing pending" + ("; removed leftover plan pack" if disarm() else ""))
return 0
failed = restore_pending(GAME)
for e, err in failed:
log(f"FAILED {e}: {err}")
disarm()
log(f"restore: {len(failed)} entr{'y' if len(failed) == 1 else 'ies'} failed"
if failed else "restored and cleared " + str(GAME / PENDING))
return 1 if failed else 0
def cmd_meta(a):
print(json.dumps(snapshot(bench_env()), indent=2))
return 0
def build_parser():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("boot", help="N cold launches; startup time + crash classification")
b.add_argument("--n", type=int, default=3)
b.add_argument("--variant", help="apply this variant once before the launches")
b.add_argument("--join", action="store_true", help="launch into the bench world, wait for join")
b.add_argument("--label", default=None)
b.add_argument("--timeout", type=int, default=300)
b.add_argument("--cooldown", type=int, default=5, help="seconds between launches")
b.add_argument("--kill-stale", action="store_true")
b.set_defaults(fn=cmd_boot)
s = sub.add_parser("session", help="one launch, datapack-driven scene plan")
s.add_argument("--variant", required=True)
s.add_argument("--plan", default="s1", help="comma list in S1->S5 order: s1,s2,s2s,s3,s4,s5")
s.add_argument("--reps", type=int, default=3)
s.add_argument("--warmup", type=int, default=15)
s.add_argument("--capture", type=int, default=30)
s.add_argument("--settle", type=int, default=10, help="seconds after join before the plan")
s.add_argument("--label", default=None, help="matrix block, e.g. B0")
s.add_argument("--jvm", choices=sorted(JVM_SETS),
help="write this BENCH-MATRIX JVM set into instance.cfg (default: untouched)")
s.add_argument("--heap", type=int, help="MaxMemAlloc MB to write into instance.cfg")
s.add_argument("--boot-timeout", type=int, default=300)
s.add_argument("--timeout", type=int, default=None, help="overall session timeout (s)")
s.add_argument("--any-order", action="store_true")
s.add_argument("--kill-stale", action="store_true")
s.add_argument("--dry-run", action="store_true", help="print the generated datapack only")
s.add_argument("--shader", default="off",
help="shaderpack file/dir name in GAME/shaderpacks, or off (Iris disabled)")
s.add_argument("--shader-options", help="file copied to shaderpacks/<NAME>.txt for the session")
s.add_argument("--dh", choices=["on", "off"], default="on",
help="DH rendering via [client.advanced.debugging] rendererMode")
s.add_argument("--disable-mods", help="comma list of globs (case-insensitive) of mods/*.jar "
"renamed to .jar.disabled for the session")
s.add_argument("--add-mods", help="comma list of jar paths copied into mods/ for the session")
s.add_argument("--world-zip", help="world zip swapped in for saves/onionbench for the session")
for p in (b, s):
p.add_argument("--display", choices=["desktop", "gamescope", "gamescope-headless"],
default="gamescope-headless")
p.add_argument("--res", default="1920x1080", help="WxH (gamescope modes)")
s.set_defaults(fn=cmd_session)
d = sub.add_parser("disarm", help="remove a leftover autoplan datapack from the world")
d.set_defaults(fn=cmd_disarm)
r = sub.add_parser("restore", help="replay and clear a leftover .harness-pending.json")
r.set_defaults(fn=cmd_restore)
m = sub.add_parser("meta", help="print the environment/metadata snapshot")
m.set_defaults(fn=cmd_meta)
return ap
def main(argv=None):
a = build_parser().parse_args(argv)
if getattr(a, "display", None):
w, h = (int(x) for x in a.res.lower().split("x"))
DISPLAY_MODE.update(mode=a.display, res=(w, h))
return a.fn(a)
if __name__ == "__main__":
raise SystemExit(main())