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)
360 lines
17 KiB
Python
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()
|