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 readbh-sdd-concurrentbefore Task 1. Dispatch every implementer withisolation: "worktree", branched offhdr-colour-space(NOTorigin/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_linear2.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 isP = 1 + (M − 1)·f, withM = HdrLook.headroom(already headroom × the player’sPEAK_CALIBRATION). Pis 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 floatmultiplier: 1.0 in SDR,P / REFin HDR. REF is the family’s reference literal, so relative weights inside a shader are kept. They are pushed through step 1’sShaderGlobals. - 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.
Decisions this plan rests on
Section titled “Decisions this plan rests on”-
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 withsrgb_to_linearon an HDR canvas.canvas.glsl:176-177: vertex attributes are linearised.canvas.glsl:254-255: instancing colours, i.e. MultiMesh AND GPUParticles2D (TYPE_PARTICLEStakes the same instancing path,.cpp:2802-2814), are multiplied in RAW.renderer_canvas_cull.cpp:2201-2205and.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.
Global Constraints
Section titled “Global Constraints”Scope and determinism
- Nothing under
/simchanges. 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 exactly1.0in SDR. GDScript sites multiply bygain / CONST, which is exactly 1.0 in SDR.tests/test_color_profiles.gdSDR_BUNDLEDpins 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 varin BOTHrender/color_profile_sdr.gdandrender/color_profile_hdr.gd, same name (pairing test). - Each is registered in
net/tunables_registry.gdin 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 asColorProfileSdr.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.jsis GENERATED; a hand edit fails the gate. - They are live through HOOK_ENV automatically (
net/live_scope.gd:284-285maps bothColorProfile*.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_BOOSTis dead (USE_BAKED_GLOW = false).- The Warden v1 drone colour (
main.gd:3060) is unreachable whilewarden_v2is true. - Player halo and rim are dead (hidden /
USE_BAKED_HULL). HeroLightsenergies: 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 BOTHproject.godotandplatform/tvos/project.godotas{"type": "float", "value": <the SDR profile value>}, with;comments only.tests/test_color_profiles.gd:292-304enforces 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.gdwindowed under--rendering-method mobileAND--rendering-method gl_compatibility. GrepERROR|WARNING|compile_check, thengit checkout -- project.godot audio/bus_layout.tres. Task 3 widens itsMARKERSso 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(-gtestdoes NOT narrow;-gselectis 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 . --importfirst, thengit checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot. - Each task ends green on
scripts/gate.shbefore 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 map
Section titled “File map”| 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,_processafter :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() -> voidHdrTier.glow_threshold(sdr_value: float, frac: float, mode: int) -> floatHdrTier.glow_scale(sdr_value: float, frac: float, mode: int) -> float- Profile static names (the
HdrTier.SOURCESkeys): 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.4and"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.5static var ENEMY_HALO: float = 1.7static var ENEMY_HOT_CORE: float = 2.0static var SWARM_NOVA_HOT: float = 1.25static var WFX_HOT: float = 1.0static var WFX_ACCENT: float = 1.0static var BOSS_HOT: float = 1.0static var DISSOLVE_EDGE: float = 1.0static var FX_WHITE_CORE: float = 1.0static var FX_HIT_FLASH: float = 1.0static var FX_SPARK_SPRITE: float = 1.0static var AURA_HOT: float = 1.6static var NOVA_EMBER: float = 1.6static var SPARK: float = 1.0static var HERO_GLOW: float = 1.0static var HERO_FLASH: float = 1.0static var FAR_STARS: float = 1.0static var GALAXY_CORE: float = 1.0static var UI_GLOW_CAP: float = 10.0static var UI_FLASH_CAP: float = 2.0static var GLASS_BACKDROP_CAP: float = 10000.0Append 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.2static var ENEMY_HALO: float = 0.2static var ENEMY_HOT_CORE: float = 0.7static var SWARM_NOVA_HOT: float = 0.45static var WFX_HOT: float = 0.7static var WFX_ACCENT: float = 0.7static var BOSS_HOT: float = 0.7static var DISSOLVE_EDGE: float = 1.0static var FX_WHITE_CORE: float = 0.7static var FX_HIT_FLASH: float = 1.0static var FX_SPARK_SPRITE: float = 0.5static var AURA_HOT: float = 0.3static var NOVA_EMBER: float = 0.7static var SPARK: float = 0.5static var HERO_GLOW: float = 0.35static var HERO_FLASH: float = 1.0static var FAR_STARS: float = 0.15static var GALAXY_CORE: float = 0.3static var UI_GLOW_CAP: float = 1.0static var UI_FLASH_CAP: float = 1.0static var GLASS_BACKDROP_CAP: float = 1.0In 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.4static var GLOW_HDR_SCALE: float = 0.5- Step 4: Write
render/hdr_tier.gd
class_name HdrTierextends 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 := 0static var _mode := -1static var _headroom := 1.0static var _cs := falsestatic var _dirty := truestatic 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 pNote: 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
GLOBALSwith the four Task 3 globals now. The shaders gain them in Task 3; declaring and pushing early is harmless. - Add
glass_captoo (Task 6). - Make
pushroute 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 lineHdrTier.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.0for F/M kinds;max 20000.0for the caps. - HDR: F/M fractions
min 0.0, max 1.5; capsmin 0.0, max 8.0.
- SDR:
- Change the HDR
GLOW_HDR_THRESHOLDandGLOW_HDR_SCALEentries’ ranges tomin 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:
- Next to
shape_boosts, record each layer’s source key at build in a parallel arrayshape_kinds, appended alongside everyshape_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(...).
- Add
var _boost_gen := -1andvar _live_boosts: Array = [](per shape: an Array of floats). - 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- In the instance loop, change
var boosts: Array = _shell_boosts[sh]tovar boosts: Array = _live_boosts[sh]. _build_meshes(rebuild) must reset_boost_gen = -1and clear_shell_kindswherever it clears_shell_boosts.
SwarmRenderer sync():
- Replace
var hot_boost := NOVA_HOT_BOOST if VisualLook.nova else HOT_CORE_BOOSTwith
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
Lto(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:882.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.6const 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 AFTERcolor_space.gdshaderinc). Pass. - Step 5: Compile check, both renderers
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.tresExpected: 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).
Task 4: Legacy FX and sparks
Section titled “Task 4: Legacy FX and sparks”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):
fx/fx_manager.gdwhite-hotdraw_*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, readvar w := HdrTier.gain(&"FX_WHITE_CORE")and replaceColor(1.0, 1.0, 1.0, x)/Color(1, 1, 1, x)withColor(w, w, w, x).
- At the top of each
fx/fx_manager.gdhit flash :910-911. Multiply the flash colour’s rgb byHdrTier.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)fx/fx_manager.gd_spawn(:986-994): multiplycolor.rgbbyHdrTier.gain(&"FX_SPARK_SPRITE")befores.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; ifadvancere-reads an un-gained colour, gain it there too.fx/enemy_aura_look.gd:146: replaceGAIN * 1.6withGAIN * HdrTier.gain(&"AURA_HOT")(all three channels).- The
_runs_cache(:142-156) must also be invalidated onHdrTier.generation. Extend the key check at :97-104 (_runs_gain) with a_runs_hdr_genint compared toHdrTier.generation.
- The
fx/weapons/plasma_nova_node.gd:98-101: replace the literal1.6on :99 withvar e := HdrTier.gain(&"NOVA_EMBER")(read once at the top of_draw).- :101
Color(1.6, 1.5, 1.3, a)becomesColor(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.
- :101
fx/gpu_burst.gd_burst(:84): multiply the emit colour’s rgb byHdrTier.gain(&"SPARK")(path R: particles are raw).fx/thruster_trail.gd:81: multiplyCOLOR’s rgb byHdrTier.gain(&"SPARK")at emit.render/shaders/gpu_burst_process_mat.tresandthruster_trail_process_mat.tres: setuse_hdr = trueon theGradientTexture1D(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 tinystatic func core_color(a: float) -> Colorper node class (or one sharedFxManager.white_core(a)) and test that.- Assert
core_color(0.5) == Color(1,1,1,0.5)in SDR, andColor(g,g,g,0.5)withg == 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 changingGAIN(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 assertcolor_ramp.use_hdr(or theGradientTexture1Dresource’suse_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).
Task 5: Hero (player ship)
Section titled “Task 5: Hero (player ship)”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):
- Thruster, per frame at :231. Multiply
_thruster_accentrgb byvar hg := HdrTier.gain(&"HERO_GLOW"), read once at the top of the per-frame function. Alpha unchanged. - 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)andColor(0.4, 1.0, 0.55)from :128 / :131; move them to named consts. - Flash, per frame at :247:
Color(hf, hf, hf, (1.0 - ft) * 0.9)withvar hf := HdrTier.gain(&"HERO_FLASH"). - Nova glow at :296 is set on tier change only. It must also refresh on
HdrTier.generationchange: addvar _hdr_gen := -1, and in the per-frame functionif _hdr_gen != HdrTier.generation: _hdr_gen = HdrTier.generation; _apply_nova_glow(). Extract the :296 write into_apply_nova_glow(), which multiplies the accent rgb byhg. - 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.refreshwithout a tier change.
- SDR: the flash modulate rgb is
- 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).
Task 6: Background highlights + UI caps
Section titled “Task 6: Background highlights + UI caps”Files:
- Modify:
render/far_stars.gd(_push_tint:195-197),render/arena_background.gd(galaxy_shader_params:612-625),main.gd(_sync_color_spaceneighbour: 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); amendtests/test_level_up_panel.gd:1171
Interfaces: Consumes HdrTier.gain(&"FAR_STARS" | &"GALAXY_CORE" | &"UI_GLOW_CAP" | &"UI_FLASH_CAP"), global glass_cap.
Changes:
- Far stars:
_push_tint()multiplies the pushedtintrgb byHdrTier.gain(&"FAR_STARS").- Path B: the uniform is cs-converted, which
HdrTieralready accounts for. - Add
func refresh_hdr() -> void: _push_tint().
- Path B: the uniform is cs-converted, which
- Galaxy core: in
galaxy_shader_params(),core_brightbecomesArenaBackground.GALAXY_CORE_BRIGHT * HdrTier.gain(&"GALAXY_CORE").- Add
func refresh_hdr() -> voidthat re-sets onlycore_brighton the existing galaxy material, with no rebuild. Find the material the same waygalaxy_shader_paramsconsumers apply it (git grep -n "galaxy_shader_params" render/).
- Add
- main: in
_process, inside the Task 1if HdrTier.refresh(...)block, after_apply_color_profile(), callfar_stars.refresh_hdr()andarena_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. - 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:1171to expect_cap(Tokens.c(&"gold.glow"), HdrTier.gain(&"UI_GLOW_CAP")). That is identical in SDR, so the test keeps its meaning.
- Meta-shop flash (:941): replace
FLASH_HDRwithHdrTier.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. - Glass backdrops: include
res://render/shaders/hdr_globals.gdshaderinc, extended withglobal 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_capis 10000, a no-op.
- glass_frost :13:
- Colour-uniform lint (spec structural guard), added to
tests/test_shader_hdr_lint.gd:- Any
uniform vec3|vec4 <name>whose name containscol|color|tintmust 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, galaxycol_*, far_starstint, death_dissolve / funzo_void_skintint.
- Any
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 and1.6 × gainin 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 thehdr-colour-spaceworktree; 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:
- Freeze scene shows a swarm.
- Today
PerfRig.DevHold.setupclears every enemy andhold_targetsparks 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. LeavePerfRiguntouched: its ABBA numbers depend on it. - Use
const SWARM := [["swarmer", 24], ["tank", 4], ["shooter", 8], ["brute", 4]]atconst SWARM_RING := 300.0, spawned inbegin()viasim.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 wayhold_targetsdoes. - 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.
- Today
tools/cs_capture:- Add a
CS_PROFILE=hdrenv option that setsHdrLook.override = 1andHdrLook.headroomviaHdrLook.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_SWARMstaging block, since the scene now shows a swarm itself. - Keep the header comment’s run command current.
- Add a
- 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 + readgain()once per sync), and the lint rules. - Link it from the spec’s Rollout step 3 line.
- The architecture section covers: the source table (point at
- What’s new: one
build: nullitem: “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 withdata == 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.
After the plan (controller, not a task)
Section titled “After the plan (controller, not a task)”- Whole-branch review (bh-reviewer), then BUILD bump + Dev install on ATV, iPhone and Mac. Follow
bump-build.shpast the highest live-branch BUILD, and do theListAgentsmerge 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;
HeroLightsenergies unchanged;- the warp-tint backdrop bug (RELEASE-1.0.1 § 1) is separate.
Execution handoff
Section titled “Execution handoff”For a fresh session with no conversation context.
State at handoff (2026-10-09):
- Worktree
.claude/worktrees/hdr-colour-space, branchhdr-colour-space, based3d852ef(BUILD 292 = step 2 + origin/main, pushed). Step 1 is on the unmerged branchhdr-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
HeroLightsenergies are left alone. - iPhone and Mac step 2 verdicts.
Environment gotchas from planning:
- No
gtimeout: useperl -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 grepfrom 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 onhdr-colour-space.
Exact next action: execute Task 1 via superpowers:subagent-driven-development (bh-implementer, model: "opus", isolation worktree), reading bh-sdd-concurrent first.