Files
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

205 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Generate Iris per-pack option files that reproduce a shaderpack's built-in profile.
Iris 1.8.x reads `shaderpacks/<pack name>.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())