From 4fa9a6ab811dda29e74cabec7a340926172e5783 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 16 Oct 2026 23:34:52 -0400 Subject: [PATCH] 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) --- analysis/client-perf/harness/README.md | 7 +- analysis/client-perf/harness/analyze.py | 3 +- analysis/client-perf/harness/bench.py | 17 +- .../harness/gen_shader_profiles.py | 204 ++++++++++++++++++ analysis/client-perf/harness/matrix.py | 36 +++- analysis/client-perf/harness/matrix.toml | 40 ++-- .../ComplementaryReimagined_r5.9.3.LOW.txt | 18 ++ .../ComplementaryReimagined_r5.9.3.MEDIUM.txt | 18 ++ .../MakeUp-UltraFast-9.5f.medium.txt | 33 +++ ...akeUp-UltraFast-9.5f.shadowless_medium.txt | 33 +++ .../harness/tests/test_bench_flags.py | 10 + .../client-perf/harness/tests/test_matrix.py | 64 +++++- .../harness/tests/test_shader_profiles.py | 100 +++++++++ 13 files changed, 552 insertions(+), 31 deletions(-) create mode 100644 analysis/client-perf/harness/gen_shader_profiles.py create mode 100644 analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.LOW.txt create mode 100644 analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.MEDIUM.txt create mode 100644 analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.medium.txt create mode 100644 analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.shadowless_medium.txt create mode 100644 analysis/client-perf/harness/tests/test_shader_profiles.py diff --git a/analysis/client-perf/harness/README.md b/analysis/client-perf/harness/README.md index a0f313a..f576db9 100644 --- a/analysis/client-perf/harness/README.md +++ b/analysis/client-perf/harness/README.md @@ -119,8 +119,11 @@ join seconds, block-atlas size, `level.dat` hash. only reaches Xwayland windows, `windowactivate --sync` hangs under kwin, focus-steal prevention can drop keystrokes, and typed chat lands in whatever has focus. Hence no input injection at all in v2. -- Still manual: **S6** (JEI/GUI overlay needs a key press), producing B7 shader option - profiles (export once from Iris' GUI, then pass `--shader-options`), checks needing F3 +- B7 shader profiles are automated: `gen_shader_profiles.py` resolves each pack's + `profile.NAME` (shaders.properties, Iris ProfileSet semantics) into + `shader-profiles/..txt`; matrix.toml passes it as `shader_options` + -> `--shader-options`. Re-run the generator (or `--check`) after a shaderpack update. +- Still manual: **S6** (JEI/GUI overlay needs a key press), checks needing F3 reads, `/sparkc` heap/GC reads (B1/B4). - MangoHud's `log_duration=1800` in `~/.local/bin/mangohud-local` caps one CSV at 30 min; `session` warns when a plan is longer (B5's 45-min decay run needs a split or a larger diff --git a/analysis/client-perf/harness/analyze.py b/analysis/client-perf/harness/analyze.py index b5251c5..252e4ec 100644 --- a/analysis/client-perf/harness/analyze.py +++ b/analysis/client-perf/harness/analyze.py @@ -22,7 +22,8 @@ import statistics import sys from pathlib import Path -INVALID = {"crashed", "timeout", "no_data", "no_markers", "low_n", "vsync_suspect"} +INVALID = {"crashed", "timeout", "no_data", "no_markers", "low_n", "vsync_suspect", + "shader_not_loaded"} DEFAULT_FILE = Path.home() / "onion-bench" / "results" / "session.jsonl" diff --git a/analysis/client-perf/harness/bench.py b/analysis/client-perf/harness/bench.py index f56cc4d..0a1ac05 100644 --- a/analysis/client-perf/harness/bench.py +++ b/analysis/client-perf/harness/bench.py @@ -1066,6 +1066,21 @@ def options_values(game, keys): 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 = [] @@ -1311,7 +1326,7 @@ def _record_session(a, sid, plan, tail, t_launch, outcome, detail, jvm_flags, cf "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"]) + noise = env_flags(meta["gpus"], meta["server_running"]) + shader_flags(a.shader, lines) if restore_failed: noise.append("restore_failed") n_ok = 0 diff --git a/analysis/client-perf/harness/gen_shader_profiles.py b/analysis/client-perf/harness/gen_shader_profiles.py new file mode 100644 index 0000000..c99a20b --- /dev/null +++ b/analysis/client-perf/harness/gen_shader_profiles.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Generate Iris per-pack option files that reproduce a shaderpack's built-in profile. + +Iris 1.8.x reads `shaderpacks/.txt` (java.util.Properties) when it loads a +pack and applies the key=value pairs as option overrides; there is no "selected +profile" key - the GUI's profile button just writes the profile's option values, and +the profile shown is re-derived by matching values (ProfileSet.scan). So a profile is +reproduced by writing its resolved option list. bench.py --shader-options copies the +file into place for one session. + +Profile syntax (shaders/shaders.properties, mirrored from Iris ProfileSet.parse): + profile.NAME = tok tok ... whitespace-separated, applied in order, later wins + profile.OTHER include another profile + !program.X disable a program (cannot be expressed in the options file -> error) + !opt opt=false + opt=val/opt:val opt=val + opt opt=true + + python3 gen_shader_profiles.py # (re)write every B7 profile in matrix.toml + python3 gen_shader_profiles.py --check # exit 1 if any file is missing/stale +""" +import argparse +import hashlib +import re +import sys +import tomllib +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +OUT = HERE / "shader-profiles" +PACKS = Path.home() / "onion-bench" / "prism" / "instances" / "OnionBench" / ".minecraft" / "shaderpacks" +MATRIX = HERE / "matrix.toml" + +IF_RE = re.compile(r"^\s*#\s*if(n?def)?\b") +ENDIF_RE = re.compile(r"^\s*#\s*endif\b") +NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]+$") +SRC_EXT = (".glsl", ".fsh", ".vsh", ".gsh", ".csh", ".tcs", ".tes", ".inc") +DEFINE_RE = re.compile(r"^\s*(?://\s*)?#define\s+(\w+)", re.M) +CONST_RE = re.compile(r"\bconst\s+\w+\s+(\w+)\s*=") + + +class ProfileError(Exception): + pass + + +def logical_lines(text): + """Java-properties logical lines: CRLF normalised, trailing-backslash continuation.""" + out, buf = [], "" + for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw if not buf else raw.lstrip() + if line.endswith("\\") and not line.endswith("\\\\"): + buf += line[:-1] + continue + out.append(buf + line) + buf = "" + if buf: + out.append(buf) + return out + + +def parse_profiles(text): + """{name: [tokens]} for every profile.NAME line. Profiles inside preprocessor + conditionals can't be evaluated without the pack's macro environment -> error.""" + profiles, depth = {}, 0 + for line in logical_lines(text): + s = line.strip() + if IF_RE.match(s): + depth += 1 + continue + if ENDIF_RE.match(s): + depth = max(0, depth - 1) + continue + if not s or s[0] in "#!": + continue + m = re.match(r"([^=:\s]+)\s*[=:]\s*(.*)$", s) + if not m or not m.group(1).startswith("profile."): + continue + name = m.group(1)[len("profile."):] + if depth: + raise ProfileError(f"profile.{name} is inside a preprocessor conditional") + profiles[name] = m.group(2).split() + return profiles + + +def resolve(name, profiles, _stack=()): + """(options dict in application order, disabled programs) for one profile.""" + if name not in profiles: + raise ProfileError(f"unknown profile {name!r} (have {sorted(profiles)})") + if name in _stack: + raise ProfileError("profile cycle: " + " -> ".join((*_stack, name))) + opts, programs = {}, [] + for tok in profiles[name]: + if tok.startswith("!program."): + programs.append(tok[len("!program."):]) + elif tok.startswith("profile."): + sub, subprog = resolve(tok[len("profile."):], profiles, (*_stack, name)) + for k, v in sub.items(): + opts.pop(k, None) + opts[k] = v + programs += subprog + else: + if tok.startswith("!"): + k, v = tok[1:], "false" + elif "=" in tok: + k, v = tok.split("=", 1) + elif ":" in tok: + k, v = tok.split(":", 1) + else: + k, v = tok, "true" + opts.pop(k, None) + opts[k] = v + return opts, programs + + +def render(opts, header=()): + lines = [f"# {h}" for h in header] + for k, v in opts.items(): + if not NAME_RE.match(k) or not v or any(c.isspace() for c in v) or "\\" in v: + raise ProfileError(f"option {k}={v!r} would need properties escaping") + lines.append(f"{k}={v}") + return "\n".join(lines) + "\n" + + +def pack_sources(zf): + props = [n for n in zf.namelist() if n.endswith("shaders/shaders.properties")] + if len(props) != 1: + raise ProfileError(f"expected one shaders/shaders.properties, found {props}") + return zf.read(props[0]).decode("utf-8", "replace") + + +def known_options(zf): + names = set() + for n in zf.namelist(): + if n.endswith(SRC_EXT): + t = zf.read(n).decode("utf-8", "replace") + names |= set(DEFINE_RE.findall(t)) | set(CONST_RE.findall(t)) + return names + + +def out_name(pack, profile): + stem = pack[:-4] if pack.endswith(".zip") else pack + return f"{stem}.{profile}.txt" + + +def generate(pack_path, profile): + """(file text, option count, unknown option names) for one pack/profile.""" + pack_path = Path(pack_path) + with zipfile.ZipFile(pack_path) as zf: + opts, programs = resolve(profile, parse_profiles(pack_sources(zf))) + if programs: + raise ProfileError(f"{profile} disables programs {programs}; an options file " + "cannot express that") + unknown = sorted(set(opts) - known_options(zf)) + sha = hashlib.sha256(pack_path.read_bytes()).hexdigest() + header = [f"Iris options reproducing profile.{profile} of {pack_path.name}", + f"pack sha256 {sha}", + "generated by gen_shader_profiles.py - do not edit; Iris rewrites the live", + "copy keeping only non-default values (bench.py journals and restores it)"] + return render(opts, header), len(opts), unknown + + +def matrix_targets(path=MATRIX): + """(pack, profile) for every session in matrix.toml with a shader_profile.""" + with open(path, "rb") as f: + m = tomllib.load(f) + seen = [] + for b in m.get("block", []): + for s in b.get("sessions", []): + if s.get("shader_profile"): + t = (s["shader"], s["shader_profile"]) + if t not in seen: + seen.append(t) + return seen + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--packs", type=Path, default=PACKS, help="shaderpacks directory") + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument("--target", action="append", metavar="PACK:PROFILE", + help="instead of the matrix.toml B7 rows") + ap.add_argument("--check", action="store_true", help="verify files are current, write nothing") + a = ap.parse_args(argv) + targets = ([tuple(t.rsplit(":", 1)) for t in a.target] if a.target else matrix_targets()) + rc = 0 + for pack, profile in targets: + text, n, unknown = generate(a.packs / pack, profile) + dst = a.out / out_name(pack, profile) + note = f" (not found in pack sources: {', '.join(unknown)})" if unknown else "" + if a.check: + ok = dst.is_file() and dst.read_text() == text + print(f"{'ok ' if ok else 'STALE'} {dst.name}: {n} options{note}") + rc |= 0 if ok else 1 + else: + a.out.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + print(f"wrote {dst.relative_to(HERE) if dst.is_relative_to(HERE) else dst}: " + f"{n} options{note}") + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/analysis/client-perf/harness/matrix.py b/analysis/client-perf/harness/matrix.py index a33e2f6..7acde53 100644 --- a/analysis/client-perf/harness/matrix.py +++ b/analysis/client-perf/harness/matrix.py @@ -38,13 +38,13 @@ DISPLAYS = {"desktop", "gamescope", "gamescope-headless"} PARAM_RE = re.compile(r"\$\{(\w+)\}") TOP_KEYS = {"defaults", "worlds", "trials", "missing_variants", "block"} -DEFAULT_KEYS = {"reps", "warmup", "capture", "display", "res", "world", "shader", "dh", +DEFAULT_KEYS = {"reps", "warmup", "capture", "settle", "display", "res", "world", "shader", "dh", "launch_overhead_s"} BLOCK_KEYS = {"id", "title", "rule", "proxy_note", "manual_checks", "sessions", "params", "deferred", "manual", "interleave"} SESSION_KEYS = {"variant", "plan", "reps", "repeat", "tag", "jvm", "heap", "shader", - "shader_profile", "dh", "disable_mods", "add_mods", "world", "warmup", - "capture", "manual", "note", "display", "res"} + "shader_profile", "shader_options", "dh", "disable_mods", "add_mods", "world", "warmup", + "capture", "settle", "manual", "note", "display", "res"} MISSING_KEYS = {"id", "blocks", "contents"} # Flags `bench.py session` accepts; bench_argv must emit only these. BENCH_SESSION_FLAGS = { @@ -173,7 +173,7 @@ def validate(m, out_dir=OUT_DIR): def _check_common(s, where, worlds, trials, err): - for k in ("reps", "warmup", "capture"): + for k in ("reps", "warmup", "capture", "settle"): if k in s and not _pos_int(s[k]): err.append(f"{where}: {k} must be a positive int") if "display" in s and s["display"] not in DISPLAYS: @@ -184,6 +184,8 @@ def _check_common(s, where, worlds, trials, err): err.append(f"{where}: dh must be on/off") if "world" in s and s["world"] not in worlds: err.append(f"{where}: world {s['world']!r} not in [worlds]") + if "shader_options" in s and s.get("shader", "off") == "off": + err.append(f"{where}: shader_options needs shader = ") def block_map(m): @@ -247,12 +249,18 @@ def resolve_path(p): return hits[-1] if hits else None +def shader_options_path(p): + """shader_options are harness-relative unless absolute / ~.""" + p = Path(os.path.expanduser(p)) + return p if p.is_absolute() else HERE / p + + def bench_argv(m, sess, live_world=False): """`bench.py session` argv for one session, plus a list of missing inputs.""" missing = [] a = ["session", "--variant", sess["variant"], "--plan", ",".join(sess["plan"]), "--reps", str(sess.get("reps", 3)), "--label", sess["label"]] - for k in ("warmup", "capture"): + for k in ("warmup", "capture", "settle"): if k in sess: a += [f"--{k}", str(sess[k])] for k in ("display", "res", "shader", "dh", "jvm"): @@ -260,6 +268,11 @@ def bench_argv(m, sess, live_world=False): a += [f"--{k}", str(sess[k])] if sess.get("heap"): a += ["--heap", str(sess["heap"])] + if sess.get("shader_options"): + so = shader_options_path(sess["shader_options"]) + if not so.is_file(): + missing.append(f"shader options {so} (run gen_shader_profiles.py)") + a += ["--shader-options", str(so)] if sess.get("disable_mods"): a += ["--disable-mods", ",".join(sess["disable_mods"])] if sess.get("add_mods"): @@ -289,11 +302,16 @@ def variant_missing(sess, out_dir=OUT_DIR): def session_seconds(sess, overhead): reps, w, c = sess.get("reps", 3), sess.get("warmup", 15), sess.get("capture", 30) scenes = sum(S5_SECONDS if p == "s5" else w + c for p in sess["plan"]) * reps - return overhead + scenes + return overhead + sess.get("settle", 10) + scenes def spec_hash(argv, sess): - blob = json.dumps({"argv": argv, "manual": bool(sess.get("manual"))}, sort_keys=True) + spec = {"argv": argv, "manual": bool(sess.get("manual"))} + if sess.get("shader_options"): # a regenerated profile file means a new spec + so = shader_options_path(sess["shader_options"]) + spec["shader_options_sha256"] = (hashlib.sha256(so.read_bytes()).hexdigest() + if so.is_file() else None) + blob = json.dumps(spec, sort_keys=True) return hashlib.sha256(blob.encode()).hexdigest()[:16] @@ -501,8 +519,10 @@ def cmd_plan(m, a): argv, miss = bench_argv(m, s, a.live_world) pre = "# MANUAL: " if s.get("manual") else "" print(f"{pre}{fmt_cmd(argv)}") - if s.get("shader_profile"): + if s.get("shader_profile") and not s.get("shader_options"): print(f"# set Iris profile {s['shader_profile']} for {s['shader']} first") + elif s.get("shader_profile"): + print(f"# Iris profile {s['shader_profile']} via shader options file") if s.get("note"): print(f"# note: {s['note']}") if unresolved(s): diff --git a/analysis/client-perf/harness/matrix.toml b/analysis/client-perf/harness/matrix.toml index 45bb5b7..19adb97 100644 --- a/analysis/client-perf/harness/matrix.toml +++ b/analysis/client-perf/harness/matrix.toml @@ -11,7 +11,9 @@ # tag distinguishes sessions that share a variant but differ by flags; the # bench label becomes "." so analyze.py groups them apart # jvm/heap BENCH-MATRIX JVM set / MaxMemAlloc MB -# shader shaderpack file name or "off"; shader_profile = Iris profile (manual today) +# shader shaderpack file name or "off"; shader_profile = the pack's profile name +# shader_options Iris per-pack options file (harness-relative), copied to +# shaderpacks/.txt; gen_shader_profiles.py builds them from the zips # dh "on" | "off" (dh-off row of B3) # disable_mods globs matched in the instance mods/ (B12a) # add_mods jar paths/globs added for the session only (B12b) @@ -25,30 +27,32 @@ reps = 3 warmup = 15 capture = 30 +settle = 45 # post-join settle; B0 v1 showed rep 1 still warming at settle 10 display = "gamescope-headless" res = "1920x1080" world = "v1" shader = "off" # pin: a shader/DH state must never leak from one session to the next dh = "on" -launch_overhead_s = 60 # launch->join (~28 s measured) + settle 10 s + teardown/restore +launch_overhead_s = 60 # launch->join (~31 s measured) + world-zip restore + teardown [worlds] -# BENCH-MATRIX §2. None of these exist yet: S4 (airships) is unbuilt. Until they do, -# `run --live-world` uses the instance's saves/onionbench as-is (no restore). +# BENCH-MATRIX §2. Built 2026-10-16 (sha256 in .pi/journal.md). `run --live-world` +# uses the instance's saves/onionbench as-is (no restore). v1 = "~/onion-bench/worlds/bench-world-v1.zip" s4-0 = "~/onion-bench/worlds/bench-world-v1-s4-0.zip" s4-1 = "~/onion-bench/worlds/bench-world-v1-s4-1.zip" s4-4 = "~/onion-bench/worlds/bench-world-v1.zip" # the main zip carries all 4 ships [trials] -# B12b trial jars: download the NeoForge 1.21.1 build from Modrinth into this directory. -gnetum = "~/onion-bench/trials/gnetum-*.jar" -better_block_entities = "~/onion-bench/trials/betterblockentities-*.jar" -flerovium = "~/onion-bench/trials/flerovium-*.jar" -super_resolution = "~/onion-bench/trials/superresolution-*.jar" -ixeris = "~/onion-bench/trials/ixeris-*.jar" -iris_flywheel_compat = "~/onion-bench/trials/irisflw-*.jar" -cull_leaves = "~/onion-bench/trials/cull-leaves-*.jar" +# B12b trial jars (NeoForge 1.21.1, Modrinth, sha512-verified 2026-10-16; see /tmp/pi-trials.md +# or the journal). Each glob matches exactly one file in ~/onion-bench/trials/. +gnetum = "~/onion-bench/trials/gnetum-3.3.6.jar" +better_block_entities = "~/onion-bench/trials/bbe-neoforge-1.3.4+mc1.21.1.jar" +flerovium = "~/onion-bench/trials/flerovium-neoforge-1.21.1-1.1.2-all.jar" +super_resolution = "~/onion-bench/trials/super_resolution-neoforge-*-0.9.2-alpha.2+opengl.jar" +ixeris = "~/onion-bench/trials/Ixeris-4.6.8+1.21.1-neoforge.jar" +iris_flywheel_compat = "~/onion-bench/trials/iris-flywheel-compat-NeoForge-2.4.0.jar" +cull_leaves = "~/onion-bench/trials/cullleaves-neoforge-4.1.1+1.21.1.jar" # Variants a block needs that build.py does not generate. Sessions using them are # reported as blocked until they exist in proposed-configs/out/. (C1-fa, C1-fa-ext, @@ -180,14 +184,14 @@ sessions = [ id = "B7" title = "shaders" rule = "Data for the owner's shader policy, no auto-decision. Report the FPS cost per profile and whether ship corruption appears with shadow profiles" -proxy_note = "Iris profiles are set in Iris' GUI; bench.py --shader only selects the pack, so profile rows stay manual until a profile mechanism exists." -manual_checks = ["Flywheel backend line in F3 (expect off with shaders)", "visual check for Iris #3164 on ships"] +proxy_note = "Profiles are reproduced by shader-profiles/*.txt (gen_shader_profiles.py resolves the pack's profile.NAME into an Iris options file); rows run unattended. Visual checks stay manual." +manual_checks = ["Flywheel backend line in F3 (expect off with shaders)", "visual check for Iris #3164 on ships (shadow profiles: makeup-medium, compl-medium)", "confirm Iris' shader screen shows the intended profile once per pack"] sessions = [ { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "off", tag = "noshader" }, - { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "MakeUp-UltraFast-9.5f.zip", shader_profile = "shadowless_medium", tag = "makeup-shadowless", manual = true }, - { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "MakeUp-UltraFast-9.5f.zip", shader_profile = "medium", tag = "makeup-medium", manual = true }, - { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "ComplementaryReimagined_r5.9.3.zip", shader_profile = "LOW", tag = "compl-low", manual = true }, - { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "ComplementaryReimagined_r5.9.3.zip", shader_profile = "MEDIUM", tag = "compl-medium", manual = true }, + { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "MakeUp-UltraFast-9.5f.zip", shader_profile = "shadowless_medium", shader_options = "shader-profiles/MakeUp-UltraFast-9.5f.shadowless_medium.txt", tag = "makeup-shadowless" }, + { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "MakeUp-UltraFast-9.5f.zip", shader_profile = "medium", shader_options = "shader-profiles/MakeUp-UltraFast-9.5f.medium.txt", tag = "makeup-medium" }, + { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "ComplementaryReimagined_r5.9.3.zip", shader_profile = "LOW", shader_options = "shader-profiles/ComplementaryReimagined_r5.9.3.LOW.txt", tag = "compl-low" }, + { variant = "C1", plan = "s1,s2,s4", world = "s4-4", shader = "ComplementaryReimagined_r5.9.3.zip", shader_profile = "MEDIUM", shader_options = "shader-profiles/ComplementaryReimagined_r5.9.3.MEDIUM.txt", tag = "compl-medium" }, ] [[block]] diff --git a/analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.LOW.txt b/analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.LOW.txt new file mode 100644 index 0000000..fb17143 --- /dev/null +++ b/analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.LOW.txt @@ -0,0 +1,18 @@ +# Iris options reproducing profile.LOW of ComplementaryReimagined_r5.9.3.zip +# pack sha256 fed6c879e732cf7fc7bc6570ebb1def43235aa7e96f62859fbdbdbfa0b484b38 +# generated by gen_shader_profiles.py - do not edit; Iris rewrites the live +# copy keeping only non-default values (bench.py journals and restores it) +SHADOW_QUALITY=0 +shadowDistance=96.0 +WATER_REFLECT_QUALITY=2 +BLOCK_REFLECT_QUALITY=1 +LIGHTSHAFT_QUALI_DEFINE=0 +SSAO_QUALI_DEFINE=2 +FXAA_DEFINE=-1 +DETAIL_QUALITY=2 +CLOUD_QUALITY=2 +ANISOTROPIC_FILTER=0 +COLORED_LIGHTING=0 +WORLD_SPACE_REFLECTIONS=-1 +ENTITY_SHADOW=-1 +RAIN_PUDDLES=0 diff --git a/analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.MEDIUM.txt b/analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.MEDIUM.txt new file mode 100644 index 0000000..6ee5b9c --- /dev/null +++ b/analysis/client-perf/harness/shader-profiles/ComplementaryReimagined_r5.9.3.MEDIUM.txt @@ -0,0 +1,18 @@ +# Iris options reproducing profile.MEDIUM of ComplementaryReimagined_r5.9.3.zip +# pack sha256 fed6c879e732cf7fc7bc6570ebb1def43235aa7e96f62859fbdbdbfa0b484b38 +# generated by gen_shader_profiles.py - do not edit; Iris rewrites the live +# copy keeping only non-default values (bench.py journals and restores it) +SHADOW_QUALITY=2 +shadowDistance=128.0 +WATER_REFLECT_QUALITY=2 +BLOCK_REFLECT_QUALITY=1 +LIGHTSHAFT_QUALI_DEFINE=1 +SSAO_QUALI_DEFINE=2 +FXAA_DEFINE=1 +DETAIL_QUALITY=2 +CLOUD_QUALITY=2 +ANISOTROPIC_FILTER=0 +COLORED_LIGHTING=0 +WORLD_SPACE_REFLECTIONS=-1 +ENTITY_SHADOW=-1 +RAIN_PUDDLES=0 diff --git a/analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.medium.txt b/analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.medium.txt new file mode 100644 index 0000000..d066be0 --- /dev/null +++ b/analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.medium.txt @@ -0,0 +1,33 @@ +# Iris options reproducing profile.medium of MakeUp-UltraFast-9.5f.zip +# pack sha256 afbf622b983d29ffa5fe5f15197f321d3921915bd44584d85f14bc11b5e0e5e8 +# generated by gen_shader_profiles.py - do not edit; Iris rewrites the live +# copy keeping only non-default values (bench.py journals and restores it) +SHADOW_CASTING=true +SHADOW_QTY_SLIDER=2 +SHADOW_DISTANCE_SLIDER=1 +REFLECTION_SLIDER=2 +DOF=false +DOF_STRENGTH=0.09 +AO=1 +AOSTEPS=5 +AO_STRENGTH=0.70 +AA_TYPE=2 +MOTION_BLUR=false +REFRACTION=1 +SUN_REFLECTION=1 +WAVING=1 +V_CLOUDS=1 +CLOUD_STEPS_AVG=10 +CLOUD_VOL_STYLE=0 +BLOOM_SAMPLES=3.0 +BLOOM=true +CHROMA_ABER=0 +VOL_LIGHT=1 +CLOUD_REFLECTION=true +WATER_ABSORPTION=0.10 +COLOR_SCHEME=1 +VANILLA_WATER=false +WATER_COLOR_SOURCE=0 +COLORED_SHADOW=true +MOTION_BLUR_SAMPLES=4 +MATERIAL_GLOSS=true diff --git a/analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.shadowless_medium.txt b/analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.shadowless_medium.txt new file mode 100644 index 0000000..ae9f6de --- /dev/null +++ b/analysis/client-perf/harness/shader-profiles/MakeUp-UltraFast-9.5f.shadowless_medium.txt @@ -0,0 +1,33 @@ +# Iris options reproducing profile.shadowless_medium of MakeUp-UltraFast-9.5f.zip +# pack sha256 afbf622b983d29ffa5fe5f15197f321d3921915bd44584d85f14bc11b5e0e5e8 +# generated by gen_shader_profiles.py - do not edit; Iris rewrites the live +# copy keeping only non-default values (bench.py journals and restores it) +SHADOW_CASTING=false +SHADOW_QTY_SLIDER=1 +SHADOW_DISTANCE_SLIDER=0 +REFLECTION_SLIDER=1 +DOF=false +DOF_STRENGTH=0.09 +AO=1 +AOSTEPS=5 +AO_STRENGTH=0.70 +AA_TYPE=0 +MOTION_BLUR=false +REFRACTION=1 +SUN_REFLECTION=1 +WAVING=1 +V_CLOUDS=1 +CLOUD_STEPS_AVG=10 +CLOUD_VOL_STYLE=0 +BLOOM_SAMPLES=3.0 +BLOOM=false +CHROMA_ABER=0 +VOL_LIGHT=0 +CLOUD_REFLECTION=false +WATER_ABSORPTION=0.10 +COLOR_SCHEME=1 +VANILLA_WATER=false +WATER_COLOR_SOURCE=0 +COLORED_SHADOW=false +MOTION_BLUR_SAMPLES=4 +MATERIAL_GLOSS=true diff --git a/analysis/client-perf/harness/tests/test_bench_flags.py b/analysis/client-perf/harness/tests/test_bench_flags.py index 3a301a7..18de97e 100644 --- a/analysis/client-perf/harness/tests/test_bench_flags.py +++ b/analysis/client-perf/harness/tests/test_bench_flags.py @@ -271,3 +271,13 @@ class OptionsAndFlags(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class ShaderLoaded(unittest.TestCase): + def test_flags(self): + ok = ["[Render thread/INFO] [Iris/]: Using shaderpack: MakeUp-UltraFast-9.5f.zip"] + bad = ok + ["[Render thread/ERROR] [Iris/]: Failed to compile shaders"] + self.assertEqual(bench.shader_flags("off", []), []) + self.assertEqual(bench.shader_flags("X.zip", ok), []) + self.assertEqual(bench.shader_flags("X.zip", []), ["shader_not_loaded"]) + self.assertEqual(bench.shader_flags("X.zip", bad), ["shader_not_loaded"]) diff --git a/analysis/client-perf/harness/tests/test_matrix.py b/analysis/client-perf/harness/tests/test_matrix.py index 76a0dc2..4d4fc41 100644 --- a/analysis/client-perf/harness/tests/test_matrix.py +++ b/analysis/client-perf/harness/tests/test_matrix.py @@ -238,7 +238,69 @@ class Commands(Fixture): def test_seconds(self): b3 = matrix.expand(self.m, matrix.block_map(self.m)["B3"]) - self.assertEqual(matrix.session_seconds(b3[0], 60), 60 + 3 * 45 + 3 * 130) + self.assertEqual(matrix.session_seconds(b3[0], 60), 60 + 10 + 3 * 45 + 3 * 130) + + +class ShaderOptions(Fixture): + def b7(self, **extra): + d = tomllib.loads(MINI.format(tmp=self.tmp)) + sess = {"variant": "C1", "plan": "s1,s4", "shader": "Pack.zip", + "shader_profile": "low", "tag": "p-low", **extra} + d["block"].append({"id": "B7", "rule": "shaders", "sessions": [sess]}) + return d + + def test_emitted(self): + f = self.tmp / "Pack.low.txt" + f.write_text("A=1\n") + d = self.b7(shader_options=str(f)) + self.assertEqual(matrix.validate(d, self.out), []) + s = matrix.expand(d, matrix.block_map(d)["B7"])[0] + argv, miss = matrix.bench_argv(d, s) + self.assertEqual(miss, []) + self.assertEqual(argv[argv.index("--shader") + 1], "Pack.zip") + self.assertEqual(argv[argv.index("--shader-options") + 1], str(f)) + self.assertIn("--shader-options", matrix.BENCH_SESSION_FLAGS) + + def test_missing_file_and_relative_path(self): + d = self.b7(shader_options="shader-profiles/does-not-exist.txt") + s = matrix.expand(d, matrix.block_map(d)["B7"])[0] + argv, miss = matrix.bench_argv(d, s) + self.assertEqual(argv[argv.index("--shader-options") + 1], + str(matrix.HERE / "shader-profiles/does-not-exist.txt")) + self.assertTrue(any("shader options" in x for x in miss)) + + def test_needs_shader(self): + d = self.b7(shader_options="x.txt", shader="off") + self.assertIn("shader_options needs shader", "\n".join(matrix.validate(d, self.out))) + + def test_spec_hash_tracks_file_content(self): + f = self.tmp / "Pack.low.txt" + f.write_text("A=1\n") + d = self.b7(shader_options=str(f)) + s = matrix.expand(d, matrix.block_map(d)["B7"])[0] + argv, _ = matrix.bench_argv(d, s) + h1 = matrix.spec_hash(argv, s) + f.write_text("A=2\n") + self.assertNotEqual(h1, matrix.spec_hash(argv, s)) + # sessions without shader_options keep the original hash formula (resume state) + b0 = matrix.expand(self.m, matrix.block_map(self.m)["B0"])[0] + a0, _ = matrix.bench_argv(self.m, b0) + blob = json.dumps({"argv": a0, "manual": False}, sort_keys=True) + import hashlib + self.assertEqual(matrix.spec_hash(a0, b0), + hashlib.sha256(blob.encode()).hexdigest()[:16]) + + def test_real_b7_automated(self): + m = matrix.load() + rows = matrix.expand(m, matrix.block_map(m)["B7"]) + shaded = [s for s in rows if s["shader"] != "off"] + self.assertEqual(len(shaded), 4) + for s in rows: + self.assertFalse(s.get("manual"), s["label"]) + for s in shaded: + argv, miss = matrix.bench_argv(m, s) + self.assertEqual([x for x in miss if "shader options" in x], [], s["label"]) + self.assertTrue(Path(argv[argv.index("--shader-options") + 1]).is_file()) class Resume(Fixture): diff --git a/analysis/client-perf/harness/tests/test_shader_profiles.py b/analysis/client-perf/harness/tests/test_shader_profiles.py new file mode 100644 index 0000000..d89b0af --- /dev/null +++ b/analysis/client-perf/harness/tests/test_shader_profiles.py @@ -0,0 +1,100 @@ +"""Offline tests for gen_shader_profiles.py (synthetic packs; no game).""" +import contextlib +import io +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) + +import gen_shader_profiles as g # noqa: E402 + +PROPS = ( + "# Profiles\r\n" + " profile.BASE = A=1 !B C D:4 \\\r\n" + " E=x\r\n" + "profile.LOW = profile.BASE A=2 !C\r\n" + "profile.TOP = profile.LOW B E=y\r\n" + "screen= A B\r\n" + "#if A == 1\r\n" + " program.world0/shadow.enabled=false\r\n" + "#endif\r\n" +) + + +class Parse(unittest.TestCase): + def test_tokens_and_continuation(self): + p = g.parse_profiles(PROPS) + self.assertEqual(sorted(p), ["BASE", "LOW", "TOP"]) + self.assertEqual(p["BASE"], ["A=1", "!B", "C", "D:4", "E=x"]) + + def test_resolve_semantics(self): + p = g.parse_profiles(PROPS) + opts, prog = g.resolve("BASE", p) + self.assertEqual(opts, {"A": "1", "B": "false", "C": "true", "D": "4", "E": "x"}) + self.assertEqual(prog, []) + opts, _ = g.resolve("TOP", p) # includes recurse; later tokens win + self.assertEqual(opts, {"D": "4", "A": "2", "C": "false", "B": "true", "E": "y"}) + + def test_errors(self): + p = {"X": ["profile.Y"], "Y": ["profile.X"]} + with self.assertRaisesRegex(g.ProfileError, "cycle"): + g.resolve("X", p) + with self.assertRaisesRegex(g.ProfileError, "unknown profile"): + g.resolve("NOPE", g.parse_profiles(PROPS)) + with self.assertRaisesRegex(g.ProfileError, "conditional"): + g.parse_profiles("#ifdef MC_OS_MAC\nprofile.M = A=1\n#endif\n") + with self.assertRaisesRegex(g.ProfileError, "escaping"): + g.render({"A": "has space"}) + + def test_programs_collected(self): + _, prog = g.resolve("P", {"P": ["!program.world0/shadow", "A"]}) + self.assertEqual(prog, ["world0/shadow"]) + + +class Generate(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.zip = self.tmp / "Pack_1.0.zip" + with zipfile.ZipFile(self.zip, "w") as z: + z.writestr("shaders/shaders.properties", PROPS) + z.writestr("shaders/lib/settings.glsl", + "#define A 1 // [1 2]\n//#define B\n#define C\nconst int D = 4; // [4 8]\n") + + def test_file_and_unknowns(self): + text, n, unknown = g.generate(self.zip, "LOW") + self.assertEqual(n, 5) + self.assertEqual(unknown, ["E"]) + body = [ln for ln in text.splitlines() if not ln.startswith("#")] + self.assertEqual(body, ["B=false", "D=4", "E=x", "A=2", "C=false"]) + self.assertIn("profile.LOW of Pack_1.0.zip", text) + self.assertEqual(g.out_name("Pack_1.0.zip", "LOW"), "Pack_1.0.LOW.txt") + + def test_program_disabling_profile_rejected(self): + z2 = self.tmp / "P2.zip" + with zipfile.ZipFile(z2, "w") as z: + z.writestr("shaders/shaders.properties", "profile.X = !program.composite A\n") + with self.assertRaisesRegex(g.ProfileError, "disables programs"): + g.generate(z2, "X") + + def test_cli_write_then_check(self): + out = self.tmp / "out" + args = ["--packs", str(self.tmp), "--out", str(out), "--target", "Pack_1.0.zip:TOP"] + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(g.main(args), 0) + self.assertTrue((out / "Pack_1.0.TOP.txt").is_file()) + self.assertEqual(g.main(args + ["--check"]), 0) + (out / "Pack_1.0.TOP.txt").write_text("stale\n") + self.assertEqual(g.main(args + ["--check"]), 1) + + def test_matrix_targets(self): + t = g.matrix_targets() + self.assertIn(("MakeUp-UltraFast-9.5f.zip", "medium"), t) + self.assertIn(("ComplementaryReimagined_r5.9.3.zip", "LOW"), t) + + +if __name__ == "__main__": + unittest.main()