Skip to content

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 read bh-sdd-concurrent before Task 1. Dispatch every implementer into its own worktree (isolation: "worktree", branched off origin/main). For the registry/docs/LiveScope recipe, use skill bh-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 / ColorProfileHdr hold the values as column-0 static vars with identical names; the registry needs class-level statics, so each profile is its own script.
  • ColorProfile is a static helper that maps a mode to its script, plus the tonemap names.
  • HdrLook owns three things:
    • the active mode and the headroom, computed from the same want that main._sync_hdr_output() already sends to HdrOutput.sync;
    • the session overrides (profile flip, per-profile tonemapper);
    • the PEAK_CALIBRATION tunable.
  • 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_ENV live 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.

Scope and determinism

  • Nothing under /sim changes. 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.gd literals. The tonemap values equal a stock Environment, because today’s apply() never touches the tonemap. The ColorProfileSdr.BORDER_HALO default 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.
  • QualityManager keeps owning glow_enabled. EnvironmentLook.apply never 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/*.json label;
    • a regenerated control/src/tunable_docs.js, which is GENERATED (a hand edit fails control/test/tunable-docs.test.mjs);
    • the LiveScope HOOK_ENV hook.
  • Debug Settings ↔ console parity (tests/test_debug_console_parity.gd). Every new Debug Settings row carries a console descriptor. The console source must contain the literal kind:'<kind>'. The caps roster must hold every option. main.apply_dev_command must return true for it.

Shaders

  • Shader globals are declared in [shader_globals] of BOTH project.godot and platform/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_list in 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 mobile AND --rendering-method gl_compatibility. Then git checkout -- project.godot audio/bus_layout.tres.

Testing and process

  • Tests: run scripts/check-test-count.sh with your own GUT_LOG= path. Never pull a raw GUT run into context. Grep the log file for failures. Run the Node tests as node --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, run ListAgents and claim merge ownership (project CLAUDE.md § Builds & releases). Unmerged branches burn BUILD numbers, so check the highest BUILD on live branches before a device install (Task 8).
  • Every new docs/**/*.md needs Starlight title: frontmatter. A tunable doc desc is 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)”
  1. Globals are declared in project.godot [shader_globals], not added only at runtime. The spec says “registered at runtime with global_shader_parameter_add() (NOT project.godot [shader_globals])”. A spike in a scratch project, run on this machine’s stock 4.7.1, showed why that fails:

    • Runtime add works when rendering (mobile and gl_compatibility, windowed).
    • BUT a release export with shader_baker/enabled=true (our platform/tvos/export_presets.cfg setting) prints SHADER 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 left project.godot unchanged.
    • A runtime global_shader_parameter_add of 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.gd pins 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.

  2. The existing EnvironmentLook.* keys are renamed to ColorProfileSdr.*. EnvironmentLook becomes the pure applier, and the 18 old keys move to the "retired" section of tools/tunable_docs/docs.json. Published manifests up to build-281 still list them, and control/test/tunable-docs.test.mjs requires a retired doc for every key a manifest still publishes. No published patch uses them (remote-config/patches/ builds 235/256/257/258 contain no EnvironmentLook keys). This follows the memory rule “fix, don’t grandfather”: EnvironmentLook.X would otherwise mean “SDR” for ever.

  3. The tonemapper selector is a session override (HdrLook.tonemap_override), like HdrOutput.override, not a write to the live tunable layer.

    • The live layer persists across runs. test_debug_console_parity drives every row through apply_dev_command, so a live-layer row would leave state behind after tests.
    • The tunables ColorProfile{Sdr,Hdr}.TONEMAP_MODE still exist and stay live through the Tunables tab. The selector wins while it is set.
  4. The command kind is color_profile, not the spec’s profile. 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’s console descriptor carries ONE option field.

  5. AgX uses tonemap_agx_white, not tonemap_white (research doc § tonemappers says “tonemap_white”). Godot 4.7 has separate tonemap_agx_white and tonemap_agx_contrast properties, and environment.cpp passes tonemap_agx_white for AgX. Each profile therefore carries both whites.

  6. 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.headroom is computed and reported now; steps 3-4 consume it.

  7. The pilot global border_halo also drives the tutorial/story walls, because render/shaders/border_look.gdshaderinc is included by both arena_border.gdshader and story_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 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 Environment enums).

  • 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 ColorProfileSdr
extends 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_LINEAR
static var TONEMAP_EXPOSURE: float = 1.0
static var TONEMAP_WHITE: float = 1.0 # Reinhard / filmic / ACES white; linear and AgX ignore it
static var TONEMAP_AGX_WHITE: float = 16.29 # AgX only: Godot passes tonemap_agx_white, not tonemap_white
static 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.15
static var GLOW_STRENGTH: float = 1.0
static var GLOW_BLOOM: float = 0.05 # constant bloom on everything: let the threshold gate what glows
static 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.0
static var GLOW_LEVEL_2: float = 0.8
static var GLOW_LEVEL_3: float = 0.4
static var GLOW_LEVEL_4: float = 0.1
static var GLOW_LEVEL_5: float = 0.0
static var GLOW_LEVEL_6: float = 0.0
static 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.0
static var CONTRAST: float = 1.08
static 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.9
static var LDR_GLOW_BLOOM: float = 0.2
  • Step 4: Create render/color_profile_hdr.gd
class_name ColorProfileHdr
extends 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 clip
static var TONEMAP_EXPOSURE: float = 1.0
static var TONEMAP_WHITE: float = 1.0
static var TONEMAP_AGX_WHITE: float = 16.29
static var TONEMAP_AGX_CONTRAST: float = 1.25
static var GLOW_INTENSITY: float = 1.15
static var GLOW_STRENGTH: float = 1.0
static 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.5
static var GLOW_HDR_SCALE: float = 2.0
static var GLOW_BLEND_MODE: int = Environment.GLOW_BLEND_MODE_ADDITIVE
static var GLOW_LEVEL_1: float = 0.0
static var GLOW_LEVEL_2: float = 0.8
static var GLOW_LEVEL_3: float = 0.4
static var GLOW_LEVEL_4: float = 0.1
static var GLOW_LEVEL_5: float = 0.0
static var GLOW_LEVEL_6: float = 0.0
static 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.0
static var CONTRAST: float = 1.0
static var SATURATION: float = 1.0
static 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 ColorProfile
extends 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 := 0
const HDR := 1
const 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 only
const 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 HdrLook
extends 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
Terminal window
git add render/color_profile_sdr.gd render/color_profile_hdr.gd render/color_profile.gd render/hdr_look.gd tests/test_color_profiles.gd
printf '%s\n' "feat(colour): SDR/HDR colour profiles + HdrLook decision (step 1, not wired yet)" "" "<attribution lines>" > $TMPDIR/msg-t1.txt
git commit --file=$TMPDIR/msg-t1.txt

Task 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:46 and :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 the ColorProfileSdr.LDR_* values.

  • Produces:

    • EnvironmentLook.apply(env: Environment, hdr: bool, mode: int = ColorProfile.SDR) -> void;
    • registry keys ColorProfileSdr.* (24), ColorProfileHdr.* (22) and HdrLook.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.gd with the applier
class_name EnvironmentLook
extends 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_BLOOM

main.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:

  1. 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.gd must print only the lines you replace next.

  2. 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.json with deletions only;
  • docs.json shows insertions only (if the stringify round-trip reformatted other lines, restore it with git checkout and add the retired entries by hand instead);
  • color_profiles.json is new;
  • tunable_docs.js has 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.tres
GUT_LOG=$TMPDIR/bh-colour-t2.log scripts/check-test-count.sh
node --test control/test/tunable-docs.test.mjs

Expected:

  • GUT: PASS. In particular test_tunables_registry, test_live_scope, test_env_border_tunables, test_color_profiles and test_hdr_output are 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.gd at:
    • :1028-1029 (HOOK_ENV);
    • :1293-1298 (_new_run env build);
    • :2144-2148 (_sync_hdr_output);
    • :3720-3722 and :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-argument EnvironmentLook.apply.

  • Produces:

    • main._apply_color_profile() -> void, the single re-apply point (Task 4 adds the globals push to it);
    • /status key "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 := -1
var _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_profile and use it.

In main.gd:

  1. Replace lines 1028-1029:
if hooks.has(LiveScope.HOOK_ENV):
_apply_color_profile() # a ColorProfileSdr/Hdr edit: environment + shader globals, live
  1. In _new_run, replace lines 1293 and 1298. Line 1293 is the comment; line 1298 is EnvironmentLook.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()
  1. 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)
  1. 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.
  1. 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).
  • 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 actions comment 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, and HdrLook.active via 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, because ShaderGlobals is not declared.

  • Step 3: Create render/shader_globals.gd

class_name ShaderGlobals
extends 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 -3

Expected: 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 := 10
var _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 --short

Expected:

  • each run prints only compile_check: drawing 2 materials and compile_check: done, with no SHADER ERROR and no Global uniform;
  • git status lists 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.godot is uncommitted). Use the subject feat(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/null
godot --headless --path . --export-release "macOS" $TMPDIR/bh-colour-mac/DarkCosmos.app 2>&1 | grep -E "SHADER ERROR|Global uniform|Baking" | head
git checkout -- project.godot audio/bus_layout.tres; rm -f export_presets.cfg

Expected: 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 to galaxy_look at :3520-3525); _dev_flag_states (after :3828); build_caps (dev_commands at :3871, rosters after galaxy_looks at :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 in dev_commands;
    • flags look:profile, look:tonemap_sdr, look:tonemap_hdr.
  • 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, lit and pick are already defined in that file: check with grep -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 true

In _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), add HdrLook.reset_session();

  • in after_each, after HdrOutput.override = _hdr_output (line 50), add HdrLook.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 to perf_scenario (:152); _sim_freeze_reason (:1096-1114); an apply_dev_command match arm next to perf_scenario_cancel (:3623); build_caps dev_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 seams dev_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()).
  • 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, because FreezeScene is not declared.

  • Step 3: Create render/freeze_scene.gd

class_name FreezeScene
extends 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.0
const TARGETS := 60
const WEBS := 40
const ZONES := 12
var frozen := false
var note := ""
var _main
var _sim: Sim
var _t0 := 0.0
var _armed := false
var _saved_invuln := false
var _saved_paused := false
var _saved_auto := false
var _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 idle

In _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 stopped

In 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" to build_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).

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, grade
brightness/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.
  • 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 -1
grep -oE '^const BUILD: int = [0-9]+' sim/constants.gd

If 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-bump

Expected: 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:

    1. 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).
    2. Console → Live column “HDR out” shows the same, with look HDR (auto, linear). /status hdr.enabled is true and color.profile is "hdr".
    3. Console → Colour profile sdr: the look changes back to the SDR grade while HDR output stays ENABLED. Then set auto again.
    4. 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.

  • Step 5: Fresh-install stall check (a new global uniform plus shader edits). Follow skill bh-fresh-install-stall-check on the installed build, and report the boot-cover time it measures.


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 ENABLED default 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/HDR are ints, used as HdrLook.tonemap_override indices and as EnvironmentLook.apply’s mode.
  • HdrLook.OVERRIDE_NAMES is index override + 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 wids profile / tonemap_sdr / tonemap_hdr the same in main and in lookCaps.
  • ShaderGlobals.GLOBALS values 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 mechanism net/tunables.gd:26/133 already uses.
  • test_debug_console_parity asserts n > 10 and n > 15 row counts. The new rows only add to them.
  • A device’s persisted live layer holding EnvironmentLook.* keys would be dropped as unknown by Tunables.check after the rename. That is a dev-only state, and set_tunables {reset:true} clears it.