#!/usr/bin/env python3 """Client-perf bench harness: drive one variant/scene and extract frametime metrics. Applies a generated variant, launches the Prism instance under the user-local MangoHud wrapper straight into the bench world, runs the scene via xdotool chat commands, records wall-clock markers, then slices the MangoHud CSV. Usage: python3 run_scene.py --variant C1 --scene s1 [--warmup 15] [--capture 30] [--runs 1] [--record out.csv] [--pre storm_on] Scenes: s1, s2, s3, s4, s5 (tour = 120s capture, no warmup), s2s (= s2 + storm). Only stdlib. Must run on the desktop session that owns DISPLAY :1. """ import argparse import bisect import csv as csvmod import os import re import shutil import signal import subprocess import sys import time from pathlib import Path HERE = Path(__file__).resolve().parent PROPOSED = HERE.parent / "proposed-configs" ROOT = Path("/home/logan/onion-bench/prism") INSTANCE = "OnionBench" GAME = ROOT / "instances" / INSTANCE / ".minecraft" LOGS = GAME / "logs" / "latest.log" MANGOLOGS = Path("/home/logan/onion-bench/mangologs") WORLD = "onionbench" ENV = { **os.environ, "DISPLAY": ":1", "XAUTHORITY": "/run/user/1000/xauth_HvvPAq", "XDG_RUNTIME_DIR": "/run/user/1000", "PATH": "/home/logan/.local/bin:" + os.environ.get("PATH", ""), } SCENE_FN = { "s1": "bench:s1", "s2": "bench:s2", "s3": "bench:s3", "s4": "bench:s4", "s5": "bench:tour_start", "s2s": "bench:s2", } def log(msg): print(f"[run_scene] {msg}", flush=True) def xdo(*args): subprocess.run(["xdotool", *args], env=ENV, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def mc_window(): out = subprocess.run(["xdotool", "search", "--name", "Minecraft"], env=ENV, capture_output=True, text=True).stdout.split() return out[-1] if out else None def client_java_pids(): out = subprocess.run(["ps", "-C", "java", "-o", "pid=,args="], capture_output=True, text=True).stdout pids = [] for line in out.splitlines(): if f"instances/{INSTANCE}/.minecraft" in line or f"instances/{INSTANCE}/natives" in line: pids.append(int(line.split()[0])) return pids def apply_variant(vid): subprocess.run([sys.executable, str(PROPOSED / "apply_variant.py"), vid, str(GAME)], check=True, stdout=subprocess.DEVNULL) def launch_client(): if LOGS.exists(): LOGS.unlink() p = subprocess.Popen( ["setsid", "prismlauncher", "-d", str(ROOT), "--launch", INSTANCE, "--world", WORLD], env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return p def wait_for_world(timeout=240): t0 = time.time() while time.time() - t0 < timeout: if LOGS.exists(): txt = LOGS.read_text(errors="replace") if "Sound engine started" in txt: return True time.sleep(2) return False def chat(command, settle=0.6): wid = mc_window() if wid: xdo("windowactivate", wid) xdo("windowfocus", wid) xdo("key", "--clearmodifiers", "t") time.sleep(0.4) xdo("type", "--clearmodifiers", "--delay", "12", command) time.sleep(0.2) xdo("key", "--clearmodifiers", "Return") time.sleep(settle) def newest_csv(): files = [p for p in MANGOLOGS.glob("*.csv") if not p.name.endswith("_summary.csv")] return max(files, key=lambda p: p.stat().st_mtime) if files else None def parse_csv(path): """Return (columns, rows) where rows are lists of floats; line 3 is the header.""" lines = path.read_text(errors="replace").splitlines() hdr_i = None for i, line in enumerate(lines[:10]): if "frametime" in line and "elapsed" in line: hdr_i = i break if hdr_i is None: raise RuntimeError(f"no frametime header in {path}") cols = lines[hdr_i].split(",") e_i = cols.index("elapsed") ft_i = cols.index("frametime") fps_i = 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]), float(parts[ft_i]), float(parts[fps_i]), parts)) except ValueError: continue return cols, rows def pct(values, p): 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 metrics_between(rows, t_start, t_end, run_start): """rows: list of (elapsed_ns, frametime_ms, fps, raw). Wall = run_start + elapsed/1e9.""" ft, fps = [], [] for e, frametime, f, raw in rows: wall = run_start + e / 1e9 if t_start <= wall <= t_end: ft.append(frametime) fps.append(f) if not ft: return None fps_sorted = sorted(fps) n1 = max(1, int(len(fps_sorted) * 0.01)) one_low = sum(fps_sorted[:n1]) / n1 return { "n": len(ft), "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(sum(fps) / len(fps), 2), "one_low_fps": round(one_low, 2), } def kill_client(prism): try: os.killpg(os.getpgid(prism.pid), signal.SIGTERM) except Exception: pass for pid in client_java_pids(): try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass time.sleep(5) for pid in client_java_pids(): try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass def run_once(variant, scene, warmup, capture, pre): apply_variant(variant) p = launch_client() run_start = time.time() try: if not wait_for_world(): log("ERROR: world never loaded") return None time.sleep(8) # let the world settle / place player fn = SCENE_FN[scene] if scene != "s5": chat(f"/function {fn}", settle=1.0) if pre: chat(f"/function bench:{pre}", settle=1.0) if scene == "s5": capture = 120 warmup = 0 chat("/function bench:tour_start", settle=0.5) time.sleep(warmup) t_start = time.time() chat(f"/say BENCH_CAPTURE_START {variant} {scene}") time.sleep(capture) t_end = time.time() chat("/say BENCH_CAPTURE_END") chat("/save-all", settle=2.0) finally: csv_path = newest_csv() kill_client(p) cols, rows = parse_csv(csv_path) m = metrics_between(rows, t_start, t_end, run_start) if m: m.update({"variant": variant, "scene": scene, "csv": csv_path.name, "run_start": round(run_start, 3)}) return m def main(): ap = argparse.ArgumentParser() ap.add_argument("--variant", required=True) ap.add_argument("--scene", required=True, choices=sorted(SCENE_FN)) ap.add_argument("--warmup", type=int, default=15) ap.add_argument("--capture", type=int, default=30) ap.add_argument("--runs", type=int, default=1) ap.add_argument("--pre", default=None) ap.add_argument("--record", default=None) a = ap.parse_args() results = [] for i in range(a.runs): log(f"run {i + 1}/{a.runs}: {a.variant} {a.scene}") m = run_once(a.variant, a.scene, a.warmup, a.capture, a.pre) if m: results.append(m) print("RESULT " + str(m), flush=True) time.sleep(3) if a.record and results: fields = ["variant", "scene", "n", "p50_ms", "p95_ms", "p99_ms", "peak_ms", "avg_fps", "one_low_fps", "csv", "run_start"] exists = Path(a.record).exists() with open(a.record, "a", newline="") as fh: w = csvmod.DictWriter(fh, fieldnames=fields) if not exists: w.writeheader() for m in results: w.writerow({k: m.get(k, "") for k in fields}) return 0 if results else 1 if __name__ == "__main__": raise SystemExit(main())