Skip to content

HDR/SDR colour systems, step 3 (headroom-relative HDR values for every source): implementation plan

HDR/SDR Colour Systems, Step 3: Implementation Plan

Section titled “HDR/SDR Colour Systems, Step 3: 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 with isolation: "worktree", branched off hdr-colour-space (NOT origin/main: steps 1 and 2 are unmerged and this plan builds on both).

Goal: Every brightness source gets its own HDR value, expressed as a fraction of the screen’s headroom, so the HDR look uses the same proportions on a Mac at 2.7×, an iPhone, and the Apple TV at 4.9×. The SDR look stays pixel-identical.

Why now (measured 2026-10-09):

  • On the Mac, HDR output engaged (/status hdr.enabled=true, max_linear 2.67), yet Chris saw no difference HDR on vs off. Step 1’s HDR glow knee is ABSOLUTE (GLOW_HDR_THRESHOLD 2.5, seeded for the ATV’s 4.93). At 2.67 headroom almost nothing reaches it, so HDR mode loses the glow SDR mode has, which cancels out the highlight gain.
  • tools/cs_capture (added by this plan, Task 7) showed:
    • colour_space_fix changes the canvas on the Mac renderer: mean |Δ| 0.0091 vs a 0.0038 noise floor.
    • The visible effect is white-hot areas keeping their hue.
    • 3% of a busy frame is above 1.0, 1.5% above 2.7 and 0.7% above 4.93.
    • The brightest pixel was 180× (stacked additive weapon hits on the freeze scene’s 60 targets).

Architecture:

  • A new HdrTier (render/hdr_tier.gd, render-only) is the ONE place a profile value becomes the number a draw path is fed.
    • It keeps a per-frame cache keyed by profile static name, recomputed only when (profile, headroom, colour_space_fix active) change or a tunable is edited.
    • In the SDR profile every value is today’s literal, so SDR stays byte-for-byte today’s.
    • In the HDR profile a value is a fraction f. The linear peak is P = 1 + (M − 1)·f, with M = HdrLook.headroom (already headroom × the player’s PEAK_CALIBRATION).
    • P is then converted for the draw path it feeds (see “Draw paths”).
  • The HDR glow knee becomes headroom-relative too: threshold 1 + (M − 1)·t, soft-ramp width (M − 1)·s. It is re-applied when headroom moves more than 1%, because a Mac’s headroom changes with its brightness slider.
  • Shader-side white-hot literals get a per-family global uniform float multiplier: 1.0 in SDR, P / REF in HDR. REF is the family’s reference literal, so relative weights inside a shader are kept. They are pushed through step 1’s ShaderGlobals.
  • Sources are wired in family tasks (enemies, shader families, legacy FX + sparks, hero, background + UI). Each family reads HdrTier.gain() once per sync/draw, never per instance through a dictionary.

Tech Stack: Godot 4.7.1 typed GDScript (GUT), Godot shading language (canvas_item), tools/tunable_docs (Node 22 ESM), the Remote Console (generated control/src/tunable_docs.js).

Spec: docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md: § 2 “Shader constants”, § 4 “Content fixes”, the Appendix inventory, and Rollout step 3. Research: docs/research/2026-10-08-hdr-colour-best-practice.md lines 135-180 and 295-335 (the 1 + (M−1)·f mapping and the tier fractions). Step 2 plan: docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step2.md.

  • Chris, 2026-10-09: HDR values are FRACTIONS OF HEADROOM (not absolute values). This deliberately refines spec Decision 1’s “seeded once from a headroom mapping”: the mapping now runs at runtime against the live headroom. The fractions themselves are still the HDR profile’s own tunables, never derived from SDR values.

  • Tier seeds come from the research table:

    Tier Seed f
    core 0.7
    flash 1.0
    halo 0.2
    zone 0
    UI 0

    Plus two plan-chosen ones: hero glow 0.35 and spark 0.5. The research doc names these two groups but gives no fraction for them. Chris tunes every one live; the seeds only need to be sane.

  • Deviation from the spec’s literal lint threshold: the spec says “a literal > 1.0”. This plan lints literals ≥ 1.6 (white-hot terms). There are dozens of 1.1-1.5 shape/body gains that carry no HDR meaning, and exempting each would bury the real ones. The threshold is one named constant in the test; Chris can lower it. Flagged in the final report.

Draw paths: how a linear peak P becomes the fed value

Section titled “Draw paths: how a linear peak P becomes the fed value”

Engine facts, verified in ~/Claude/godot-tvos/godot on 2026-10-09:

  • renderer_canvas_render_rd.cpp:693-694, 2427-2428, 2647-2648: modulate, draw_*, polygon and rect colours are linearised with srgb_to_linear on an HDR canvas.
  • canvas.glsl:176-177: vertex attributes are linearised.
  • canvas.glsl:254-255: instancing colours, i.e. MultiMesh AND GPUParticles2D (TYPE_PARTICLES takes the same instancing path, .cpp:2802-2814), are multiplied in RAW.
  • renderer_canvas_cull.cpp:2201-2205 and .cpp:554: PointLight2D colour is stored and used raw.
Path What Fed value for linear peak P
A modulate, draw_*, source_color, Line2D, Sprite2D modulate pow(P, 1/2.4) (the engine raises a colour × gain to ≈2.4)
B MultiMesh instance COLOR, cs-converted plain uniforms pow(P, 1/2.4) while ColorSpace.active, else P
R particle emit colours, shader scalar globals/uniforms, light energy P

pow(P, 1/2.4) is exact in the power region of the sRGB curve. Gains multiply colours whose channels are almost all above 0.04, and the error below that is invisible.

Scope and determinism

  • Nothing under /sim changes. Render-only. The determinism baselines (tests/test_determinism*.gd) must not move.
  • SDR = today, byte for byte. Every SDR profile value added here equals today’s literal. Multiplier-style values (shader families, GALAXY_CORE) are exactly 1.0 in SDR. GDScript sites multiply by gain / CONST, which is exactly 1.0 in SDR. tests/test_color_profiles.gd SDR_BUNDLED pins every new SDR value.
  • HDR M = 1 (the profile forced on an SDR screen) gives every fraction peak 1.0. That is expected (dev preview only), not a bug.

Tunables

  • Every new value is a static var in BOTH render/color_profile_sdr.gd and render/color_profile_hdr.gd, same name (pairing test).
  • Each is registered in net/tunables_registry.gd in the existing SDR (:997-1020) and HDR (:1021-1042) blocks. The shape is {"key": "ColorProfileHdr.X", "script": COLOR_HDR, "name": "X", "min": …, "max": …}, never "local".
  • Each has a label in tools/tunable_docs/docs.d/color_profiles.json, same shape as ColorProfileSdr.BORDER_HALO (:147-153), group "Glow & colour grade". HDR descs say “fraction of headroom: 1 + (headroom − 1) × f”.
  • Regenerate with node tools/tunable_docs/generate.mjs. control/src/tunable_docs.js is GENERATED; a hand edit fails the gate.
  • They are live through HOOK_ENV automatically (net/live_scope.gd:284-285 maps both ColorProfile*. prefixes).
  • No value that can be a tunable is left a const (project rule). Consts that stay const in this plan, with why:
    • SHELL_GLOW_BOOST is dead (USE_BAKED_GLOW = false).
    • The Warden v1 drone colour (main.gd:3060) is unreachable while warden_v2 is true.
    • Player halo and rim are dead (hidden / USE_BAKED_HULL).
    • HeroLights energies: a light brightens the BODIES it touches, which the research says stay at reference. Out of scope; noted in the report.

Shaders

  • Every new global is declared in [shader_globals] of BOTH project.godot and platform/tvos/project.godot as {"type": "float", "value": <the SDR profile value>}, with ; comments only. tests/test_color_profiles.gd:292-304 enforces it.
  • Set only via ShaderGlobals.push → RenderingServer.global_shader_parameter_set. Never _add / _get.
  • The headless gate never compiles shaders. After every shader edit, run tools/shader_check/compile_check.gd windowed under --rendering-method mobile AND --rendering-method gl_compatibility. Grep ERROR|WARNING|compile_check, then git checkout -- project.godot audio/bus_layout.tres. Task 3 widens its MARKERS so every shader touched here is discovered.

Testing and process

  • Single script: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit -gselect=<exact_name>.gd (-gtest does NOT narrow; -gselect is a substring match, so use the full file name).
  • Full suite: GUT_LOG=$TMPDIR/bh-gut-step3-<task>.log scripts/check-test-count.sh. Never pull a raw GUT run into context.
  • Fresh worktree: godot --headless --path . --import first, then git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot.
  • Each task ends green on scripts/gate.sh before its commit. git commit --file=<msg file> (no heredocs).
  • Copy rules: no em dashes in player-visible strings; the docs.d labels are dev-facing but keep the same style.

File Responsibility Task
render/hdr_tier.gd (new) Source table, per-frame gain cache, path conversion 1
render/color_profile_sdr.gd, render/color_profile_hdr.gd New statics (SDR literal / HDR fraction or cap) 1
render/environment_look.gd Headroom-relative HDR glow knee 1
render/shader_globals.gd Push HdrTier-computed values for multiplier globals 1
main.gd HdrTier.refresh each frame; HOOK_ENV invalidate 1
net/tunables_registry.gd, tools/tunable_docs/docs.d/color_profiles.json, control/src/tunable_docs.js (generated) Registration + labels 1
render/archetype_renderer.gd, render/swarm_renderer.gd Enemy shell/halo/hot-core gains 2
fx/weapons/*.gdshader, fx/weapons/element_accents.gdshaderinc, fx/boss/warden_*.gdshader, render/shaders/death_dissolve.gdshader, render/shaders/funzo_void_skin.gdshader, fx/weapons/plasma_nova.gdshader, both project.godot Family multiplier globals 3
tools/shader_check/compile_check.gd, tests/test_shader_hdr_lint.gd (new) Compile coverage + literal lint 3
fx/fx_manager.gd, fx/enemy_aura_look.gd, fx/weapons/plasma_nova_node.gd, fx/gpu_burst.gd, fx/thruster_trail.gd, render/shaders/gpu_burst_process_mat.tres, render/shaders/thruster_trail_process_mat.tres Legacy FX + sparks 4
render/player_renderer.gd Hero glow + flash 5
render/far_stars.gd, render/arena_background.gd, ui/level_up_panel.gd, ui/meta_shop_panel.gd, ui/theme/glass_frost.gdshader, ui/theme/glass_snapshot.gdshader Background highlights + UI caps 6
render/freeze_scene.gd, tools/cs_capture/* (already written, uncommitted, in the hdr-colour-space worktree) Comparison tooling 7
docs/architecture/rendering-and-performance.md, control/src/whats_new.js Docs + What’s new 7

Task 1: HdrTier core, the source table, and the headroom-relative glow knee

Section titled “Task 1: HdrTier core, the source table, and the headroom-relative glow knee”

Files:

  • Create: render/hdr_tier.gd, tests/test_hdr_tier.gd
  • Modify: render/color_profile_sdr.gd, render/color_profile_hdr.gd, render/environment_look.gd, render/shader_globals.gd, main.gd (_apply_color_profile :2225, _process after :2328, HOOK_ENV :1044-1045), net/tunables_registry.gd, tools/tunable_docs/docs.d/color_profiles.json, control/src/tunable_docs.js (generated), tests/test_color_profiles.gd (SDR_BUNDLED, HDR_SEEDS)

Interfaces:

  • Produces:
    • HdrTier.gain(key: StringName) -> float: the cached fed value. Safe to call once per sync or draw; never call it per instance.
    • HdrTier.generation: int: bumps whenever any cached value changes. Build-time caches compare against it.
    • HdrTier.refresh(mode: int, headroom: float, cs_active: bool) -> bool: true when the cache changed.
    • HdrTier.invalidate() -> void
    • HdrTier.glow_threshold(sdr_value: float, frac: float, mode: int) -> float
    • HdrTier.glow_scale(sdr_value: float, frac: float, mode: int) -> float
    • Profile static names (the HdrTier.SOURCES keys): the table below.

The source table. This is the whole step’s data; later tasks only wire consumers.

Key (static name) Kind Path SDR value (today’s literal) HDR seed REF (kind M) Consumer task
ENEMY_SHELL F B 1.5 0.2 2
ENEMY_HALO F B 1.7 0.2 2
ENEMY_HOT_CORE F B 2.0 0.7 2
SWARM_NOVA_HOT F B 1.25 0.45 2
WFX_HOT M R 1.0 0.7 2.2 3 (global wfx_hot)
WFX_ACCENT M R 1.0 0.7 2.6 3 (global wfx_accent)
BOSS_HOT M R 1.0 0.7 1.8 3 (global boss_hot)
DISSOLVE_EDGE M R 1.0 1.0 1.6 3 (global dissolve_edge)
FX_WHITE_CORE F A 1.0 0.7 4
FX_HIT_FLASH F A 1.0 1.0 4
FX_SPARK_SPRITE F A 1.0 0.5 4
AURA_HOT F A 1.6 0.3 4
NOVA_EMBER F A 1.6 0.7 4
SPARK F R 1.0 0.5 4
HERO_GLOW F A 1.0 0.35 5
HERO_FLASH F A 1.0 1.0 5
FAR_STARS F B 1.0 0.15 6
GALAXY_CORE M R 1.0 0.3 1.6 6
UI_GLOW_CAP X – 10.0 1.0 6
UI_FLASH_CAP X – 2.0 1.0 6
GLASS_BACKDROP_CAP X R 10000.0 1.0 6 (global glass_cap)

Kinds:

  • F (fraction): SDR fed value = the SDR static. HDR fed value = path_feed(1 + (M−1)·f).
  • M (multiplier on an existing literal): SDR = the SDR static (1.0). HDR = (1 + (M−1)·f) / REF (path R, no conversion).
  • X (absolute cap): both profiles’ statics are used as is.

Glow knee (existing statics, semantics change in the HDR profile only):

  • GLOW_HDR_THRESHOLD: HDR seed 2.5 → 0.4 (fraction t). Applied threshold = 1 + (M−1)·0.4, which is 2.57 on the ATV (≈ today’s 2.5) and 1.67 on the Mac.

  • GLOW_HDR_SCALE: HDR seed 2.0 → 0.5 (fraction s). Applied = max(0.0, (M−1)·0.5), which is 1.97 on the ATV and 0.83 on the Mac.

  • SDR: unchanged absolute values.

  • Step 1: Write the failing tests (tests/test_hdr_tier.gd)

extends GutTest
# HdrTier (HDR/SDR colour systems step 3): profile value -> fed value, per draw path.
func before_each() -> void:
HdrTier.invalidate()
func after_all() -> void:
HdrTier.invalidate()
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
func test_sdr_values_are_the_sdr_statics_exactly() -> void:
HdrTier.refresh(ColorProfile.SDR, 4.93, true) # headroom and cs must not matter in SDR
for k: StringName in HdrTier.SOURCES:
assert_eq(HdrTier.gain(k), float(ColorProfileSdr.get(String(k))), String(k))
func test_hdr_fraction_on_raw_path_is_the_linear_peak() -> void:
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
# SPARK: kind F, path R, f 0.5 -> 1 + 3.93 * 0.5
assert_almost_eq(HdrTier.gain(&"SPARK"), 1.0 + 3.93 * ColorProfileHdr.SPARK, 1e-5)
func test_hdr_fraction_on_path_a_is_pre_linearised() -> void:
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
var p := 1.0 + 3.93 * ColorProfileHdr.FX_HIT_FLASH
assert_almost_eq(HdrTier.gain(&"FX_HIT_FLASH"), pow(p, 1.0 / 2.4), 1e-5)
# The engine raises the fed gain by ~2.4: the screen gets the linear peak back.
assert_almost_eq(pow(HdrTier.gain(&"FX_HIT_FLASH"), 2.4), p, 1e-4)
func test_hdr_path_b_converts_only_while_colour_space_fix_is_active() -> void:
var p := 1.0 + 3.93 * ColorProfileHdr.ENEMY_HOT_CORE
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
assert_almost_eq(HdrTier.gain(&"ENEMY_HOT_CORE"), p, 1e-5)
HdrTier.refresh(ColorProfile.HDR, 4.93, true)
assert_almost_eq(HdrTier.gain(&"ENEMY_HOT_CORE"), pow(p, 1.0 / 2.4), 1e-5)
func test_hdr_multiplier_divides_by_its_reference_literal() -> void:
HdrTier.refresh(ColorProfile.HDR, 4.93, true) # kind M is path R: cs never matters
var p := 1.0 + 3.93 * ColorProfileHdr.WFX_HOT
assert_almost_eq(HdrTier.gain(&"WFX_HOT"), p / 2.2, 1e-5)
func test_caps_are_absolute_in_both_profiles() -> void:
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
assert_eq(HdrTier.gain(&"UI_GLOW_CAP"), ColorProfileHdr.UI_GLOW_CAP)
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
assert_eq(HdrTier.gain(&"UI_GLOW_CAP"), ColorProfileSdr.UI_GLOW_CAP)
func test_unit_headroom_gives_unit_peaks() -> void:
HdrTier.refresh(ColorProfile.HDR, 1.0, true)
assert_almost_eq(HdrTier.gain(&"ENEMY_HOT_CORE"), 1.0, 1e-6)
assert_almost_eq(HdrTier.gain(&"WFX_HOT"), 1.0 / 2.2, 1e-6)
func test_refresh_reports_change_once_and_bumps_generation() -> void:
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
var g := HdrTier.generation
assert_true(HdrTier.refresh(ColorProfile.HDR, 4.93, false))
assert_eq(HdrTier.generation, g + 1)
assert_false(HdrTier.refresh(ColorProfile.HDR, 4.93, false))
assert_false(HdrTier.refresh(ColorProfile.HDR, 4.95, false), "under 1% headroom drift is ignored")
assert_true(HdrTier.refresh(ColorProfile.HDR, 2.67, false), "a Mac brightness change re-applies")
func test_invalidate_forces_a_recompute_after_a_live_edit() -> void:
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
var old := ColorProfileHdr.SPARK
ColorProfileHdr.SPARK = 0.9
HdrTier.invalidate()
assert_true(HdrTier.refresh(ColorProfile.HDR, 4.93, false))
assert_almost_eq(HdrTier.gain(&"SPARK"), 1.0 + 3.93 * 0.9, 1e-5)
ColorProfileHdr.SPARK = old
func test_glow_knee_is_headroom_relative_in_hdr_and_absolute_in_sdr() -> void:
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
assert_almost_eq(HdrTier.glow_threshold(9.0, 0.4, ColorProfile.HDR), 1.0 + 3.93 * 0.4, 1e-5)
assert_almost_eq(HdrTier.glow_scale(9.0, 0.5, ColorProfile.HDR), 3.93 * 0.5, 1e-5)
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
assert_eq(HdrTier.glow_threshold(1.0, 0.4, ColorProfile.SDR), 1.0)
assert_eq(HdrTier.glow_scale(1.5, 0.5, ColorProfile.SDR), 1.5)
func test_every_source_is_a_profile_static_in_both_profiles() -> void:
# Same lookup HdrTier.refresh uses (ColorProfile.script_for(mode).get(name)); null = missing static.
for k: StringName in HdrTier.SOURCES:
assert_not_null(ColorProfile.script_for(ColorProfile.SDR).get(String(k)), "SDR has %s" % k)
assert_not_null(ColorProfile.script_for(ColorProfile.HDR).get(String(k)), "HDR has %s" % k)

Also amend tests/test_color_profiles.gd:

  • add every new key’s SDR value (the table’s “SDR value” column) to SDR_BUNDLED (:39-54);

  • add every new key’s HDR seed to HDR_SEEDS (:68-77), plus "GLOW_HDR_THRESHOLD": 0.4 and "GLOW_HDR_SCALE": 0.5 (replacing 2.5 and 2.0).

  • Step 2: Run them to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit -gselect=test_hdr_tier.gd 2>&1 | tail -5 Expected: parse failure, “Identifier HdrTier not declared”.

  • Step 3: Add the profile statics

Append to render/color_profile_sdr.gd, after BORDER_HALO:

# Step 3 brightness sources (render/hdr_tier.gd SOURCES). SDR: today's literal at each site, so the
# SDR look is unchanged. Multipliers (WFX_HOT, WFX_ACCENT, BOSS_HOT, DISSOLVE_EDGE, GALAXY_CORE) are
# 1.0 = the shader's own literal. Caps (UI_*_CAP, GLASS_BACKDROP_CAP) are large enough to never bite.
static var ENEMY_SHELL: float = 1.5
static var ENEMY_HALO: float = 1.7
static var ENEMY_HOT_CORE: float = 2.0
static var SWARM_NOVA_HOT: float = 1.25
static var WFX_HOT: float = 1.0
static var WFX_ACCENT: float = 1.0
static var BOSS_HOT: float = 1.0
static var DISSOLVE_EDGE: float = 1.0
static var FX_WHITE_CORE: float = 1.0
static var FX_HIT_FLASH: float = 1.0
static var FX_SPARK_SPRITE: float = 1.0
static var AURA_HOT: float = 1.6
static var NOVA_EMBER: float = 1.6
static var SPARK: float = 1.0
static var HERO_GLOW: float = 1.0
static var HERO_FLASH: float = 1.0
static var FAR_STARS: float = 1.0
static var GALAXY_CORE: float = 1.0
static var UI_GLOW_CAP: float = 10.0
static var UI_FLASH_CAP: float = 2.0
static var GLASS_BACKDROP_CAP: float = 10000.0

Append to render/color_profile_hdr.gd, after BORDER_HALO:

# Step 3 brightness sources (render/hdr_tier.gd SOURCES). FRACTIONS OF HEADROOM (Chris 2026-10-09):
# the linear peak is 1 + (headroom - 1) * f, so the look keeps its proportions on a 2.7x Mac and a
# 4.9x TV. Seeds from the research tiers: core 0.7, flash 1.0, halo 0.2, hero glow 0.35, spark 0.5.
# Caps (UI_*_CAP, GLASS_BACKDROP_CAP) are absolute linear values: UI stays at UI white.
static var ENEMY_SHELL: float = 0.2
static var ENEMY_HALO: float = 0.2
static var ENEMY_HOT_CORE: float = 0.7
static var SWARM_NOVA_HOT: float = 0.45
static var WFX_HOT: float = 0.7
static var WFX_ACCENT: float = 0.7
static var BOSS_HOT: float = 0.7
static var DISSOLVE_EDGE: float = 1.0
static var FX_WHITE_CORE: float = 0.7
static var FX_HIT_FLASH: float = 1.0
static var FX_SPARK_SPRITE: float = 0.5
static var AURA_HOT: float = 0.3
static var NOVA_EMBER: float = 0.7
static var SPARK: float = 0.5
static var HERO_GLOW: float = 0.35
static var HERO_FLASH: float = 1.0
static var FAR_STARS: float = 0.15
static var GALAXY_CORE: float = 0.3
static var UI_GLOW_CAP: float = 1.0
static var UI_FLASH_CAP: float = 1.0
static var GLASS_BACKDROP_CAP: float = 1.0

In render/color_profile_hdr.gd, replace the two glow-knee lines and their comment with:

# FRACTIONS OF HEADROOM (step 3): threshold = 1 + (M-1) * 0.4, ramp width = (M-1) * 0.5. On the ATV
# (M 4.93) that is 2.57 / 1.97, step 1's absolute 2.5 / 2.0; on a Mac at M 2.67 it is 1.67 / 0.83
# instead of a 2.5 knee almost nothing reached (the 2026-10-09 "no difference on Mac" report).
static var GLOW_HDR_THRESHOLD: float = 0.4
static var GLOW_HDR_SCALE: float = 0.5
  • Step 4: Write render/hdr_tier.gd
class_name HdrTier
extends RefCounted
# Profile value -> the number a draw path is fed (HDR/SDR colour systems, step 3). Render-only,
# never read by /sim. ONE place does the HDR maths so every source keeps the same proportions:
# SDR profile: the SDR static as is (today's literal: the SDR look is unchanged).
# HDR profile: a fraction f of headroom M (HdrLook.headroom: screen x PEAK_CALIBRATION); the linear
# peak P = 1 + (M - 1) * f, then fed per draw path (the engine treats them differently on an HDR
# canvas, verified in the fork 2026-10-09):
# PATH_A modulate / draw_* / source_color / Line2D: engine linearises -> feed pow(P, 1/2.4)
# PATH_B MultiMesh COLOR / cs-converted uniform: linearised only while ColorSpace.active
# PATH_R particle emit colour, shader scalar global, light energy: raw -> feed P
# Kind M (a multiplier on a shader's own literal): P / REF, so 1.0 in SDR keeps the literal exact.
# Kind X (an absolute cap): the static as is in both profiles.
# main._process calls refresh() every frame (cheap: compares inputs); a change re-applies the
# environment + shader globals (main._apply_color_profile). Hot paths read gain() ONCE per sync/draw.
enum { KIND_F, KIND_M, KIND_X }
enum { PATH_A, PATH_B, PATH_R }
# key -> [kind, path, REF (kind M only)]. Keys are static names in BOTH ColorProfileSdr/Hdr.
const SOURCES := {
&"ENEMY_SHELL": [KIND_F, PATH_B, 0.0],
&"ENEMY_HALO": [KIND_F, PATH_B, 0.0],
&"ENEMY_HOT_CORE": [KIND_F, PATH_B, 0.0],
&"SWARM_NOVA_HOT": [KIND_F, PATH_B, 0.0],
&"WFX_HOT": [KIND_M, PATH_R, 2.2],
&"WFX_ACCENT": [KIND_M, PATH_R, 2.6],
&"BOSS_HOT": [KIND_M, PATH_R, 1.8],
&"DISSOLVE_EDGE": [KIND_M, PATH_R, 1.6],
&"FX_WHITE_CORE": [KIND_F, PATH_A, 0.0],
&"FX_HIT_FLASH": [KIND_F, PATH_A, 0.0],
&"FX_SPARK_SPRITE": [KIND_F, PATH_A, 0.0],
&"AURA_HOT": [KIND_F, PATH_A, 0.0],
&"NOVA_EMBER": [KIND_F, PATH_A, 0.0],
&"SPARK": [KIND_F, PATH_R, 0.0],
&"HERO_GLOW": [KIND_F, PATH_A, 0.0],
&"HERO_FLASH": [KIND_F, PATH_A, 0.0],
&"FAR_STARS": [KIND_F, PATH_B, 0.0],
&"GALAXY_CORE": [KIND_M, PATH_R, 1.6],
&"UI_GLOW_CAP": [KIND_X, PATH_R, 0.0],
&"UI_FLASH_CAP": [KIND_X, PATH_R, 0.0],
&"GLASS_BACKDROP_CAP": [KIND_X, PATH_R, 0.0],
}
const HEADROOM_EPS := 0.01 # relative headroom drift that re-applies (the Mac's slider moves it live)
const ENGINE_GAMMA := 2.4
static var generation := 0
static var _mode := -1
static var _headroom := 1.0
static var _cs := false
static var _dirty := true
static var _gains := {}
static func refresh(mode: int, headroom: float, cs_active: bool) -> bool:
var drift := absf(headroom - _headroom) > HEADROOM_EPS * maxf(_headroom, 1.0)
if not _dirty and mode == _mode and cs_active == _cs and not drift:
return false
_mode = mode
_headroom = headroom
_cs = cs_active
_dirty = false
var p := ColorProfile.script_for(mode)
for k: StringName in SOURCES:
_gains[k] = _fed(float(p.get(String(k))), SOURCES[k])
generation += 1
return true
static func invalidate() -> void:
_dirty = true
static func gain(key: StringName) -> float:
return float(_gains.get(key, 1.0))
static func peak(f: float) -> float:
return 1.0 + (_headroom - 1.0) * f
static func glow_threshold(sdr_value: float, frac: float, mode: int) -> float:
return peak(frac) if mode == ColorProfile.HDR else sdr_value
static func glow_scale(sdr_value: float, frac: float, mode: int) -> float:
return maxf(0.0, (_headroom - 1.0) * frac) if mode == ColorProfile.HDR else sdr_value
static func _fed(v: float, src: Array) -> float:
var kind: int = src[0]
if _mode != ColorProfile.HDR or kind == KIND_X:
return v
var p := peak(v)
if kind == KIND_M:
return p / float(src[2])
match int(src[1]):
PATH_A:
return pow(p, 1.0 / ENGINE_GAMMA)
PATH_B:
return pow(p, 1.0 / ENGINE_GAMMA) if _cs else p
return p

Note: glow_threshold / glow_scale take the profile’s own static as sdr_value when called in SDR (EnvironmentLook passes the active profile’s static, which is the absolute SDR value in SDR and the fraction in HDR). Simplify the call to pass the one static twice if that reads better; the tests pin the behaviour.

  • Step 5: Wire EnvironmentLook, ShaderGlobals and main

render/environment_look.gd apply(), inside if hdr:, replace the two glow-knee lines with:

# HDR profile: the knee and ramp are fractions of headroom (step 3, HdrTier).
env.glow_hdr_threshold = HdrTier.glow_threshold(float(p.get("GLOW_HDR_THRESHOLD")),
float(p.get("GLOW_HDR_THRESHOLD")), mode)
env.glow_hdr_scale = HdrTier.glow_scale(float(p.get("GLOW_HDR_SCALE")),
float(p.get("GLOW_HDR_SCALE")), mode)

render/shader_globals.gd:

  • Extend GLOBALS with the four Task 3 globals now. The shaders gain them in Task 3; declaring and pushing early is harmless.
  • Add glass_cap too (Task 6).
  • Make push route HdrTier keys through the cache:
const GLOBALS := {&"border_halo": "BORDER_HALO", &"wfx_hot": "WFX_HOT", &"wfx_accent": "WFX_ACCENT",
&"boss_hot": "BOSS_HOT", &"dissolve_edge": "DISSOLVE_EDGE", &"glass_cap": "GLASS_BACKDROP_CAP"}
static func push(mode: int) -> void:
var p := ColorProfile.script_for(mode)
for g: StringName in GLOBALS:
var key := StringName(GLOBALS[g])
setter.call(g, HdrTier.gain(key) if HdrTier.SOURCES.has(key) else p.get(String(key)))

Declare the five new globals in [shader_globals] of BOTH project files, right after border_halo, each with its SDR value (1.0, except glass_cap 10000.0):

wfx_hot={
"type": "float",
"value": 1.0
}

main.gd:

  • _apply_color_profile() (:2225): first line HdrTier.refresh(HdrLook.active, HdrLook.headroom, ColorSpace.active). This keeps the cache current before the push; the return value is ignored here.
  • _process, directly after the _sync_color_space(...) call (:2328):
# Step 3: a profile, headroom (>1%) or colour_space_fix change re-feeds every brightness source.
if HdrTier.refresh(HdrLook.active, HdrLook.headroom, ColorSpace.active):
_apply_color_profile()
  • HOOK_ENV (:1044-1045): call HdrTier.invalidate() before _apply_color_profile().

  • Step 6: Register + label + regenerate

net/tunables_registry.gd:

  • Add one entry per new key to the SDR block and one to the HDR block. Ranges:
    • SDR: min 0.0, max 8.0 for F/M kinds; max 20000.0 for the caps.
    • HDR: F/M fractions min 0.0, max 1.5; caps min 0.0, max 8.0.
  • Change the HDR GLOW_HDR_THRESHOLD and GLOW_HDR_SCALE entries’ ranges to min 0.0, max 1.0.

tools/tunable_docs/docs.d/color_profiles.json:

  • One SDR and one HDR label per key, same shape as BORDER_HALO. Example pair:
"ColorProfileSdr.ENEMY_HOT_CORE": {
"label": "SDR enemy hot core",
"desc": "SDR look: gain on the white-hot centre of every enemy (ArchetypeRenderer + SwarmRenderer). 2.0 = the shipped look.",
"src": "code", "group": "Glow & colour grade", "unit": "×"
},
"ColorProfileHdr.ENEMY_HOT_CORE": {
"label": "HDR enemy hot core",
"desc": "HDR look: fraction of the screen's headroom the enemy hot core reaches (peak = 1 + (headroom - 1) × f). 0.7 = core tier.",
"src": "code", "group": "Glow & colour grade", "unit": "f"
},
  • Rewrite the two HDR glow-knee descs to the fraction meaning.

Then: node tools/tunable_docs/generate.mjs && node --test control/test/*.test.mjs.

  • Step 7: Run tests

Run the -gselect=test_hdr_tier.gd command, then -gselect=test_color_profiles.gd. Expected: all pass. Then GUT_LOG=$TMPDIR/bh-gut-step3-t1.log scripts/check-test-count.sh passes, and test_main_pushes_globals_on_a_profile_change still passes. Its spy now also records the five new globals; widen its assert only if it compares the whole list.

  • Step 8: Gate and commit

scripts/gate.sh, then commit: feat(colour): HdrTier: HDR brightness as fractions of headroom; headroom-relative glow knee (step 3 core).


Task 2: Enemy family (ArchetypeRenderer + SwarmRenderer)

Section titled “Task 2: Enemy family (ArchetypeRenderer + SwarmRenderer)”

Files:

  • Modify: render/archetype_renderer.gd (consts :165, :187, :190; build :327, :338, :382; sync :836-884), render/swarm_renderer.gd (:17, :26, sync :129-156)
  • Test: tests/test_hdr_enemy_gains.gd (new)

Interfaces:

  • Consumes: HdrTier.gain(&"ENEMY_SHELL" | &"ENEMY_HALO" | &"ENEMY_HOT_CORE" | &"SWARM_NOVA_HOT"), HdrTier.generation.
  • Produces: nothing new for later tasks.

Recipe. The consts stay as the SDR identity: they are the authored layer weights and the denominators. Each sync multiplies by gain / CONST, which is exactly 1.0 in SDR.

ArchetypeRenderer:

  1. Next to shape_boosts, record each layer’s source key at build in a parallel array shape_kinds, appended alongside every shape_boosts.append:
    • &"ENEMY_HALO" at :327;
    • &"ENEMY_HOT_CORE" at :338;
    • in the procedural loop at :382, &"ENEMY_SHELL" if si == 0 else &"". Store _shell_kinds.append(shape_kinds) next to _shell_boosts.append(...).
  2. Add var _boost_gen := -1 and var _live_boosts: Array = [] (per shape: an Array of floats).
  3. At the top of sync(), before the instance loop:
if _boost_gen != HdrTier.generation or _live_boosts.size() != _shell_boosts.size():
_rebuild_live_boosts()

with

# Step 3: each layer's authored boost x (profile gain / the const it was authored at). Exactly the
# authored boost in SDR (gain == const); HDR re-feeds it per headroom and colour_space_fix.
func _rebuild_live_boosts() -> void:
_boost_gen = HdrTier.generation
var k := {&"ENEMY_SHELL": HdrTier.gain(&"ENEMY_SHELL") / SHELL_BOOST,
&"ENEMY_HALO": HdrTier.gain(&"ENEMY_HALO") / HALO_BOOST,
&"ENEMY_HOT_CORE": HdrTier.gain(&"ENEMY_HOT_CORE") / HOT_CORE_BOOST}
_live_boosts.resize(_shell_boosts.size())
for sh in range(_shell_boosts.size()):
var src: Array = _shell_boosts[sh]
var kinds: Array = _shell_kinds[sh]
var out: Array = []
for si in range(src.size()):
out.append(float(src[si]) * float(k.get(kinds[si], 1.0)))
_live_boosts[sh] = out
  1. In the instance loop, change var boosts: Array = _shell_boosts[sh] to var boosts: Array = _live_boosts[sh].
  2. _build_meshes (rebuild) must reset _boost_gen = -1 and clear _shell_kinds wherever it clears _shell_boosts.

SwarmRenderer sync():

  • Replace var hot_boost := NOVA_HOT_BOOST if VisualLook.nova else HOT_CORE_BOOST with
var hot_boost := HdrTier.gain(&"SWARM_NOVA_HOT") if VisualLook.nova else HdrTier.gain(&"ENEMY_HOT_CORE")
  • Keep the two consts. Comment them as the SDR literals mirrored in ColorProfileSdr, and the test pins the equality.

  • Step 1: Write the failing test (tests/test_hdr_enemy_gains.gd)

extends GutTest
func after_each() -> void:
HdrTier.invalidate()
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
func test_sdr_profile_mirrors_the_authored_consts() -> void:
assert_eq(ColorProfileSdr.ENEMY_SHELL, ArchetypeRenderer.SHELL_BOOST)
assert_eq(ColorProfileSdr.ENEMY_HALO, ArchetypeRenderer.HALO_BOOST)
assert_eq(ColorProfileSdr.ENEMY_HOT_CORE, ArchetypeRenderer.HOT_CORE_BOOST)
assert_eq(ColorProfileSdr.ENEMY_HOT_CORE, SwarmRenderer.HOT_CORE_BOOST)
assert_eq(ColorProfileSdr.SWARM_NOVA_HOT, SwarmRenderer.NOVA_HOT_BOOST)
func test_live_boosts_equal_authored_in_sdr_and_follow_hdr() -> void:
var r := ArchetypeRenderer.new()
add_child_autofree(r)
# Build the real meshes the same way the game does (read the existing archetype tests for the
# configure/build entry point used there: e.g. tests/test_archetype_renderer*.gd), then:
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
r._rebuild_live_boosts()
assert_eq(r._live_boosts, r._shell_boosts, "SDR: byte-identical boosts")
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
r._rebuild_live_boosts()
var hot_si: int = (r._shell_kinds[0] as Array).find(&"ENEMY_HOT_CORE")
if hot_si >= 0:
assert_almost_eq(float(r._live_boosts[0][hot_si]), HdrTier.gain(&"ENEMY_HOT_CORE"), 1e-5)

The implementer must replace the comment line with the real build call used by the existing ArchetypeRenderer tests (git grep -n "ArchetypeRenderer.new" tests/), and pick a shape index whose layers include a hot core (sprite shapes). Asserting on a shape with no hot-core layer is not allowed: make the if unconditional by choosing the right shape.

Also add a SwarmRenderer case: configure a renderer with one instance (copy the setup of an existing tests/test_swarm_renderer*.gd case), sync once in HDR (cs off), and assert hotcore.multimesh.get_instance_color(0).r ≈ hot.r * HdrTier.gain(&"ENEMY_HOT_CORE").

  • Step 2: Run, fail. Then implement the recipe, then run, pass.
  • Step 3: Full suite + determinism via check-test-count.sh (render-only: determinism must not move).
  • Step 4: Commit feat(colour): enemy shell/halo/hot-core gains follow the HDR profile (step 3).

Task 3: Shader families (weapons, element accents, Warden, dissolve) + compile coverage + literal lint

Section titled “Task 3: Shader families (weapons, element accents, Warden, dissolve) + compile coverage + literal lint”

Files:

  • Modify: the shaders in the table below, tools/shader_check/compile_check.gd (MARKERS :16)
  • Create: render/shaders/hdr_globals.gdshaderinc, tests/test_shader_hdr_lint.gd
  • (Globals are already declared and pushed by Task 1.)

Interfaces:

  • Consumes: globals wfx_hot, wfx_accent, boss_hot, dissolve_edge (declared in Task 1).
  • Produces: render/shaders/hdr_globals.gdshaderinc, which declares them, and the lint test.

The include (render/shaders/hdr_globals.gdshaderinc):

// Step 3 per-family white-hot multipliers (render/hdr_tier.gd SOURCES, kind M): 1.0 in SDR = each
// shader's own literal; in HDR = (1 + (headroom - 1) * f) / REF. Declared in [shader_globals] of
// BOTH project.godot files (export baker). Multiply ONLY white-hot terms (literal >= 1.6) by these.
global uniform float wfx_hot;
global uniform float wfx_accent;
global uniform float boss_hot;
global uniform float dissolve_edge;

Recipe. For each line in the table:

  • add #include "res://render/shaders/hdr_globals.gdshaderinc" once per shader, after its existing includes;
  • change the white-hot literal L to (L * <global>). Example: vec3(1.0, 0.98, 0.92) * lead_edge * 2.2 → vec3(1.0, 0.98, 0.92) * lead_edge * (2.2 * wfx_hot).
  • Leave every other literal alone.
  • Before editing, open the file and confirm the exact text. Line numbers are from d3d852ef. If a line has moved, find it by its text; if the text differs, stop and report.
Shader Line (d3d852ef) Literal(s) to wrap Global
fx/weapons/blade_slash.gdshader 88, 113 2.2, 2.0 wfx_hot
fx/weapons/blaster_flash.gdshader 53, 67, 70 1.6, 2.0, 2.0 wfx_hot
fx/weapons/chalice_pool.gdshader 167 1.6, 1.8 wfx_hot
fx/weapons/momentum_charge.gdshader 74 1.6 wfx_hot
fx/weapons/momentum_release.gdshader 88, 118 1.6, 1.8 wfx_hot
fx/weapons/orbit_shards.gdshader 127, 227, 241, 252 1.6; 2.0; 2.0; 1.8 wfx_hot
fx/weapons/player_bolt.gdshader 87, 170 1.9, 1.7 wfx_hot
fx/weapons/railgun_lance.gdshader 192, 198 1.6, 2.6 wfx_hot
fx/weapons/scatter_burst.gdshader 78, 121, 126 1.6, 1.6, 2.4 wfx_hot
fx/weapons/scythe_reap.gdshader 90, 126 1.9, 1.6 wfx_hot
fx/weapons/plasma_nova.gdshader 80 1.6 wfx_hot
fx/weapons/element_accents.gdshaderinc 49, 56, 63 2.2, 1.6, 2.6 wfx_accent (this include is pulled into weapon shaders; declare via the include, and make sure no shader ends up including hdr_globals twice: guard hdr_globals.gdshaderinc with #ifndef HDR_GLOBALS / #define HDR_GLOBALS / #endif)
fx/boss/warden_bolt.gdshader 52 1.7 boss_hot
fx/boss/warden_burst.gdshader 71 1.6 boss_hot
fx/boss/warden_charge.gdshader 119 1.6 (the core * 1.6 term) boss_hot
fx/boss/warden_drone.gdshader 47 1.8 boss_hot
fx/boss/warden_missile.gdshader 59 1.6 (spine) boss_hot
fx/boss/warden_swing.gdshader 81 1.8 boss_hot
render/shaders/death_dissolve.gdshader 17 1.6 dissolve_edge
render/shaders/funzo_void_skin.gdshader 17 1.6 dissolve_edge

Literals that STAY, each with a // sdr-hdr: <reason> comment on its line so the lint passes:

  • render/web_look_b.gdshader:88 2.2 * ripple: zone tier (f 0), a web is a large-area zone.
  • render/funzo_zone_field.gdshader (alpha-to-rgb carry, not a boost).
  • Any ≥1.6 literal the lint finds that is NOT in the table above: report it to the controller before choosing.

tools/shader_check/compile_check.gd: add "hdr_globals.gdshaderinc", "plasma_nova.gdshader", "glass_frost.gdshader" and "glass_snapshot.gdshader" to MARKERS, so every shader touched in Tasks 3 and 6 is compiled. If MARKERS matches on shader CODE, the include marker covers plasma_nova once it includes the file; keep the list minimal and explain it in a comment.

  • Step 1: Write the lint test (tests/test_shader_hdr_lint.gd)
extends GutTest
# Spec "Structural guards" (step 3 threshold): a white-hot literal (>= HOT) multiplying colour in a
# shader must be scaled by a profile global or carry `// sdr-hdr: <reason>`. Below HOT are shape and
# body gains with no HDR meaning (plan deviation from the spec's > 1.0, flagged to Chris).
const HOT := 1.6
const ROOTS := ["res://render", "res://fx", "res://ui"]
const GLOBALS := ["wfx_hot", "wfx_accent", "boss_hot", "dissolve_edge", "border_halo"]
func _shaders(dir: String, out: Array[String]) -> void:
for f in DirAccess.get_files_at(dir):
if f.ends_with(".gdshader") or f.ends_with(".gdshaderinc"):
out.append(dir.path_join(f))
for d in DirAccess.get_directories_at(dir):
_shaders(dir.path_join(d), out)
func test_white_hot_literals_are_profiled_or_exempt() -> void:
var files: Array[String] = []
for r in ROOTS:
_shaders(r, files)
var re := RegEx.create_from_string("\\*\\s*\\(?\\s*(\\d+\\.\\d+)")
var bad: Array[String] = []
for path in files:
var lines := FileAccess.get_file_as_string(path).split("\n")
for i in lines.size():
var line: String = lines[i]
var code := line.split("//")[0]
if line.contains("sdr-hdr:"):
continue
for m in re.search_all(code):
if float(m.get_string(1)) < HOT:
continue
var tail := code.substr(m.get_end(), 24)
var scaled := false
for g in GLOBALS:
if tail.contains(g):
scaled = true
if not scaled:
bad.append("%s:%d %s" % [path, i + 1, line.strip_edges()])
assert_eq(bad, [] as Array[String], "\n".join(bad))
func test_include_declares_every_family_global_once() -> void:
var src := FileAccess.get_file_as_string("res://render/shaders/hdr_globals.gdshaderinc")
for g in ["wfx_hot", "wfx_accent", "boss_hot", "dissolve_edge"]:
assert_eq(src.count("global uniform float %s;" % g), 1, g)
  • Step 2: Run, verify fail. Expected: a list of every un-wrapped ≥1.6 literal. Use it to confirm the table: anything listed that is not in the table goes to the controller.
  • Step 3: Apply the recipe to every table row and add the exemption comments.
  • Step 4: Run the lint and test_color_space_shaders.gd (the cs_in-first-statement rule must still hold, so the new include goes AFTER color_space.gdshaderinc). Pass.
  • Step 5: Compile check, both renderers
Terminal window
godot --path . --rendering-method mobile -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "ERROR|WARNING|compile_check"
godot --path . --rendering-method gl_compatibility -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "ERROR|WARNING|compile_check"
git checkout -- project.godot audio/bus_layout.tres

Expected: only the two compile_check summary lines, with a material count higher than step 2’s 57.

  • Step 6: Full suite, gate, commit feat(colour): weapon/accent/Warden/dissolve white-hot terms scale with the HDR profile; shader literal lint (step 3).

Files:

  • Modify: fx/fx_manager.gd, fx/enemy_aura_look.gd, fx/weapons/plasma_nova_node.gd, fx/gpu_burst.gd, fx/thruster_trail.gd, render/shaders/gpu_burst_process_mat.tres, render/shaders/thruster_trail_process_mat.tres
  • Test: tests/test_hdr_fx_gains.gd (new)

Interfaces:

  • Consumes: HdrTier.gain(&"FX_WHITE_CORE" | &"FX_HIT_FLASH" | &"FX_SPARK_SPRITE" | &"AURA_HOT" | &"NOVA_EMBER" | &"SPARK"), HdrTier.generation.

Changes (all SDR-identical: SDR gains are 1.0, or the replaced literal 1.6):

  1. fx/fx_manager.gd white-hot draw_* cores: _RingNode._draw :128 and :131, _BeamNode._draw :172 and :174, _TellNode._draw :216, _MarkFlashNode._draw :233, _BoltNode._draw :290 and :292-293, _SlashNode._draw :340.
    • At the top of each _draw, read var w := HdrTier.gain(&"FX_WHITE_CORE") and replace Color(1.0, 1.0, 1.0, x) / Color(1, 1, 1, x) with Color(w, w, w, x).
  2. fx/fx_manager.gd hit flash :910-911. Multiply the flash colour’s rgb by HdrTier.gain(&"FX_HIT_FLASH"), read once in that function:
var hf := HdrTier.gain(&"FX_HIT_FLASH")
var fc := Color(1.0, 0.92, 0.55) if big else Color(1.0, 1.0, 1.0)
fc = Color(fc.r * hf, fc.g * hf, fc.b * hf, fc.a)
  1. fx/fx_manager.gd _spawn (:986-994): multiply color.rgb by HdrTier.gain(&"FX_SPARK_SPRITE") before s.modulate = color. The per-frame fade at :1026 reuses the stored colour, so the gain carries through. Check that the stored colour IS the gained one; if advance re-reads an un-gained colour, gain it there too.
  2. fx/enemy_aura_look.gd:146: replace GAIN * 1.6 with GAIN * HdrTier.gain(&"AURA_HOT") (all three channels).
    • The _runs_cache (:142-156) must also be invalidated on HdrTier.generation. Extend the key check at :97-104 (_runs_gain) with a _runs_hdr_gen int compared to HdrTier.generation.
  3. fx/weapons/plasma_nova_node.gd:98-101: replace the literal 1.6 on :99 with var e := HdrTier.gain(&"NOVA_EMBER") (read once at the top of _draw).
    • :101 Color(1.6, 1.5, 1.3, a) becomes Color(e, e * (1.5 / 1.6), e * (1.3 / 1.6), a). That is SDR-identical (1.6, 1.5, 1.3) and keeps the warm tint in HDR.
  4. fx/gpu_burst.gd _burst (:84): multiply the emit colour’s rgb by HdrTier.gain(&"SPARK") (path R: particles are raw).
  5. fx/thruster_trail.gd:81: multiply COLOR’s rgb by HdrTier.gain(&"SPARK") at emit.
  6. render/shaders/gpu_burst_process_mat.tres and thruster_trail_process_mat.tres: set use_hdr = true on the GradientTexture1D (spec § 4). Same ramp values, so SDR is unchanged.
  • Step 1: Failing tests. For each site, test the real path, not a hand-set value:
    • _RingNode / _BoltNode: instance the node, call the colour-building helper. If colours are built inline in _draw, extract a tiny static func core_color(a: float) -> Color per node class (or one shared FxManager.white_core(a)) and test that.
    • Assert core_color(0.5) == Color(1,1,1,0.5) in SDR, and Color(g,g,g,0.5) with g == HdrTier.gain(&"FX_WHITE_CORE") in HDR (M 4.93).
    • Hit flash: same via an extracted FxManager.hit_flash_color(big: bool) -> Color.
    • EnemyAuraLook: the cached runs’ hot colour changes after HdrTier.refresh(HDR, 4.93, false) without changing GAIN (proves the generation invalidation).
    • PlasmaNova: an extracted static func ember_colors(c: Color, a: float) -> Array[Color]; SDR equals today’s two literals exactly.
    • GpuBurst: the emit colour helper; SDR equals the input colour.
    • The two .tres: load each and assert color_ramp.use_hdr (or the GradientTexture1D resource’s use_hdr) is true.
  • Step 2: Fail → implement → pass, one site at a time.
  • Step 3: Full suite, gate, commit feat(colour): legacy FX white cores, hit flash, sparks, aura hot line and nova embers follow the HDR profile (step 3).

Files:

  • Modify: render/player_renderer.gd
  • Test: tests/test_hdr_hero.gd (new)

Interfaces: Consumes HdrTier.gain(&"HERO_GLOW" | &"HERO_FLASH").

Changes (spec § 4 “Hero brightness”; today the hero is capped ≤ 1 while enemy hot cores reach 2.0):

  1. Thruster, per frame at :231. Multiply _thruster_accent rgb by var hg := HdrTier.gain(&"HERO_GLOW"), read once at the top of the per-frame function. Alpha unchanged.
  2. Heal glow and heal ring, set at :263 and :269 (alpha only today). Set the rgb to the base colour × hg, written each frame with the alpha. Base colours: Color(0.3, 1.0, 0.45) and Color(0.4, 1.0, 0.55) from :128 / :131; move them to named consts.
  3. Flash, per frame at :247: Color(hf, hf, hf, (1.0 - ft) * 0.9) with var hf := HdrTier.gain(&"HERO_FLASH").
  4. Nova glow at :296 is set on tier change only. It must also refresh on HdrTier.generation change: add var _hdr_gen := -1, and in the per-frame function if _hdr_gen != HdrTier.generation: _hdr_gen = HdrTier.generation; _apply_nova_glow(). Extract the :296 write into _apply_nova_glow(), which multiplies the accent rgb by hg.
  5. Do NOT touch the dead halo (:124 hidden) or rim (USE_BAKED_HULL). Note both in the commit body.
  • Step 1: Failing test. Build a PlayerRenderer the way its existing tests do (git grep -n "PlayerRenderer.new\|player_renderer.gd" tests/) and drive one frame with a flash active:
    • SDR: the flash modulate rgb is (1,1,1) and the thruster rgb equals today’s accent.
    • HDR (M 4.93): rgb equals HdrTier.gain(...) × base.
    • Nova glow updates after HdrTier.refresh without a tier change.
  • Step 2: Fail → implement → pass.
  • Step 3: Full suite, gate, commit feat(colour): player thruster, heal glow, flash and nova glow get hero-tier HDR values (step 3).

Files:

  • Modify: render/far_stars.gd (_push_tint :195-197), render/arena_background.gd (galaxy_shader_params :612-625), main.gd (_sync_color_space neighbour: push on HdrTier change), ui/level_up_panel.gd (:731-735), ui/meta_shop_panel.gd (:36, :941), ui/theme/glass_frost.gdshader (:13-15), ui/theme/glass_snapshot.gdshader (:19-23)
  • Test: tests/test_hdr_background_ui.gd (new); amend tests/test_level_up_panel.gd:1171

Interfaces: Consumes HdrTier.gain(&"FAR_STARS" | &"GALAXY_CORE" | &"UI_GLOW_CAP" | &"UI_FLASH_CAP"), global glass_cap.

Changes:

  1. Far stars: _push_tint() multiplies the pushed tint rgb by HdrTier.gain(&"FAR_STARS").
    • Path B: the uniform is cs-converted, which HdrTier already accounts for.
    • Add func refresh_hdr() -> void: _push_tint().
  2. Galaxy core: in galaxy_shader_params(), core_bright becomes ArenaBackground.GALAXY_CORE_BRIGHT * HdrTier.gain(&"GALAXY_CORE").
    • Add func refresh_hdr() -> void that re-sets only core_bright on the existing galaxy material, with no rebuild. Find the material the same way galaxy_shader_params consumers apply it (git grep -n "galaxy_shader_params" render/).
  3. main: in _process, inside the Task 1 if HdrTier.refresh(...) block, after _apply_color_profile(), call far_stars.refresh_hdr() and arena_bg.refresh_hdr() (null/instance-guarded like _sync_color_space). These owners have no per-frame poll, so this push is how a frozen sim picks up the change. That’s the step 2 lesson.
  4. Level-up gold pulse (:731-735). The pulse target becomes _cap(Tokens.c(PULSE_GOLD), HdrTier.gain(&"UI_GLOW_CAP")) with
# HDR keeps the pulse at UI white (spec § 4): the token's x1.35 lift is above it. SDR cap 10 = no-op.
static func _cap(c: Color, cap: float) -> Color:
return Color(minf(c.r, cap), minf(c.g, cap), minf(c.b, cap), c.a)
  • Amend tests/test_level_up_panel.gd:1171 to expect _cap(Tokens.c(&"gold.glow"), HdrTier.gain(&"UI_GLOW_CAP")). That is identical in SDR, so the test keeps its meaning.
  1. Meta-shop flash (:941): replace FLASH_HDR with HdrTier.gain(&"UI_FLASH_CAP") (2.0 in SDR = today). Delete the const, or keep it as a comment-documented mirror pinned by a test, the same as the enemy consts.
  2. Glass backdrops: include res://render/shaders/hdr_globals.gdshaderinc, extended with global uniform float glass_cap; inside the same include guard. Clamp the backdrop sample before the mix:
    • glass_frost :13: vec3 s = min(textureLod(screen_tex, SCREEN_UV, blur_lod).rgb, vec3(glass_cap));
    • glass_snapshot :19: vec3 s = min(texture(backdrop_tex, SCREEN_UV).rgb, vec3(glass_cap));
    • SDR glass_cap is 10000, a no-op.
  3. Colour-uniform lint (spec structural guard), added to tests/test_shader_hdr_lint.gd:
    • Any uniform vec3|vec4 <name> whose name contains col|color|tint must be one of:
      • source_color;
      • read through cs_in( somewhere in the same file;
      • carrying // sdr-hdr: on its line.
    • Known cs-converted ones that must pass via the cs_in( rule: eh_core, veil_color, galaxy col_*, far_stars tint, death_dissolve / funzo_void_skin tint.
func test_colour_uniforms_are_source_color_or_converted() -> void:
var files: Array[String] = []
for r in ROOTS:
_shaders(r, files)
var re := RegEx.create_from_string("uniform\\s+vec[34]\\s+(\\w*(?:col|color|tint)\\w*)")
var bad: Array[String] = []
for path in files:
var src := FileAccess.get_file_as_string(path)
var lines := src.split("\n")
for i in lines.size():
var m := re.search(lines[i])
if m == null or lines[i].contains("source_color") or lines[i].contains("sdr-hdr:"):
continue
if src.contains("cs_in(%s" % m.get_string(1)):
continue
bad.append("%s:%d %s" % [path, i + 1, lines[i].strip_edges()])
assert_eq(bad, [] as Array[String], "\n".join(bad))
  • If it lists uniforms outside this plan’s reach, add // sdr-hdr: <reason> only when the reason is real (e.g. “UI tint, post-tonemap”). Otherwise report it to the controller.

  • Step 1: Failing tests:

    • FarStars: the pushed tint, read back from its material, = base × gain (HDR M 4.93, cs off).
    • Galaxy: galaxy_shader_params()["core_bright"] = 1.6 in SDR and 1.6 × gain in HDR.
    • Level-up: _cap (SDR identical; HDR channels ≤ 1).
    • Meta-shop: the flash target in HDR has rgb ≤ the gold token’s.
    • The colour-uniform lint.
  • Step 2: Fail → implement → pass, then the compile check (both renderers) for the two glass shaders, then git checkout -- project.godot audio/bus_layout.tres.

  • Step 3: UI fit unchanged. Run the existing level-up and shop tests at the device profiles. No layout changed, so this only confirms nothing broke.

  • Step 4: Full suite, gate, commit feat(colour): far stars + galaxy core HDR highlight values; UI glow/flash/glass capped at UI white in HDR; colour-uniform lint (step 3).


Task 7: Comparison tooling, docs, What’s new

Section titled “Task 7: Comparison tooling, docs, What’s new”

Files:

  • Modify: render/freeze_scene.gd
  • Add: tools/cs_capture/cs_capture.gd + .tscn. These already exist UNCOMMITTED in the hdr-colour-space worktree; the controller copies them into the task worktree.
  • Docs: docs/architecture/rendering-and-performance.md (new “HDR brightness sources (step 3)” section next to the colour_space_fix one), control/src/whats_new.js
  • Test: tests/test_freeze_scene.gd (extend if it exists; else create)

Changes:

  1. Freeze scene shows a swarm.
    • Today PerfRig.DevHold.setup clears every enemy and hold_targets parks 60 targets 70 px from P1, under the weapon ring, so the scene shows almost no enemies. Enemies are what most of steps 2 and 3 change.
    • Add a second held ring to FreezeScene. Leave PerfRig untouched: its ABBA numbers depend on it.
    • Use const SWARM := [["swarmer", 24], ["tank", 4], ["shooter", 8], ["brute", 4]] at const SWARM_RING := 300.0, spawned in begin() via sim.spawn_story_enemy(name, pos, 1.0, false). Check each name against _ENEMY_NAME_TO_TID; drop any that don’t exist.
    • Spawned in angle order round P1, with enemies.data[i] = PerfRig.TARGET_HP (unkillable).
    • In _hold_layers(), re-pin their positions each pre-freeze frame, the same way hold_targets does.
    • Track their indices by spawning them last and recording range(start, count). Swap-remove can’t happen: they never die and nothing else is removed during the hold. Assert that in the test.
  2. tools/cs_capture:
    • Add a CS_PROFILE=hdr env option that sets HdrLook.override = 1 and HdrLook.headroom via HdrLook.update(true, float(OS.get_environment("CS_HEADROOM", "4.93"))). That lets one run compare SDR vs HDR feeds at a chosen headroom on the canvas (pre-display).
    • With the freeze-scene swarm in place, delete the CS_SWARM staging block, since the scene now shows a swarm itself.
    • Keep the header comment’s run command current.
  3. Docs:
    • The architecture section covers: the source table (point at HdrTier.SOURCES, don’t copy values), the three draw paths with the engine line references from this plan, kinds F/M/X, the glow-knee formula, how to add a new source (a static in both profiles + registry + docs.d + a SOURCES row + read gain() once per sync), and the lint rules.
    • Link it from the spec’s Rollout step 3 line.
  4. What’s new: one build: null item: “HDR screens: glow, hot cores, sparks and the ship’s flash now scale with your screen’s real brightness range, so HDR looks right on Mac and iPhone as well as the TV.”
  • Step 1: Failing test: a FreezeScene built on a real run has ≥ 30 non-custom enemies within 320 px of P1 after begin() and 10 frames of _hold_layers(), all with data == PerfRig.TARGET_HP.
  • Step 2: Fail → implement → pass.
  • Step 3: Run the capture (windowed, mobile) for SDR vs HDR at headroom 4.93 and 2.67, then git checkout -- project.godot audio/bus_layout.tres. Attach the printed numbers to the report:
    • brightest pixel;
    • share > 1 / > 2.7 / > 4.93;
    • mean |Δ|.
  • Step 4: Full suite, gate, commit feat(dev): freeze scene holds a visible swarm; cs_capture compares SDR/HDR feeds; step 3 docs + What's new.

  • Whole-branch review (bh-reviewer), then BUILD bump + Dev install on ATV, iPhone and Mac. Follow bump-build.sh past the highest live-branch BUILD, and do the ListAgents merge claim first.
  • Chris’s verdict per device, with screen brightness at about half on Mac/iPhone for a fair HDR read, comparing SDR vs HDR on the freeze scene (which now shows a swarm).
  • Fresh-install stall check (bh-fresh-install-stall-check). Task 3 adds a global include to ~25 shaders.
  • Persist Chris’s tuned fractions by remote-config patch, with his authorization.
  • Known and left alone, for the report:
    • additive stacking can still exceed the headroom where many hot hits overlap (180× measured on the old freeze scene). A “highlight density governor” (research doc) is a separate item;
    • HeroLights energies unchanged;
    • the warp-tint backdrop bug (RELEASE-1.0.1 § 1) is separate.

For a fresh session with no conversation context.

State at handoff (2026-10-09):

  • Worktree .claude/worktrees/hdr-colour-space, branch hdr-colour-space, base d3d852ef (BUILD 292 = step 2 + origin/main, pushed). Step 1 is on the unmerged branch hdr-colour-spec; step 2 is on this branch. Neither is merged to main.
  • BUILD 292 is installed on the ATV, iPhone and Mac Dev apps. Chris’s ATV verdict on step 2: “colourspace on atv on is definitely better”. iPhone and Mac verdicts are still open.
  • tools/cs_capture/ is committed with this plan (it was written during planning). Task 7 extends it; the controller does NOT need to copy it, because it is on the branch.

Decisions Chris made in conversation:

  • “lets do step 3”: step 3 goes ahead before the step 1/2 merge, stacked on hdr-colour-space.
  • “Fraction of headroom (Recommended)”: HDR values are f, peak = 1 + (M−1)·f, M = HdrLook.headroom.
  • “ok” to the before/after capture run before step 3. It ran; results are in the plan’s “Why now”.

Chris’s report that drives this: “I cant see any differemce on mac or iphone with either hdr or colourspace settings.”

  • Diagnosis [Likely]: the Mac headroom is 2.67, and the ATV-seeded absolute glow knee of 2.5 kills HDR glow there.
  • colour_space_fix works on the Mac renderer [Certain] but is subtle.
  • Advice already given: judge HDR with Mac/iPhone brightness at about half.

Authorized: writing code on this branch via worktree subagents, running gates, committing to hdr-colour-space, and pushing that branch.

Needs Chris’s same-day OK:

  • any merge to main (ListAgents claim first);
  • BUILD bump + device installs;
  • console Worker redeploy;
  • remote-config patch publish.

Open questions for Chris (ask in the final report, don’t block on them):

  • The lint threshold of 1.6 deviates from the spec’s 1.0.
  • The HeroLights energies are left alone.
  • iPhone and Mac step 2 verdicts.

Environment gotchas from planning:

  • No gtimeout: use perl -e 'alarm N; exec @ARGV' godot ....
  • Never drive Chris’s running Mac app (console override / osascript was denied). Use a separate windowed Godot run in the worktree.
  • After any windowed run: git checkout -- project.godot audio/bus_layout.tres.
  • git grep from the main checkout sees main, not this branch. Work from the worktree.
  • A subagent with isolation: "worktree" branches from the CURRENT branch tip. Dispatch from this worktree so it bases on hdr-colour-space.

Exact next action: execute Task 1 via superpowers:subagent-driven-development (bh-implementer, model: "opus", isolation worktree), reading bh-sdd-concurrent first.