HDR/SDR colour systems, step 1 (profiles, HdrLook, shader globals, console flip, freeze scene): implementation plan
HDR/SDR Colour Systems, Step 1: Implementation Plan
Section titled “HDR/SDR Colour Systems, Step 1: Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo, also readbh-sdd-concurrentbefore Task 1. Dispatch every implementer into its own worktree (isolation: "worktree", branched offorigin/main). For the registry/docs/LiveScope recipe, use skillbh-tunable-batch.
Goal: Give the game two independent colour profiles, SDR and HDR, each a full set of registered tunables (tonemap, grade, bloom, one pilot shader global). The active profile follows the real HDR-output decision. Chris can flip profiles and pick a tonemapper per profile from the console and Debug Settings, and can freeze a fixed busy scene to compare the two. The SDR look stays pixel-identical.
Architecture:
ColorProfileSdr/ColorProfileHdrhold the values as column-0static vars with identical names; the registry needs class-level statics, so each profile is its own script.ColorProfileis a static helper that maps a mode to its script, plus the tonemap names.HdrLookowns three things:- the active mode and the headroom, computed from the same
wantthatmain._sync_hdr_output()already sends toHdrOutput.sync; - the session overrides (profile flip, per-profile tonemapper);
- the
PEAK_CALIBRATIONtunable.
- the active mode and the headroom, computed from the same
EnvironmentLook.apply(env, hdr, mode)reads the active profile.ShaderGlobals.push(mode)sets each profile-driven global shader uniform.main._apply_color_profile()is the one place that re-applies both. It is called from:_new_run;- the
HOOK_ENVlive hook; - an HdrLook mode change in
_sync_hdr_output; - the tonemap command.
Tech Stack: Godot 4.7.1 typed GDScript (GUT), the Remote Console (Cloudflare Worker JS with node --test), and tools/tunable_docs (Node 22 ESM).
Spec: docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md. This plan covers Rollout step 1 only. Research: docs/research/2026-10-08-hdr-colour-best-practice.md.
Global Constraints
Section titled “Global Constraints”Scope and determinism
- Nothing under
/simchanges. Everything here is render-side, and the three determinism baselines (tests/test_determinism*.gd) must not move. - SDR is pixel-identical. The SDR profile’s bundled values equal today’s
render/environment_look.gdliterals. The tonemap values equal a stockEnvironment, because today’sapply()never touches the tonemap. TheColorProfileSdr.BORDER_HALOdefault is 2.2, the literal it replaces. - HDR output stays default OFF (
HdrOutput.ENABLED = false). The HDR profile only shows when HDR output is engaged (Debug Settings / console “HDR output (A/B)”), or when the new profile flip forces it. QualityManagerkeeps owningglow_enabled.EnvironmentLook.applynever writes it (render/quality_manager.gd:167).
Tunables and console
- Every profile value is a registered remote tunable, with:
- a
tools/tunable_docs/docs.d/*.jsonlabel; - a regenerated
control/src/tunable_docs.js, which is GENERATED (a hand edit failscontrol/test/tunable-docs.test.mjs); - the LiveScope
HOOK_ENVhook.
- a
- Debug Settings ↔ console parity (
tests/test_debug_console_parity.gd). Every new Debug Settings row carries aconsoledescriptor. The console source must contain the literalkind:'<kind>'. The caps roster must hold every option.main.apply_dev_commandmust return true for it.
Shaders
- Shader globals are declared in
[shader_globals]of BOTHproject.godotandplatform/tvos/project.godot, at the SDR value. At runtime, values are only set, never added (see Deviation 1). - Never call
RenderingServer.global_shader_parameter_get/global_shader_parameter_get_listin game code. They print “should never be used outside the editor” at runtime. - The headless gate never compiles shaders. After the shader edit, run the windowed compile check (Task 4) under
--rendering-method mobileAND--rendering-method gl_compatibility. Thengit checkout -- project.godot audio/bus_layout.tres.
Testing and process
- Tests: run
scripts/check-test-count.shwith your ownGUT_LOG=path. Never pull a raw GUT run into context. Grep the log file for failures. Run the Node tests asnode --test control/test/*.test.mjs(file globs, not the directory form). - Commits:
git commit --file=<msgfile>, ending with the session’s attribution lines. - Merging and BUILD numbers: one session merges to main at a time. Before any merge or
bump-build.sh, runListAgentsand claim merge ownership (project CLAUDE.md § Builds & releases). Unmerged branches burn BUILD numbers, so check the highestBUILDon live branches before a device install (Task 8). - Every new
docs/**/*.mdneeds Starlighttitle:frontmatter. A tunable docdescis 12-200 characters, with no dates, no em dashes and no “Sentinel”. A label is at most 48 characters.
Deviations from the spec (decided while planning, from verified evidence)
Section titled “Deviations from the spec (decided while planning, from verified evidence)”-
Globals are declared in
project.godot [shader_globals], not added only at runtime. The spec says “registered at runtime withglobal_shader_parameter_add()(NOTproject.godot [shader_globals])”. A spike in a scratch project, run on this machine’s stock 4.7.1, showed why that fails:- Runtime
addworks when rendering (mobile and gl_compatibility, windowed). - BUT a release export with
shader_baker/enabled=true(ourplatform/tvos/export_presets.cfgsetting) printsSHADER ERROR: Global uniform 'probe_gain' does not exist. Create it in Project Settings.That shader is never baked, so the device stalls on first use. - With the global declared in
[shader_globals], the baker ran clean (“Started Baking shaders (202 steps)”, no errors), and the export leftproject.godotunchanged. - A runtime
global_shader_parameter_addof a declared name is an engine error (“Condition global_shader_uniforms.variables.has(p_name) is true”).
So the plan declares, then sets at runtime.
tests/test_color_profiles.gdpins each declaration in both project files, so a Godot rewrite that drops the section fails the gate. Chris should OK this before Task 4 merges. - Runtime
-
The existing
EnvironmentLook.*keys are renamed toColorProfileSdr.*. EnvironmentLook becomes the pure applier, and the 18 old keys move to the"retired"section oftools/tunable_docs/docs.json. Published manifests up to build-281 still list them, andcontrol/test/tunable-docs.test.mjsrequires a retired doc for every key a manifest still publishes. No published patch uses them (remote-config/patches/builds 235/256/257/258 contain noEnvironmentLookkeys). This follows the memory rule “fix, don’t grandfather”:EnvironmentLook.Xwould otherwise mean “SDR” for ever. -
The tonemapper selector is a session override (
HdrLook.tonemap_override), likeHdrOutput.override, not a write to the live tunable layer.- The live layer persists across runs.
test_debug_console_paritydrives every row throughapply_dev_command, so a live-layer row would leave state behind after tests. - The tunables
ColorProfile{Sdr,Hdr}.TONEMAP_MODEstill exist and stay live through the Tunables tab. The selector wins while it is set.
- The live layer persists across runs.
-
The command kind is
color_profile, not the spec’sprofile. It is unambiguous next to the perf-scenario/perf_*commands and greps cleanly in the parity test. The tonemapper uses two kinds,tonemap_sdr/tonemap_hdr, because a Debug row’sconsoledescriptor carries ONE option field. -
AgX uses
tonemap_agx_white, nottonemap_white(research doc § tonemappers says “tonemap_white”). Godot 4.7 has separatetonemap_agx_whiteandtonemap_agx_contrastproperties, andenvironment.cpppassestonemap_agx_whitefor AgX. Each profile therefore carries both whites. -
The HDR bloom seeds are static numbers, rounded from the research formula for the Apple TV’s assumed M ≈ 4.93:
- threshold
1 + (M−1)·0.4 ≈ 2.5; - soft ramp
(M−1)·0.5 ≈ 2.0.
They are not recomputed from the live headroom (spec Decision 1: seeded once, then tuned independently).
HdrLook.headroomis computed and reported now; steps 3-4 consume it. - threshold
-
The pilot global
border_haloalso drives the tutorial/story walls, becauserender/shaders/border_look.gdshaderincis included by botharena_border.gdshaderandstory_wall.gdshader. SDR is unchanged (2.2). Retuning HDR changes both, which is the intent of the shared look.
Verified Godot 4.7.1 facts this plan relies on
Section titled “Verified Godot 4.7.1 facts this plan relies on”These came from a headless Environment.get_property_list() probe on /opt/homebrew/bin/godot 4.7.1.stable. The tvOS fork (~/Claude/godot-tvos/godot @ efa0097) has the same AgX properties.
| Property | Range (hint) | Default |
|---|---|---|
tonemap_mode |
Linear 0 (TONE_MAPPER_LINEAR), Reinhard 1 (TONE_MAPPER_REINHARDT), Filmic 2, ACES 3, AgX 4 (TONE_MAPPER_AGX) |
0 |
tonemap_exposure |
0-4, or_greater | 1.0 |
tonemap_white |
1-16, or_greater | 1.0 |
tonemap_agx_white |
2-16.5, or_greater | 16.29 |
tonemap_agx_contrast |
1-2, or_greater | 1.25 |
glow_intensity |
0-8 | 0.3 |
glow_strength |
0-2 | 1.0 |
glow_bloom |
0-1 | 0 |
glow_hdr_threshold |
0-4 | 1.0 |
glow_hdr_scale |
0-4 | 2.0 |
glow_blend_mode |
Additive 0 / Screen 1 / Soft Light 2 / Replace 3 / Mix 4 | 1 |
glow_levels/1..7 |
0-16 | 0, 0.8, 0.4, 0.1, 0, 0, 0 |
adjustment_brightness |
0-2, or_greater | 1 |
adjustment_contrast |
0.75-1.25 | 1 |
adjustment_saturation |
0-2 | 1 |
Other properties present: glow_mix 0.05, glow_normalized false, glow_hdr_luminance_cap 12, glow_map_strength 0.8, adjustment_enabled false.
Effective white in environment_storage.cpp:
- Linear: output max.
- Filmic / ACES:
max(1, white). - AgX:
max(2, agx_white)× output max. - Reinhard:
max(output max, white).
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
render/color_profile_sdr.gd (new) |
class_name ColorProfileSdr: the SDR profile statics (today’s values) plus the SDR-only LDR_* web values |
render/color_profile_hdr.gd (new) |
class_name ColorProfileHdr: the same names minus LDR_*, holding the HDR seeds |
render/color_profile.gd (new) |
class_name ColorProfile: mode consts, TONEMAP_NAMES, script_for(mode), value(mode, name) |
render/hdr_look.gd (new) |
class_name HdrLook: PEAK_CALIBRATION tunable, session overrides, active / headroom, pure decision functions, status() |
render/shader_globals.gd (new) |
class_name ShaderGlobals: the global → profile-var map and push(mode), with an injectable setter |
render/freeze_scene.gd (new) |
class_name FreezeScene: the dev freeze-scene preset node |
render/environment_look.gd |
Becomes the profile-driven applier (no statics left) |
render/shaders/border_look.gdshaderinc |
Pilot global uniform float border_halo replaces the halo’s 2.2 |
project.godot, platform/tvos/project.godot |
[shader_globals] border_halo = 2.2 |
net/tunables_registry.gd |
EnvironmentLook.* entries → ColorProfileSdr.* + ColorProfileHdr.* + HdrLook.PEAK_CALIBRATION |
net/live_scope.gd |
HOOK_PREFIXES: both profile prefixes → HOOK_ENV (replaces EnvironmentLook.) |
main.gd |
_apply_color_profile(); _sync_hdr_output drives HdrLook.update; _new_run / HOOK_ENV call sites; dev commands; caps; flags; status "color"; freeze_scene var + freeze reason |
ui/debug_settings_panel.gd |
Three cycle rows (Colour Profile, SDR Tonemap, HDR Tonemap); the HDR line adds the profile |
control/src/panel/client.js, control/src/panel/markup.js, control/src/panel/tooltips.js |
Console seg rows, Freeze card, HDR line suffix, tooltips |
control/test/_panel-harness.mjs, control/test/panel.test.mjs |
Harness exports hdrLine; tests for the new rows, gates and line |
tools/tunable_docs/docs.d/color_profiles.json (new), tools/tunable_docs/docs.d/RC9.json, tools/tunable_docs/docs.json, tools/tunable_docs/generate.mjs |
Docs for the new keys; old keys retired; group rule |
control/src/tunable_docs.js |
Regenerated |
tools/shader_check/compile_check.gd (new) |
Windowed compile check for the profile-driven materials |
tests/test_color_profiles.gd (new) |
Structural + behavioural tests for profiles, HdrLook, EnvironmentLook, ShaderGlobals, main wiring, commands |
tests/test_freeze_scene.gd (new) |
Freeze-scene behaviour through the real main |
tests/test_env_border_tunables.gd, tests/test_debug_console_parity.gd, tests/test_hdr_output.gd |
Key rename; HdrLook session restore |
control/src/whats_new.js |
Dev-builds item in the build: null entry |
docs/architecture/rendering-and-performance.md, docs/architecture/remote-config.md, docs/architecture/dev-tools.md |
Current-architecture docs |
Task 1: The two profiles, ColorProfile and HdrLook (pure logic, no wiring yet)
Section titled “Task 1: The two profiles, ColorProfile and HdrLook (pure logic, no wiring yet)”Files:
- Create:
render/color_profile_sdr.gd,render/color_profile_hdr.gd,render/color_profile.gd,render/hdr_look.gd,tests/test_color_profiles.gd
Interfaces:
-
Consumes: nothing new (Godot
Environmentenums). -
Produces:
ColorProfile.SDR/ColorProfile.HDR(int 0/1),ColorProfile.MODE_NAMES,ColorProfile.TONEMAP_NAMES,ColorProfile.SDR_ONLY_PREFIX;ColorProfile.script_for(mode) -> GDScript,ColorProfile.value(mode, name) -> Variant;HdrLook.PEAK_CALIBRATION,HdrLook.OVERRIDE_NAMES,HdrLook.override,HdrLook.tonemap_override,HdrLook.active,HdrLook.headroom;HdrLook.mode_for(engaged),headroom_for(engaged, max_linear),update(engaged, max_linear) -> bool,override_label(),set_override_label(s) -> bool,tonemap_mode(mode) -> int,tonemap_label(mode),set_tonemap_label(mode, s) -> bool,status() -> Dictionary,reset_session().
-
Step 1: Write the failing test
tests/test_color_profiles.gd:
extends GutTest
# HDR/SDR colour systems, step 1 (docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md# § Structural guards). Two full profiles with the same names; the SDR profile's bundled values ARE# the pre-profile look (step 1 is pixel-identical in SDR); HdrLook's mode / headroom decision and the# session overrides the console and Debug Settings drive.
const SDR_PATH := "res://render/color_profile_sdr.gd"const HDR_PATH := "res://render/color_profile_hdr.gd"
func after_each() -> void: Tunables.reset() HdrLook.reset_session() HdrLook.PEAK_CALIBRATION = 1.0 # not registered until Task 2; Tunables.reset() covers it after
func _statics(path: String) -> Array[String]: var out: Array[String] = [] var re := RegEx.create_from_string("(?m)^static var ([A-Za-z_][A-Za-z0-9_]*)") for m in re.search_all(FileAccess.get_file_as_string(path)): out.append(m.get_string(1)) return out
# ---- the two profiles ---------------------------------------------------------------------------
func test_every_sdr_value_has_an_hdr_counterpart_and_back() -> void: var sdr := _statics(SDR_PATH) var hdr := _statics(HDR_PATH) assert_gt(sdr.size(), 20, "the scan found the SDR profile") for n in sdr: if n.begins_with(ColorProfile.SDR_ONLY_PREFIX): continue # the web (gl_compatibility) path: SDR-only by definition assert_has(hdr, n, "ColorProfileSdr.%s has no HDR counterpart" % n) for n in hdr: assert_has(sdr, n, "ColorProfileHdr.%s has no SDR counterpart" % n) assert_false(n.begins_with(ColorProfile.SDR_ONLY_PREFIX), "%s: LDR_* values are SDR-only" % n)
# The pre-profile render/environment_look.gd literals, plus Godot 4.7's stock Environment tonemap# (the old apply() never touched the tonemap) and the border halo's old shader literal.const SDR_BUNDLED := { "TONEMAP_MODE": 0, "TONEMAP_EXPOSURE": 1.0, "TONEMAP_WHITE": 1.0, "TONEMAP_AGX_WHITE": 16.29, "TONEMAP_AGX_CONTRAST": 1.25, "GLOW_INTENSITY": 1.15, "GLOW_STRENGTH": 1.0, "GLOW_BLOOM": 0.05, "GLOW_HDR_THRESHOLD": 1.0, "GLOW_HDR_SCALE": 1.5, "GLOW_BLEND_MODE": 0, "GLOW_LEVEL_1": 0.0, "GLOW_LEVEL_2": 0.8, "GLOW_LEVEL_3": 0.4, "GLOW_LEVEL_4": 0.1, "GLOW_LEVEL_5": 0.0, "GLOW_LEVEL_6": 0.0, "GLOW_LEVEL_7": 0.0, "BRIGHTNESS": 1.0, "CONTRAST": 1.08, "SATURATION": 1.14, "BORDER_HALO": 2.2, "LDR_GLOW_INTENSITY": 0.9, "LDR_GLOW_BLOOM": 0.2,}
func test_sdr_bundled_values_are_todays_look() -> void: assert_eq(_statics(SDR_PATH).size(), SDR_BUNDLED.size(), "every SDR value is pinned here") for n: String in SDR_BUNDLED: assert_almost_eq(float(ColorProfile.value(ColorProfile.SDR, n)), float(SDR_BUNDLED[n]), 1e-4, n)
func test_sdr_tonemap_is_a_stock_environment() -> void: var stock := Environment.new() assert_eq(ColorProfileSdr.TONEMAP_MODE, int(stock.tonemap_mode)) assert_almost_eq(ColorProfileSdr.TONEMAP_EXPOSURE, stock.tonemap_exposure, 1e-6) assert_almost_eq(ColorProfileSdr.TONEMAP_WHITE, stock.tonemap_white, 1e-6) assert_almost_eq(ColorProfileSdr.TONEMAP_AGX_WHITE, stock.tonemap_agx_white, 1e-4) assert_almost_eq(ColorProfileSdr.TONEMAP_AGX_CONTRAST, stock.tonemap_agx_contrast, 1e-6)
# HDR seeds (research doc 2026-10-08-hdr-colour-best-practice.md, recommendations 2-4): a neutral# grade (contrast/saturation expand highlights past the clip), no constant bloom (wide bloomed areas# trigger TV auto-dimming), threshold 1 + (M-1)*0.4 and soft ramp (M-1)*0.5 rounded for the Apple# TV's assumed M = 4.93, Linear. Everything else starts at the SDR value; Chris tunes it on device.const HDR_SEEDS := {"BRIGHTNESS": 1.0, "CONTRAST": 1.0, "SATURATION": 1.0, "GLOW_BLOOM": 0.0, "GLOW_HDR_THRESHOLD": 2.5, "GLOW_HDR_SCALE": 2.0, "TONEMAP_MODE": 0}
func test_hdr_seeds() -> void: for n: String in HDR_SEEDS: assert_almost_eq(float(ColorProfile.value(ColorProfile.HDR, n)), float(HDR_SEEDS[n]), 1e-6, n) for n in _statics(HDR_PATH): if not HDR_SEEDS.has(n): assert_almost_eq(float(ColorProfile.value(ColorProfile.HDR, n)), float(ColorProfile.value(ColorProfile.SDR, n)), 1e-6, "%s seeds at the SDR value" % n)
func test_script_for_maps_modes() -> void: assert_eq(ColorProfile.script_for(ColorProfile.SDR).resource_path, SDR_PATH) assert_eq(ColorProfile.script_for(ColorProfile.HDR).resource_path, HDR_PATH) assert_eq(ColorProfile.MODE_NAMES, ["sdr", "hdr"] as Array[String])
func test_tonemap_names_are_godots_tonemapper_enum() -> void: assert_eq(ColorProfile.TONEMAP_NAMES.size(), 5, "phase 2 adds the sixth (fork shoulder)") assert_eq(ColorProfile.TONEMAP_NAMES.find("linear"), Environment.TONE_MAPPER_LINEAR) assert_eq(ColorProfile.TONEMAP_NAMES.find("reinhard"), Environment.TONE_MAPPER_REINHARDT) assert_eq(ColorProfile.TONEMAP_NAMES.find("filmic"), Environment.TONE_MAPPER_FILMIC) assert_eq(ColorProfile.TONEMAP_NAMES.find("aces"), Environment.TONE_MAPPER_ACES) assert_eq(ColorProfile.TONEMAP_NAMES.find("agx"), Environment.TONE_MAPPER_AGX)
# ---- HdrLook ------------------------------------------------------------------------------------
func test_mode_follows_engaged_hdr_output_unless_overridden() -> void: assert_eq(HdrLook.override_label(), "auto") assert_eq(HdrLook.mode_for(false), ColorProfile.SDR) assert_eq(HdrLook.mode_for(true), ColorProfile.HDR) assert_true(HdrLook.set_override_label("sdr")) assert_eq(HdrLook.mode_for(true), ColorProfile.SDR, "force SDR values on an HDR screen") assert_true(HdrLook.set_override_label("hdr")) assert_eq(HdrLook.mode_for(false), ColorProfile.HDR, "HDR values on an SDR screen: a preview") assert_false(HdrLook.set_override_label("bogus")) assert_eq(HdrLook.override_label(), "hdr", "an unknown label changes nothing")
func test_headroom_is_peak_times_calibration_only_while_engaged() -> void: assert_almost_eq(HdrLook.headroom_for(false, 4.93), 1.0, 1e-6, "SDR: 1.0 whatever the screen says") assert_almost_eq(HdrLook.headroom_for(true, 4.93), 4.93, 1e-6) HdrLook.PEAK_CALIBRATION = 0.5 assert_almost_eq(HdrLook.headroom_for(true, 4.93), 2.465, 1e-6) HdrLook.PEAK_CALIBRATION = 0.1 assert_almost_eq(HdrLook.headroom_for(true, 4.93), 1.0, 1e-6, "never below SDR white")
func test_update_reports_a_profile_change_once() -> void: assert_eq(HdrLook.active, ColorProfile.SDR) assert_false(HdrLook.update(false, 1.0), "SDR stays SDR: nothing to re-apply") assert_true(HdrLook.update(true, 4.93)) assert_eq(HdrLook.active, ColorProfile.HDR) assert_almost_eq(HdrLook.headroom, 4.93, 1e-6) assert_false(HdrLook.update(true, 4.93), "no change, no re-apply") assert_true(HdrLook.update(false, 4.93)) assert_almost_eq(HdrLook.headroom, 1.0, 1e-6)
func test_tonemap_selector_is_per_profile_and_wins_over_the_tunable() -> void: assert_eq(HdrLook.tonemap_label(ColorProfile.SDR), "linear") assert_true(HdrLook.set_tonemap_label(ColorProfile.HDR, "agx")) assert_eq(HdrLook.tonemap_mode(ColorProfile.HDR), Environment.TONE_MAPPER_AGX) assert_eq(HdrLook.tonemap_mode(ColorProfile.SDR), Environment.TONE_MAPPER_LINEAR, "the other profile keeps its own") assert_false(HdrLook.set_tonemap_label(ColorProfile.SDR, "shoulder"), "phase 2's sixth option is not here yet") HdrLook.reset_session() assert_eq(HdrLook.tonemap_label(ColorProfile.HDR), "linear", "reset clears the selector")
func test_status_is_json_safe() -> void: HdrLook.update(true, 4.93) var s := HdrLook.status() assert_eq(s, {"profile": "hdr", "override": "auto", "headroom": 4.93, "tonemap": "linear"}) assert_eq(JSON.parse_string(JSON.stringify(s))["profile"], "hdr")- Step 2: Run it and watch it fail
Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-colour-t1.log scripts/check-test-count.sh
Expected: FAIL. The log names test_color_profiles.gd parse errors (ColorProfile / HdrLook not declared).
- Step 3: Create
render/color_profile_sdr.gd
class_name ColorProfileSdrextends RefCounted
# The SDR colour profile (HDR/SDR colour systems, step 1; spec# docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md). Every screen NOT showing HDR# output uses these values (HdrLook.active == ColorProfile.SDR). Bundled values == the pre-profile# EnvironmentLook literals + Godot's stock tonemap, so step 1 leaves SDR pixel-identical# (tests/test_color_profiles.gd pins them). ColorProfileHdr holds the same names except the LDR_*# web values, which only SDR has.## Render-only, never read by /sim. Registered tunables (net/tunables_registry.gd), live through# LiveScope.HOOK_ENV: main._apply_color_profile() re-applies the environment and the shader globals.
# Tonemap (Environment.tonemap_*). TONEMAP_MODE: 0 linear, 1 Reinhard, 2 filmic, 3 ACES, 4 AgX# (ColorProfile.TONEMAP_NAMES). The Debug Settings / console selector (HdrLook.tonemap_override)# wins over TONEMAP_MODE for the session.static var TONEMAP_MODE: int = Environment.TONE_MAPPER_LINEARstatic var TONEMAP_EXPOSURE: float = 1.0static var TONEMAP_WHITE: float = 1.0 # Reinhard / filmic / ACES white; linear and AgX ignore itstatic var TONEMAP_AGX_WHITE: float = 16.29 # AgX only: Godot passes tonemap_agx_white, not tonemap_whitestatic var TONEMAP_AGX_CONTRAST: float = 1.25 # AgX only# Glow (bloom). With the HDR 2D canvas, only pixels above the threshold bloom.static var GLOW_INTENSITY: float = 1.15static var GLOW_STRENGTH: float = 1.0static var GLOW_BLOOM: float = 0.05 # constant bloom on everything: let the threshold gate what glowsstatic var GLOW_HDR_THRESHOLD: float = 1.0 # only >1.0 (overbright neon) blooms# Width of the soft ramp above the threshold: glow fades in from THRESHOLD and is full at# THRESHOLD + SCALE (keyed on the max channel). Not a bloom spread or a blur-pass count.static var GLOW_HDR_SCALE: float = 1.5# Environment.GlowBlendMode: 0 additive, 1 screen, 2 softlight, 3 replace, 4 mix.static var GLOW_BLEND_MODE: int = Environment.GLOW_BLEND_MODE_ADDITIVE# Godot's per-level glow weights (levels 1..7 = idx 0..6), bundled at Godot 4.7's defaults. Each level# is computed from the one before it, so a non-zero high level (5-7) adds blur passes: a perf cost.static var GLOW_LEVEL_1: float = 0.0static var GLOW_LEVEL_2: float = 0.8static var GLOW_LEVEL_3: float = 0.4static var GLOW_LEVEL_4: float = 0.1static var GLOW_LEVEL_5: float = 0.0static var GLOW_LEVEL_6: float = 0.0static var GLOW_LEVEL_7: float = 0.0# Colour grade, folded into the tonemap pass (no extra render pass). It runs AFTER the tonemap, in# sRGB-encoded space: >1 contrast/saturation expand highlights.static var BRIGHTNESS: float = 1.0static var CONTRAST: float = 1.08static var SATURATION: float = 1.14# Shader globals (ShaderGlobals.GLOBALS -> global uniform, declared in both project.godot files at# THIS value): the arena edge / tutorial wall halo's near gain (render/shaders/border_look.gdshaderinc).static var BORDER_HALO: float = 2.2# LDR (gl_compatibility / web) path: SDR-only, no HDR canvas there.static var LDR_GLOW_INTENSITY: float = 0.9static var LDR_GLOW_BLOOM: float = 0.2- Step 4: Create
render/color_profile_hdr.gd
class_name ColorProfileHdrextends RefCounted
# The HDR colour profile (HDR/SDR colour systems, step 1): used while HDR output is engaged on this# screen, or forced by the profile flip (HdrLook). Same names as ColorProfileSdr (tests/test_color_profiles.gd# pins the pairing); see that file for what each value does. SEEDED once from the headroom mapping in# docs/research/2026-10-08-hdr-colour-best-practice.md, then tuned independently: nothing derives these# from the SDR values at runtime (spec Decision 1). Registered tunables, live through LiveScope.HOOK_ENV.
static var TONEMAP_MODE: int = Environment.TONE_MAPPER_LINEAR # passes values through to the headroom clipstatic var TONEMAP_EXPOSURE: float = 1.0static var TONEMAP_WHITE: float = 1.0static var TONEMAP_AGX_WHITE: float = 16.29static var TONEMAP_AGX_CONTRAST: float = 1.25static var GLOW_INTENSITY: float = 1.15static var GLOW_STRENGTH: float = 1.0static var GLOW_BLOOM: float = 0.0 # no constant bloom: wide bloomed areas trigger TV auto-dimming# 1 + (M-1)*0.4 and (M-1)*0.5 for the Apple TV's assumed headroom M = 4.93, rounded: only the hot# part of the HDR range blooms, instead of everything above 1.0 at full feedback.static var GLOW_HDR_THRESHOLD: float = 2.5static var GLOW_HDR_SCALE: float = 2.0static var GLOW_BLEND_MODE: int = Environment.GLOW_BLEND_MODE_ADDITIVEstatic var GLOW_LEVEL_1: float = 0.0static var GLOW_LEVEL_2: float = 0.8static var GLOW_LEVEL_3: float = 0.4static var GLOW_LEVEL_4: float = 0.1static var GLOW_LEVEL_5: float = 0.0static var GLOW_LEVEL_6: float = 0.0static var GLOW_LEVEL_7: float = 0.0# Neutral grade: the post-tonemap contrast/saturation would push more values past the per-channel clip.static var BRIGHTNESS: float = 1.0static var CONTRAST: float = 1.0static var SATURATION: float = 1.0static var BORDER_HALO: float = 2.2 # seeded at the SDR value; Chris tunes it on device- Step 5: Create
render/color_profile.gd
class_name ColorProfileextends RefCounted
# The two colour profiles' shared vocabulary (HDR/SDR colour systems, step 1). The values live in# ColorProfileSdr / ColorProfileHdr (one script each: the tunables registry needs class-level statics);# HdrLook decides which one is active; EnvironmentLook and ShaderGlobals read it through value().# Render-only, never read by /sim.
const SDR := 0const HDR := 1const MODE_NAMES: Array[String] = ["sdr", "hdr"]# Index == Environment.ToneMapper (TONE_MAPPER_LINEAR .. TONE_MAPPER_AGX). Linear passes values to the# HDR clip; Reinhard equals linear unless its white exceeds the headroom; filmic/ACES cap near 1.0# (no HDR gain); AgX adapts to the headroom but desaturates toward white.const TONEMAP_NAMES: Array[String] = ["linear", "reinhard", "filmic", "aces", "agx"]const SDR_ONLY_PREFIX := "LDR_" # the web path's values exist in the SDR profile onlyconst SDR_PATH := "res://render/color_profile_sdr.gd"const HDR_PATH := "res://render/color_profile_hdr.gd"
static func script_for(mode: int) -> GDScript: return load(HDR_PATH if mode == HDR else SDR_PATH) as GDScript
# One profile value by its static var name (Tunables reads/writes the same statics this way).static func value(mode: int, name: String) -> Variant: return script_for(mode).get(name)- Step 6: Create
render/hdr_look.gd
class_name HdrLookextends RefCounted
# Which colour profile is active, and the usable HDR headroom (HDR/SDR colour systems, step 1).# Render-only, never read by /sim. main._sync_hdr_output() feeds update() the SAME `want` it gives# HdrOutput.sync() and QualityManager.set_hdr_output(), so the window request, the root canvas hold# and the profile can never disagree; a change re-applies the environment + shader globals that frame# (main._apply_color_profile). Session state (override, tonemap_override) is written by the Debug# Settings rows and the console's color_profile / tonemap_sdr / tonemap_hdr commands; like# HdrOutput.override, it survives runs because it is NOT a registered tunable (Tunables.apply resets# those at every run start).
# The player's peak calibration (step 4 adds the UI): scales the headroom the screen reports.# Registered "local": it describes THIS screen, so LAN peers may differ.static var PEAK_CALIBRATION: float = 1.0
const OVERRIDE_NAMES: Array[String] = ["auto", "sdr", "hdr"]static var override := -1 # -1 auto (follow HDR output); 0 SDR values; 1 HDR values (a preview on an SDR screen)# Per profile (index ColorProfile.SDR / HDR): -1 = that profile's TONEMAP_MODE tunable, else the# Environment.ToneMapper picked by the selector.static var tonemap_override: Array[int] = [-1, -1]static var active: int = ColorProfile.SDR # written only by update()static var headroom: float = 1.0 # brightest usable linear value; 1.0 whenever HDR output is off
static func mode_for(engaged: bool) -> int: if override >= 0: return ColorProfile.HDR if override == 1 else ColorProfile.SDR return ColorProfile.HDR if engaged else ColorProfile.SDR
static func headroom_for(engaged: bool, max_linear: float) -> float: return maxf(1.0, max_linear * PEAK_CALIBRATION) if engaged else 1.0
# Every frame from main._sync_hdr_output. True when the active profile changed (re-apply needed).static func update(engaged: bool, max_linear: float) -> bool: headroom = headroom_for(engaged, max_linear) var m := mode_for(engaged) if m == active: return false active = m return true
static func override_label() -> String: return OVERRIDE_NAMES[override + 1]
static func set_override_label(s: String) -> bool: var i := OVERRIDE_NAMES.find(s) if i < 0: return false override = i - 1 return true
static func tonemap_mode(mode: int) -> int: var o: int = tonemap_override[mode] return o if o >= 0 else int(ColorProfile.value(mode, "TONEMAP_MODE"))
static func tonemap_label(mode: int) -> String: return ColorProfile.TONEMAP_NAMES[clampi(tonemap_mode(mode), 0, ColorProfile.TONEMAP_NAMES.size() - 1)]
static func set_tonemap_label(mode: int, s: String) -> bool: var i := ColorProfile.TONEMAP_NAMES.find(s) if i < 0: return false tonemap_override[mode] = i return true
# Console /status "color" (JSON-safe) and the Debug Settings HDR line.static func status() -> Dictionary: return {"profile": ColorProfile.MODE_NAMES[active], "override": override_label(), "headroom": headroom, "tonemap": tonemap_label(active)}
# Tests: back to a fresh session (the statics outlive a freed main).static func reset_session() -> void: override = -1 tonemap_override = [-1, -1] active = ColorProfile.SDR headroom = 1.0- Step 7: Run the tests and watch them pass
Run: godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres; GUT_LOG=$TMPDIR/bh-colour-t1.log scripts/check-test-count.sh
Expected: PASS, with the script count equal to the tests/test_*.gd file count. If anything fails, grep -n "FAILED\|ERROR" $TMPDIR/bh-colour-t1.log | head.
- Step 8: Commit
git add render/color_profile_sdr.gd render/color_profile_hdr.gd render/color_profile.gd render/hdr_look.gd tests/test_color_profiles.gdprintf '%s\n' "feat(colour): SDR/HDR colour profiles + HdrLook decision (step 1, not wired yet)" "" "<attribution lines>" > $TMPDIR/msg-t1.txtgit commit --file=$TMPDIR/msg-t1.txtTask 2: Profile-driven EnvironmentLook + the registry rename, LiveScope and tunable docs
Section titled “Task 2: Profile-driven EnvironmentLook + the registry rename, LiveScope and tunable docs”Files:
- Modify:
render/environment_look.gd(whole file),net/tunables_registry.gd:46and:986-1003,net/live_scope.gd:284,tools/tunable_docs/generate.mjs:56,tools/tunable_docs/docs.d/RC9.json,tools/tunable_docs/docs.json,control/src/tunable_docs.js(generated),tests/test_env_border_tunables.gd,tests/test_color_profiles.gd - Create:
tools/tunable_docs/docs.d/color_profiles.json
Interfaces:
-
Consumes: Task 1’s
ColorProfile.script_for,HdrLook.tonemap_mode, and theColorProfileSdr.LDR_*values. -
Produces:
EnvironmentLook.apply(env: Environment, hdr: bool, mode: int = ColorProfile.SDR) -> void;- registry keys
ColorProfileSdr.*(24),ColorProfileHdr.*(22) andHdrLook.PEAK_CALIBRATION(local); - LiveScope prefixes
ColorProfileSdr./ColorProfileHdr.→HOOK_ENV.
-
Step 1: Add the failing tests to
tests/test_color_profiles.gd, appending:
# ---- EnvironmentLook reads the profile ----------------------------------------------------------
func _env(hdr: bool, mode: int) -> Environment: var env := Environment.new() env.background_mode = Environment.BG_CANVAS env.glow_enabled = true EnvironmentLook.apply(env, hdr, mode) return env
func test_sdr_apply_leaves_the_tonemap_stock() -> void: var env := _env(true, ColorProfile.SDR) var stock := Environment.new() assert_eq(env.tonemap_mode, stock.tonemap_mode) assert_almost_eq(env.tonemap_exposure, stock.tonemap_exposure, 1e-6) assert_almost_eq(env.tonemap_white, stock.tonemap_white, 1e-6) assert_almost_eq(env.tonemap_agx_white, stock.tonemap_agx_white, 1e-4) assert_almost_eq(env.tonemap_agx_contrast, stock.tonemap_agx_contrast, 1e-6)
func test_hdr_profile_reaches_the_environment() -> void: var env := _env(true, ColorProfile.HDR) assert_almost_eq(env.glow_hdr_threshold, ColorProfileHdr.GLOW_HDR_THRESHOLD, 1e-6) assert_almost_eq(env.glow_hdr_scale, ColorProfileHdr.GLOW_HDR_SCALE, 1e-6) assert_almost_eq(env.glow_bloom, 0.0, 1e-6) assert_true(env.adjustment_enabled) assert_almost_eq(env.adjustment_contrast, 1.0, 1e-6) assert_almost_eq(env.adjustment_saturation, 1.0, 1e-6) assert_true(env.glow_enabled, "never touches glow_enabled (QualityManager owns it)")
func test_a_profile_patch_and_the_tonemap_selector_reach_the_environment() -> void: assert_eq(Tunables.apply({"ColorProfileHdr.TONEMAP_MODE": Environment.TONE_MAPPER_REINHARDT, "ColorProfileHdr.TONEMAP_EXPOSURE": 1.2, "ColorProfileHdr.TONEMAP_WHITE": 6.0, "ColorProfileHdr.TONEMAP_AGX_WHITE": 8.0, "ColorProfileHdr.TONEMAP_AGX_CONTRAST": 1.5}), []) var env := _env(true, ColorProfile.HDR) assert_eq(env.tonemap_mode, Environment.TONE_MAPPER_REINHARDT) assert_almost_eq(env.tonemap_exposure, 1.2, 1e-6) assert_almost_eq(env.tonemap_white, 6.0, 1e-6) assert_almost_eq(env.tonemap_agx_white, 8.0, 1e-6) assert_almost_eq(env.tonemap_agx_contrast, 1.5, 1e-6) HdrLook.set_tonemap_label(ColorProfile.HDR, "agx") EnvironmentLook.apply(env, true, ColorProfile.HDR) # what LiveScope HOOK_ENV re-runs assert_eq(env.tonemap_mode, Environment.TONE_MAPPER_AGX, "the session selector wins over the tunable")
func test_web_ldr_path_is_sdr_only_and_unchanged() -> void: var env := _env(false, ColorProfile.SDR) assert_almost_eq(env.glow_intensity, ColorProfileSdr.LDR_GLOW_INTENSITY, 1e-6) assert_almost_eq(env.glow_bloom, ColorProfileSdr.LDR_GLOW_BLOOM, 1e-6) assert_false(env.adjustment_enabled, "the grade stays RenderingDevice-only")
func _entry(key: String) -> Dictionary: for e: Dictionary in TunablesRegistry.ENTRIES: if String(e["key"]) == key: return e return {}
func test_every_profile_value_is_a_registered_live_tunable() -> void: for pair: Array in [["ColorProfileSdr", SDR_PATH], ["ColorProfileHdr", HDR_PATH]]: for n in _statics(String(pair[1])): var key := "%s.%s" % [pair[0], n] var e := _entry(key) assert_false(e.is_empty(), "%s is registered" % key) assert_false(bool(e.get("local", false)), "%s: one look per build, part of the LAN config hash" % key) assert_eq(LiveScope.tunable_hook(key), LiveScope.HOOK_ENV, "%s re-applies live" % key) var pc := _entry("HdrLook.PEAK_CALIBRATION") assert_false(pc.is_empty()) assert_true(bool(pc.get("local", false)), "peak calibration is per screen") assert_true(_entry("EnvironmentLook.CONTRAST").is_empty(), "the old keys are retired")Also add this line to after_each(), before the PEAK_CALIBRATION line (from now on Tunables.reset() covers that static too, so keep it as a harmless belt):
HdrLook.reset_session()(It is already there from Task 1. No change is needed if it is.)
- Step 2: Run and watch it fail
Run: GUT_LOG=$TMPDIR/bh-colour-t2.log scripts/check-test-count.sh
Expected: FAIL. EnvironmentLook.apply takes 2 arguments, and the registry has no ColorProfileSdr.*.
- Step 3: Replace
render/environment_look.gdwith the applier
class_name EnvironmentLookextends RefCounted
# Applies the active colour profile to the WorldEnvironment main._new_run builds (glow, colour grade,# tonemap). The values live in ColorProfileSdr / ColorProfileHdr (HDR/SDR colour systems, step 1; they# were this class's own statics, RC-9, until then: the SDR profile's bundled values are those same# literals). Called through main._apply_color_profile(): at run start, on a LiveScope.HOOK_ENV edit,# on an HdrLook profile change and on a tonemap selector change.## apply() never touches glow_enabled: QualityManager owns that switch (bloom is L0 only), so a live# re-apply can't switch bloom back on at a lower tier.## `hdr` is the HDR 2D CANVAS (RenderingDevice backends: Metal on Apple), not HDR display output (that# decides `mode`, via HdrLook). gl_compatibility (the web build) has no HDR canvas: SDR there keeps the# old wider-but-flatter LDR glow (ColorProfileSdr.LDR_*). With the canvas, only pixels above the# threshold bloom (the additive halos are pushed >1.0 in ArchetypeRenderer): a true bloom, not a flat glow.static func apply(env: Environment, hdr: bool, mode: int = ColorProfile.SDR) -> void: if env == null: return var p := ColorProfile.script_for(mode) env.glow_blend_mode = int(p.get("GLOW_BLEND_MODE")) as Environment.GlowBlendMode env.tonemap_mode = HdrLook.tonemap_mode(mode) as Environment.ToneMapper env.tonemap_exposure = float(p.get("TONEMAP_EXPOSURE")) env.tonemap_white = float(p.get("TONEMAP_WHITE")) env.tonemap_agx_white = float(p.get("TONEMAP_AGX_WHITE")) env.tonemap_agx_contrast = float(p.get("TONEMAP_AGX_CONTRAST")) if hdr or mode == ColorProfile.HDR: env.glow_intensity = float(p.get("GLOW_INTENSITY")) env.glow_strength = float(p.get("GLOW_STRENGTH")) env.glow_bloom = float(p.get("GLOW_BLOOM")) env.glow_hdr_threshold = float(p.get("GLOW_HDR_THRESHOLD")) env.glow_hdr_scale = float(p.get("GLOW_HDR_SCALE")) for i in 7: env.set_glow_level(i, float(p.get("GLOW_LEVEL_%d" % (i + 1)))) env.adjustment_enabled = true env.adjustment_brightness = float(p.get("BRIGHTNESS")) env.adjustment_contrast = float(p.get("CONTRAST")) env.adjustment_saturation = float(p.get("SATURATION")) else: env.glow_intensity = ColorProfileSdr.LDR_GLOW_INTENSITY env.glow_bloom = ColorProfileSdr.LDR_GLOW_BLOOMmain.gd:1029 and :1298 still call apply(env, hdr). The default mode keeps them compiling, and Task 3 rewires them.
- Step 4: Rename the registry block.
In net/tunables_registry.gd:
-
Replace line 46
const ENV_LOOK := "res://render/environment_look.gd"with:const COLOR_SDR := "res://render/color_profile_sdr.gd"const COLOR_HDR := "res://render/color_profile_hdr.gd"const HDR_LOOK := "res://render/hdr_look.gd"Then confirm nothing else uses the old const:
grep -n "ENV_LOOK" net/tunables_registry.gdmust print only the lines you replace next. -
Replace the 18
EnvironmentLook.*entries (lines 986-1003) with:
# Colour profiles (HDR/SDR colour systems step 1, 2026-10-09; render/color_profile_*.gd): the # WorldEnvironment tonemap/glow/grade + shader globals per profile, live via LiveScope.HOOK_ENV. # The SDR keys were EnvironmentLook.* (RC-9) until then: same bundled values and bounds. Pinned by # tests/test_color_profiles.gd (every static of both profiles registered, SDR == the old look). {"key": "ColorProfileSdr.TONEMAP_MODE", "script": COLOR_SDR, "name": "TONEMAP_MODE", "min": 0, "max": 4}, {"key": "ColorProfileSdr.TONEMAP_EXPOSURE", "script": COLOR_SDR, "name": "TONEMAP_EXPOSURE", "min": 0.0, "max": 4.0}, {"key": "ColorProfileSdr.TONEMAP_WHITE", "script": COLOR_SDR, "name": "TONEMAP_WHITE", "min": 1.0, "max": 16.0}, {"key": "ColorProfileSdr.TONEMAP_AGX_WHITE", "script": COLOR_SDR, "name": "TONEMAP_AGX_WHITE", "min": 2.0, "max": 16.5}, {"key": "ColorProfileSdr.TONEMAP_AGX_CONTRAST", "script": COLOR_SDR, "name": "TONEMAP_AGX_CONTRAST", "min": 1.0, "max": 2.0}, {"key": "ColorProfileSdr.GLOW_INTENSITY", "script": COLOR_SDR, "name": "GLOW_INTENSITY", "min": 0.0, "max": 4.0}, {"key": "ColorProfileSdr.GLOW_STRENGTH", "script": COLOR_SDR, "name": "GLOW_STRENGTH", "min": 0.0, "max": 2.0}, {"key": "ColorProfileSdr.GLOW_BLOOM", "script": COLOR_SDR, "name": "GLOW_BLOOM", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_HDR_THRESHOLD", "script": COLOR_SDR, "name": "GLOW_HDR_THRESHOLD", "min": 0.0, "max": 4.0}, {"key": "ColorProfileSdr.GLOW_HDR_SCALE", "script": COLOR_SDR, "name": "GLOW_HDR_SCALE", "min": 0.0, "max": 4.0}, {"key": "ColorProfileSdr.GLOW_BLEND_MODE", "script": COLOR_SDR, "name": "GLOW_BLEND_MODE", "min": 0, "max": 4}, {"key": "ColorProfileSdr.GLOW_LEVEL_1", "script": COLOR_SDR, "name": "GLOW_LEVEL_1", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_LEVEL_2", "script": COLOR_SDR, "name": "GLOW_LEVEL_2", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_LEVEL_3", "script": COLOR_SDR, "name": "GLOW_LEVEL_3", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_LEVEL_4", "script": COLOR_SDR, "name": "GLOW_LEVEL_4", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_LEVEL_5", "script": COLOR_SDR, "name": "GLOW_LEVEL_5", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_LEVEL_6", "script": COLOR_SDR, "name": "GLOW_LEVEL_6", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.GLOW_LEVEL_7", "script": COLOR_SDR, "name": "GLOW_LEVEL_7", "min": 0.0, "max": 1.0}, {"key": "ColorProfileSdr.BRIGHTNESS", "script": COLOR_SDR, "name": "BRIGHTNESS", "min": 0.5, "max": 2.0}, {"key": "ColorProfileSdr.CONTRAST", "script": COLOR_SDR, "name": "CONTRAST", "min": 0.5, "max": 2.0}, {"key": "ColorProfileSdr.SATURATION", "script": COLOR_SDR, "name": "SATURATION", "min": 0.0, "max": 2.0}, {"key": "ColorProfileSdr.BORDER_HALO", "script": COLOR_SDR, "name": "BORDER_HALO", "min": 0.0, "max": 8.0}, {"key": "ColorProfileSdr.LDR_GLOW_INTENSITY", "script": COLOR_SDR, "name": "LDR_GLOW_INTENSITY", "min": 0.0, "max": 4.0}, {"key": "ColorProfileSdr.LDR_GLOW_BLOOM", "script": COLOR_SDR, "name": "LDR_GLOW_BLOOM", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.TONEMAP_MODE", "script": COLOR_HDR, "name": "TONEMAP_MODE", "min": 0, "max": 4}, {"key": "ColorProfileHdr.TONEMAP_EXPOSURE", "script": COLOR_HDR, "name": "TONEMAP_EXPOSURE", "min": 0.0, "max": 4.0}, {"key": "ColorProfileHdr.TONEMAP_WHITE", "script": COLOR_HDR, "name": "TONEMAP_WHITE", "min": 1.0, "max": 16.0}, {"key": "ColorProfileHdr.TONEMAP_AGX_WHITE", "script": COLOR_HDR, "name": "TONEMAP_AGX_WHITE", "min": 2.0, "max": 16.5}, {"key": "ColorProfileHdr.TONEMAP_AGX_CONTRAST", "script": COLOR_HDR, "name": "TONEMAP_AGX_CONTRAST", "min": 1.0, "max": 2.0}, {"key": "ColorProfileHdr.GLOW_INTENSITY", "script": COLOR_HDR, "name": "GLOW_INTENSITY", "min": 0.0, "max": 4.0}, {"key": "ColorProfileHdr.GLOW_STRENGTH", "script": COLOR_HDR, "name": "GLOW_STRENGTH", "min": 0.0, "max": 2.0}, {"key": "ColorProfileHdr.GLOW_BLOOM", "script": COLOR_HDR, "name": "GLOW_BLOOM", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_HDR_THRESHOLD", "script": COLOR_HDR, "name": "GLOW_HDR_THRESHOLD", "min": 0.0, "max": 4.0}, {"key": "ColorProfileHdr.GLOW_HDR_SCALE", "script": COLOR_HDR, "name": "GLOW_HDR_SCALE", "min": 0.0, "max": 4.0}, {"key": "ColorProfileHdr.GLOW_BLEND_MODE", "script": COLOR_HDR, "name": "GLOW_BLEND_MODE", "min": 0, "max": 4}, {"key": "ColorProfileHdr.GLOW_LEVEL_1", "script": COLOR_HDR, "name": "GLOW_LEVEL_1", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_LEVEL_2", "script": COLOR_HDR, "name": "GLOW_LEVEL_2", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_LEVEL_3", "script": COLOR_HDR, "name": "GLOW_LEVEL_3", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_LEVEL_4", "script": COLOR_HDR, "name": "GLOW_LEVEL_4", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_LEVEL_5", "script": COLOR_HDR, "name": "GLOW_LEVEL_5", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_LEVEL_6", "script": COLOR_HDR, "name": "GLOW_LEVEL_6", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.GLOW_LEVEL_7", "script": COLOR_HDR, "name": "GLOW_LEVEL_7", "min": 0.0, "max": 1.0}, {"key": "ColorProfileHdr.BRIGHTNESS", "script": COLOR_HDR, "name": "BRIGHTNESS", "min": 0.5, "max": 2.0}, {"key": "ColorProfileHdr.CONTRAST", "script": COLOR_HDR, "name": "CONTRAST", "min": 0.5, "max": 2.0}, {"key": "ColorProfileHdr.SATURATION", "script": COLOR_HDR, "name": "SATURATION", "min": 0.0, "max": 2.0}, {"key": "ColorProfileHdr.BORDER_HALO", "script": COLOR_HDR, "name": "BORDER_HALO", "min": 0.0, "max": 8.0}, # Peak calibration (step 4 adds the player UI): read every frame by main._sync_hdr_output (live). # "local": it describes THIS screen, like HdrOutput.ENABLED. {"key": "HdrLook.PEAK_CALIBRATION", "script": HDR_LOOK, "name": "PEAK_CALIBRATION", "min": 0.25, "max": 2.0, "local": true},- Step 5: Point LiveScope at the profiles.
In net/live_scope.gd:
- Line 30’s comment becomes
# EnvironmentLook.apply() + ShaderGlobals.push() (main._apply_color_profile). - Line 284
"EnvironmentLook.": HOOK_ENV,becomes:
"ColorProfileSdr.": HOOK_ENV, "ColorProfileHdr.": HOOK_ENV,- Step 6: Group rule.
In tools/tunable_docs/generate.mjs:56, replace [/^EnvironmentLook\./, 'Glow & colour grade'], with:
// EnvironmentLook.* stays matched: retired keys (docs.json "retired") still need a group. [/^(EnvironmentLook|ColorProfileSdr|ColorProfileHdr|HdrLook)\./, 'Glow & colour grade'],- Step 7: Move the docs: new keys documented, old keys retired.
Write this one-off script to $TMPDIR/colour-docs.mjs. Do not commit it. It deletes the 18 EnvironmentLook.* entries from docs.d/RC9.json, retires them in docs.json, and writes docs.d/color_profiles.json:
// One-off (HDR/SDR colour systems step 1): EnvironmentLook.* docs -> ColorProfileSdr/Hdr.* + retired.import { readFileSync, writeFileSync } from 'node:fs';const root = process.argv[2];const rc9Path = root + '/tools/tunable_docs/docs.d/RC9.json';const docsPath = root + '/tools/tunable_docs/docs.json';const rc9 = JSON.parse(readFileSync(rc9Path, 'utf8'));const docs = JSON.parse(readFileSync(docsPath, 'utf8'));const old = {};for (const k of Object.keys(rc9)) if (k.startsWith('EnvironmentLook.')) { old[k] = rc9[k]; delete rc9[k]; }if (Object.keys(old).length !== 18) throw new Error('expected 18 EnvironmentLook docs, got ' + Object.keys(old).length);for (const [k, d] of Object.entries(old)) { docs.retired[k] = { label: (d.label + ' (old builds)').slice(0, 48), desc: 'Older builds only: ' + d.desc.replace(/ on Metal( \(HDR\))? builds/g, '') + ' Newer builds: ColorProfileSdr.', src: 'code' };}// [name, label, desc, unit?]: one base text per value; each profile prefixes its own.const BASE = [ ['TONEMAP_MODE', 'tonemapper', 'Tonemap curve: 0 linear, 1 Reinhard, 2 filmic, 3 ACES, 4 AgX. Linear passes values through; filmic and ACES cap near white.'], ['TONEMAP_EXPOSURE', 'tonemap exposure', 'Scene exposure before the tonemap curve. 1 = unchanged.', '×'], ['TONEMAP_WHITE', 'tonemap white point', 'Reference white for Reinhard, filmic and ACES. Linear and AgX ignore it.', '×'], ['TONEMAP_AGX_WHITE', 'AgX white point', 'White point of the AgX curve only. Godot default 16.29.', '×'], ['TONEMAP_AGX_CONTRAST', 'AgX contrast', 'Contrast of the AgX curve only. Godot default 1.25.', '×'], ['GLOW_INTENSITY', 'bloom intensity', 'Overall strength of the bloom glow.', '×'], ['GLOW_STRENGTH', 'bloom strength', 'Glow spread strength. Higher = wider, softer bloom.', '×'], ['GLOW_BLOOM', 'bloom base', 'Constant bloom added to everything, not only bright neon. Higher washes the scene out.', 'frac'], ['GLOW_HDR_THRESHOLD', 'bloom threshold', 'Brightness above which a pixel blooms. 1 = only overbright neon glows.', '×'], ['GLOW_HDR_SCALE', 'bloom soft ramp', 'Width of the fade-in above the threshold: glow is full at threshold plus this.', '×'], ['GLOW_BLEND_MODE', 'bloom blend mode', 'How the glow is combined with the scene: 0 additive, 1 screen, 2 softlight, 3 replace, 4 mix.'], ['GLOW_LEVEL_1', 'bloom level 1', 'Weight of the finest bloom blur level.', 'frac'], ['GLOW_LEVEL_2', 'bloom level 2', 'Weight of the fine bloom blur level.', 'frac'], ['GLOW_LEVEL_3', 'bloom level 3', 'Weight of the medium bloom blur level.', 'frac'], ['GLOW_LEVEL_4', 'bloom level 4', 'Weight of the wide bloom blur level.', 'frac'], ['GLOW_LEVEL_5', 'bloom level 5', 'Weight of the wider bloom blur level. Non-zero adds blur passes: a fill-rate cost.', 'frac'], ['GLOW_LEVEL_6', 'bloom level 6', 'Weight of the very wide bloom blur level. Non-zero adds blur passes: a fill-rate cost.', 'frac'], ['GLOW_LEVEL_7', 'bloom level 7', 'Weight of the widest bloom blur level. Non-zero adds blur passes: a fill-rate cost.', 'frac'], ['BRIGHTNESS', 'grade brightness', 'Final-image brightness. 1 = unchanged.', '×'], ['CONTRAST', 'grade contrast', 'Final-image contrast, applied after the tonemap. 1 = unchanged.', '×'], ['SATURATION', 'grade saturation', 'Final-image colour saturation. 1 = unchanged, 0 = greyscale.', '×'], ['BORDER_HALO', 'arena edge halo gain', 'Near gain of the arena edge and tutorial wall halo (shader global border_halo). 2.2 = the shipped look.', '×'],];const WHO = { ColorProfileSdr: ['SDR', 'SDR look (no HDR output): '], ColorProfileHdr: ['HDR', 'HDR look (HDR output on): '] };const out = {};for (const [cls, [tag, lead]] of Object.entries(WHO)) { for (const [name, label, desc, unit] of BASE) { const d = { label: tag + ' ' + label, desc: lead + desc, src: 'code', group: 'Glow & colour grade' }; if (unit) d.unit = unit; out[cls + '.' + name] = d; }}out['ColorProfileSdr.LDR_GLOW_INTENSITY'] = { label: 'Web bloom intensity', desc: 'Glow strength on the web (compatibility renderer) build, which has no HDR bloom.', src: 'code', unit: '×', group: 'Glow & colour grade' };out['ColorProfileSdr.LDR_GLOW_BLOOM'] = { label: 'Web bloom base', desc: 'Constant glow added to everything on the web (compatibility renderer) build.', src: 'code', unit: 'frac', group: 'Glow & colour grade' };out['HdrLook.PEAK_CALIBRATION'] = { label: 'HDR peak calibration', desc: 'Scales the HDR headroom this screen reports. 1 = trust the screen. Only used while HDR output is engaged.', src: 'code', unit: '×', group: 'Glow & colour grade' };writeFileSync(rc9Path, JSON.stringify(rc9, null, 1) + '\n');writeFileSync(docsPath, JSON.stringify(docs, null, 1) + '\n');writeFileSync(root + '/tools/tunable_docs/docs.d/color_profiles.json', JSON.stringify(out, null, 1) + '\n');console.log('retired', Object.keys(old).length, 'new', Object.keys(out).length);Run: node $TMPDIR/colour-docs.mjs "$PWD" && node tools/tunable_docs/generate.mjs && git diff --stat -- tools/tunable_docs control/src/tunable_docs.js
Expected:
- the script prints
retired 18 new 47; - the diff stat shows
RC9.jsonwith deletions only; docs.jsonshows insertions only (if the stringify round-trip reformatted other lines, restore it withgit checkoutand add theretiredentries by hand instead);color_profiles.jsonis new;tunable_docs.jshas changed.
Then check that no label exceeds 48 characters. The longest is “SDR arena edge halo gain” (24).
- Step 8: Rename the keys in
tests/test_env_border_tunables.gd
Run: sed -i '' 's/"EnvironmentLook\./"ColorProfileSdr./g' tests/test_env_border_tunables.gd
Then edit line 109 so it covers both profiles:
const RC9_PREFIXES := ["NebulaBg.", "ArenaBackground.", "ArenaBorder.", "ColorProfileSdr.", "ColorProfileHdr."]Its EnvironmentLook.apply(env, hdr) / apply(env, true) calls stay as they are: the default mode is SDR, which is exactly what it pins.
- Step 9: Run everything
Run:
godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tresGUT_LOG=$TMPDIR/bh-colour-t2.log scripts/check-test-count.shnode --test control/test/tunable-docs.test.mjsExpected:
-
GUT: PASS. In particular
test_tunables_registry,test_live_scope,test_env_border_tunables,test_color_profilesandtest_hdr_outputare green. -
Node: all pass, including “retired tunables are out of the registry and still documented for older builds”.
-
Step 10: Commit
Stage the changed files plus the new docs.d/color_profiles.json, then commit with the subject feat(colour): profile-driven EnvironmentLook; EnvironmentLook.* keys become ColorProfileSdr/Hdr.* (old keys retired), via git commit --file.
Task 3: Wire the profile into main (one trigger, one re-apply)
Section titled “Task 3: Wire the profile into main (one trigger, one re-apply)”Files:
- Modify:
main.gdat::1028-1029(HOOK_ENV);:1293-1298(_new_runenv build);:2144-2148(_sync_hdr_output);:3720-3722and:3748(build_status, both branches);
- Modify:
tests/test_color_profiles.gd,tests/test_hdr_output.gd
Interfaces:
-
Consumes:
HdrLook.update,HdrLook.active,HdrLook.status, and the 3-argumentEnvironmentLook.apply. -
Produces:
main._apply_color_profile() -> void, the single re-apply point (Task 4 adds the globals push to it);/statuskey"color"(HdrLook.status()), in the menu and in a run.
-
Step 1: Add the failing main-level tests to
tests/test_color_profiles.gd:
# ---- main wiring (real boot, real _process) ------------------------------------------------------
var _was_hdr_override := -1var _was_root_hdr := false
func before_each() -> void: _was_hdr_override = HdrOutput.override _was_root_hdr = get_tree().root.use_hdr_2d
func _restore_main_state() -> void: HdrOutput.override = _was_hdr_override get_tree().root.use_hdr_2d = _was_root_hdr
func _boot() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") return m
func _win() -> Window: var w := Window.new() autofree(w) return w
# Headless has no RenderingDevice: stand in for a Metal device on an HDR screen (as test_hdr_output does).func _hdr_device(m: Node) -> void: m._hdr_output_capable = true m._hdr_window_override = _win() m._hdr_supported_override = true m.quality_manager.hdr_capable = true m._apply_color_profile() # the run was built on the LDR path; rebuild it as a Metal run
func test_profile_follows_hdr_output_and_reapplies_the_running_environment() -> void: var m: Node = await _boot() _hdr_device(m) m._process(1.0 / 60.0) assert_eq(HdrLook.active, ColorProfile.SDR, "HDR output default off: the SDR profile") var env: Environment = m.world_env.environment assert_almost_eq(env.adjustment_contrast, 1.08, 1e-6) HdrOutput.override = 1 m._process(1.0 / 60.0) assert_eq(HdrLook.active, ColorProfile.HDR, "the same frame as the request") assert_almost_eq(env.adjustment_contrast, ColorProfileHdr.CONTRAST, 1e-6) assert_almost_eq(env.glow_hdr_threshold, ColorProfileHdr.GLOW_HDR_THRESHOLD, 1e-6) HdrOutput.override = 0 m._process(1.0 / 60.0) assert_eq(HdrLook.active, ColorProfile.SDR) assert_almost_eq(env.adjustment_contrast, 1.08, 1e-6, "back to SDR, live") _restore_main_state()
func test_a_live_profile_edit_reaches_the_running_environment() -> void: var m: Node = await _boot() _hdr_device(m) m.apply_dev_command({"kind": "set_tunables", "tunables": {"ColorProfileSdr.SATURATION": 0.7}}) assert_almost_eq(m.world_env.environment.adjustment_saturation, 0.7, 1e-6, "HOOK_ENV re-applies") m.apply_dev_command({"kind": "set_tunables", "reset": true}) _restore_main_state()
func test_status_carries_the_colour_profile_in_menu_and_run() -> void: var m: Node = await _boot() var s: Dictionary = m.build_status() assert_has(s, "color") assert_eq(s["color"]["profile"], "sdr") assert_eq(s["color"]["tonemap"], "linear") _restore_main_state()test_a_live_profile_edit... goes through _dev_set_tunables → _apply_live_config → HOOK_ENV (main.gd:973-990, :1028). The reset command clears the live layer it wrote (RemoteConfig.set_live honours reset, net/remote_config.gd:237).
- Step 2: Run and watch it fail
Run: GUT_LOG=$TMPDIR/bh-colour-t3.log scripts/check-test-count.sh
Expected: FAIL. _apply_color_profile does not exist on main.
- Step 3: Add
_apply_color_profileand use it.
In main.gd:
- Replace lines 1028-1029:
if hooks.has(LiveScope.HOOK_ENV): _apply_color_profile() # a ColorProfileSdr/Hdr edit: environment + shader globals, live- In
_new_run, replace lines 1293 and 1298. Line 1293 is the comment; line 1298 isEnvironmentLook.apply(env, hdr). Lines 1294-1297 stay as they are:
# (web) returns null here and stays LDR. The look is the active colour profile (HdrLook.active): # main._apply_color_profile() below, once world_env holds the environment.Then replace the line EnvironmentLook.apply(env, hdr) and the two lines after it (world_env.environment = env, add_child(world_env)) with:
world_env.environment = env add_child(world_env) _apply_color_profile()- Directly after
_hdr_window()(main.gd:2155-2156), add:
# The ONE re-apply of the active colour profile (HDR/SDR colour systems): the WorldEnvironment look and# the profile-driven shader globals, from HdrLook.active. Callers: _new_run, the LiveScope HOOK_ENV# edit, an HdrLook profile change (_sync_hdr_output) and the tonemap selector commands.func _apply_color_profile() -> void: if world_env != null and world_env.environment != null and quality_manager != null: EnvironmentLook.apply(world_env.environment, quality_manager.hdr_capable, HdrLook.active)- Extend
_sync_hdr_output(main.gd:2144-2148) so it reads:
func _sync_hdr_output() -> void: var want := HdrOutput.wanted(_hdr_output_capable, _hdr_screen_supported()) var win := _hdr_window() HdrOutput.sync(win, want) if quality_manager != null: quality_manager.set_hdr_output(want, get_viewport()) # The colour profile follows the same decision (HdrLook), re-applied in this frame on a change. if HdrLook.update(want, win.get_output_max_linear_value() if want and win != null else 1.0): _apply_color_profile()Extend the comment block above the function (main.gd:2138-2143) with the line:
# The colour profile (HdrLook.active) rides the same `want`, so request, hold and look never disagree.- In
build_status, add"color": HdrLook.status()next to each"hdr": ...key:- menu branch,
main.gd:3722: append, "color": HdrLook.status()inside the dict; - run branch,
main.gd:3748: add a line"color": HdrLook.status(), # active colour profile (HdrLook).
- menu branch,
- Step 4: Restore HdrLook in
tests/test_hdr_output.gd
Its tests flip HDR output through main, which now moves HdrLook.active. In its after_each() (tests/test_hdr_output.gd:18-22), add:
HdrLook.reset_session()- Step 5: Run and watch it pass
Run: GUT_LOG=$TMPDIR/bh-colour-t3.log scripts/check-test-count.sh
Expected: PASS. test_live_scope’s one-shot scan stays green: _apply_color_profile reads no registry key by name.
- Step 6: Commit with the subject
feat(colour): main applies the active colour profile; HdrLook follows the HDR-output decision.
Task 4: Shader globals plumbing + the border_halo pilot + the windowed compile check
Section titled “Task 4: Shader globals plumbing + the border_halo pilot + the windowed compile check”Files:
- Create:
render/shader_globals.gd,tools/shader_check/compile_check.gd - Modify:
project.godot: add a[shader_globals]section after[rendering], before the trailing# Movement input actionscomment block, as its own section;platform/tvos/project.godot: the same, after[rendering]at line 43 and before[debug]at line 54, using;comments only (a#there swallowed the[sentry]section once);render/shaders/border_look.gdshaderinc:26;main.gd(_apply_color_profile);tests/test_color_profiles.gd.
Interfaces:
-
Consumes:
ColorProfile.script_for, andHdrLook.activevia main. -
Produces:
ShaderGlobals.GLOBALS: Dictionary(StringName global → profile var name);ShaderGlobals.setter: Callable;ShaderGlobals.push(mode: int);- the shader global
border_halo(float, project default 2.2).
-
Step 1: Add the failing tests to
tests/test_color_profiles.gd:
# ---- shader globals -----------------------------------------------------------------------------
var _pushed: Array = []
func _spy(name: StringName, v: Variant) -> void: _pushed.append([name, v])
func test_every_global_is_declared_in_both_projects_at_its_sdr_value() -> void: # The export shader baker (shader_baker/enabled) fails on a runtime-only global and leaves that # shader unbaked: a first-use stall on device. So each one is declared, and only SET at runtime. for path: String in ["res://project.godot", "res://platform/tvos/project.godot"]: var cf := ConfigFile.new() assert_eq(cf.load(path), OK, path) for g: StringName in ShaderGlobals.GLOBALS: var d: Variant = cf.get_value("shader_globals", String(g), null) assert_true(d is Dictionary, "%s declares [shader_globals] %s" % [path, g]) if d is Dictionary: assert_eq(String((d as Dictionary)["type"]), "float", "%s %s" % [path, g]) assert_almost_eq(float((d as Dictionary)["value"]), float(ColorProfile.value(ColorProfile.SDR, String(ShaderGlobals.GLOBALS[g]))), 1e-6, "%s %s == the SDR profile value (the look before any push)" % [path, g])
func test_push_sends_the_profiles_values() -> void: var was: Callable = ShaderGlobals.setter ShaderGlobals.setter = _spy assert_eq(Tunables.apply({"ColorProfileHdr.BORDER_HALO": 3.5}), []) _pushed.clear() ShaderGlobals.push(ColorProfile.HDR) assert_eq(_pushed, [[&"border_halo", 3.5]]) _pushed.clear() ShaderGlobals.push(ColorProfile.SDR) assert_eq(_pushed, [[&"border_halo", 2.2]]) ShaderGlobals.setter = was
func test_border_halo_reads_the_global() -> void: var src := FileAccess.get_file_as_string("res://render/shaders/border_look.gdshaderinc") assert_true(src.contains("global uniform float border_halo;")) assert_true(src.contains("exp(-hd / 5.0) * border_halo"), "the halo's near gain is the global")
func test_main_pushes_globals_on_a_profile_change() -> void: var m: Node = await _boot() _hdr_device(m) var was: Callable = ShaderGlobals.setter ShaderGlobals.setter = _spy assert_eq(Tunables.apply({"ColorProfileHdr.BORDER_HALO": 4.0}), []) _pushed.clear() HdrOutput.override = 1 m._process(1.0 / 60.0) assert_has(_pushed, [&"border_halo", 4.0], "HDR engaged: the HDR value, same frame") ShaderGlobals.setter = was _restore_main_state()-
Step 2: Run and watch it fail. Run
GUT_LOG=$TMPDIR/bh-colour-t4.log scripts/check-test-count.sh. Expected: FAIL, becauseShaderGlobalsis not declared. -
Step 3: Create
render/shader_globals.gd
class_name ShaderGlobalsextends RefCounted
# Global shader uniforms whose value is per colour profile (HDR/SDR colour systems, step 1; spec § 2).# Each one is DECLARED in [shader_globals] of BOTH project.godot and platform/tvos/project.godot at the# SDR profile's value: the export shader baker (shader_baker/enabled in platform/tvos/export_presets.cfg)# compiles every shader at export and fails on a global that only exists at runtime ("Global uniform# ... does not exist. Create it in Project Settings.", so that shader is not baked: a first-use stall on# device). At runtime the active profile only SETS values. global_shader_parameter_add on a declared# name is an engine error, and global_shader_parameter_get / get_list are editor-only, so nothing here# reads back. tests/test_color_profiles.gd pins the declarations against GLOBALS.## Adding a global: a `global uniform float <name>;` in the shader, a <NAME> static in BOTH profiles# (registered, docs.d label), an entry here, and the [shader_globals] block in both project files.
const GLOBALS := {&"border_halo": "BORDER_HALO"} # global uniform -> the profile static var it reads# Tests swap in a spy (they can't read a global back).static var setter: Callable = Callable(RenderingServer, "global_shader_parameter_set")
static func push(mode: int) -> void: var p := ColorProfile.script_for(mode) for g: StringName in GLOBALS: setter.call(g, p.get(String(GLOBALS[g])))- Step 4: Declare the global in both project files.
Root project.godot: insert a new section between the [rendering] section’s last key line (environment/defaults/default_clear_color=...) and the # Movement input actions ... comment:
[shader_globals]
# Profile-driven globals (render/shader_globals.gd): declared here so the export shader baker can# compile them; the value is the SDR profile's (ColorProfileSdr), the runtime pushes the active one.border_halo={"type": "float","value": 2.2}platform/tvos/project.godot: insert the same block before [debug] (line 54), using ; comments:
[shader_globals]
; Profile-driven globals (render/shader_globals.gd): declared here so the export shader baker can; compile them; the value is the SDR profile's (ColorProfileSdr), the runtime pushes the active one.border_halo={"type": "float","value": 2.2}Then check that ConfigFile still parses the whole tvOS file and [sentry] survived:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_release_presets.gd -gexit > $TMPDIR/bh-presets.log 2>&1; grep -E "Passing|Failing" $TMPDIR/bh-presets.log | tail -3Expected: no failing tests.
- Step 5: Switch the shader to the global.
In render/shaders/border_look.gdshaderinc, insert this immediately above the // d: signed distance (px)... comment (currently line 18):
// The halo's near gain, per colour profile (ShaderGlobals / ColorProfileSdr|Hdr.BORDER_HALO). Declared// in both project.godot [shader_globals] at the SDR value 2.2, so the SDR edge is unchanged.global uniform float border_halo;Then on line 26, replace exp(-hd / 5.0) * 2.2 with exp(-hd / 5.0) * border_halo.
- Step 6: Push from main. Extend
_apply_color_profile()(Task 3) so it reads:
func _apply_color_profile() -> void: if world_env != null and world_env.environment != null and quality_manager != null: EnvironmentLook.apply(world_env.environment, quality_manager.hdr_capable, HdrLook.active) ShaderGlobals.push(HdrLook.active) # no world_env needed: the menu's profile change lands too-
Step 7: Headless tests. Run
GUT_LOG=$TMPDIR/bh-colour-t4.log scripts/check-test-count.sh. Expected: PASS. -
Step 8: Create the windowed compile check
tools/shader_check/compile_check.gd:
extends SceneTree# DEV ONLY: windowed shader compile check for the profile-driven materials (the headless gate never# compiles shaders; HDR/SDR colour systems step 1). Draws each material once on a ColorRect, renders a# few frames, quits. Run under BOTH renderers and grep the output, then restore the files Godot rewrites:# godot --path . --rendering-method mobile -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "SHADER ERROR|Global uniform|compile_check"# godot --path . --rendering-method gl_compatibility -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "SHADER ERROR|Global uniform|compile_check"# git checkout -- project.godot audio/bus_layout.tres# Add every material whose shader reads a ShaderGlobals global.
const MATERIALS: Array[String] = [ "res://render/shaders/arena_border_mat.tres", "res://render/shaders/story_wall_mat.tres",]const FRAMES := 10var _frames := 0
func _initialize() -> void: for i in MATERIALS.size(): var r := ColorRect.new() r.material = load(MATERIALS[i]) as Material r.position = Vector2(40.0 + 240.0 * i, 40.0) r.size = Vector2(200.0, 200.0) root.add_child(r) print("compile_check: drawing %d materials" % MATERIALS.size())
func _process(_dt: float) -> bool: _frames += 1 if _frames == FRAMES: print("compile_check: done") return _frames >= FRAMES- Step 9: Run the windowed compile check under both renderers
Run:
godot --path . --rendering-method mobile -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "SHADER ERROR|Global uniform|compile_check"godot --path . --rendering-method gl_compatibility -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "SHADER ERROR|Global uniform|compile_check"git checkout -- project.godot audio/bus_layout.tres; git status --shortExpected:
- each run prints only
compile_check: drawing 2 materialsandcompile_check: done, with noSHADER ERRORand noGlobal uniform; git statuslists only this task’s intended files.
Careful: git checkout -- project.godot would also discard the Step 4 edit if it is uncommitted. Commit the [shader_globals] block first (Step 10), or diff before restoring and keep only the section.
If either renderer prints an error about the global uniform inside an include, move the declaration out of the include: put global uniform float border_halo; into arena_border.gdshader and story_wall.gdshader just above their #include line instead (an include is textual, so the function still sees it). Then re-run.
-
Step 10: Commit (do this BEFORE the Step 9 restore if
project.godotis uncommitted). Use the subjectfeat(colour): profile-driven shader globals; border_halo pilot (declared in both project.godot files for the export baker). -
Step 11: Check the export baker locally (it is the reason for Deviation 1):
cp /Users/chris/Claude/bullet-heaven/export_presets.cfg . 2>/dev/nullgodot --headless --path . --export-release "macOS" $TMPDIR/bh-colour-mac/DarkCosmos.app 2>&1 | grep -E "SHADER ERROR|Global uniform|Baking" | headgit checkout -- project.godot audio/bus_layout.tres; rm -f export_presets.cfgExpected: no Global uniform 'border_halo' does not exist.
If the root macOS preset does not have the shader baker enabled (check grep -n shader_baker export_presets.cfg), record that in the task report. The device proof is then Task 8’s fresh-install check on the tvOS/iOS export, which does bake.
Task 5: Console + Debug Settings: profile flip and per-profile tonemapper (parity)
Section titled “Task 5: Console + Debug Settings: profile flip and per-profile tonemapper (parity)”Files:
- Modify:
main.gd:apply_dev_command(before the sim guard, next togalaxy_lookat:3520-3525);_dev_flag_states(after:3828);build_caps(dev_commandsat:3871, rosters aftergalaxy_looksat:3890);ui/debug_settings_panel.gd:_build_rows(:585), new row builders after_galaxy_look_row(:796-806),_refresh_hdr_label(:1245-1247);control/src/panel/client.js:lookCaps/renderLooks(:502-536),hdrLine/renderHdr(:121-132);control/src/panel/tooltips.js;control/test/_panel-harness.mjs:54,control/test/panel.test.mjs;tests/test_debug_console_parity.gd(before/after),tests/test_color_profiles.gd.
Interfaces:
-
Consumes:
HdrLook.set_override_label/override_label/set_tonemap_label/tonemap_label/OVERRIDE_NAMES,ColorProfile.TONEMAP_NAMES,main._sync_hdr_output,main._apply_color_profile. -
Produces:
- dev commands
{kind:"color_profile", profile:"auto"|"sdr"|"hdr"}and{kind:"tonemap_sdr"|"tonemap_hdr", mode:"linear"|"reinhard"|"filmic"|"aces"|"agx"}; - caps
color_profiles,tonemap_modes, plus those 3 kinds indev_commands; - flags
look:profile,look:tonemap_sdr,look:tonemap_hdr.
- dev commands
-
Step 1: Add the failing GUT tests to
tests/test_color_profiles.gd:
# ---- console + Debug Settings -------------------------------------------------------------------
func test_console_flips_the_profile_live_without_hdr_output() -> void: var m: Node = await _boot() _hdr_device(m) m._hdr_supported_override = false # an SDR screen: HDR output never engages m._process(1.0 / 60.0) assert_true(m.apply_dev_command({"kind": "color_profile", "profile": "hdr"})) assert_eq(HdrLook.active, ColorProfile.HDR, "HDR values on an SDR screen, same call") assert_almost_eq(m.world_env.environment.adjustment_contrast, ColorProfileHdr.CONTRAST, 1e-6) assert_eq(m._dev_flag_states()["look:profile"], "hdr") assert_true(m.apply_dev_command({"kind": "color_profile", "profile": "auto"})) assert_eq(HdrLook.active, ColorProfile.SDR) _restore_main_state()
func test_console_picks_a_tonemapper_per_profile() -> void: var m: Node = await _boot() _hdr_device(m) assert_true(m.apply_dev_command({"kind": "tonemap_sdr", "mode": "aces"})) assert_eq(m.world_env.environment.tonemap_mode, Environment.TONE_MAPPER_ACES, "SDR active: applied at once") assert_true(m.apply_dev_command({"kind": "tonemap_hdr", "mode": "agx"})) assert_eq(m.world_env.environment.tonemap_mode, Environment.TONE_MAPPER_ACES, "the inactive profile waits") m.apply_dev_command({"kind": "color_profile", "profile": "hdr"}) assert_eq(m.world_env.environment.tonemap_mode, Environment.TONE_MAPPER_AGX) var flags: Dictionary = m._dev_flag_states() assert_eq(flags["look:tonemap_sdr"], "aces") assert_eq(flags["look:tonemap_hdr"], "agx") var caps: Dictionary = m.build_caps() assert_eq(caps["color_profiles"], HdrLook.OVERRIDE_NAMES) assert_eq(caps["tonemap_modes"], ColorProfile.TONEMAP_NAMES) for k: String in ["color_profile", "tonemap_sdr", "tonemap_hdr"]: assert_has(caps["dev_commands"], k) _restore_main_state()- Step 2: Add the failing console tests to
control/test/panel.test.mjs. Append at the end.loadPanel,segOf,labels,litandpickare already defined in that file: check withgrep -n "^const segOf\|^const labels\|^const lookRow\|^function segOf\|^function labels" control/test/panel.test.mjs, and use the file’s own helper names if they differ.
test('colour profile and per-profile tonemapper rows send their own kinds', () => { const { api, els, sent } = loadPanel(); const list = api.lookCaps({ color_profiles: ['auto', 'sdr', 'hdr'], tonemap_modes: ['linear', 'reinhard', 'filmic', 'aces', 'agx'] }); assert.deepEqual(list.map((e) => e.kind), ['color_profile', 'tonemap_sdr', 'tonemap_hdr']); api.renderLooks(list); const rows = els['look-rows'].children; assert.deepEqual(rows.map((r) => r.children[0].textContent), ['Colour profile', 'SDR tonemap', 'HDR tonemap']); pick(segOf(rows[0]), 'hdr').onclick(); pick(segOf(rows[2]), 'agx').onclick(); assert.deepEqual(sent, [{ kind: 'color_profile', profile: 'hdr' }, { kind: 'tonemap_hdr', mode: 'agx' }]); api.updateFlags({ 'look:profile': 'sdr', 'look:tonemap_sdr': 'aces' }); assert.deepEqual(lit(segOf(rows[0])), ['sdr']); assert.deepEqual(lit(segOf(rows[1])), ['aces']);});
test('the HDR line names the active colour profile when the game reports it', () => { const { api } = loadPanel(); const h = { capable: true, supported: true, on: true, enabled: true, max_nits: 1000, ref_nits: 203, max_linear: 4.93 }; assert.equal(api.hdrLine(h), 'ENABLED · max 1000 nits · ref 203 nits · peak 4.93x'); assert.equal(api.hdrLine(h, { profile: 'hdr', override: 'auto', headroom: 4.93, tonemap: 'linear' }), 'ENABLED · max 1000 nits · ref 203 nits · peak 4.93x · look HDR (auto, linear)'); assert.equal(api.hdrLine(null, { profile: 'sdr', override: 'auto', headroom: 1, tonemap: 'linear' }), '– · look SDR (auto, linear)');});In control/test/_panel-harness.mjs:54, add hdrLine, to the returned API list. The string ' renderToggles, updateFlags, renderTabs, showTab, renderOptions, applyStatus, renderLooks, lookCaps,' becomes ' renderToggles, updateFlags, renderTabs, showTab, renderOptions, applyStatus, renderLooks, lookCaps, hdrLine,'.
- Step 3: Run both and watch them fail
Run: GUT_LOG=$TMPDIR/bh-colour-t5.log scripts/check-test-count.sh; node --test control/test/panel.test.mjs 2>&1 | tail -5
Expected: both FAIL. The GUT log shows the unknown color_profile kind returning false; Node reports the wrong row count.
- Step 4: Main: the commands, flags and caps.
In apply_dev_command, directly after the galaxy_look block (it ends return true at main.gd:3525), insert:
if String(cmd.get("kind", "")) == "color_profile": # Colour profile flip (HDR/SDR colour systems; session state, render-only, needs no run): # auto follows HDR output, sdr/hdr force that profile's values (hdr on an SDR screen = a preview). if HdrLook.set_override_label(String(cmd.get("profile", ""))): _sync_hdr_output() # re-decides HdrLook.active now; a change re-applies env + globals return true if String(cmd.get("kind", "")) == "tonemap_sdr" or String(cmd.get("kind", "")) == "tonemap_hdr": # Per-profile tonemapper selector (session state; wins over the TONEMAP_MODE tunable). var tm_mode := ColorProfile.HDR if String(cmd["kind"]) == "tonemap_hdr" else ColorProfile.SDR if HdrLook.set_tonemap_label(tm_mode, String(cmd.get("mode", ""))): _apply_color_profile() return trueIn _dev_flag_states, after out["look:galaxy"] = ... (main.gd:3828):
out["look:profile"] = HdrLook.override_label() # the console's "Colour profile" selector out["look:tonemap_sdr"] = HdrLook.tonemap_label(ColorProfile.SDR) # "SDR tonemap" out["look:tonemap_hdr"] = HdrLook.tonemap_label(ColorProfile.HDR) # "HDR tonemap"In build_caps, append "color_profile", "tonemap_sdr", "tonemap_hdr" to the "dev_commands" list (main.gd:3871-3872). After "galaxy_looks": ... (main.gd:3890), add:
"color_profiles": HdrLook.OVERRIDE_NAMES, # colour profile flip -- same roster as the Debug row "tonemap_modes": ColorProfile.TONEMAP_NAMES, # per-profile tonemapper -- same roster as the rows- Step 5: Debug Settings rows.
In ui/debug_settings_panel.gd _build_rows, after _rows.append(_galaxy_look_row()) (line 585), add:
_rows.append(_color_profile_row()) _rows.append(_tonemap_row(ColorProfile.SDR)) _rows.append(_tonemap_row(ColorProfile.HDR))After _galaxy_look_row() (ends at line 806), add:
# Colour profile flip (HDR/SDR colour systems): auto -> sdr -> hdr. Shows the active profile too, since# "auto" can be either.func _color_profile_row() -> Dictionary: return { "label": "Colour Profile", "is_graphics": false, "is_cycle": true, "get_text": func() -> String: return "Colour Profile: %s (%s)" % [HdrLook.override_label(), ColorProfile.MODE_NAMES[HdrLook.active].to_upper()], "advance": func() -> void: var names := HdrLook.OVERRIDE_NAMES var nxt := (names.find(HdrLook.override_label()) + 1) % names.size() dev_command_requested.emit({"kind": "color_profile", "profile": names[nxt]}), "console": {"kind": "color_profile", "caps": "color_profiles", "field": "profile"}, }
# Per-profile tonemapper selector: linear -> reinhard -> filmic -> aces -> agx.func _tonemap_row(mode: int) -> Dictionary: var tag := ColorProfile.MODE_NAMES[mode] return { "label": "%s Tonemap" % tag.to_upper(), "is_graphics": false, "is_cycle": true, "get_text": func() -> String: return "%s Tonemap: %s" % [tag.to_upper(), HdrLook.tonemap_label(mode)], "advance": func() -> void: var names := ColorProfile.TONEMAP_NAMES var nxt := (names.find(HdrLook.tonemap_label(mode)) + 1) % names.size() dev_command_requested.emit({"kind": "tonemap_" + tag, "mode": names[nxt]}), "console": {"kind": "tonemap_" + tag, "caps": "tonemap_modes", "field": "mode"}, }Change _refresh_hdr_label (line 1247) so the HDR line also names the look:
_hdr_label.text = "%s · look %s (%s, %s)" % [ HdrOutput.label(HdrOutput.status(get_window(), HdrOutput.capable())), ColorProfile.MODE_NAMES[HdrLook.active].to_upper(), HdrLook.override_label(), HdrLook.tonemap_label(HdrLook.active)]Check tests/test_hdr_output.gd’s Debug-line tests (test_debug_settings_row_reads_the_live_flag, line 296) for an exact-text assertion: grep -n "_hdr_label.text\|label(" tests/test_hdr_output.gd. If one compares the full text, change it to begins_with(HdrOutput.label(...)).
- Step 6: Restore the HdrLook session in the parity test.
In tests/test_debug_console_parity.gd, its every-row tests press the new rows:
-
in
before_each, after_hdr_output = HdrOutput.override(line 45), addHdrLook.reset_session(); -
in
after_each, afterHdrOutput.override = _hdr_output(line 50), addHdrLook.reset_session() # the colour profile / tonemap rows are session state. -
Step 7: Console UI.
In control/src/panel/client.js lookCaps, before return list; (line 508), add:
// Colour profiles (HDR/SDR colour systems): the profile flip and one tonemapper row per profile ride // the same rows; flags['look:profile'|'look:tonemap_sdr'|'look:tonemap_hdr'] light the live choice. if(Array.isArray(caps.color_profiles)) list = list.concat([{wid:'profile', looks:caps.color_profiles, kind:'color_profile', label:'Colour profile'}]); if(Array.isArray(caps.tonemap_modes)) list = list.concat([ {wid:'tonemap_sdr', looks:caps.tonemap_modes, kind:'tonemap_sdr', label:'SDR tonemap'}, {wid:'tonemap_hdr', looks:caps.tonemap_modes, kind:'tonemap_hdr', label:'HDR tonemap'}]);In renderLooks, change the name line (line 520) to:
name.textContent = entry.label || (wid.charAt(0).toUpperCase()+wid.slice(1)+' look');and add these branches before the final }else{ (line 531):
}else if(entry.kind === 'color_profile'){ segRow(seg, 'look:'+wid, opts, (profile)=>({kind:'color_profile', profile}), (b)=>tip(b,'visuals-color-profile')); }else if(entry.kind === 'tonemap_sdr'){ segRow(seg, 'look:'+wid, opts, (mode)=>({kind:'tonemap_sdr', mode}), (b)=>tip(b,'visuals-tonemap')); }else if(entry.kind === 'tonemap_hdr'){ segRow(seg, 'look:'+wid, opts, (mode)=>({kind:'tonemap_hdr', mode}), (b)=>tip(b,'visuals-tonemap'));Replace hdrLine / renderHdr (lines 123-132) and the call at line 121:
renderHdr(s.hdr, s.color);}// HDR display output readout (game: HdrOutput.status): what the screen actually engaged, not just the// toggle; plus the active colour profile (game: HdrLook.status) when the game reports one.function hdrLine(h, c){ let out; if(!h || typeof h !== 'object') out = '–'; else if(!h.capable) out = 'n/a (no HDR renderer)'; else if(!h.supported) out = (h.on ? 'on, waiting' : 'off') + ' · screen not HDR'; else if(!h.on) out = 'off · screen supports it'; else out = (h.enabled ? 'ENABLED' : 'requested, not engaged') + ' · max ' + Math.round(Number(h.max_nits)||0) + ' nits · ref ' + Math.round(Number(h.ref_nits)||0) + ' nits · peak ' + (Number(h.max_linear)||1).toFixed(2) + 'x'; if(c && typeof c === 'object') out += ' · look ' + String(c.profile).toUpperCase() + ' (' + String(c.override) + ', ' + String(c.tonemap) + ')'; return out;}function renderHdr(h, c){ setText('live-hdr', hdrLine(h, c)); }In control/src/panel/tooltips.js, after the 'visuals-galaxy-look' entry (line 44), add:
'visuals-color-profile': {icon:'◐', title:'Colour profile', body:'Which colour profile the game draws with. auto: HDR values while HDR output is engaged on this screen, SDR otherwise. sdr / hdr force one; hdr on an SDR screen is a preview, not real HDR. Session only.'}, 'visuals-tonemap': {icon:'◑', title:'Tonemapper', body:'Tonemap curve per profile (session only; overrides the TONEMAP_MODE tunable). linear passes values through; reinhard equals linear unless its white is above the headroom; filmic and ACES cap near white; AgX adapts to the headroom but desaturates toward white.'},- Step 8: Run everything
Run: GUT_LOG=$TMPDIR/bh-colour-t5.log scripts/check-test-count.sh; node --test control/test/*.test.mjs 2>&1 | grep -E "^# (pass|fail)"
Expected: GUT PASS (including all four test_debug_console_parity tests, which now drive the three new rows), and Node # fail 0.
- Step 9: Commit with the subject
feat(colour): console + Debug Settings colour profile flip and per-profile tonemapper (parity).
Task 6: Freeze-scene dev preset (console-only)
Section titled “Task 6: Freeze-scene dev preset (console-only)”Files:
- Create:
render/freeze_scene.gd,tests/test_freeze_scene.gd - Modify:
main.gd: a var next toperf_scenario(:152);_sim_freeze_reason(:1096-1114); anapply_dev_commandmatch arm next toperf_scenario_cancel(:3623);build_capsdev_commands;build_status(run branch, next to"perf_scenario"at:3745);control/src/panel/markup.js(after the Determinism probe card,:64-71),control/src/panel/client.js(GATED,:829),control/src/panel/tooltips.js,control/test/panel.test.mjs.
Interfaces:
-
Consumes:
PerfScenario.PRESETS["batch1_evolved"],PerfRig.hold_targets/hold_webs/hold_zones/hold_xp/XP_FROZEN,main._dev_spawn_boss("warden"),main.apply_dev_command({"kind":"reset_upgrades"}), and the sim dev seamsdev_clear_enemies,dev_force_grant_weapon,dev_add_crystals,player.dev_invuln,dev_suppress_spawns. -
Produces:
- dev command
{kind:"freeze_scene", on:true|false}(dev builds, survival only, not LAN-safe); main.freeze_scene: FreezeScene;- freeze reason
"freeze_scene"; /status"freeze_scene"(FreezeScene.status()).
- dev command
-
Step 1: Write the failing test
tests/test_freeze_scene.gd:
extends GutTest
# Freeze-scene preset (HDR/SDR colour systems step 1, spec § 6 Comparison tooling): one fixed busy# scene, then the sim clock stops so SDR and HDR are compared on the same pixels. Real main, real# dev-command path, real _physics_process gate.
func after_each() -> void: HdrLook.reset_session()
func _boot() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") return m
func test_builds_the_scene_then_stops_the_sim_clock_and_releases() -> void: var m: Node = await _boot() var was_invuln: bool = m.sim.player.dev_invuln assert_true(m.apply_dev_command({"kind": "freeze_scene", "on": true})) var fs: FreezeScene = m.freeze_scene assert_not_null(fs) assert_eq(fs.note, "") assert_true(m.sim.player.dev_invuln) assert_true(m.sim.dev_suppress_spawns) assert_eq(m.sim.webs.size(), FreezeScene.WEBS) assert_eq(m.sim.zones.size(), FreezeScene.ZONES) assert_true(m.sim.player.arsenal.weapon_by_id.has("scythe"), "the batch1_evolved preset was granted") assert_eq(m._sim_freeze_reason(), "", "warming up: the weapons get going first") var guard := 0 while not fs.frozen and guard < 600: # 600 ticks = 10 s of sim time, well past WARMUP_S m._physics_process(Sim_Const.DT) fs._process(Sim_Const.DT) guard += 1 assert_true(fs.frozen, "froze after WARMUP_S of sim time") assert_eq(m._sim_freeze_reason(), "freeze_scene") var t: float = m.sim.run_time m._physics_process(Sim_Const.DT) assert_eq(m.sim.run_time, t, "frozen: the sim does not tick") assert_true(bool(m.build_status()["freeze_scene"]["frozen"])) assert_true(m.apply_dev_command({"kind": "freeze_scene", "on": false})) assert_eq(m._sim_freeze_reason(), "") assert_eq(m.sim.player.dev_invuln, was_invuln, "invuln restored") assert_false(m.sim.dev_suppress_spawns, "spawns restored")
func test_refuses_outside_survival_and_is_not_lan_safe() -> void: assert_false(preload("res://main.gd").NET_SAFE_DEV_KINDS.has("freeze_scene"), "it mutates the sim") var m: Node = await _boot() assert_has(m.build_caps()["dev_commands"], "freeze_scene")-
Step 2: Run and watch it fail. Run
GUT_LOG=$TMPDIR/bh-colour-t6.log scripts/check-test-count.sh. Expected: FAIL, becauseFreezeSceneis not declared. -
Step 3: Create
render/freeze_scene.gd
class_name FreezeSceneextends Node# Freeze-scene preset -- DEV ONLY (main.apply_dev_command "freeze_scene", dev builds, survival only;# HDR/SDR colour systems step 1, spec § 6). Builds one fixed busy scene round P1 (the batch1_evolved# weapons + crystals, the Warden, held webs, zones and a ring of held targets), lets it run WARMUP_S# of sim time so the weapons are firing, then stops the sim clock (main._sim_freeze_reason# "freeze_scene") so SDR and HDR are compared on the same pixels. Shader TIME keeps running and# transient FX already in flight fade out; the held layers stay. {on:false} puts everything back.# Uses the same dev seams as PerfScenario (render/perf_rig.gd holds); the Warden's spawn draws from# sim.rng, which is fine: the dev console already abandons run reproducibility.
const PRESET := "batch1_evolved"const WARMUP_S := 3.0const TARGETS := 60const WEBS := 40const ZONES := 12
var frozen := falsevar note := ""var _mainvar _sim: Simvar _t0 := 0.0var _armed := falsevar _saved_invuln := falsevar _saved_paused := falsevar _saved_auto := falsevar _saved_xp_to_next: Array[float] = []
func begin(m) -> String: if m.sim == null or m.sim.ruleset != Sim.RULESET_SURVIVAL: note = "survival runs only" return note _main = m _sim = m.sim m.apply_dev_command({"kind": "reset_upgrades"}) _sim.dev_clear_enemies() _saved_invuln = _sim.player.dev_invuln _saved_paused = _sim.dev_suppress_spawns _sim.player.dev_invuln = true _sim.dev_suppress_spawns = true _saved_auto = m._auto m._auto = false # nothing steers, warps or picks: the ship holds still _saved_xp_to_next.clear() for pl in _sim.pilots: _saved_xp_to_next.append(pl.xp_to_next) pl.xp_to_next = PerfRig.XP_FROZEN var pr: Dictionary = PerfScenario.PRESETS[PRESET] for wid in pr.weapons: _sim.dev_force_grant_weapon(_sim.player, wid) for el in pr.crystals: var left: int = pr.crystals[el] while left > 0: # dev_add_crystals clamps one call to DEV_CRYSTAL_MAX_STEP var step := mini(left, 100) _sim.dev_add_crystals(el, step) left -= step m._dev_spawn_boss("warden") _armed = true _hold() _t0 = _sim.run_time note = "" return ""
func _hold() -> void: PerfRig.hold_targets(_sim, TARGETS) PerfRig.hold_webs(_sim, WEBS) PerfRig.hold_zones(_sim, ZONES) PerfRig.hold_xp(_sim)
func _process(_dt: float) -> void: if not _armed: return if _main == null or _main.sim != _sim: _armed = false # a new run replaced the scene's sim: nothing to hold or restore frozen = false queue_free() return if frozen: return # the sim is stopped; the held state can't decay _hold() if _sim.run_time - _t0 >= WARMUP_S: frozen = true
func release() -> void: frozen = false if not _armed: return _armed = false _sim.player.dev_invuln = _saved_invuln _sim.dev_suppress_spawns = _saved_paused for i in mini(_saved_xp_to_next.size(), _sim.pilots.size()): _sim.pilots[i].xp_to_next = _saved_xp_to_next[i] _main._auto = _saved_auto _sim.dev_clear_enemies() # drops the held targets and the boss _sim.webs.clear() _sim.zones.clear()
func status() -> Dictionary: return {"frozen": frozen, "note": note, "warmup_left": maxf(0.0, WARMUP_S - (_sim.run_time - _t0)) if _armed else 0.0}
func _exit_tree() -> void: release()- Step 4: Wire main.
After var perf_scenario: PerfScenario (main.gd:152), add:
var freeze_scene: FreezeScene # dev-only freeze-scene preset (render/freeze_scene.gd); null when idleIn _sim_freeze_reason(), before the final return "" (main.gd:1114):
if freeze_scene != null and is_instance_valid(freeze_scene) and freeze_scene.frozen: return "freeze_scene" # dev SDR/HDR comparison: one fixed scene, clock stoppedIn the apply_dev_command match, after the "perf_scenario_cancel": arm (main.gd:3623-3625):
"freeze_scene": if not BuildConfig.dev_tools(): return false if freeze_scene != null and is_instance_valid(freeze_scene): freeze_scene.release() freeze_scene.queue_free() freeze_scene = null if bool(cmd.get("on", true)): freeze_scene = FreezeScene.new() add_child(freeze_scene) if freeze_scene.begin(self) != "": _toast("freeze_scene: %s" % freeze_scene.note)Then:
- append
"freeze_scene"tobuild_caps"dev_commands"; - in the run branch of
build_status, after the"perf_scenario": ...line (main.gd:3745), add:
"freeze_scene": freeze_scene.status() if freeze_scene != null and is_instance_valid(freeze_scene) else null,- Step 5: Console card.
In control/src/panel/markup.js, after the Determinism probe card’s closing </div> (line 71), add:
<div class="card"> <h3>Freeze scene <small>dev</small></h3> <p class="hint">Survival only: a fixed busy scene (evolved weapons, the Warden, webs, zones, a target ring) runs 3 s, then the sim stops so SDR and HDR can be compared on the same pixels. Release puts it back.</p> <div class="row"> <button id="freeze-on" data-tip="freeze-scene" onclick="send({kind:'freeze_scene', on:true})">Freeze</button> <button id="freeze-off" data-tip="freeze-scene" onclick="send({kind:'freeze_scene', on:false})">Release</button> </div> </div>In client.js, append ['freeze-on','freeze_scene'],['freeze-off','freeze_scene'] to the GATED array (line 829). In tooltips.js, add:
'freeze-scene': {icon:'❄', title:'Freeze scene', body:'Dev builds, Survival only. Resets the build to the batch1_evolved weapons, spawns the Warden and holds webs, zones and a target ring, runs 3 s of sim time, then stops the sim clock (shaders keep animating; in-flight sparks fade). Flip the colour profile or tonemapper while frozen.', touches:'Your run: upgrades reset, invulnerable, spawns paused until Release'},In control/test/panel.test.mjs, add:
test('freeze scene buttons send freeze_scene and grey out on an older game', () => { assert.match(panelHtml(), /id="freeze-on"[^>]*onclick="send[(][{]kind:'freeze_scene', on:true[}][)]"/); const p = loadPanel(); p.api.applyCapsGates({ dev_commands: ['lan_coop'] }); assert.equal(p.document.getElementById('freeze-on').getAttribute('data-stale'), '1'); p.api.applyCapsGates({ dev_commands: ['freeze_scene'] }); assert.equal(p.document.getElementById('freeze-off').getAttribute('data-stale'), null);});If panelHtml is not imported in panel.test.mjs, add import { panelHtml } from '../src/panel.html.js'; at the top. Check with grep -n "panelHtml" control/test/panel.test.mjs | head -2.
- Step 6: Run everything
Run: GUT_LOG=$TMPDIR/bh-colour-t6.log scripts/check-test-count.sh; node --test control/test/*.test.mjs 2>&1 | grep -E "^# (pass|fail)"
Expected: GUT PASS and Node # fail 0.
If the freeze test loops to the guard without freezing, check whether the Warden spawn opened a freeze of its own: grep -n "freeze\|dialogue" $TMPDIR/bh-colour-t6.log | head, then read main._sim_frozen_why (main.gd:1082-1092). The fix belongs in FreezeScene.begin (pick a boss without an intro), not in the test.
- Step 7: Commit with the subject
feat(dev): freeze-scene preset for SDR/HDR comparison (console-only).
Task 7: Docs, What’s new, full gate
Section titled “Task 7: Docs, What’s new, full gate”Files:
- Modify:
docs/architecture/rendering-and-performance.md: the “HDR display output” section at:351, including its “Not retuned” bullet at:427;docs/architecture/remote-config.md::290, the HOOK table row at:305, the EnvironmentLook paragraph at:367;docs/architecture/dev-tools.md: after the Perf scenario bullet at:75;control/src/whats_new.js.
Interfaces:
-
Consumes: everything above.
-
Produces: current-architecture docs, a What’s new item, and a green gate.
-
Step 1: rendering-and-performance.md.
Replace the - **Not retuned.** ... bullet (line 427, plus its continuation lines up to the next bullet; read them first) with a new subsection directly after the HDR display output section:
### Colour profiles: `ColorProfileSdr` / `ColorProfileHdr` + `HdrLook` (HDR/SDR colour systems step 1)
Two full colour profiles (spec `docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md`):`render/color_profile_sdr.gd` and `render/color_profile_hdr.gd` hold the same names (tonemap mode/exposure/white/AgX white/AgX contrast, glow intensity/strength/bloom/threshold/soft ramp/blend/levels 1-7, gradebrightness/contrast/saturation, and the shader-global values), all registered tunables, live via`LiveScope.HOOK_ENV`. SDR's bundled values are the old EnvironmentLook literals (pixel-identical); the`LDR_*` web values are SDR-only. HDR is seeded from the research doc's headroom mapping (neutral grade,no constant bloom, threshold 2.5 / ramp 2.0 for the Apple TV's assumed 4.93 headroom), tuned on device.
- **Which profile:** `HdrLook.update(want, max_linear)` runs in `main._sync_hdr_output()` with the SAME `want` as `HdrOutput.sync` and `QualityManager.set_hdr_output`; a change calls `main._apply_color_profile()` in that frame. `HdrLook.headroom` = screen max linear × `HdrLook.PEAK_CALIBRATION` (local tunable), 1.0 when HDR output is off. Console `/status` `"color"` and the Debug Settings HDR line report it.- **The one re-apply:** `main._apply_color_profile()` = `EnvironmentLook.apply(env, hdr_canvas, HdrLook.active)` + `ShaderGlobals.push(HdrLook.active)`. Callers: `_new_run`, HOOK_ENV, a profile change, the tonemap selector. `EnvironmentLook.apply` never touches `glow_enabled` (QualityManager owns it).- **Shader globals:** `render/shader_globals.gd` maps each global uniform to a profile var. Each is DECLARED in `[shader_globals]` of both `project.godot` and `platform/tvos/project.godot` at the SDR value (the export shader baker fails on a runtime-only global and leaves the shader unbaked); the runtime only calls `global_shader_parameter_set`. Never `_add` (error on a declared name) or `_get`/`_get_list` (editor-only). Pilot: `border_halo` (the arena edge + tutorial wall halo, `border_look.gdshaderinc`). Compile check: `tools/shader_check/compile_check.gd` (windowed, both renderers).- **AgX** reads `tonemap_agx_white` and `tonemap_agx_contrast`, not `tonemap_white`.- **Comparison:** console/Debug `color_profile` (auto/sdr/hdr; hdr on an SDR screen is a preview) and `tonemap_sdr` / `tonemap_hdr` (session overrides that win over `TONEMAP_MODE`); console-only `freeze_scene` (`render/freeze_scene.gd`).-
Step 2: remote-config.md.
- Line 290:
EnvironmentLook→EnvironmentLook (now ColorProfileSdr). - Line 305 table row:
|ColorProfileSdr./ColorProfileHdr.|HOOK_ENV|main._apply_color_profile()(EnvironmentLook.apply + ShaderGlobals.push) |. - Line 367: prepend
**(Superseded by the colour profiles, step 1: the keys are ColorProfileSdr.*; old EnvironmentLook.* keys are retired docs for builds up to 281+.)**. Read the paragraph first and keep its history.
- Line 290:
-
Step 3: dev-tools.md. After line 75 (the Perf scenario bullet), add:
- **Colour profile + tonemapper + freeze scene (HDR/SDR colour systems step 1).** `{kind:"color_profile", profile:"auto"|"sdr"|"hdr"}`, `{kind:"tonemap_sdr"|"tonemap_hdr", mode:"linear"|"reinhard"|"filmic"|"aces"|"agx"}`: session state in `HdrLook` (render-only, need no run, Debug Settings parity rows "Colour Profile" / "SDR Tonemap" / "HDR Tonemap"; caps `color_profiles` / `tonemap_modes`; flags `look:profile|tonemap_sdr|tonemap_hdr`). `{kind:"freeze_scene", on}` (dev builds, survival, console-only like `perf_scenario`, not LAN-safe): `render/freeze_scene.gd` builds the batch1_evolved + Warden + webs/zones/targets scene, runs 3 s of sim time, then `_sim_freeze_reason()` returns `"freeze_scene"` until `on:false`.- Step 4: What’s new.
In control/src/whats_new.js, add this item to the build: null entry’s items (create the entry at the top if absent, per the file’s CONVENTION comment). Append ; Dev builds: SDR and HDR colour profiles to its title.
{ text: 'Dev builds: SDR and HDR now each have their own colour profile (tonemap, bloom, colour grade and the arena edge glow), all live console settings. SDR looks exactly as before. With HDR output on, the HDR profile takes over: a neutral colour grade and bloom only on the brightest neon, a first guess to tune on the TV. New controls flip the profile (auto, SDR, HDR) and pick a tonemapper per profile (linear, Reinhard, filmic, ACES, AgX), and a Freeze scene button builds one busy fight and stops it so both looks can be compared on the same frame.', where: 'Console: Visuals, Colour profile / SDR tonemap / HDR tonemap; Console: Freeze scene card; Console: Tunables, Glow & colour grade (ColorProfileSdr / ColorProfileHdr); Debug Settings: Colour Profile, SDR Tonemap, HDR Tonemap' },- Step 5: Full gate
Run: scripts/gate.sh > $TMPDIR/bh-colour-gate.log 2>&1; tail -15 $TMPDIR/bh-colour-gate.log
Expected: the gate’s success line, with the determinism tests green (nothing in /sim changed: git diff --stat origin/main -- sim/ prints nothing).
- Step 6: Commit with the subject
docs(colour): profiles, HdrLook, shader globals, freeze scene; What's new.
Task 8: On Chris’s iPhone: confirm HDR engages and the profile follows (MANUAL, needs Chris’s device)
Section titled “Task 8: On Chris’s iPhone: confirm HDR engages and the profile follows (MANUAL, needs Chris’s device)”Files: none, unless the BUILD bump commit applies. Read skill bh-worktree-device-install first.
Interfaces:
-
Consumes: the branch at Task 7,
scripts/dev/install-dev.sh iphone, and the Remote Console. -
Produces: Chris’s confirmation, recorded in the task report and in memory
bullet-heaven-hdr-output(📍 line). -
Step 1: Bring the branch up to date with main (so the console, deployed from main, does not grey out controls):
git fetch origin && git merge origin/main- Step 2: Pick a BUILD number nobody has used (project CLAUDE.md § Builds & releases):
git for-each-ref --format='%(refname:short)' refs/heads refs/remotes | while read -r b; do git show "$b:sim/constants.gd" 2>/dev/null | grep -oE '^const BUILD: int = [0-9]+' | grep -oE '[0-9]+$'; done | sort -n | tail -1grep -oE '^const BUILD: int = [0-9]+' sim/constants.gdIf the first number is ≥ this branch’s BUILD, run bash scripts/bump-build.sh until this branch is one past it. Then install with --no-bump. Otherwise let the script bump.
- Step 3: Install the Dev app.
The iPhone has ONE Dev app slot. First ask Chris whether another branch’s build on the iPhone is still waiting for a verdict. If one is, build a throwaway test-N-combined branch instead of overwriting it.
scripts/dev/install-dev.sh iphone # or: scripts/dev/install-dev.sh iphone --no-bumpExpected: the script finishes with the app installed on device 00008130-0012616E2250001C. Commit the bump (sim/constants.gd, the presets, and control/src/whats_new.js if stamped) on this branch with git commit --file.
-
Step 4: Chris checks HDR on the iPhone. These steps need Chris and his device; report each answer verbatim:
- Debug Settings → “HDR output (A/B)” ON. The line under the title reads
HDR output: ENABLED · max … nits · ref … nits · peak …x · look HDR (auto, linear). - Console → Live column “HDR out” shows the same, with
look HDR (auto, linear)./statushdr.enabledistrueandcolor.profileis"hdr". - Console → Colour profile
sdr: the look changes back to the SDR grade while HDR output stays ENABLED. Then setautoagain. - Console → Freeze scene → Freeze, then flip Colour profile sdr/hdr and HDR tonemap linear/agx while frozen. Release.
If step 1 says “screen not HDR” or “requested, not engaged”, record it. That is the open iOS question from spec Decision 5 (stock EDR path), not a defect in this branch.
- Debug Settings → “HDR output (A/B)” ON. The line under the title reads
-
Step 5: Fresh-install stall check (a new global uniform plus shader edits). Follow skill
bh-fresh-install-stall-checkon the installed build, and report the boot-cover time it measures.
Self-review
Section titled “Self-review”Spec coverage (Rollout step 1). Each requirement and the task that covers it:
| Step 1 requirement | Task |
|---|---|
| Profile infrastructure: two full profiles of registered tunables with docs and LiveScope hooks | 1, 2 |
HdrLook: mode from the _sync_hdr_output decision, headroom × calibration, auto / force_sdr / force_hdr override |
1, 3 |
| Tonemapper selectors per profile, console + Debug Settings parity | 5 |
Shader-globals plumbing plus one pilot (border_halo, SDR 2.2) |
4 |
Console flip (color_profile) |
5 |
| Freeze scene | 6 |
| SDR pixel-identical | SDR_BUNDLED + stock-tonemap tests (Tasks 1-2); existing test_env_border_tunables kept on the SDR path; border global SDR = 2.2 declared in both projects (Task 4) |
| HDR seeded, HDR output still default off | HDR_SEEDS (Task 1); HdrOutput.ENABLED untouched |
| Confirm HDR engages on Chris’s iPhone | 8 |
| Gate green; determinism untouched; windowed compile under both renderers | 7, 4 |
| Fresh-install stall check | 8 |
Out of scope by design, deferred to later steps:
- colour-space fixes;
- per-source values, brightness tiers and UI caps;
- the player toggle, calibration UI, and the
ENABLEDdefault flip; - the VFX-library dual scores;
- the shader and colour-uniform lint tests (spec § Structural guards).
Placeholder scan. Every step has its code or exact command. Two lines are conditional fallbacks, each with a concrete check and fix:
- the
global uniform-in-include fallback in Task 4 Step 9; - the Warden-intro check in Task 6 Step 6.
Type and name consistency:
ColorProfile.SDR/HDRare ints, used asHdrLook.tonemap_overrideindices and asEnvironmentLook.apply’smode.HdrLook.OVERRIDE_NAMESis indexoverride + 1.- Command fields match the Debug descriptors (
profile,mode) and the console senders. - Caps keys (
color_profiles,tonemap_modes) match the descriptors’caps. - Flags use
look:<wid>, with widsprofile/tonemap_sdr/tonemap_hdrthe same in main and inlookCaps. ShaderGlobals.GLOBALSvalues are profile static names present in both profiles (BORDER_HALO).
Known risks:
- Deviation 1 needs Chris’s OK. It rests on a scratch-project spike on stock 4.7.1. The tvOS fork’s baker path is assumed to behave the same way; Task 8 Step 5 checks it on device.
GDScript.get(name)for statics is the mechanismnet/tunables.gd:26/133already uses.test_debug_console_parityassertsn > 10andn > 15row counts. The new rows only add to them.- A device’s persisted live layer holding
EnvironmentLook.*keys would be dropped as unknown byTunables.checkafter the rename. That is a dev-only state, andset_tunables {reset:true}clears it.