Files
onion-mc/analysis/client-perf/proposed-configs/build.py
T
Onion Agent e5e369a693 chore(bench): add client bench harness and Phase 2 handoff
The client now boots cleanly on the bench machine (the CAC spawn-key
crash was fixed in 8e8483d). Add the harness used to drive bench scenes
and capture frametimes, the bench-world generation workflow, and a
self-contained handoff brief. All files live under analysis/ (packwizignored),
so nothing player-facing changes.

- harness/rcon.py: minimal RCON client for the temp worldgen server
- harness/run_scene.py: apply variant -> launch Prism under MangoHud ->
  xdotool scene commands -> p50/p95/p99/1%-low from the CSV
- HANDOFF.md: state, gotchas, next steps
- build.py: disable DH autoUpdater for bench variants (suppress modal)
2026-10-14 23:16:31 -04:00

360 lines
17 KiB
Python

#!/usr/bin/env python3
"""Generate the staged client-perf configs (tiers + Phase 2 bench variants).
Every override is applied to a *base* file and must hit a key that already
exists in that base, otherwise the build fails. Bases are either the observed
defaults generated by the exact pinned jar (DH 3.3.2, Sodium 0.8.13, EMF 3.3.9,
Flywheel 1.0.6, Sable 2.0.5, SPR 1.5.1 - versions verified against the
2026-09-24 launch log) or hand-authored templates whose keys were read from the
pinned jar's source (see README.md, "Key provenance").
Output: out/<variant>/<path relative to the game dir>, plus out/MANIFEST.md.
python3 build.py # from this directory; stdlib only
python3 build.py --ship # also copy T-default into the repo (see SHIP), then
# run `packwiz refresh` from the repo root
"""
import json
import re
import shutil
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
OBSERVED = HERE.parent / "data" / "observed-default-configs"
TEMPLATES = HERE / "templates"
OUT = HERE / "out"
REPO = HERE.parents[2]
# What `--ship` copies from out/T-default into the repo, and where.
# Default Options seeds these client-side only, and only if the file is absent
# (see README §1). Not shipped: sable-client.toml and sodium-options.json (no change
# from upstream). modernfix-mixins.properties is held back until bench block B6
# passes; when it does, add it here with destination "config/modernfix-mixins.properties"
# (enforced packwiz file, because ModernFix reads it before Default Options runs) and
# re-include it in .packwizignore.
DO = "config/defaultoptions"
SHIP = {
"options.txt": f"{DO}/options.txt",
"config/DistantHorizons.toml": f"{DO}/extra/config/DistantHorizons.toml",
"config/entity_model_features.json": f"{DO}/extra/config/entity_model_features.json",
"config/flywheel-client.toml": f"{DO}/extra/config/flywheel-client.toml",
"config/lambdynlights.toml": f"{DO}/extra/config/lambdynlights.toml",
"config/ssrd-common.toml": f"{DO}/extra/config/ssrd-common.toml",
"config/sound_physics_remastered/soundphysics.properties":
f"{DO}/extra/config/sound_physics_remastered/soundphysics.properties",
"config/sound_physics_remastered/sound_rates.properties":
f"{DO}/extra/config/sound_physics_remastered/sound_rates.properties",
}
# game-dir path -> (base file, format, may_append_new_keys)
FILES = {
"options.txt": (TEMPLATES / "options.txt", "options", False),
"config/DistantHorizons.toml": (OBSERVED / "DistantHorizons.toml", "toml", False),
"config/sodium-options.json": (OBSERVED / "sodium-options.json", "json", False),
"config/entity_model_features.json": (OBSERVED / "entity_model_features.json", "json", False),
"config/flywheel-client.toml": (OBSERVED / "flywheel-client.toml", "toml", False),
"config/sable-client.toml": (OBSERVED / "sable-client.toml", "toml", False),
"config/ssrd-common.toml": (TEMPLATES / "config/ssrd-common.toml", "toml", False),
"config/lambdynlights.toml": (TEMPLATES / "config/lambdynlights.toml", "toml", False),
"config/modernfix-mixins.properties": (TEMPLATES / "config/modernfix-mixins.properties", "properties", False),
"config/sound_physics_remastered/soundphysics.properties": (
OBSERVED / "sound_physics_remastered/soundphysics.properties", "properties", False),
# SPR merges partial files over its generated defaults (SoundRateConfig.load)
"config/sound_physics_remastered/sound_rates.properties": (
TEMPLATES / "config/sound_physics_remastered/sound_rates.properties", "properties", True),
}
DH_Q = "client.advanced.graphics.quality"
DH_G = "client.advanced.graphics"
DH_T = "common.multiThreading"
DH_W = "common.worldGenerator"
# Create 6.0.10 sound IDs (from the observed sound_rates.properties, same Create version).
# Over the cap, SPR plays the sound dry (SoundPhysics.java: setDefaultEnvironment), it is not dropped.
CREATE_NOISY = [
"create:cogs", "create:mixing", "create:mixing_compounded_1",
"create:mechanical_press_activation", "create:mechanical_press_activation_compounded_1",
"create:mechanical_press_activation_belt", "create:mechanical_press_activation_belt_compounded_1",
"create:crushing_1", "create:crushing_2", "create:crushing_3",
"create:saw_activate_stone", "create:saw_activate_wood",
"create:funnel_flap", "create:funnel_flap_compounded_1",
"create:depot_slide", "create:depot_plop", "create:steam",
"create:sanding_short", "create:sanding_long",
]
def sound_caps(n):
return {s.replace(":", "\\:"): n for s in CREATE_NOISY}
# Observed DH values were computed on a 4-thread box; DH's real default is
# ceil(nproc * 0.5) (ThreadPresetConfigEventHandler) = 4 on every 8-thread fleet
# machine. Applied first to every variant so C0 is the fleet's true default.
UPSTREAM = {"config/DistantHorizons.toml": {(DH_T, "numberOfThreads"): 4}}
# Per-install values that must not ship: DH regenerates missing keys with their
# default (ConfigFileHandler.loadEntry -> saveEntry); serverId is new Random().nextInt().
STRIP = {"config/DistantHorizons.toml": [("server", "serverId")]}
# --------------------------------------------------------------------------
# Tiers. "default" is the pack-wide conservative default; deck/high are deltas
# on top of it; mid == default.
# --------------------------------------------------------------------------
DEFAULT = {
"options.txt": {
"renderDistance": 8, # server view-distance is 8; more loads nothing in MP
"simulationDistance": 8, # singleplayer only
"biomeBlendRadius": 1, # 3x3; chunk-build cost (Sodium tooltip)
"mipmapLevels": 2, # block atlas is >=4096^2
"entityDistanceScaling": 0.75, # slider steps are 0.25
},
"config/DistantHorizons.toml": {
(DH_Q, "lodChunkRenderDistanceRadius"): 128, # default 256
(DH_G, "enableSsao"): False, # default true
(DH_T, "numberOfThreads"): 2, # default ceil(nproc*0.5) = 4 on the fleet
},
"config/entity_model_features.json": {
"animationLODDistance": 12, # default 20; lower = more frame skipping
"modelUpdateFrequency": "Slow", # default Average; model refresh, not animation
},
"config/flywheel-client.toml": {
("flw_backends", "lightSmoothness"): "TRI_LINEAR", # default SMOOTH
},
"config/ssrd-common.toml": {
("", "physicsRenderDistance"): 16, # default 128; slider floor is minPhysicsRenderDistance=16
},
"config/lambdynlights.toml": {
("", "mode"): "fast", # default fancy; fast = TickMode.SLOW
},
"config/modernfix-mixins.properties": {
"mixin.perf.dynamic_resources": "true", # GATED on bench block B6
},
"config/sound_physics_remastered/soundphysics.properties": {
"environment_evaluation_ray_count": 16, # default 32
"environment_evaluation_ray_bounces": 2, # default 4
},
"config/sound_physics_remastered/sound_rates.properties": sound_caps(4),
}
DECK = {
"options.txt": {"biomeBlendRadius": 0, "particles": 1, "entityShadows": "false"},
# = DH's own LOW quality preset (RenderQualityPresetConfigEventHandler) + radius 64
"config/DistantHorizons.toml": {
(DH_Q, "lodChunkRenderDistanceRadius"): 64,
(DH_Q, "horizontalQuality"): "LOW",
(DH_Q, "verticalQuality"): "LOW",
(DH_Q, "transparency"): "DISABLED",
(DH_Q, "vanillaFadeMode"): "SINGLE_PASS",
(DH_Q, "lodBiomeBlending"): 1,
},
"config/sodium-options.json": {
"quality.weather_quality": "FAST",
"quality.leaves_quality": "FAST",
},
"config/entity_model_features.json": {
"animationLODDistance": 8,
"retainDetailOnLowFps": False,
},
"config/flywheel-client.toml": {("flw_backends", "lightSmoothness"): "FLAT"},
"config/lambdynlights.toml": {
("", "mode"): "fastest",
("light_sources", "entities"): False,
},
"config/sound_physics_remastered/soundphysics.properties": {"strict_occlusion": "true"},
}
HIGH = { # strong GPU, but still a 4C/8T CPU (true of every fleet machine)
"options.txt": {"mipmapLevels": 4, "biomeBlendRadius": 2, "entityDistanceScaling": 1.0},
"config/DistantHorizons.toml": {(DH_G, "enableSsao"): True},
"config/flywheel-client.toml": {("flw_backends", "lightSmoothness"): "SMOOTH"},
"config/ssrd-common.toml": {("", "physicsRenderDistance"): 32},
"config/lambdynlights.toml": {("light_sources", "entities"): False},
"config/sound_physics_remastered/soundphysics.properties": {"strict_occlusion": "true"},
}
# --------------------------------------------------------------------------
# Phase 2 bench variants. C0 = every file at upstream default (explicit, so stale
# instance edits can't leak in). Everything else is C1 (= T-mid) plus ONE change.
# BENCH is layered on every bench variant: uncapped, vsync off, phase0 pins,
# no DH world generation during runs (world is pre-generated).
# --------------------------------------------------------------------------
BENCH = {
"options.txt": {"maxFps": 260, "enableVsync": "false", "renderDistance": 8,
"simulationDistance": 8},
"config/DistantHorizons.toml": {
(DH_W, "generatorPlan"): "DISABLED",
# Harness: suppress DH's "new update available" modal on every launch.
("client.advanced.autoUpdater", "enableAutoUpdater"): False,
},
}
def c1(extra):
return [DEFAULT, extra]
VARIANTS = {
# player tiers (no BENCH layer)
"T-default": ([DEFAULT], False),
"T-deck": ([DEFAULT, DECK], False),
"T-mid": ([DEFAULT], False),
"T-high": ([DEFAULT, HIGH], False),
# bench: composites
"C0": ([], True),
"C1": ([DEFAULT], True),
"C1-deck": ([DEFAULT, DECK], True),
"C1-high": ([DEFAULT, HIGH], True),
# bench: Distant Horizons
"dh-r256": (c1({"config/DistantHorizons.toml": {(DH_Q, "lodChunkRenderDistanceRadius"): 256}}), True),
"dh-r192": (c1({"config/DistantHorizons.toml": {(DH_Q, "lodChunkRenderDistanceRadius"): 192}}), True),
"dh-r64": (c1({"config/DistantHorizons.toml": {(DH_Q, "lodChunkRenderDistanceRadius"): 64}}), True),
"dh-ssao": (c1({"config/DistantHorizons.toml": {(DH_G, "enableSsao"): True}}), True),
"dh-thr4": (c1({"config/DistantHorizons.toml": {(DH_T, "numberOfThreads"): 4}}), True),
"dh-qlow": (c1({"config/DistantHorizons.toml": DECK["config/DistantHorizons.toml"]
| {(DH_Q, "lodChunkRenderDistanceRadius"): 128}}), True),
# bench: Flywheel / ModernFix / Sable / SSRD
"fw-flat": (c1({"config/flywheel-client.toml": {("flw_backends", "lightSmoothness"): "FLAT"}}), True),
"fw-smooth": (c1({"config/flywheel-client.toml": {("flw_backends", "lightSmoothness"): "SMOOTH"}}), True),
"mf-dynres-off": (c1({"config/modernfix-mixins.properties": {"mixin.perf.dynamic_resources": "false"}}), True),
"ssrd-32": (c1({"config/ssrd-common.toml": {("", "physicsRenderDistance"): 32}}), True),
"ssrd-64": (c1({"config/ssrd-common.toml": {("", "physicsRenderDistance"): 64}}), True),
"ssrd-128": (c1({"config/ssrd-common.toml": {("", "physicsRenderDistance"): 128}}), True),
"sable-noshade": (c1({"config/sable-client.toml": {("", "sub_level_dynamic_shading"): False}}), True),
# bench: entities / lights / sound
"emf-lod20": (c1({"config/entity_model_features.json": {"animationLODDistance": 20,
"modelUpdateFrequency": "Average"}}), True),
"emf-lod8": (c1({"config/entity_model_features.json": {"animationLODDistance": 8,
"retainDetailOnLowFps": False}}), True),
"ldl-fancy": (c1({"config/lambdynlights.toml": {("", "mode"): "fancy"}}), True),
"ldl-fastest": (c1({"config/lambdynlights.toml": {("", "mode"): "fastest",
("light_sources", "entities"): False}}), True),
"spr-up": (c1({"config/sound_physics_remastered/soundphysics.properties": {
"environment_evaluation_ray_count": 32, "environment_evaluation_ray_bounces": 4}}), True),
"spr-strict": (c1({"config/sound_physics_remastered/soundphysics.properties": {"strict_occlusion": "true"}}), True),
"spr-nocap": (c1({"config/sound_physics_remastered/sound_rates.properties": sound_caps(-1)}), True),
# bench: vanilla / Sodium
"sod-thr3": (c1({"config/sodium-options.json": {"performance.chunk_builder_threads": 3}}), True),
"opt-blend2": (c1({"options.txt": {"biomeBlendRadius": 2}}), True),
"opt-blend0": (c1({"options.txt": {"biomeBlendRadius": 0}}), True),
"opt-mip4": (c1({"options.txt": {"mipmapLevels": 4}}), True),
}
# --------------------------------------------------------------------------
def toml_val(v):
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
return str(v)
return json.dumps(v)
def apply_toml(text, ov, name):
lines = text.split("\n")
hdr = re.compile(r"^\s*\[([^\]]+)\]\s*$")
for (section, key), val in ov.items():
cur, hits = "", []
for i, ln in enumerate(lines):
m = hdr.match(ln)
if m:
cur = m.group(1).strip()
continue
if cur == section and re.match(rf"^\s*{re.escape(key)}\s*=", ln):
hits.append(i)
if len(hits) != 1:
raise SystemExit(f"{name}: [{section}] {key}: {len(hits)} matches in base")
i = hits[0]
indent = re.match(r"^(\s*)", lines[i]).group(1)
lines[i] = f"{indent}{key} = {toml_val(val)}"
return "\n".join(lines)
def apply_json(text, ov, name):
data = json.loads(text)
for path, val in ov.items():
node, parts = data, path.split(".")
for p in parts[:-1]:
node = node[p]
if parts[-1] not in node:
raise SystemExit(f"{name}: {path} not in base")
if type(node[parts[-1]]) is not type(val):
raise SystemExit(f"{name}: {path} type {type(node[parts[-1]]).__name__} != {type(val).__name__}")
node[parts[-1]] = val
return json.dumps(data, indent=2) + "\n"
def apply_kv(text, ov, name, sep, append):
lines = text.rstrip("\n").split("\n")
for key, val in ov.items():
sval = toml_val(val) if isinstance(val, bool) else str(val)
rx = re.compile(rf"^{re.escape(key)}{re.escape(sep)}")
hits = [i for i, ln in enumerate(lines) if rx.match(ln)]
if len(hits) > 1 or (not hits and not append):
raise SystemExit(f"{name}: {key}: {len(hits)} matches in base")
if hits:
lines[hits[0]] = f"{key}{sep}{sval}"
else:
lines.append(f"{key}{sep}{sval}")
return "\n".join(lines) + "\n"
def strip(text, keys, name):
lines = text.split("\n")
hdr = re.compile(r"^\s*\[([^\]]+)\]\s*$")
for section, key in keys:
cur, hits = "", []
for i, ln in enumerate(lines):
m = hdr.match(ln)
if m:
cur = m.group(1).strip()
elif cur == section and re.match(rf"^\s*{re.escape(key)}\s*=", ln):
hits.append(i)
if len(hits) != 1:
raise SystemExit(f"{name}: strip [{section}] {key}: {len(hits)} matches")
del lines[hits[0]]
return "\n".join(lines)
def render(layers, name):
base, fmt, append = FILES[name]
text = strip(base.read_text(), STRIP.get(name, []), name)
merged = {}
for layer in [UPSTREAM] + layers:
merged.update(layer.get(name, {}))
if fmt == "toml":
return apply_toml(text, merged, name), merged
if fmt == "json":
return apply_json(text, merged, name), merged
sep = ":" if fmt == "options" else "="
return apply_kv(text, merged, name, sep, append), merged
def main():
if OUT.exists():
shutil.rmtree(OUT)
manifest = ["# Generated by build.py - do not edit\n",
"Changed keys per variant, relative to the upstream default of each file.\n"]
for vid, (layers, bench) in VARIANTS.items():
layers = list(layers) + ([BENCH] if bench else [])
manifest.append(f"\n## {vid}\n")
for name in FILES:
text, merged = render(layers, name)
dest = OUT / vid / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(text)
for k, v in merged.items():
k = f"[{k[0]}] {k[1]}" if isinstance(k, tuple) else k
manifest.append(f"- `{name}`: `{k}` = `{v}`\n")
(OUT / "MANIFEST.md").write_text("".join(manifest))
print(f"wrote {len(VARIANTS)} variants x {len(FILES)} files to {OUT}")
if "--ship" in sys.argv:
shutil.rmtree(REPO / DO, ignore_errors=True)
for src, dst in SHIP.items():
(REPO / dst).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(OUT / "T-default" / src, REPO / dst)
print(f"shipped {len(SHIP)} files to {REPO / DO}; now run `packwiz refresh`")
main()