Skip to content

HDR/SDR colour systems, step 2 (colour-space consistency behind the colour_space_fix A/B): implementation plan

HDR/SDR Colour Systems, Step 2: Implementation Plan

Section titled “HDR/SDR Colour Systems, Step 2: 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: step 1 is unmerged and this plan builds on it).

Goal: One A/B row, colour_space_fix (default OFF), makes every colour the game hands the GPU land in the same colour space as modulate/draw_* colours on an HDR canvas. It covers MultiMesh instance colours, plain vec3/vec4 colour uniforms and the three LDR SubViewports. With the row OFF the game is byte-for-byte today’s.

Architecture:

  • ColorSpace (render/color_space.gd) holds the remote tunable FIX, a session override, and active (= on AND the root canvas is HDR). main._process calls ColorSpace.sync(root.use_hdr_2d) every frame, right after _sync_hdr_output(). On a change it sets the declared global shader uniform cs_linearize and bumps ColorSpace.generation.
  • Shaders convert their colour INPUTS with cs_in() from render/shaders/color_space.gdshaderinc. It is the engine’s own srgb_to_linear formula, gated by cs_linearize. MultiMesh colours are converted as the first statement of vertex(), before the engine multiplies in the texture. Plain colour uniforms are converted where they are read.
  • MultiMesh layers drawn with the default material or fx/additive_mat.tres (a CanvasItemMaterial, which cannot convert) swap to two new saved shader materials while active: cs_mix_mat.tres and cs_add_mat.tres. The swap is driven by ColorSpace.generation. With the row OFF they keep today’s exact materials.
  • The nebula, threat-radar and codex-preview SubViewports follow the root’s use_hdr_2d while active. The nebula’s pow(1/2.2) pre-compensation applies only while inactive.
  • The warp dim is compensated on the converted renderers, because their modulate would otherwise be linearised twice (see “Why the warp dim needs compensation”).

Tech Stack: Godot 4.7.1 typed GDScript (GUT), Godot shading language (canvas_item), the Remote Console (Cloudflare Worker JS, node --test), tools/tunable_docs (Node 22 ESM).

Spec: docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md, § 3 “Colour-space consistency” and Rollout step 2. Step 1’s plan: docs/superpowers/plans/2026-10-09-hdr-sdr-colour-systems-step1.md. Research: docs/research/2026-10-08-hdr-colour-best-practice.md.

Scope and determinism

  • Nothing under /sim changes. Render-only. The three determinism baselines (tests/test_determinism*.gd) must not move.
  • Row OFF = today, byte for byte. ColorSpace.FIX = false by default. While inactive: cs_linearize stays false (the declared value), no material is swapped, no SubViewport changes, the nebula compensation runs as today, and the warp dim writes today’s values.
  • Active only on an HDR root canvas. active = on() and root.use_hdr_2d. On gl_compatibility (web), and at any QualityManager tier that drops the root HDR, the fix is inert even when switched on. An LDR canvas does no linearisation, so there is nothing to match.

Tunables and console

  • ColorSpace.FIX is a registered remote tunable (bool, NOT "local": it is a look choice, not a property of one screen). It is read live every frame by main._process, so it needs no LiveScope hook, the same as HdrOutput.ENABLED. It needs a tools/tunable_docs/docs.d/color_space.json label. Then regenerate with node tools/tunable_docs/generate.mjs, because control/src/tunable_docs.js is GENERATED (a hand edit fails control/test/tunable-docs.test.mjs).
  • Debug Settings ↔ console parity (tests/test_debug_console_parity.gd). The row is a GRAPHICS_TOGGLES entry ["colour_space_fix", "Colour space fix (A/B)"]. The console’s existing graphics command (control/src/panel/client.js:299) and main.build_caps graphics_toggles then cover it with no console code change. Like hdr_output, it goes in NO_REPUSH_GRAPHICS and live_graphics_state, so opening the panel never pins an override over a remote patch.

Shaders

  • cs_linearize is declared in [shader_globals] of BOTH project.godot and platform/tvos/project.godot as {"type": "bool", "value": false}. Otherwise the export shader baker fails on it and leaves every including shader unbaked (step 1, Deviation 1). Only RenderingServer.global_shader_parameter_set at runtime. Never _add, never _get.
  • cs_linearize is NOT a profile global. Do not add it to ShaderGlobals.GLOBALS, because it has no SDR/HDR value pair. tests/test_color_profiles.gd:297 iterates only ShaderGlobals.GLOBALS, so it is unaffected.
  • New materials are SAVED .tres files, preloaded and never built in code (tests/test_baked_shader_materials.gd). The swap assigns the shared preloaded resource, with no duplicate(): the two materials have no per-node parameters.
  • 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 2 makes the tool discover every material whose shader includes color_space.gdshaderinc, so no list goes stale.

Testing and process

  • Tests: GUT_LOG=$TMPDIR/bh-gut-step2-<task>.log scripts/check-test-count.sh. Never pull a raw GUT run into context; grep the log. Node tests: node --test control/test/*.test.mjs (file globs).
  • Headless can’t read shader globals or render. Test the global through the injectable ColorSpace.setter spy, the same pattern as ShaderGlobals.setter. Test materials, SubViewport flags and modulates as node state. Test shaders as source text: the include line and the exact conversion statement.
  • Commits: git commit --file=<msgfile>, ending with the session’s attribution lines.
  • Merging and BUILD numbers: one session merges to main at a time. Before any merge or bump-build.sh, run ListAgents and claim merge ownership (project CLAUDE.md § Builds & releases). Check the highest BUILD on live branches before a device install.
  • Every new docs/**/*.md needs Starlight title: frontmatter. A tunable doc desc is 12-200 characters, with no dates, no em dashes and no “Sentinel”. A label is at most 48 characters.

Facts this plan rests on (verified 2026-10-09 against the engine source and this branch)

Section titled “Facts this plan rests on (verified 2026-10-09 against the engine source and this branch)”
  1. Engine colour path on an HDR canvas (~/Claude/godot-tvos/godot/servers/rendering/renderer_rd/, fork efa0097; the canvas path is stock):
    • renderer_canvas_render_rd.cpp:672-694 sets use_linear_colors = render_target_is_using_hdr(...) and linearises the item’s modulate on the CPU (p_modulate.srgb_to_linear()).
    • shaders/canvas.glsl:175-179 linearises mesh vertex colours (CANVAS_FLAGS_CONVERT_ATTRIBUTES_TO_LINEAR), then color *= modulation.
    • shaders/canvas.glsl:246-256: for a MultiMesh the instance colour is then multiplied in RAW.
    • So a user vertex() sees COLOR = linear(vertex colour) × linear(modulate) × RAW(instance colour). A shader cannot separate the instance colour from the modulate.
  2. The engine formula (canvas.glsl:118-120): mix(pow((c + 0.055) / 1.055, 2.4), c / 12.92, lessThan(c, vec3(0.04045))), unclamped above 1 (an authored 2.0 becomes ≈4.95). cs_in() uses exactly this, so a converted instance colour matches what the same value gives through modulate.
  3. Where the canvas is HDR: main.gd:1305 sets the root use_hdr_2d = true on any RenderingDevice backend (Metal on Mac, iOS and tvOS). QualityManager can drop it at low tiers (render/quality_manager.gd:177-178) and set_hdr_output holds it (:221-234). platform/tvos/project.godot:48 also sets viewport/hdr_2d=true. gl_compatibility (web) has no RenderingDevice, so it stays LDR.
  4. The existing precedent: render/zone_field.gdshader:17,41-44 already converts COLOR.rgb with this formula, gated by a per-material hdr_linear uniform that follows get_viewport().use_hdr_2d (render/zone_renderer.gd:660-663). It is independent of this A/B. This plan leaves zone_field alone (see “Out of scope”).
  5. MultiMesh layers on custom shaders (each has a vertex() function):
    • 7 Warden looks, fx/boss/warden_{bolt,burst,charge,drone,lane,missile,swing}.gdshader.
    • 13 weapon looks, fx/weapons/{blade_slash,blaster_flash,chalice_pool,momentum_charge,momentum_release,nova_burst,orbit_shards,player_bolt,railgun_lance,railgun_mark,scatter_burst,scythe_reap}.gdshader: all drawn by WeaponFxLayer subclasses (mm.use_colors = true, fx/weapons/weapon_fx_layer.gd:53).
    • 24 candidate looks under fx/weapons/candidates/**/*.gdshader (also WeaponFxLayer subclasses).
    • render/shaders/far_stars.gdshader (render/far_stars.gd:102).
    • render/star_field.gdshader (the ArenaBackground star layer, render/arena_background.gd:708-731, use_colors + use_custom_data).
    • render/shaders/enemy_smear.gdshader (every ArchetypeRenderer core, render/archetype_renderer.gd:198,304,399).
  6. NOT MultiMesh, so NOT converted: fx/weapons/plasma_nova.gdshader. Its colour arrives through draw_texture_rect modulate (fx/weapons/plasma_nova_node.gd:54), which the engine already linearises. Converting it would double-linearise it.
  7. MultiMesh layers on stock materials (instance colours raw, no shader to edit):
    • SwarmRenderer: the core has no material; halo and hotcore use fx/additive_mat.tres (render/swarm_renderer.gd:39-55).
    • ArchetypeRenderer: halo, hot, glow and shell layers all use fx/additive_mat.tres (render/archetype_renderer.gd:321,332,360,374,407-412).
    • ThreatRadar._sharp_mm uses fx/additive_mat.tres (ui/threat_radar.gd:185-190).
    • AimLance.make_renderer() gives its SwarmRenderer core aim_lance_mat.tres (render/aim_lance.gd:45-50, colour from uniforms). Its additive halo is stock.
  8. Plain colour uniforms that carry authored sRGB colours: see the Task 4 table. far_stars tint “may exceed 1” (render/shaders/far_stars.gdshader:16), so it stays a plain uniform with explicit conversion. The plan uses explicit cs_in() for ALL of them instead of source_color, because source_color would change the look with the row OFF (see Deviation 1).
  9. LDR SubViewports:
    • render/nebula_bg.gd:82 (_viewport, never sets use_hdr_2d), plus the pow(1/2.2) pre-compensation at :160-180, keyed on the PARENT viewport’s use_hdr_2d.
    • ui/threat_radar.gd:161 (_aggregate_viewport).
    • ui/codex_card.gd:18-22 (the preview sv).
    • Already following the root: ui/theme/glass_panel.gd:141 and the galaxy target (tests/test_galaxy_look_switch.gd:173-176).
  10. The warp dim: main.gd:3415-3425 writes a grey modulate (down to 0.65) on 16 renderers. Of those, enemy_renderer (ArchetypeRenderer) and proj_renderer, enemy_proj_renderer, gem_renderer (SwarmRenderer) are fully made of converted layers once active. arena_bg has one converted child, _star_layer.
  11. Prewarm: render/prewarm_pass.gd:202-210 (SwarmRenderer, 3 layers) and :460-462 (ArchetypeRenderer.prewarm_fill()) warm today’s pipelines. The two new materials are new pipelines and need warming, or toggling the row stalls on first use on the ATV.

On a converted layer the shader computes cs_in(inst × L(m)), where L is sRGB→linear and m is the warp grey. Because L is close to a power law, that is ≈ L(inst) × L(L(m)): the dim gets linearised twice (0.65 → 0.12 instead of 0.38). The correct value is L(inst) × L(m).

  • Whole renderer converted (Swarm/Archetype): write m' = S(dim), where S is linear→sRGB. Then L(m') = dim, and the shader’s L(inst × dim) matches L(inst) × L(dim) exactly for a white-hot core (inst = 1) and closely elsewhere.
  • One converted child under a dimmed parent (arena_bg._star_layer): set the child’s own modulate to c = S(p / L(p)). Then L(p) × L(c) = S(L(p)) and the shader output for inst = 1 is L(p).

Both helpers live in ColorSpace and are tested numerically. With the row OFF, main writes today’s m.

Deviations from the spec (decided while planning, from verified evidence)

Section titled “Deviations from the spec (decided while planning, from verified evidence)”
  1. Explicit cs_in() instead of source_color for plain colour uniforms. The spec says “Plain colour uniforms become source_color”. But source_color makes the ENGINE convert the uniform whenever the target is HDR. That would change the shipped look with the A/B row OFF, which breaks the spec’s own “ship behind one A/B row” rule (§ 3, last bullet). cs_in() gives the same result gated by the row, and also handles values above 1 (the spec’s “keep it plain and convert explicitly” case) in one convention.
  2. Stock-material MultiMesh layers swap to new shader materials (Fact 7). The spec says “every shader receiving a colour from code converts”. SwarmRenderer and ArchetypeRenderer’s additive layers have no shader to edit; they draw with a CanvasItemMaterial. Swapping only while active keeps the row-OFF path on today’s exact materials.
  3. Warp-dim compensation (section above) is not in the spec. Without it, turning the row on would make the 0.3 s warp dim three times deeper on enemies, bullets and gems, which would be a false negative in the A/B.
  4. The nebula 8-bit fallback tier is a decision gate, not code in this step. The spec says “measure ATV cost first”. Task 7 measures it; a fallback is built only if the measurement asks for it (a step-2b item for Chris to decide).

Out of scope (written down so nobody “fixes” it here)

Section titled “Out of scope (written down so nobody “fixes” it here)”
  • render/zone_field.gdshader’s own hdr_linear conversion. It is already on whenever the canvas is HDR, regardless of this row, and its warp dim already double-linearises. Changing it would change the row-OFF look. It is logged in Task 7’s doc note as a step-3 item.
  • Any value retune (step 3). Expect boosted instance colours (e.g. ArchetypeRenderer shell boosts, SwarmRenderer HOT_CORE_BOOST) to brighten sharply with the row ON: L(2.0) ≈ 4.95. That is the inconsistency this step exposes, and step 3 re-seeds the values in the corrected space.
  • ThreatAggregateGlow’s clamp(total, 0, 1) stays (spec § 3).
File Change Task
render/color_space.gd (new) ColorSpace: tunable, override, sync, generation, sRGB helpers, warp-dim helpers, material swap helpers 1, 3
render/shaders/color_space.gdshaderinc (new) global uniform bool cs_linearize; + cs_in() 1
project.godot, platform/tvos/project.godot [shader_globals] cs_linearize (bool, false) 1
net/tunables_registry.gd ColorSpace.FIX row (+ COLOR_SPACE script const) 1
tools/tunable_docs/docs.d/color_space.json (new), control/src/tunable_docs.js (generated) label 1
ui/debug_settings_panel.gd colour_space_fix graphics row 1
main.gd ColorSpace.sync per frame; warp-dim compensation 1, 6
46 MultiMesh .gdshader files (Fact 5) include + COLOR.rgb = cs_in(COLOR.rgb); 2
tools/shader_check/compile_check.gd auto-discover including materials 2
render/shaders/cs_mix.gdshader, cs_add.gdshader + _mat.tres (new) stock-material replacements 3
render/swarm_renderer.gd, render/archetype_renderer.gd, ui/threat_radar.gd swap on generation 3
render/prewarm_pass.gd warm both new materials 3
10 shaders with plain colour uniforms (Task 4 table) cs_in() at the read 4
render/nebula_bg.gd, ui/threat_radar.gd, ui/codex_card.gd SubViewport follows root while active 5
render/arena_background.gd set_star_dim_comp(c) 6
docs/architecture/rendering-and-performance.md “Colour space (colour_space_fix)” section 7
tests: tests/test_color_space.gd (new), tests/test_color_space_shaders.gd (new), tests/test_color_space_layers.gd (new) 1-6

Task 1: ColorSpace core, the shader include, the global, the tunable and the A/B row

Section titled “Task 1: ColorSpace core, the shader include, the global, the tunable and the A/B row”

Files:

  • Create: render/color_space.gd, render/shaders/color_space.gdshaderinc, tools/tunable_docs/docs.d/color_space.json, tests/test_color_space.gd
  • Modify: project.godot and platform/tvos/project.godot ([shader_globals]), net/tunables_registry.gd (script const near :63, row after :792), ui/debug_settings_panel.gd (GRAPHICS_TOGGLES after :102, NO_REPUSH_GRAPHICS :106, live_graphics_state :109-115, apply_graphics_toggle after :348, graphics_state after :530), main.gd (_process right after the _sync_hdr_output() call at :2283), control/src/tunable_docs.js (regenerated)

Interfaces:

  • Produces:

    • ColorSpace.FIX: bool, ColorSpace.override: int, ColorSpace.on() -> bool, ColorSpace.set_on(v: bool), ColorSpace.reset_session()
    • ColorSpace.active: bool and ColorSpace.generation: int (read-only outside sync)
    • ColorSpace.sync(root_hdr: bool) -> bool (true on a change)
    • ColorSpace.setter: Callable (test spy)
    • ColorSpace.srgb_to_linear(x: float) -> float, ColorSpace.linear_to_srgb(x: float) -> float
    • ColorSpace.dim_for_linearized(d: float) -> float, ColorSpace.child_comp(p: float) -> float
    • shader: cs_in(vec3) -> vec3, via #include "res://render/shaders/color_space.gdshaderinc"
  • Step 1: Write the failing tests in tests/test_color_space.gd:

extends GutTest
# colour_space_fix A/B (HDR/SDR colour systems step 2, render/color_space.gd). Headless can't read a
# shader global back, so the push goes through the ColorSpace.setter spy (same as ShaderGlobals).
var _pushed: Array = []
var _was_setter: Callable
var _was_fix := false
var _was_override := -1
func _spy(name: StringName, value: Variant) -> void:
_pushed.append([name, value])
func before_each() -> void:
_was_setter = ColorSpace.setter
_was_fix = ColorSpace.FIX
_was_override = ColorSpace.override
ColorSpace.setter = _spy
ColorSpace.reset_for_tests()
_pushed.clear()
func after_each() -> void:
Tunables.reset()
ColorSpace.FIX = _was_fix
ColorSpace.override = _was_override
ColorSpace.reset_for_tests()
ColorSpace.setter = _was_setter
func test_default_off_pushes_nothing_and_stays_inactive() -> void:
assert_false(ColorSpace.FIX, "bundled default OFF")
assert_false(ColorSpace.sync(true))
assert_false(ColorSpace.active)
assert_eq(_pushed, [], "row OFF never touches the global (its declared value is false)")
func test_on_with_hdr_root_activates_and_pushes_once() -> void:
ColorSpace.set_on(true)
var g := ColorSpace.generation
assert_true(ColorSpace.sync(true))
assert_true(ColorSpace.active)
assert_eq(ColorSpace.generation, g + 1)
assert_eq(_pushed, [[&"cs_linearize", true]])
assert_false(ColorSpace.sync(true), "no change, no push")
assert_eq(_pushed.size(), 1)
func test_on_with_ldr_root_is_inert() -> void:
ColorSpace.set_on(true)
assert_false(ColorSpace.sync(false), "an LDR canvas (web, low tier) linearises nothing: nothing to match")
assert_false(ColorSpace.active)
func test_root_dropping_hdr_deactivates() -> void:
ColorSpace.set_on(true)
ColorSpace.sync(true)
assert_true(ColorSpace.sync(false))
assert_false(ColorSpace.active)
assert_eq(_pushed, [[&"cs_linearize", true], [&"cs_linearize", false]])
func test_remote_tunable_drives_it_through_the_real_apply_path() -> void:
assert_eq(Tunables.apply({"ColorSpace.FIX": true}), [])
assert_true(ColorSpace.on())
assert_true(ColorSpace.sync(true))
func test_session_override_beats_the_tunable() -> void:
assert_eq(Tunables.apply({"ColorSpace.FIX": true}), [])
ColorSpace.set_on(false)
assert_false(ColorSpace.on())
func test_srgb_helpers_match_the_engine_formula() -> void:
assert_almost_eq(ColorSpace.srgb_to_linear(0.5), Color(0.5, 0.5, 0.5).srgb_to_linear().r, 1e-6)
assert_almost_eq(ColorSpace.srgb_to_linear(0.02), 0.02 / 12.92, 1e-7)
assert_almost_eq(ColorSpace.srgb_to_linear(2.0), pow(2.055 / 1.055, 2.4), 1e-5, "unclamped above 1, like the engine")
for x in [0.001, 0.02, 0.38, 0.65, 1.0, 1.7]:
assert_almost_eq(ColorSpace.linear_to_srgb(ColorSpace.srgb_to_linear(x)), x, 1e-5, "round trip %s" % x)
func test_dim_for_linearized_restores_a_single_linearisation() -> void:
# Converted layer: shader output for a white-hot instance = L(1 * L(m')). Want L(dim).
for d in [0.65, 0.8, 0.95, 1.0]:
var m := ColorSpace.dim_for_linearized(d)
assert_almost_eq(ColorSpace.srgb_to_linear(ColorSpace.srgb_to_linear(m)), ColorSpace.srgb_to_linear(d), 1e-5)
func test_child_comp_restores_the_parent_dim_on_a_converted_child() -> void:
# Child under parent modulate p: COLOR = inst * L(p) * L(c); shader = L(that). Want L(p) at inst = 1.
for p in [0.65, 0.8, 1.0]:
var c := ColorSpace.child_comp(p)
var lin := ColorSpace.srgb_to_linear(p) * ColorSpace.srgb_to_linear(c)
assert_almost_eq(ColorSpace.srgb_to_linear(lin), ColorSpace.srgb_to_linear(p), 1e-5)
assert_almost_eq(ColorSpace.child_comp(1.0), 1.0, 1e-6, "no dim, no compensation")
func test_shader_global_declared_in_both_project_files() -> void:
for path in ["res://project.godot", "res://platform/tvos/project.godot"]:
var cf := ConfigFile.new()
assert_eq(cf.load(path), OK, path)
var d: Variant = cf.get_value("shader_globals", "cs_linearize", null)
assert_true(d is Dictionary, "%s declares cs_linearize (the baker fails on runtime-only globals)" % path)
if d is Dictionary:
assert_eq(String((d as Dictionary)["type"]), "bool")
assert_false(bool((d as Dictionary)["value"]), "declared OFF: row-OFF look before any push")
func test_include_is_the_engine_formula_gated_by_the_global() -> void:
var src := FileAccess.get_file_as_string("res://render/shaders/color_space.gdshaderinc")
assert_true(src.contains("global uniform bool cs_linearize;"))
assert_true(src.contains("vec3 cs_in(vec3 c)"))
assert_true(src.contains("pow((c + vec3(0.055)) * (1.0 / 1.055), vec3(2.4))"))
assert_true(src.contains("c * (1.0 / 12.92), lessThan(c, vec3(0.04045))"))
func test_debug_row_writes_the_session_override_and_reads_live() -> void:
assert_true(DebugSettingsPanel.has_toggle(DebugSettingsPanel.GRAPHICS_TOGGLES, "colour_space_fix"))
assert_true("colour_space_fix" in DebugSettingsPanel.NO_REPUSH_GRAPHICS)
DebugSettingsPanel.apply_graphics_toggle("colour_space_fix", true, null)
assert_eq(ColorSpace.override, 1)
assert_eq(DebugSettingsPanel.live_graphics_state("colour_space_fix"), true)
  • Step 2: Run, confirm they fail (ColorSpace not declared): GUT_LOG=$TMPDIR/bh-gut-step2-t1.log scripts/check-test-count.sh, then grep -n "test_color_space" $TMPDIR/bh-gut-step2-t1.log | head.

  • Step 3: Create render/shaders/color_space.gdshaderinc:

// colour_space_fix A/B (render/color_space.gd; HDR/SDR colour systems step 2). On an HDR canvas the engine
// linearises modulate / draw_* / vertex colours but passes MultiMesh instance colours and plain vec3/vec4
// uniforms RAW. cs_in() applies the engine's own srgb_to_linear (canvas.glsl), unclamped above 1, so a value
// authored once means the same thing on every path. cs_linearize is DECLARED in [shader_globals] of both
// project files (the export baker fails on runtime-only globals) and is true only while ColorSpace.active.
// MultiMesh colours: call it as the FIRST statement of vertex(), before the texture multiply.
global uniform bool cs_linearize;
vec3 cs_in(vec3 c) {
if (!cs_linearize) {
return c;
}
return mix(pow((c + vec3(0.055)) * (1.0 / 1.055), vec3(2.4)), c * (1.0 / 12.92), lessThan(c, vec3(0.04045)));
}
  • Step 4: Create render/color_space.gd:
class_name ColorSpace
extends RefCounted
# colour_space_fix A/B (HDR/SDR colour systems step 2; spec § 3), default OFF. On an HDR canvas the
# engine linearises modulate / draw_* colours but hands MultiMesh instance colours and plain colour
# uniforms to the shader RAW, so the codebase mixes two conventions (an authored 2.0 is ~4.95 one way,
# 2.0 the other). While active, every such input is converted by cs_in()
# (render/shaders/color_space.gdshaderinc), stock-material MultiMesh layers swap to cs_mix/cs_add, the
# LDR SubViewports follow the root and the warp dim is compensated. Render-only, never read by /sim.
# - main._process calls sync(root.use_hdr_2d) every frame (menu included), after _sync_hdr_output.
# - active = on() AND the root canvas is HDR: an LDR canvas (web, a low QualityManager tier)
# linearises nothing, so there is nothing to match.
# - Consumers poll `generation` (an int compare per frame) and re-apply on a change.
# - The switch: Debug Settings "Colour space fix (A/B)" (GRAPHICS_TOGGLES "colour_space_fix", console
# parity through the `graphics` command; writes `override`) over the remote tunable FIX.
static var FIX: bool = false # remote tunable default; Tunables.apply resets it at run start
static var override := -1 # -1 = follow FIX; 0 = forced off; 1 = forced on (the A/B row, this session)
static var active := false # written only by sync()
static var generation := 0 # bumps on every change of `active`
# Tests swap in a spy (headless can't read a global back).
static var setter: Callable = Callable(RenderingServer, "global_shader_parameter_set")
const GLOBAL := &"cs_linearize"
const ADDITIVE: CanvasItemMaterial = preload("res://fx/additive_mat.tres")
const CS_ADD: ShaderMaterial = preload("res://render/shaders/cs_add_mat.tres")
const CS_MIX: ShaderMaterial = preload("res://render/shaders/cs_mix_mat.tres")
static func on() -> bool:
return FIX if override < 0 else override == 1
static func set_on(v: bool) -> void:
override = 1 if v else 0
static func reset_session() -> void:
override = -1
# Back to the boot state (declared global false, nothing active). Tests only.
static func reset_for_tests() -> void:
override = -1
active = false
static func sync(root_hdr: bool) -> bool:
var want := on() and root_hdr
if want == active:
return false
active = want
generation += 1
setter.call(GLOBAL, want)
return true
# The engine's sRGB transfer (canvas.glsl srgb_to_linear), unclamped above 1, and its inverse.
static func srgb_to_linear(x: float) -> float:
return x / 12.92 if x < 0.04045 else pow((x + 0.055) / 1.055, 2.4)
static func linear_to_srgb(x: float) -> float:
return x * 12.92 if x < 0.0031308 else 1.055 * pow(x, 1.0 / 2.4) - 0.055
# Warp dim on a renderer whose layers ALL convert: the engine linearises the modulate, then the shader
# linearises it again with the instance colour. Writing S(dim) leaves exactly one linearisation.
static func dim_for_linearized(d: float) -> float:
return linear_to_srgb(d)
# A converted child under a parent dimmed by p: its own modulate c = S(p / L(p)) makes
# L(p) * L(c) = S(L(p)), so the shader's L(...) gives L(p) for a white-hot instance.
static func child_comp(p: float) -> float:
var lp := srgb_to_linear(p)
return 1.0 if lp <= 0.0 else linear_to_srgb(p / lp)

The CS_ADD/CS_MIX preloads need Task 3’s files. To keep Task 1 self-contained, create them now. They are Task 3’s Step 3 content, copied here verbatim so the class parses:

render/shaders/cs_mix.gdshader:

shader_type canvas_item;
// colour_space_fix stand-in for the DEFAULT canvas material on MultiMesh layers (render/color_space.gd):
// identical except the instance colour is linearised while cs_linearize is on.
#include "res://render/shaders/color_space.gdshaderinc"
void vertex() {
COLOR.rgb = cs_in(COLOR.rgb);
}

render/shaders/cs_add.gdshader: the same four lines with render_mode blend_add; after shader_type canvas_item;, and the comment naming fx/additive_mat.tres. render/shaders/cs_mix_mat.tres:

[gd_resource type="ShaderMaterial" load_steps=2 format=3]
[ext_resource type="Shader" path="res://render/shaders/cs_mix.gdshader" id="1"]
[resource]
shader = ExtResource("1")

render/shaders/cs_add_mat.tres: the same, pointing at cs_add.gdshader.

  • Step 5: Declare the global in BOTH project.godot and platform/tvos/project.godot, inside the existing [shader_globals] block, after border_halo (; comments only):
; colour_space_fix A/B (render/color_space.gd): false = today's look; ColorSpace.sync sets it at runtime.
cs_linearize={
"type": "bool",
"value": false
}
  • Step 6: Register the tunable.
    • In net/tunables_registry.gd, next to const HDR_OUTPUT (:63): const COLOR_SPACE := "res://render/color_space.gd".
    • After the HdrOutput.ENABLED row (:792):
# colour_space_fix A/B (HDR/SDR colour systems step 2, default OFF): render-only, read every frame by
# main._process -> ColorSpace.sync (live, menu included). Not "local": a look choice, not a screen fact.
# Pinned by tests/test_color_space.gd through Tunables.apply.
{"key": "ColorSpace.FIX", "script": COLOR_SPACE, "name": "FIX", "type": "bool"},
  • Create tools/tunable_docs/docs.d/color_space.json:
{
"ColorSpace.FIX": {"label": "Colour space fix (A/B)", "desc": "Converts MultiMesh and shader-uniform colours to the canvas colour space on an HDR canvas, so every path matches. Off = the shipped look.", "src": "code", "group": "Glow & colour grade"}
}
  • Run node tools/tunable_docs/generate.mjs, then git diff --stat -- control/src/tunable_docs.js (one entry added).

  • Step 7: Debug Settings row. In ui/debug_settings_panel.gd:

    • After the hdr_output entry (:102):
# Colour-space consistency (render/color_space.gd). Live; also the remote tunable ColorSpace.FIX.
["colour_space_fix", "Colour space fix (A/B)"],
  • NO_REPUSH_GRAPHICS becomes ["hdr_output", "colour_space_fix"].
  • In live_graphics_state, before return null:
if effect == "colour_space_fix":
return ColorSpace.on() # with no row press, a remote-config patch (ColorSpace.FIX) moves it
  • In apply_graphics_toggle’s match, after "hdr_output"::
"colour_space_fix":
ColorSpace.set_on(enabled) # main._process -> ColorSpace.sync applies it next frame
  • In graphics_state, after g["hdr_output"] = HdrOutput.on(): g["colour_space_fix"] = ColorSpace.on().

  • Step 8: main. In main.gd _process, on the line after _sync_hdr_output() (:2283):

ColorSpace.sync(get_viewport().use_hdr_2d) # colour_space_fix A/B: after the HDR hold settles the root

Also extend tests/test_debug_console_parity.gd’s before_each/after_each save/restore for ColorSpace.override (pattern: its _hdr_output var), so the parity test’s presses don’t leak.

  • Step 9: Run the tests: GUT_LOG=$TMPDIR/bh-gut-step2-t1.log scripts/check-test-count.sh. Expected: all pass, including test_debug_console_parity.gd, which now walks the new row. Then run node --test control/test/*.test.mjs.

  • Step 10: Windowed compile check of the two new materials. Temporarily add "res://render/shaders/cs_mix_mat.tres", "res://render/shaders/cs_add_mat.tres" to tools/shader_check/compile_check.gd MATERIALS (Task 2 replaces the list with discovery). Run both renderers (commands in Global Constraints), expecting only the two compile_check: lines, then git checkout -- project.godot audio/bus_layout.tres. Careful: that checkout would also revert your Step 5 [shader_globals] edit if it is uncommitted, so commit first (Step 11) and run the check after.

  • Step 11: Commit: feat(render): ColorSpace A/B core: cs_linearize global, cs_in include, tunable + Debug Settings row (default off).


Task 2: Convert the instance colour in every custom MultiMesh shader

Section titled “Task 2: Convert the instance colour in every custom MultiMesh shader”

Files:

  • Modify: the 46 shaders in Fact 5: fx/boss/warden_*.gdshader (7), fx/weapons/*.gdshader except plasma_nova.gdshader (13), fx/weapons/candidates/**/*.gdshader (24), render/shaders/far_stars.gdshader, render/star_field.gdshader, render/shaders/enemy_smear.gdshader. (Count them with git ls-files 'fx/boss/*.gdshader' 'fx/weapons/*.gdshader' 'fx/weapons/candidates/**/*.gdshader' | grep -v plasma_nova | wc -l → 44, plus the 3 render ones = 47. Re-count before starting and use the real number in the commit message.)
  • Modify: tools/shader_check/compile_check.gd
  • Create: tests/test_color_space_shaders.gd

Interfaces:

  • Consumes: cs_in() and the include path (Task 1).
  • Produces: ColorSpaceShaders.MULTIMESH_SHADERS is NOT a thing. The set is DERIVED in the test (below), so a new look can’t silently skip it.

The recipe (identical for every file):

  1. Add #include "res://render/shaders/color_space.gdshaderinc" on its own line directly after the render_mode ...; line, or after shader_type canvas_item; if there is no render_mode. If the shader already has #include lines, put it as the FIRST include.
  2. Make COLOR.rgb = cs_in(COLOR.rgb); the FIRST statement inside void vertex() {, before any v_col = COLOR; / v_rgb = COLOR.rgb; copy.

Nothing else changes. In particular, do not touch alpha (COLOR.a carries progress/seed data in several looks, e.g. fx/weapons/player_bolt.gdshader:7).

  • Step 1: Write the failing test tests/test_color_space_shaders.gd:
extends GutTest
# colour_space_fix A/B: every shader that receives a RAW MultiMesh instance colour converts it as the
# first statement of vertex() (render/shaders/color_space.gdshaderinc). The set is DERIVED from the
# tree (every .gdshader under fx/, plus the render-side MultiMesh shaders), minus a short, reasoned
# exclusion list, so a new look can't silently skip it.
const INCLUDE := "#include \"res://render/shaders/color_space.gdshaderinc\""
const CONVERT := "COLOR.rgb = cs_in(COLOR.rgb);"
const RENDER_MULTIMESH := [
"res://render/shaders/far_stars.gdshader", # FarStars MultiMesh (use_colors)
"res://render/star_field.gdshader", # ArenaBackground star layer (use_colors)
"res://render/shaders/enemy_smear.gdshader", # every ArchetypeRenderer core
]
# fx/ shaders whose COLOR is NOT a raw instance colour (path -> reason). Keep SHORT.
const NOT_MULTIMESH := {
"res://fx/weapons/plasma_nova.gdshader": "draw_texture_rect modulate: the engine already linearises it",
}
func _walk(dir: String, out: Array[String]) -> void:
var d := DirAccess.open(dir)
if d == null:
return
for f in d.get_files():
if f.get_extension() == "gdshader":
out.append(dir.path_join(f))
for sub in d.get_directories():
_walk(dir.path_join(sub), out)
func _targets() -> Array[String]:
var out: Array[String] = []
_walk("res://fx", out)
out = out.filter(func(p: String) -> bool: return not NOT_MULTIMESH.has(p))
out.append_array(RENDER_MULTIMESH)
return out
func test_the_set_is_not_empty_and_has_the_known_families() -> void:
var t := _targets()
assert_gt(t.size(), 40)
assert_has(t, "res://fx/boss/warden_bolt.gdshader")
assert_has(t, "res://fx/weapons/player_bolt.gdshader")
assert_does_not_have(t, "res://fx/weapons/plasma_nova.gdshader")
func test_every_multimesh_shader_converts_first_thing_in_vertex() -> void:
for p in _targets():
var src := FileAccess.get_file_as_string(p)
assert_true(src.contains(INCLUDE), "%s includes color_space" % p)
var v := src.find("void vertex()")
assert_gt(v, -1, "%s has vertex()" % p)
var body := src.substr(src.find("{", v) + 1).strip_edges()
# skip leading comment lines inside the body
while body.begins_with("//"):
body = body.substr(body.find("\n") + 1).strip_edges()
assert_true(body.begins_with(CONVERT), "%s: first vertex() statement is the conversion" % p)
func test_excluded_shader_does_not_convert() -> void:
for p: String in NOT_MULTIMESH:
assert_false(FileAccess.get_file_as_string(p).contains("cs_in("), "%s: %s" % [p, NOT_MULTIMESH[p]])
  • Step 2: Run, confirm it fails on the first un-converted shader.
  • Step 3: Apply the recipe to every file in the derived set (the test prints any you miss).
  • Step 4: Make compile_check.gd discover its materials. Replace the MATERIALS const and the loop in _initialize with:
# Every saved ShaderMaterial under these dirs whose shader reads a global (ShaderGlobals or
# color_space.gdshaderinc), discovered so a new including look can't be missed.
const DIRS := ["res://render", "res://fx", "res://ui"]
const MARKERS := ["color_space.gdshaderinc", "border_look.gdshaderinc"]
func _mats(dir: String, out: Array[String]) -> void:
var d := DirAccess.open(dir)
if d == null:
return
for f in d.get_files():
if f.get_extension() == "tres":
var m := load(dir.path_join(f)) as ShaderMaterial
if m != null and m.shader != null and MARKERS.any(func(k: String) -> bool: return m.shader.code.contains(k)):
out.append(dir.path_join(f))
for sub in d.get_directories():
_mats(dir.path_join(sub), out)
func _initialize() -> void:
var mats: Array[String] = []
for dir in DIRS:
_mats(dir, mats)
for i in mats.size():
var r := ColorRect.new()
r.material = load(mats[i]) as Material
r.position = Vector2(20.0 + 60.0 * (i % 18), 20.0 + 60.0 * (i / 18))
r.size = Vector2(50.0, 50.0)
root.add_child(r)
print("compile_check: drawing %d materials" % mats.size())

Shader.code holds the source text including the #include line, so the marker match works. If any candidate look builds its material in code with no .tres, the discovery won’t see it. Then add its .gdshader via Shader + ShaderMaterial in this DEV tool only (this tool is not shipped; the baked-material rule is about shipped code).

  • Step 5: Run the windowed compile check under both renderers. Expected: compile_check: drawing N materials with N ≥ 40, then compile_check: done, and NO ERROR/WARNING lines. Then git checkout -- project.godot audio/bus_layout.tres.
  • Step 6: Gate-lite GUT_LOG=$TMPDIR/bh-gut-step2-t2.log scripts/check-test-count.sh. Expected: green, determinism untouched.
  • Step 7: Commit: feat(render): MultiMesh look shaders linearise instance COLOR under colour_space_fix.

Task 3: Stock-material MultiMesh layers swap to cs_mix / cs_add while active, and prewarm

Section titled “Task 3: Stock-material MultiMesh layers swap to cs_mix / cs_add while active, and prewarm”

Files:

  • Modify: render/color_space.gd (swap helpers), render/swarm_renderer.gd (configure :39-55, sync :101), render/archetype_renderer.gd (_build_meshes :283, sync :779), ui/threat_radar.gd (_ready :185-190, update_radar :268), render/prewarm_pass.gd (:202-210, :460-462)
  • Create: tests/test_color_space_layers.gd
  • (cs_mix/cs_add shaders and .tres already exist from Task 1.)

Interfaces:

  • Consumes: ColorSpace.active, ColorSpace.generation, ColorSpace.ADDITIVE/CS_ADD/CS_MIX (Task 1).

  • Produces:

    • ColorSpace.stock_layers(root: Node) -> Array[MultiMeshInstance2D]: root and its descendants that are a MultiMeshInstance2D with multimesh.use_colors AND material null or ADDITIVE.
    • ColorSpace.swap_layers(layers: Array[MultiMeshInstance2D], on: bool): null ↔ CS_MIX, ADDITIVE ↔ CS_ADD. Stateless; any other material is left alone (e.g. aim_lance_mat).
    • SwarmRenderer.apply_color_space(on: bool), ArchetypeRenderer.apply_color_space(on: bool), ThreatRadar.apply_color_space(on: bool).
  • Step 1: Write the failing tests tests/test_color_space_layers.gd:

extends GutTest
var _was_setter: Callable
func before_each() -> void:
_was_setter = ColorSpace.setter
ColorSpace.setter = func(_n: StringName, _v: Variant) -> void: pass
ColorSpace.reset_for_tests()
func after_each() -> void:
ColorSpace.reset_for_tests()
ColorSpace.setter = _was_setter
func _activate(on: bool) -> void:
ColorSpace.set_on(on)
ColorSpace.sync(true)
func test_swarm_layers_swap_and_restore_exactly() -> void:
var sr := SwarmRenderer.new()
sr.configure(4.0, Color.WHITE)
add_child_autofree(sr)
assert_null(sr.material)
assert_eq(sr.halo.material, ColorSpace.ADDITIVE)
_activate(true)
sr.sync(EntityPool.new(), Color.WHITE)
assert_eq(sr.material, ColorSpace.CS_MIX)
assert_eq(sr.halo.material, ColorSpace.CS_ADD)
assert_eq(sr.hotcore.material, ColorSpace.CS_ADD)
_activate(false)
sr.sync(EntityPool.new(), Color.WHITE)
assert_null(sr.material, "row OFF: back to today's exact materials")
assert_eq(sr.halo.material, ColorSpace.ADDITIVE)
func test_aim_lance_core_keeps_its_own_shader() -> void:
var r := AimLance.make_renderer()
add_child_autofree(r)
var own := r.material
_activate(true)
r.sync(EntityPool.new(), Color.WHITE)
assert_eq(r.material, own, "custom core shader untouched (its colour comes from uniforms)")
assert_eq(r.halo.material, ColorSpace.CS_ADD)
func test_archetype_additive_layers_swap_core_keeps_smear() -> void:
var a := ArchetypeRenderer.new()
add_child_autofree(a)
a.prewarm_fill()
var layers := ColorSpace.stock_layers(a)
assert_gt(layers.size(), 0, "archetype has stock additive layers")
_activate(true)
a.sync(EnemyPool.new(), PackedColorArray())
for l in layers:
assert_eq(l.material, ColorSpace.CS_ADD, str(l.get_path()))
_activate(false)
a.sync(EnemyPool.new(), PackedColorArray())
for l in layers:
assert_eq(l.material, ColorSpace.ADDITIVE)
func test_threat_radar_sharp_layer_swaps() -> void:
var tr := ThreatRadar.new()
add_child_autofree(tr)
_activate(true)
tr.apply_color_space(ColorSpace.active)
assert_eq(tr._sharp_mm.material, ColorSpace.CS_ADD)
func test_prewarm_pass_warms_both_cs_materials() -> void:
var src := FileAccess.get_file_as_string("res://render/prewarm_pass.gd")
assert_true(src.contains("apply_color_space(true)"), "the cs_mix / cs_add pipelines are warmed too")

Before writing these, check the real constructors: whether EnemyPool.new() / EntityPool.new() take arguments, and that ArchetypeRenderer.sync accepts an empty pool. Adjust only the construction lines to match, never the asserts.

  • Step 2: Run, confirm they fail.
  • Step 3: Add the helpers to render/color_space.gd:
# MultiMesh layers drawn with a stock material (none, or the shared additive one): a CanvasItemMaterial
# can't convert the raw instance colour, so while active they draw with cs_mix / cs_add instead
# (render/shaders/cs_*.gdshader, identical except the conversion). Any other material is the layer's
# own shader and is left alone.
static func stock_layers(root: Node) -> Array[MultiMeshInstance2D]:
var out: Array[MultiMeshInstance2D] = []
_collect(root, out)
return out
static func _collect(n: Node, out: Array[MultiMeshInstance2D]) -> void:
var mmi := n as MultiMeshInstance2D
if mmi != null and mmi.multimesh != null and mmi.multimesh.use_colors \
and (mmi.material == null or mmi.material == ADDITIVE or mmi.material == CS_MIX or mmi.material == CS_ADD):
out.append(mmi)
for c in n.get_children():
_collect(c, out)
static func swap_layers(layers: Array[MultiMeshInstance2D], on: bool) -> void:
for l in layers:
if on:
if l.material == null:
l.material = CS_MIX
elif l.material == ADDITIVE:
l.material = CS_ADD
else:
if l.material == CS_MIX:
l.material = null
elif l.material == CS_ADD:
l.material = ADDITIVE
  • Step 4: Wire the three owners. The same shape in each:
var _cs_gen := -1
var _cs_layers: Array[MultiMeshInstance2D] = []
func apply_color_space(on: bool) -> void:
ColorSpace.swap_layers(_cs_layers, on)
  • SwarmRenderer: at the end of configure(), _cs_layers = ColorSpace.stock_layers(self). At the top of sync():
if _cs_gen != ColorSpace.generation: # colour_space_fix A/B: an int compare per frame
_cs_gen = ColorSpace.generation
apply_color_space(ColorSpace.active)
  • ArchetypeRenderer: at the end of _build_meshes(), set _cs_layers = ColorSpace.stock_layers(self) and _cs_gen = -1 (a rebuild re-applies). Put the same generation check at the top of sync(). The cores keep enemy_smear (converted in Task 2).
  • ThreatRadar: after add_child(_sharp_mm) in _ready, _cs_layers = [_sharp_mm]. Put the generation check at the top of update_radar().
  • Step 5: Prewarm. In render/prewarm_pass.gd:
    • After step 1’s SwarmRenderer block (:202-210), add a second SwarmRenderer built the same way, followed by sr_cs.apply_color_space(true).
    • After arch.prewarm_fill() (:462), add a second ArchetypeRenderer with prewarm_fill() then apply_color_space(true).
    • Comment: # colour_space_fix: the cs_mix / cs_add pipelines (render/color_space.gd), so the A/B row never stalls on first use.
  • Step 6: Run the tests (GUT_LOG=$TMPDIR/bh-gut-step2-t3.log scripts/check-test-count.sh). Expected: green, determinism untouched.
  • Step 7: Commit: feat(render): stock MultiMesh layers swap to cs_mix/cs_add under colour_space_fix; prewarmed.

Task 4: Plain colour uniforms convert at the read

Section titled “Task 4: Plain colour uniforms convert at the read”

Files:

  • Modify: the 10 shaders below. Extend tests/test_color_space_shaders.gd.

Interfaces:

  • Consumes: cs_in() (Task 1).

The recipe: add the include (as in Task 2, rule 1). At each read of a listed uniform, wrap the vec3 in cs_in(...). For a vec4 tint, use vec4(cs_in(tint.rgb), tint.a). Never convert alpha, never convert a uniform that is not a colour, and never convert the same value twice (if a uniform is read in several places, hoist vec3 x_l = cs_in(x); once at the top of the function that reads it).

Shader Uniform(s) (verified line)
render/shaders/nebula_bg.gdshader col_lo :5, col_hi :6, col_accent :16, star_color :30
render/shaders/low_hp_border.gdshader glow_color :6
render/shaders/threat_aggregate_glow.gdshader glow_color :5
render/shaders/aim_lance.gdshader tail_low :3, tail_high :4, nose_low :5, nose_high :6
render/shaders/arena_galaxy.gdshader col_core :29, col_arm :30, col_outer :31
render/shaders/death_dissolve.gdshader tint :4 (vec4)
render/shaders/funzo_void_skin.gdshader tint :4 (vec4)
fx/weapons/orbit_shards.gdshader eh_core :43
ui/theme/glass_snapshot.gdshader veil_color :11
render/shaders/far_stars.gdshader tint :16 (may exceed 1; cs_in is unclamped)

The nebula pre-compensation and this conversion interact. With the row OFF, nebula_bg.gd:160-180 pushes pow(c, 1/2.2) into col_lo/col_hi while the root is HDR. With the row ON, Task 5 stops that pre-compensation, so cs_in sees the authored colour. Do NOT edit the GDScript here; Task 5 does.

  • Step 1: Extend the test:
const UNIFORM_READS := {
"res://render/shaders/nebula_bg.gdshader": ["col_lo", "col_hi", "col_accent", "star_color"],
"res://render/shaders/low_hp_border.gdshader": ["glow_color"],
"res://render/shaders/threat_aggregate_glow.gdshader": ["glow_color"],
"res://render/shaders/aim_lance.gdshader": ["tail_low", "tail_high", "nose_low", "nose_high"],
"res://render/shaders/arena_galaxy.gdshader": ["col_core", "col_arm", "col_outer"],
"res://render/shaders/death_dissolve.gdshader": ["tint.rgb"],
"res://render/shaders/funzo_void_skin.gdshader": ["tint.rgb"],
"res://fx/weapons/orbit_shards.gdshader": ["eh_core"],
"res://ui/theme/glass_snapshot.gdshader": ["veil_color"],
"res://render/shaders/far_stars.gdshader": ["tint"],
}
func test_plain_colour_uniforms_convert_at_the_read() -> void:
for p: String in UNIFORM_READS:
var src := FileAccess.get_file_as_string(p)
assert_true(src.contains(INCLUDE), "%s includes color_space" % p)
for u: String in UNIFORM_READS[p]:
assert_true(src.contains("cs_in(%s)" % u), "%s converts %s" % [p, u])
  • Step 2: Run, confirm it fails. Step 3: apply the recipe. Step 4: run the windowed compile check under both renderers, then git checkout -- project.godot audio/bus_layout.tres. Step 5: scripts/check-test-count.sh with your own GUT_LOG.
  • Step 6: Commit: feat(render): plain colour uniforms convert via cs_in under colour_space_fix.

Task 5: The three LDR SubViewports follow the root while active; nebula compensation only while inactive

Section titled “Task 5: The three LDR SubViewports follow the root while active; nebula compensation only while inactive”

Files:

  • Modify: render/nebula_bg.gd (_process :160-180), ui/threat_radar.gd (update_radar, beside Task 3’s generation check), ui/codex_card.gd (the inner class with _process at :93, which hosts the preview), and extend tests/test_color_space_layers.gd.

Interfaces:

  • Consumes: ColorSpace.active, ColorSpace.generation.

  • Produces: NebulaBg keeps its _viewport; ThreatRadar._aggregate_viewport; codex sv. Each sets use_hdr_2d = ColorSpace.active and <root>.use_hdr_2d, where <root> = get_viewport() of the owning node (the root canvas in game). When inactive the value is false, exactly today’s default.

  • Step 1: Failing tests (append to tests/test_color_space_layers.gd):

func test_nebula_viewport_follows_root_only_while_active() -> void:
var root := get_tree().root
var was := root.use_hdr_2d
root.use_hdr_2d = true
var n := NebulaBg.new()
add_child_autofree(n)
n._process(0.0)
assert_false(n._viewport.use_hdr_2d, "row OFF: today's 8-bit target")
_activate(true)
n._process(0.0)
assert_true(n._viewport.use_hdr_2d, "row ON: follows the HDR root")
_activate(false)
n._process(0.0)
assert_false(n._viewport.use_hdr_2d)
root.use_hdr_2d = was
func test_nebula_skips_the_gamma_precompensation_while_active() -> void:
var root := get_tree().root
var was := [root.use_hdr_2d, VisualLook.nova, VisualLook.nova_ground]
root.use_hdr_2d = true
VisualLook.nova = true
VisualLook.nova_ground = true
var n := NebulaBg.new()
add_child_autofree(n)
_activate(true)
n._process(0.0)
var lo := VisualLook.NOVA_COL_LO
assert_eq(n._mat.get_shader_parameter("col_lo"), Vector3(lo.r, lo.g, lo.b), "authored values; the shader converts")
_activate(false)
n._process(0.0)
assert_almost_eq((n._mat.get_shader_parameter("col_lo") as Vector3).x, pow(lo.r, 1.0 / 2.2), 1e-6, "row OFF: today's pre-compensation")
root.use_hdr_2d = was[0]
VisualLook.nova = was[1]
VisualLook.nova_ground = was[2]
func test_threat_radar_aggregate_viewport_follows_root_while_active() -> void:
var root := get_tree().root
var was := root.use_hdr_2d
root.use_hdr_2d = true
var tr := ThreatRadar.new()
add_child_autofree(tr)
_activate(true)
tr.apply_color_space(ColorSpace.active)
assert_true(tr._aggregate_viewport.use_hdr_2d)
_activate(false)
tr.apply_color_space(ColorSpace.active)
assert_false(tr._aggregate_viewport.use_hdr_2d)
root.use_hdr_2d = was

Codex: read ui/codex_card.gd around :10-30 and :90-100 first. The preview sv is built in a static/RefCounted builder, and the inner class with _process (:93) is the per-frame hook. Give that inner class a reference to sv. Add a test that builds a card through the real builder (see tests/test_codex_card.gd for how it constructs one) and asserts sv.use_hdr_2d after _activate(true) and one _process.

  • Step 2: Run, confirm they fail.
  • Step 3: Implement.
    • NebulaBg _process: at the top, after the _mat == null return:
var cs := ColorSpace.active
var follow := cs and get_viewport() != null and get_viewport().use_hdr_2d
if _viewport.use_hdr_2d != follow:
_viewport.use_hdr_2d = follow # colour_space_fix: the nebula target follows the HDR root (reallocates: change only)
Then change the existing early-return and gamma logic so the compensation is keyed on `hdr and not cs`. The cached `_nova_hdr` becomes that combined value:
var hdr := get_viewport() != null and get_viewport().use_hdr_2d and not cs # cs: the shader converts instead
Everything else in the function stays.
  • ThreatRadar.apply_color_space(on): after the swap, _aggregate_viewport.use_hdr_2d = on and get_viewport() != null and get_viewport().use_hdr_2d. It is already called from the generation check (Task 3).
  • Codex: the same assignment in the inner class’s _process, guarded by its own _cs_gen compare.
  • Step 4: Run the tests. Step 5: Commit: feat(render): nebula, threat-radar and codex SubViewports follow the HDR root under colour_space_fix.

Files:

  • Modify: main.gd (warp loop :3415-3425), render/arena_background.gd (new set_star_dim_comp), extend tests/test_color_space_layers.gd.

Interfaces:

  • Consumes: ColorSpace.active, ColorSpace.dim_for_linearized, ColorSpace.child_comp.

  • Produces: ArenaBackground.set_star_dim_comp(c: float) sets _star_layer.modulate = Color(c, c, c) (null-safe).

  • Step 1: Failing tests. main.gd’s warp loop runs inside _process, so put the per-renderer decision in a pure static that main calls. Test that static, and pin that main uses it with a source check:

func test_warp_modulate_for_converted_renderers() -> void:
assert_eq(Main.warp_modulate(0.65, false, true), Color(0.65, 0.65, 0.65), "row OFF: today's grey")
var m := Main.warp_modulate(0.65, true, true)
assert_almost_eq(m.r, ColorSpace.dim_for_linearized(0.65), 1e-6, "fully converted renderer")
assert_eq(Main.warp_modulate(0.65, true, false), Color(0.65, 0.65, 0.65), "unconverted renderer: unchanged")
func test_main_routes_the_warp_dim_through_warp_modulate() -> void:
var src := FileAccess.get_file_as_string("res://main.gd")
assert_true(src.contains("warp_modulate(dim, ColorSpace.active"))
assert_true(src.contains("set_star_dim_comp("))
func test_arena_star_layer_comp() -> void:
var bg := ArenaBackground.new()
add_child_autofree(bg)
bg.set_star_dim_comp(1.2)
if bg._star_layer != null:
assert_eq(bg._star_layer.modulate, Color(1.2, 1.2, 1.2))
else:
pass_test("no star layer in this variant; the setter is null-safe")

Check what main’s class is called for a static call (main.gd may have no class_name). If it has none, put warp_modulate on ColorSpace instead, as ColorSpace.warp_modulate(dim, active, converted), and adjust the tests accordingly.

  • Step 2: Implement (shown with the ColorSpace.warp_modulate placement):
# The warp dim (main) for one renderer. converted = every colour layer under it linearises its instance
# colour (SwarmRenderer / ArchetypeRenderer): their modulate would otherwise be linearised twice.
static func warp_modulate(dim: float, is_active: bool, converted: bool) -> Color:
if not (is_active and converted):
return Color(dim, dim, dim)
var d := dim_for_linearized(dim)
return Color(d, d, d)

In main.gd’s loop, replace r.modulate = m with:

r.modulate = ColorSpace.warp_modulate(dim, ColorSpace.active, r is SwarmRenderer or r is ArchetypeRenderer)

After the loop:

if arena_bg != null: # its star layer converts; its other children don't (render/color_space.gd child_comp)
arena_bg.set_star_dim_comp(ColorSpace.child_comp(dim) if ColorSpace.active else 1.0)

Keep var m only if something else still reads it; otherwise delete it (the source-check asserts warp_modulate(dim, ColorSpace.active).

The end of the warp: the loop runs while _warp_dimming (:3413). Check where _warp_dimming is cleared and the modulates reset to white. The star comp must reset to 1.0 there too, or a toggle mid-warp leaves it stuck. Add that line next to the existing reset, and a test if the reset is a separate function.

ArenaBackground.set_star_dim_comp:

# colour_space_fix warp dim: the star layer converts its instance colour, so under the parent's dimmed
# modulate it carries its own compensation (ColorSpace.child_comp). 1.0 = none.
func set_star_dim_comp(c: float) -> void:
if _star_layer != null and is_instance_valid(_star_layer):
_star_layer.modulate = Color(c, c, c)

Check that _star_layer.modulate isn’t already used for something else (grep -n "_star_layer.modulate\|_star_layer.self_modulate" render/arena_background.gd). If it is, use self_modulate, which composes the same way for the layer’s own draw.

  • Step 3: Run the tests and the determinism files (untouched). Step 4: Commit: feat(render): warp dim compensated on converted renderers under colour_space_fix.

Task 7: Docs, full gate, device verification and the verdict

Section titled “Task 7: Docs, full gate, device verification and the verdict”

Files:

  • Modify: docs/architecture/rendering-and-performance.md (new section “Colour space (colour_space_fix)”).

  • Step 1: Doc section (current architecture, not a changelog), covering:

    • the two conventions (engine source refs from Fact 1);
    • ColorSpace and its gate (on() and root HDR);
    • the shader recipe and the derived test;
    • the stock-material swap;
    • the SubViewports;
    • the warp-dim maths;
    • the plasma_nova exclusion and why;
    • the out-of-scope zone_field double-dim (step-3 item);
    • “adding a MultiMesh look: include + first vertex() statement, the test enforces it”.

    Also fix the spec’s Rollout step 2 line to point at this plan.

  • Step 2: Full gate: scripts/gate.sh (snapshots and restores project.godot). Expected: green, determinism untouched. Then node --test control/test/*.test.mjs scripts/test/*.test.mjs.

  • Step 3: Windowed compile check under both renderers one last time, then git checkout -- project.godot audio/bus_layout.tres.

  • Step 4: Mac visual A/B (the dev app, bh-deploy-mac-dev). Freeze scene (step 1’s console preset), then flip colour_space_fix from the console. Capture both states with the HDR profile both on and off. Expected with the row ON: halos/shells/hot cores clearly brighter (boosts now ≈ ×2.4 in exponent), the nebula/galaxy colours deeper (mid-tones darker, as authored), and the warp dim on enemies the SAME depth as with the row OFF. Send the captures to Chris.

  • Step 5: Device install (Chris approves first). Check the highest BUILD on live branches (git show <branch>:sim/constants.gd), merge origin/main into hdr-colour-space, bump past it, then scripts/dev/install-dev.sh atv and the iPhone. ATV:

    • ABBA perf of the nebula HDR target: row OFF/ON/ON/OFF, 60 s each, same freeze scene, console fps + the render_cpu_ms timeline (skill bh-telemetry). Record the per-round numbers in the doc section. If ON costs > 0.5 ms GPU or ≥ 2 fps on the ATV, write a step-2b item (“nebula 8-bit fallback tier under colour_space_fix”) into docs/RELEASE-1.0.1.md for Chris’s call. Do not build it here.
    • Fresh-install stall check (skill bh-fresh-install-stall-check) with the row ON from a remote patch, so cs_linearize + cs_mix/cs_add are baked and warmed. Zero new hitches attributed to them.
  • Step 6: Chris’s verdict on ATV and iPhone, both profiles × both rows. The spec says no step is done on headless evidence. Judge consistency, not final brightness. With the row ON, boosted glows (shell/halo/hot-core boosts ×1.6-2.2 become ≈ ×3-6.6 after linearisation) will look blown out, especially on SDR output, where they clip per channel. That is the inconsistency made visible, and step 3 re-tunes it. The questions for this verdict:

    • Does one authored colour now look the same through every path (sprite vs MultiMesh vs uniform)?
    • Do mid-tones (nebula, galaxy, element tints) read as authored?
    • Is the warp dim depth unchanged?

    A “too bright” glow alone is NOT a reject. Record it in memory bullet-heaven-hdr-output and in the doc section.

  • Step 7: Merge (only with Chris’s OK): ListAgents claim, bh-merge-reviewed-branch after a bh-reviewer MERGE verdict. Step 1 (hdr-colour-spec) must merge first or together. Then redeploy the console Worker: control/src/tunable_docs.js changed.

  • Spec § 3 coverage:
    • MultiMesh COLOR in swarm, archetype, far_stars, star_field, weapon_fx_layer and Warden: Tasks 2 and 3.
    • Shared include + cs_linearize + A/B row: Task 1.
    • Plain uniforms: Task 4 (Deviation 1).
    • LDR SubViewports + nebula pow delete: Task 5. Compensation kept for row OFF, which is the spec’s own A/B requirement.
    • 8-bit fallback: Task 7 gate (Deviation 4).
    • ThreatAggregateGlow clamp stays: untouched.
    • Own verdict before default: Task 7 Step 6.
  • Spec verification items: gate, both-renderer compile, ATV perf of the nebula HDR target, fresh-install stall check, Chris’s eyes on both modes. All in Task 7.
  • Names used across tasks: ColorSpace.{FIX, override, on, set_on, reset_session, reset_for_tests, active, generation, sync, setter, srgb_to_linear, linear_to_srgb, dim_for_linearized, child_comp, stock_layers, swap_layers, warp_modulate, ADDITIVE, CS_ADD, CS_MIX, GLOBAL}, apply_color_space(on) on the three owners, ArenaBackground.set_star_dim_comp(c).
  • Chris’s decisions (2026-10-09): asked for “step 3”, then chose “Step 2 first (Recommended)” when asked, because step 3’s values tune against step 2’s corrected space. This plan is step 2 only.
  • Branch/worktree: hdr-colour-space at .claude/worktrees/hdr-colour-space, cut from hdr-colour-spec 4101460f (step 1, UNMERGED, BUILD 290 on Chris’s iPhone). Subagents branch off hdr-colour-space, not origin/main. main has moved since (21bd283f, a skill-only commit from session bullet-heaven-39); merge origin/main in before any device install.
  • Authorized: writing code, tests, local commits and pushing the branch. Needs Chris’s same-day OK: device installs (ATV/iPhone), any remote-config patch, merge to main, console Worker redeploy.
  • Open on step 1 (not this plan’s job, but blocking its merge): the border_halo fresh-install stall check on ATV + iPhone, Chris’s sdr↔hdr profile-flip check, and step 1’s merge (ListAgents claim + Chris OK).
  • Judging rule for the A/B (a side note Chris surfaced, agreed): judge consistency, not glow brightness; see Task 7 Step 6.
  • Gotchas hit while planning: grep is the ugrep shim (/usr/bin/grep for repo-wide); zsh globs with --include=*.x need quoting; the engine source is at ~/Claude/godot-tvos/godot/ (fork efa0097).
  • Next action: Task 1 via superpowers:subagent-driven-development (read bh-sdd-concurrent first).