Skip to content

HDR Display P3 palette-authored wide gamut: implementation plan

HDR Display P3 Palette-Authored Wide Gamut Implementation Plan

Section titled “HDR Display P3 Palette-Authored Wide Gamut 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 skill bh-sdd-concurrent before Task 1, and dispatch code tasks as bh-implementer with model: "opus" (memory bullet-heaven-subagent-model-haiku).

Goal: With HDR output on, saturated gameplay colours reach the Display P3 edge on the Apple TV and the iPhone. Hue stays put, HUD/UI never changes, SDR, web and Android stay byte-identical, and a live tunable dials the effect from 0 to 1.

Architecture:

  • Engine. A new runtime property, Viewport.output_gamut (0 = sRGB, 1 = P3), is plumbed per render target, like use_hdr_2d. When it is set, the Mobile tonemap and the final HDR blit clamp to the P3 gamut instead of Rec.709, so negative extended-sRGB components that encode P3 colours survive to the CAMetalLayer. The layer tag itself never changes.
  • Game. ColorGamut (pure GDScript) widens each gameplay colour source in OKLCH. It keeps hue and lightness and moves chroma from its position relative to the sRGB boundary to the same relative position on the P3 boundary. The result is cached and is aware of the draw path (A/B/R).
  • Coupling. One decision, ColorGamut.active() = HDR output engaged AND engine capable AND GAMUT_P3, drives both the engine property and every widened source. The game can therefore never emit P3 colours that the engine clips.

Tech Stack:

  • Godot 4.8-dev C++/GLSL (tvos-4.8dev) for tvOS and iOS; the 4.7 fork and 4.7.1-stable are fallbacks.
  • Typed GDScript + GUT.
  • The Node console MCP (tools/console_mcp).

Spec: docs/superpowers/specs/2026-10-11-hdr-p3-palette-design.md (approved by Chris, 2026-10-11). It supersedes docs/superpowers/plans/2026-10-11-hdr-p3-gamut.md. That plan’s blit reinterpretation is NOT used here; only its build and repack commands are reused.

  • Render-only. Nothing under /sim changes. The three seed-1234 baselines (tests/test_determinism_survival.gd, tests/test_determinism.gd, tests/test_determinism_coop.gd) must not move, and scripts/gate.sh proves it.
  • Byte-identical where P3 is off. SDR, web, Android, and any build where ColorGamut.active() is false must be byte-identical to today: every new engine branch sits behind output_gamut == 1, and every game source returns its input unchanged when inactive.
  • Target gamut: Display P3 (D65), not Rec.2020. OUTPUT_GAMUT_P3_WORKING (approach C) is reserved and not implemented.
  • HUD/UI never widens.
    • Nothing under ui/ calls ColorGamut.widen or ColorGamut.out, and a GUT grep test enforces it.
    • UI that shows an element colour uses ElementPalette.color_for_srgb.
    • Layers above canvas_max_layer skip the tonemap anyway.
  • Tunables:
    • ColorProfileHdr.GAMUT_P3: bool = true is the kill switch.
    • ColorProfileHdr.GAMUT_WIDEN: float = 1.0 is the dial.
    • Both are HDR-profile only, with no SDR counterpart.
    • Both are registered tunables with a tools/tunable_docs/docs.d label, the LiveScope HOOK_ENV hook, and the on-device change note.
    • control/src/tunable_docs.js is GENERATED: run node tools/tunable_docs/generate.mjs and never hand-edit it.
  • Debug Settings row parity: hdr_gamut = “HDR wide gamut P3 (A/B)” (memory bullet-heaven-debug-settings-console-parity).
  • Never call Color.h/.s/.v (or from_hsv on its result) on a widened colour. Widening is the LAST step at a source. Lerp/multiply after widening is allowed; HSV is not.
  • Godot’s transfer, never a sign-mirrored pow. Convert with Color.srgb_to_linear() / Color.linear_to_srgb(). Their linear segment is what the engine’s canvas uses (canvas.glsl:118-119, core/math/color.h:192-203), so negatives round-trip as c * 12.92 and c / 12.92.
  • Fork rules:
    • Never push to origin (someone else’s upstream). Push only to the lumara remote.
    • A fork change reaches a build only after the template zip is repackaged AND the Xcode shell’s libgodot.a is refreshed (--export-pack never re-copies it).
    • The EXPORT EDITOR must also be rebuilt from the patched source, because the shader baker embeds the editor’s compiled-in engine shaders (editor/export/shader_baker_export_plugin.cpp:144-150).
  • The headless gate never compiles shaders. Every edited .gdshader/.gdshaderinc needs a windowed compile check under BOTH --rendering-method mobile and --rendering-method gl_compatibility. Afterwards run git checkout -- project.godot audio/bus_layout.tres.
  • Shader edits can re-trigger the A17 iPhone baker draw bug (godot#124088). A BAKED iPhone Dev install must be checked before merge.
  • Tests:
    • Never pull a whole GUT run into context. Use scripts/check-test-count.sh (the full log goes to $TMPDIR/bh-gut-last.log; set GUT_LOG=<own path> when worktrees run gates concurrently).
    • Narrow a run with -gselect=<exact_name>.gd.
    • Every new docs/**/*.md needs Starlight title: frontmatter.
  • Needs Chris’s OK: publishing a remote-config patch, any store/TestFlight build, and merging to main. Device steps are marked (controller + Chris).
  • Git:
    • Work in this worktree (.claude/worktrees/hdr-colour-space, branch hdr-colour-space).
    • git pull --rebase before each commit.
    • Commit with git commit --file=<msgfile>, ending with the Co-Authored-By and Claude-Session lines.
    • git push origin hdr-colour-space.
  • Secrets: never put secret values in files or the transcript, and never Read ~/.secrets.

These are linear, D65. Each row sums to 1 (±3e-7), so white maps to white. GLSL mat3(...) takes columns, so the GLSL literals below are the transposes.

row 0 row 1 row 2
SRGB_TO_P3 0.8224621, 0.1775380, 0.0 0.0331941, 0.9668058, 0.0 0.0170827, 0.0723974, 0.9105199
P3_TO_SRGB 1.2249401, −0.2249404, 0.0 −0.0420569, 1.0420571, 0.0 −0.0196376, −0.0786361, 1.0982735

Anchors:

  • P3 red in extended linear sRGB = (1.2249401, −0.0420569, −0.0196376).
  • P3 green = (−0.2249404, 1.0420571, −0.0786361).

Findings from the real code that change the spec (read before Task 1)

Section titled “Findings from the real code that change the spec (read before Task 1)”

These were found while writing this plan, and the tasks below already account for them. Raise them with Chris at the Task 3 checkpoint.

  1. The game tonemaps through tonemap_mobile.glsl, not tonemap.glsl.
    • Why: platform/tvos/project.godot sets .mobile="mobile" and .macos="mobile", and the path is _render_buffers_post_process_and_tonemap → ToneMapper::tonemapper_mobile.
    • The mobile clamps are at apply_tonemapping :258 (max(0)), the screen-glow clamp at :293, the softlight clamp at :313, and the AgX min(output_max) at :230.
    • The spec’s tonemap.glsl:253/424 are the Forward+ (clustered) shader, which only the root project’s editor runs use. That shader is left alone: its push constant is already 112 bytes with no spare pad.
  2. AgX ships negative components today, and the blit clips them.
    • tonemap_agx ends “simply return the color, even if it has negative components” (tonemap_mobile.glsl:232-236). BCS SATURATION 1.35 can also push in-sRGB colours negative, because the BCS linear_to_srgb takes the linear branch for negatives.
    • So GAMUT_P3 on with GAMUT_WIDEN 0 is NOT byte-identical to today under AgX: the P3 blit lets those values through, up to the P3 edge. “Today’s look” is GAMUT_P3 off.
    • AgX runs on P3 primaries, as the spec says (Chris confirmed 2026-10-11). It is implemented as tonemap_agx(p3_to_srgb_matrix * p3_clamped): by linearity, rec709_to_rec2020_agx_inset · P3→709 IS the P3-input AgX inset matrix, and outset→709 followed by the P3-clamping blit gives the same pixels as outset→P3. So this is not a deviation; it reuses the stock matrices instead of adding new constants. What must NOT happen is the naive version (raw P3 values pushed through the 709 inset), which skews hue, the fault that sank the old plan.
    • Reinhard, Filmic and linear are per-channel, so they run in P3 as the spec says. ACES is NOT: its fitted matrices mix channels and assume Rec.709 input, so it gets the AgX treatment (tonemap_aces(p3_to_srgb_matrix * p3_clamped)). Found by the Task 1 review (raw P3 through ACES: P3 green -29% chroma, 2-3 degree hue shift); fixed in the Task 1 fix round.
  3. No ui/ file calls ElementPalette. The Codex path is content/bestiary_loader.gd:71 → render/codex_preview.gd:49/63 (vivid) → ui/codex_card.gd. main._element_color_caps (main.gd:4157-4161) feeds the console its #rrggbb. All three move to the sRGB accessor.
  4. One widened value cannot serve every draw path.
    • PATH_A (modulate, draw_*, source_color, Line2D) is linearised by the engine, so it gets an encoded result.
    • PATH_B (MultiMesh colour, cs_in uniforms) gets an encoded result only while ColorSpace.active, otherwise linear.
    • PATH_R (particles, Light2D.color, raw uniforms) gets linear.
    • So ColorGamut.out(c, path) caches per path, and its cache keys on ColorSpace.active.
  5. The “hot-core constants” in the spec (ENEMY_HOT_CORE, WFX_HOT, BOSS_HOT, …) are SCALAR gains (render/hdr_tier.gd:27-48), not colours. There is nothing to widen there. The tints they multiply are what widens: the element palette, looks constants and shader literals (Tasks 6-8).
  6. The nebula SubViewport is 8-bit unless colour_space_fix is on (render/nebula_bg.gd:164-167). Widening COL_ACCENT is therefore wasted there, so it widens only when that SubViewport is HDR (Task 7).
  7. The export editor must be rebuilt (see Global Constraints).
    • tvOS and iOS: ~/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64 (4.8 is primary; install-dev.sh:111/build-tvos.sh:13 still name the 4.7 editor until the 4.8 fix agent switches them).
    • iOS today: Today iOS exports with stock /opt/homebrew/bin/godot 4.7.1 (install-dev.sh:130, build-ios.sh:8) and the stock 4.7.1.stable/ios.zip.
    • A patched runtime with a stock-baked pack is the BUILD 221 mismatch in reverse.
  8. Pure sRGB blue and yellow gain nothing at constant OKLCH L and hue (verified numerically). Red, green, cyan and magenta gain the most, so expect golds and blues to move least.

Engine base and iOS (revised 2026-10-11: Chris picked Godot 4.8)

Section titled “Engine base and iOS (revised 2026-10-11: Chris picked Godot 4.8)”
  • Primary base, tvOS and iOS: tvos-4.8dev (worktree ~/Claude/godot-tvos/godot-48, HEAD 655cdf8b9f when this plan was written).
    • The GPU-bound A/B favoured 4.8: 41.7 vs 33.1 fps, p50 27 vs 33 ms.
    • Every edited shader line was verified identical on 4.8. The C++ line numbers differ; Task 1 has both columns.
  • Do not start Task 1 while the 4.8 build/export fix agent is still working in godot-48.
    • That agent owns the Xcode export template, the debug lib in 4.8.dev/tvos.zip, and switching install-dev.sh:111 / build-tvos.sh:13 off the 4.7 editor path.
    • Start Task 1 from its finished HEAD and re-run the line check in Task 1 Step 1.
  • Fallback: the 4.7 fork ~/Claude/godot-tvos/godot, branch tvos-render-1080p, efa009712e (ATV Dev BUILD 302). It is used only if the 4.7 repeat run reverses the A/B; see Task 1 Step 10.
  • iOS on 4.8 (primary):
    • Verified in the source: platform/ios is present, the generate_bundle scons builder is present (platform/ios/SCsub:49, platform_ios_builders.py), and misc/dist/apple_embedded_xcode carries libgodot.ios.{debug,release}.xcframework.
    • NOT verified: no iOS lib has ever been built from this branch. Its tvOS commits touch the shared apple_embedded code (“keep touch long-press arrays out of tvOS builds”, “compile the Swift @main into libgodot like iOS”).
    • Task 2 Step 1 is therefore a build gate. One 4.8.dev editor serves both platforms, since one macOS editor exports tvOS and iOS alike.
    • Moving iOS from stock 4.7.1 to 4.8-dev also moves the SHIPPING iOS engine forward several months of upstream. Flag this to Chris; it was his 4.8 call for tvOS, so confirm it covers iOS.
  • iOS fallback (only if the 4.8 iOS build fails or misbehaves on device): branch ios-p3-4.7.1 off 4.7.1-stable (a13da4feb), built with GODOT_VERSION_STATUS=p3 into its own 4.7.1.p3 template dir (methods.py:161-162). See Task 2 “Fallback B”.
File Responsibility
fork servers/rendering/renderer_rd/shaders/gamut_p3_inc.glsl (new) The matrices and clamp_in_p3(), shared by tonemap and blit
fork .../shaders/effects/tonemap_mobile.glsl, .../shaders/blit.glsl The P3 branches
fork scene/main/viewport.{h,cpp}, servers/rendering/{rendering_server.h,rendering_server_default.h,rendering_server.cpp,renderer_viewport.{h,cpp}}, servers/rendering/storage/texture_storage.h, .../storage_rd/texture_storage.{h,cpp}, .../effects/tone_mapper.{h,cpp}, .../renderer_scene_render_rd.cpp, .../renderer_compositor_rd.{h,cpp} Plumbing output_gamut from the Viewport to the push constants
platform/engine-patches/0001-p3-output-gamut.patch + README.md (new) The patch of record for both bases, its rebuild and rollback
tests/test_engine_patch_p3.gd (new) Pins the patch’s matrices and its property name to ColorGamut
render/color_gamut.gd (new) OKLab maths, widen, the active() decision, the path-aware cached out()
render/gamut_swatch.gd (new) Dev-only P3/sRGB swatch for the device proof
render/gamut_globals.gd (new) Pushes widened shader-literal colours as global uniforms
render/shaders/gamut_palette.gdshaderinc (new) global uniform vec3 gp_* declarations
render/color_profile_hdr.gd, render/color_profile.gd The two tunables, HDR_ONLY_PREFIX
render/element_palette.gd color_for_srgb, weapon_color_srgb, path-aware color_for/weapon_color
main.gd _sync_output_gamut, LUT rebuild, the swatch command, the caps colour
ui/debug_settings_panel.gd The hdr_gamut row
net/tunables_registry.gd, tools/tunable_docs/docs.d/color_profiles.json Registration and labels
scripts/dev/install-dev.sh, scripts/release/build-ios.sh, platform/tvos/CLAUDE.md iOS export on the patched editor
docs/architecture/rendering-and-performance.md The “Display P3 output” section

Task 1: Engine patch on tvos-4.8dev (fallback: the 4.7 fork)

Section titled “Task 1: Engine patch on tvos-4.8dev (fallback: the 4.7 fork)”

Line numbers: the “after line N” references in Steps 4-6 are 4.7 fork numbers. On 4.8, use this verified map:

Site 4.7 fork 4.8 (655cdf8b9f)
tonemap_mobile.glsl pad / apply_tonemapping / glow clamps 97 / 251-268 / 293, 313 same
blit.glsl pad1 / clamp 27, 73 / 169 same
tone_mapper.h push pad / convert_to_srgb 141 / 209 same
tone_mapper.cpp output_max_value 246, 337 same
renderer_scene_render_rd.cpp convert_to_srgb = !using_hdr 752, 957 752, 966
renderer_compositor_rd.h pad[2] 91 92
renderer_compositor_rd.cpp output_max_value 114, 300 115, 301
storage/texture_storage.h render_target_is_using_hdr 167 172
storage_rd/texture_storage.h use_hdr / is_using_hdr 378 / 803 421 / 877
renderer_viewport.h 119, 268 same
renderer_viewport.cpp viewport_is_using_hdr_2d ends ~1420 starts 1429
rendering_server.h / _default.h / .cpp 570 / 770 / 2892 590 / 787 / 2911
scene/main/viewport.h 270, 568 274, 572
scene/main/viewport.cpp is_using_hdr_2d / bind / property 1319 / 5169 / 5370 1338 / 5215 / 5418

Files (fork ~/Claude/godot-tvos/godot-48; the 4.7 numbers are listed, see the map):

  • Create: servers/rendering/renderer_rd/shaders/gamut_p3_inc.glsl
  • Modify:
    • servers/rendering/renderer_rd/shaders/effects/tonemap_mobile.glsl:96-97 (push constant), :251-268 (apply_tonemapping), :293 and :313 (glow clamps), plus an include near the top.
    • servers/rendering/renderer_rd/shaders/blit.glsl:27, :73 and :169, plus an include in the #[fragment] section.
    • servers/rendering/renderer_rd/effects/tone_mapper.h:140-141 (push), :166-212 (TonemapSettings); servers/rendering/renderer_rd/effects/tone_mapper.cpp:246 and :337.
    • servers/rendering/renderer_rd/renderer_scene_render_rd.cpp:752 and :957.
    • servers/rendering/renderer_rd/renderer_compositor_rd.h:91; servers/rendering/renderer_rd/renderer_compositor_rd.cpp:114 and :300.
    • servers/rendering/storage/texture_storage.h:167; servers/rendering/renderer_rd/storage_rd/texture_storage.h:378 and :803, plus texture_storage.cpp.
    • servers/rendering/renderer_viewport.h:119, :268; servers/rendering/renderer_viewport.cpp:1420.
    • servers/rendering/rendering_server.h:570, servers/rendering/rendering_server_default.h:770, servers/rendering/rendering_server.cpp:2892.
    • scene/main/viewport.h:270, :568; scene/main/viewport.cpp:1319, :5169, :5370.
  • Repo: platform/engine-patches/0001-p3-output-gamut.patch, platform/engine-patches/README.md, tests/test_engine_patch_p3.gd.

Interfaces:

  • Produces:

    • GDScript Viewport.output_gamut: int (0 = sRGB, 1 = P3), live-settable.
    • ClassDB.class_has_method(&"Viewport", &"set_output_gamut") is true only on patched engines (ClassDB has NO class_has_property; it is a parse error, found in Task 1).
    • The constant names srgb_to_p3_matrix / p3_to_srgb_matrix in gamut_p3_inc.glsl.
  • Step 1: Preflight the fork

Terminal window
cd ~/Claude/godot-tvos/godot-48
git branch --show-current # tvos-4.8dev
git log --oneline -3 # the 4.8 fix agent's finished HEAD (descends from 655cdf8b9f)
git status --short # clean apart from untracked build output; uncommitted edits = the fix agent is still working: STOP
pgrep -fl "scons|godot.macos.editor" || echo idle # no concurrent build in this tree
grep -n "color = max(vec3(0.0), color);" servers/rendering/renderer_rd/shaders/effects/tonemap_mobile.glsl # 258
grep -n "uint pad1" servers/rendering/renderer_rd/shaders/blit.glsl # 27, 73

If any line in the Task 1 map has moved, re-grep that site and use its new number. The code itself does not change.

  • Step 2: Write the failing parity test in the repo (pins the patch before it exists)

Create tests/test_engine_patch_p3.gd in the worktree:

extends GutTest
# The engine patch of record (platform/engine-patches/0001-p3-output-gamut.patch) must carry the SAME
# matrices ColorGamut (render/color_gamut.gd) widens against, and the property name the game probes.
# GLSL mat3() takes COLUMNS, so a parsed literal is transposed back into rows here.
const PATCH := "res://platform/engine-patches/0001-p3-output-gamut.patch"
const SRGB_TO_P3_ROWS := [[0.8224621, 0.1775380, 0.0], [0.0331941, 0.9668058, 0.0], [0.0170827, 0.0723974, 0.9105199]]
const P3_TO_SRGB_ROWS := [[1.2249401, -0.2249404, 0.0], [-0.0420569, 1.0420571, 0.0], [-0.0196376, -0.0786361, 1.0982735]]
func _patch() -> String:
var text := FileAccess.get_file_as_string(PATCH)
assert_false(text.is_empty(), "the patch file exists and is readable from the root project")
return text
func _glsl_rows(text: String, mat_name: String) -> Array:
var re := RegEx.create_from_string("const mat3 " + mat_name + r" = mat3\(([^;]+)\);")
var m := re.search(text)
assert_not_null(m, "patch defines " + mat_name)
if m == null:
return []
var nums: Array[float] = []
for n: RegExMatch in RegEx.create_from_string(r"-?\d+\.\d+").search_all(m.get_string(1)):
nums.append(float(n.get_string()))
assert_eq(nums.size(), 9, mat_name + " has 9 numbers")
return [[nums[0], nums[3], nums[6]], [nums[1], nums[4], nums[7]], [nums[2], nums[5], nums[8]]]
func test_patch_matrices_match_color_gamut() -> void:
var text := _patch()
for pair: Array in [["srgb_to_p3_matrix", SRGB_TO_P3_ROWS], ["p3_to_srgb_matrix", P3_TO_SRGB_ROWS]]:
var rows := _glsl_rows(text, String(pair[0]))
for i in rows.size():
for j in 3:
assert_almost_eq(float(rows[i][j]), float(pair[1][i][j]), 1e-7, "%s[%d][%d]" % [pair[0], i, j])
func test_patch_exposes_the_property_the_game_probes() -> void:
assert_true(_patch().contains('"output_gamut"'), "ADD_PROPERTY name == ColorGamut.ENGINE_PROPERTY")
func test_patch_keeps_the_stock_lines_on_the_srgb_path() -> void:
var text := _patch()
assert_true(text.contains("color.rgb = clamp(color.rgb, vec3(0.0), vec3(data.output_max_value));"), "blit else-branch")
assert_true(text.contains("color = max(vec3(0.0), color);"), "tonemap sRGB path")

Run GUT_LOG=$TMPDIR/hdr-p3-gut.log godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_engine_patch_p3.gd -gexit 2>&1 | tail -5.

Expected: FAIL (“the patch file exists”).

  • Step 3: Create the shared include

servers/rendering/renderer_rd/shaders/gamut_p3_inc.glsl. The *_inc.glsl suffix makes shaders/SCsub treat it as an include, so it is not compiled standalone.

// Display P3 output gamut (Viewport.output_gamut, Dark Cosmos patch 0001).
// Linear sRGB (Rec.709 primaries) <-> linear Display P3, both D65. GLSL mat3() takes COLUMNS, so these are
// the transposes of the row-major matrices (rows: SRGB_TO_P3 = [0.8224621, 0.1775380, 0], ...).
#define OUTPUT_GAMUT_SRGB 0u
#define OUTPUT_GAMUT_P3 1u
const mat3 srgb_to_p3_matrix = mat3(
0.8224621, 0.0331941, 0.0170827,
0.1775380, 0.9668058, 0.0723974,
0.0, 0.0, 0.9105199);
const mat3 p3_to_srgb_matrix = mat3(
1.2249401, -0.0420569, -0.0196376,
-0.2249404, 1.0420571, -0.0786361,
0.0, 0.0, 1.0982735);
// Clamp a linear-sRGB colour to [lo, hi] per P3 channel; returns linear sRGB, negatives allowed
// (they encode colours outside Rec.709 but inside P3 on an extended-linear-sRGB layer).
vec3 clamp_in_p3(vec3 c, float lo, float hi) {
return p3_to_srgb_matrix * clamp(srgb_to_p3_matrix * c, vec3(lo), vec3(hi));
}
  • Step 4: Edit tonemap_mobile.glsl

(a) Add #include "../gamut_p3_inc.glsl" on the line after the existing #version/#VERSION_DEFINES header block of the fragment section. This is the same form effects/copy.glsl:7 uses for ../oct_inc.glsl.

(b) Push constant, lines 96-97. Same 80-byte size:

float output_max_value;
uint output_gamut;
float pad[2];

(c) Replace apply_tonemapping (lines 251-268) with:

vec3 apply_tonemapping(vec3 color) { // inputs are LINEAR
if (tonemapper_linear) {
return color;
}
if (params.output_gamut == OUTPUT_GAMUT_P3) {
// P3 output (Viewport.output_gamut): clamp negatives in P3, not Rec.709, so colours inside
// Display P3 survive (they are positive in P3).
color = max(vec3(0.0), srgb_to_p3_matrix * color);
if (tonemapper_agx) {
// AgX on P3 primaries: inset(709->2020) * P3_TO_SRGB is exactly the P3-input inset matrix,
// so feeding the P3-clamped colour in 709 coords (negatives allowed) is primaries-correct.
return tonemap_agx(p3_to_srgb_matrix * color);
}
if (tonemapper_reinhard) {
color = tonemap_reinhard(color);
} else if (tonemapper_filmic) {
color = tonemap_filmic(color);
} else {
color = tonemap_aces(color);
}
return p3_to_srgb_matrix * color;
}
// Ensure color values passed to tonemappers are positive.
// They can be negative in the case of negative lights, which leads to undesired behavior.
color = max(vec3(0.0), color);
if (tonemapper_reinhard) {
return tonemap_reinhard(color);
} else if (tonemapper_filmic) {
return tonemap_filmic(color);
} else if (tonemapper_aces) {
return tonemap_aces(color);
} else { // tonemapper_agx
return tonemap_agx(color);
}
}

Before relying on the else in the P3 branch, confirm the four spec constants are tonemapper_reinhard/filmic/aces/agx (grep -n "constant_id" effects/tonemap_mobile.glsl). Mirror the names exactly.

(d) Line 293 (screen glow):

glow.rgb = (params.output_gamut == OUTPUT_GAMUT_P3) ? clamp_in_p3(glow.rgb, 0.0, white) : clamp(glow.rgb, 0.0, white);

(e) Line 313 (softlight glow):

glow.rgb = (params.output_gamut == OUTPUT_GAMUT_P3) ? clamp_in_p3(glow.rgb, 0.0, 1.0) : clamp(glow.rgb, 0.0, 1.0);

The additive glow path (the game’s GLOW_BLEND_MODE_ADDITIVE) has no clamp, and the glow blur (blur_raster.glsl:205-208) only caps from above. Nothing else to change.

  • Step 5: Edit blit.glsl

(a) In BOTH push-constant blocks (lines 27 and 73), replace uint pad1; with uint output_gamut;. Keep uint pad2;.

(b) Add #include "gamut_p3_inc.glsl" inside the #[fragment] section, after its #VERSION_DEFINES line.

(c) Line 169, in the COLOR_SPACE_REC709_LINEAR branch only:

if (data.output_gamut == OUTPUT_GAMUT_P3) {
// Dark Cosmos patch 0001: keep colours inside Display P3 (negative extended-sRGB components
// are legal on the extended-linear-sRGB layer); still capped at output_max_value per P3 channel.
color.rgb = clamp_in_p3(color.rgb, 0.0, data.output_max_value);
} else {
color.rgb = clamp(color.rgb, vec3(0.0), vec3(data.output_max_value));
}
  • Step 6: C++ plumbing

Each edit mirrors the neighbouring use_hdr_2d/use_hdr line.

servers/rendering/storage/texture_storage.h, after line 167. Default bodies, so the dummy and GLES3 storages need no change:

virtual void render_target_set_output_gamut(RID p_render_target, int p_gamut) {}
virtual int render_target_get_output_gamut(RID p_render_target) const { return 0; }

servers/rendering/renderer_rd/storage_rd/texture_storage.h. In struct RenderTarget, after bool use_hdr = false; (line 378), add int output_gamut = 0;. After line 803, add:

virtual void render_target_set_output_gamut(RID p_render_target, int p_gamut) override;
virtual int render_target_get_output_gamut(RID p_render_target) const override;

texture_storage.cpp, next to render_target_is_using_hdr:

void TextureStorage::render_target_set_output_gamut(RID p_render_target, int p_gamut) {
RenderTarget *rt = render_target_owner.get_or_null(p_render_target);
ERR_FAIL_NULL(rt);
rt->output_gamut = CLAMP(p_gamut, 0, 1);
}
int TextureStorage::render_target_get_output_gamut(RID p_render_target) const {
RenderTarget *rt = render_target_owner.get_or_null(p_render_target);
ERR_FAIL_NULL_V(rt, 0);
return rt->output_gamut;
}

servers/rendering/renderer_viewport.h:

  • In the Viewport struct after bool use_hdr_2d = false; (line 119), add int output_gamut = 0;.
  • After line 268, add void viewport_set_output_gamut(RID p_viewport, int p_gamut); and int viewport_get_output_gamut(RID p_viewport) const;.

renderer_viewport.cpp, after viewport_is_using_hdr_2d (line 1420):

void RendererViewport::viewport_set_output_gamut(RID p_viewport, int p_gamut) {
Viewport *viewport = viewport_owner.get_or_null(p_viewport);
ERR_FAIL_NULL(viewport);
viewport->output_gamut = p_gamut;
RSG::texture_storage->render_target_set_output_gamut(viewport->render_target, p_gamut);
}
int RendererViewport::viewport_get_output_gamut(RID p_viewport) const {
const Viewport *viewport = viewport_owner.get_or_null(p_viewport);
ERR_FAIL_NULL_V(viewport, 0);
return viewport->output_gamut;
}

rendering_server.h, after line 570:

virtual void viewport_set_output_gamut(RID p_viewport, int p_gamut) = 0;
virtual int viewport_get_output_gamut(RID p_viewport) const = 0;

rendering_server_default.h, after line 770:

FUNC2(viewport_set_output_gamut, RID, int)
FUNC1RC(int, viewport_get_output_gamut, RID)

rendering_server.cpp, after line 2892:

ClassDB::bind_method(D_METHOD("viewport_set_output_gamut", "viewport", "gamut"), &RenderingServer::viewport_set_output_gamut);
ClassDB::bind_method(D_METHOD("viewport_get_output_gamut", "viewport"), &RenderingServer::viewport_get_output_gamut);

scene/main/viewport.h:

  • After bool use_hdr_2d = false; (line 270), add int output_gamut = 0;.
  • After line 568, add void set_output_gamut(int p_gamut); and int get_output_gamut() const;.

viewport.cpp:

  • After is_using_hdr_2d (line 1319):
void Viewport::set_output_gamut(int p_gamut) {
ERR_MAIN_THREAD_GUARD;
output_gamut = CLAMP(p_gamut, 0, 1);
RS::get_singleton()->viewport_set_output_gamut(viewport, output_gamut);
}
int Viewport::get_output_gamut() const {
ERR_READ_THREAD_GUARD_V(0);
return output_gamut;
}
  • After line 5169:
ClassDB::bind_method(D_METHOD("set_output_gamut", "gamut"), &Viewport::set_output_gamut);
ClassDB::bind_method(D_METHOD("get_output_gamut"), &Viewport::get_output_gamut);
  • After line 5370:
ADD_PROPERTY(PropertyInfo(Variant::INT, "output_gamut", PROPERTY_HINT_ENUM, "sRGB,Display P3"), "set_output_gamut", "get_output_gamut");

If set_use_hdr_2d (line 1311) has no ERR_MAIN_THREAD_GUARD, drop the guards to match it.

Tone mapper:

  • tone_mapper.h:140-141: float output_max_value; uint32_t output_gamut; float pad[2]; // 4 + 4 + 8 - 80.
  • In TonemapSettings, after bool convert_to_srgb = false; (line 209), add uint32_t output_gamut = 0;.
  • tone_mapper.cpp: after line 246 AND after line 337, add tonemap_mobile.push_constant.output_gamut = p_settings.output_gamut;.

renderer_scene_render_rd.cpp:

  • After line 752: tonemap.output_gamut = using_hdr ? (uint32_t)texture_storage->render_target_get_output_gamut(render_target) : 0;
  • After line 957: tonemap.output_gamut = using_hdr ? (uint32_t)texture_storage->render_target_get_output_gamut(rb->get_render_target()) : 0;

Compositor:

  • renderer_compositor_rd.h:91: uint32_t output_gamut; uint32_t pad; // 8 - 96.

  • renderer_compositor_rd.cpp: after line 114 add blit.push_constant.output_gamut = texture_storage->render_target_get_output_gamut(p_render_targets[i].render_target);, and after line 300 (boot image) add blit.push_constant.output_gamut = 0;.

  • Fix any remaining pad[...] reference the compiler flags.

  • Step 7: Build the tvOS template and the fork editor

Terminal window
cd ~/Claude/godot-tvos/godot-48
cp bin/godot.macos.editor.arm64 bin/godot.macos.editor.arm64.2026-10-11-pre-p3.bak
cp bin/libgodot.tvos.template_release.arm64.a bin/libgodot.tvos.template_release.arm64.a.bak.20261011-pre-p3
cp bin/libgodot.tvos.template_debug.arm64.a bin/libgodot.tvos.template_debug.arm64.a.bak.20261011-pre-p3
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=tvos target=template_release arch=arm64 -j10
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=tvos target=template_debug arch=arm64 -j10
scons platform=macos target=editor arch=arm64 vulkan=no -j10 # ~4 min; the baker embeds THIS build's engine shaders
# strings bin/godot.macos.editor.arm64 | grep -c "srgb_to_p3_matrix" # DOES NOT WORK: RD shaders are zlib-compressed in *.glsl.gen.h (_code_compressed[]); use check-p3-shaders (below)
# strings bin/libgodot.tvos.template_release.arm64.a | grep -c "srgb_to_p3_matrix" # DOES NOT WORK: RD shaders are zlib-compressed in *.glsl.gen.h (_code_compressed[]); use check-p3-shaders (below)

Shader-presence check (replaces every strings | grep srgb_to_p3_matrix line, found in Task 1). strings finds nothing because the engine stores RD shader sources zlib-compressed (*.glsl.gen.h, _code_compressed[]). Instead: decompress the regenerated blit.glsl.gen.h / tonemap_mobile.glsl.gen.h arrays, confirm the text contains srgb_to_p3_matrix and output_gamut, then confirm those exact compressed byte arrays occur in each binary (editor, template libs, or the zip member via unzip -p). Task 2 turns this into platform/engine-patches/check-p3-shaders.py <fork-dir> <binary> and uses it for the iOS libs.

  • Step 8: Probe the property and compile-check the shaders on the Mac

Probe (headless is fine for the property):

Terminal window
P=$(mktemp -d); printf '[application]\nconfig/name="p3probe"\n' > "$P/project.godot"
cat > "$P/probe.gd" <<'EOF'
extends SceneTree
func _init() -> void:
var ok := ClassDB.class_has_method(&"Viewport", &"set_output_gamut")
root.set("output_gamut", 1)
print("P3PROBE has=%s get=%s" % [ok, root.get("output_gamut")])
quit()
EOF
~/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64 --headless --path "$P" -s probe.gd 2>&1 | grep P3PROBE

Expected: P3PROBE has=true get=1. Stock /opt/homebrew/bin/godot on the same probe must print has=false get=<null>.

Windowed compile check. The engine shaders compile at first use on Metal, and the mobile tonemap runs whenever glow is on:

Terminal window
cd ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/tvos
perl -e 'alarm 90; exec @ARGV' ~/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64 \
--path . --rendering-method mobile --quit-after 900 > $TMPDIR/p3-boot.log 2>&1; true
git checkout -- project.godot audio/bus_layout.tres 2>/dev/null; true
grep -iE "tonemap|blit|shader.*(error|fail)|SPIR|metal.*compil" $TMPDIR/p3-boot.log | head

Expected: the window draws, and the grep finds no tonemap/blit compile error. A broken shader gives a black window and errors on the first frame. (gl_compatibility has no RD tonemap/blit, so there is no compat check for engine shaders.)

  • Step 9: Commit the fork, push to lumara, repackage the template, and record the patch
Terminal window
cd ~/Claude/godot-tvos/godot-48
git add servers/rendering scene/main
git commit -m "Dark Cosmos 0001: Viewport.output_gamut (Display P3) through mobile tonemap + HDR blit"
git push lumara tvos-4.8dev
T="$HOME/Library/Application Support/Godot/export_templates/4.8.dev"
cp "$T/tvos.zip" "$T/tvos.zip.bak-2026-10-11-pre-p3"
unzip -l "$T/tvos.zip" | grep "libgodot.a" # list the entries the fix agent's zip carries (release, and debug once it has added it)
W=$(mktemp -d); mkdir -p "$W/libgodot.tvos.release.xcframework/tvos-arm64" "$W/libgodot.tvos.debug.xcframework/tvos-arm64"
cp bin/libgodot.tvos.template_release.arm64.a "$W/libgodot.tvos.release.xcframework/tvos-arm64/libgodot.a"
cp bin/libgodot.tvos.template_debug.arm64.a "$W/libgodot.tvos.debug.xcframework/tvos-arm64/libgodot.a"
(cd "$W" && zip "$T/tvos.zip" libgodot.tvos.release.xcframework/tvos-arm64/libgodot.a)
unzip -l "$T/tvos.zip" | grep -q "libgodot.tvos.debug.xcframework/tvos-arm64/libgodot.a" && (cd "$W" && zip "$T/tvos.zip" libgodot.tvos.debug.xcframework/tvos-arm64/libgodot.a)
# unzip -p "$T/tvos.zip" libgodot.tvos.release.xcframework/tvos-arm64/libgodot.a | strings | grep -c "srgb_to_p3_matrix" # DOES NOT WORK: RD shaders are zlib-compressed in *.glsl.gen.h (_code_compressed[]); use check-p3-shaders (below)
# No Xcode-shell lib swap (resolved 2026-10-11): the only 4.8 shell on disk (g48-probe/platform/tvos/build48) was
# hand-patched and is NOT reused. Since tvos48-shell-patch (706f1373, merged into hdr-colour-space 0151fc83) a clean
# FULL 4.8 export + scripts/release/patch-tvos-shell.sh builds as-is, so Task 9 regenerates the shell from this zip.
mkdir -p ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/engine-patches
git format-patch -1 HEAD --stdout > ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/engine-patches/0001-p3-output-gamut.patch

Do NOT touch any Xcode shell (platform/tvos/build*/ in the main checkout is the 4.7 Dev shell and shared by every worktree). Task 9 does a full 4.8 export instead.

Write platform/engine-patches/README.md:

---
title: "Engine patches (Dark Cosmos)"
---
# Engine patches
## 0001-p3-output-gamut.patch
`Viewport.output_gamut` (0 sRGB, 1 Display P3). When it is 1 on an HDR render target:
- the Mobile tonemap clamps in P3 (AgX runs on P3 primaries via the stock inset matrix times P3→709);
- the screen/softlight glow clamps in P3;
- the HDR blit (`COLOR_SPACE_REC709_LINEAR`) clamps to [0, output_max_value] in P3.
The layer tag never changes. The game drives it from `ColorGamut.active()` (render/color_gamut.gd).
The Forward+ `tonemap.glsl` is NOT patched (only root-project editor runs use it).
| Base | Branch | Commit |
|---|---|---|
| tvOS + iOS 4.8-dev (primary) | `tvos-4.8dev` | (fill in from `git log -1` in Step 9) |
| tvOS fork 4.7.dev (fallback) | `tvos-render-1080p` | only if the 4.7 repeat run reverses the A/B |
| iOS 4.7.1-stable (fallback) | `ios-p3-4.7.1` | only if the 4.8 iOS build fails |
Every rebuild = template + EDITOR (the shader baker embeds the editor's engine shaders) + repack the
template zip + refresh the Xcode shell `libgodot.a`. Backups: `tvos.zip.bak-2026-10-11-pre-p3`,
`bin/godot.macos.editor.arm64.2026-10-11-pre-p3.bak`.
Rollback: restore the zip and the editor (the Xcode shell is regenerated by a full export).
**When upgrading Godot, re-apply this patch or P3 silently stops (the game reports "not capable").**

Then fill in the tvOS commit hash in the table.

  • Step 10 (FALLBACK, only if the 4.7 repeat run reverses the A/B): cherry-pick onto the 4.7 fork tvos-render-1080p
Terminal window
cd ~/Claude/godot-tvos/godot && git branch --show-current && git status --short && git log --oneline -1 # tvos-render-1080p, efa009712e
git cherry-pick <the Step 9 commit>

On a conflict, resolve by hand to exactly the Step 3-6 code, using the 4.7 column of the map. The shader lines were checked to be identical, so report any conflict. Then repeat Steps 7-9 against ~/Claude/godot-tvos/godot:

  • its own bin/ (tvOS release only; 4.7.dev/tvos.zip has no debug entry);
  • platform/tvos/build/ as the shell.

Confirm git merge-base --is-ancestor 4e0000a2f7 HEAD && git merge-base --is-ancestor efa009712e HEAD, then git push lumara tvos-render-1080p and record the hash in the README.

  • Step 11: Run the parity test, the gate, and commit (repo)

GUT_LOG=$TMPDIR/hdr-p3-gut.log godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_engine_patch_p3.gd -gexit 2>&1 | tail -5 → PASS.

If res://platform/... is unreadable from the root project (because platform/tvos holds its own project.godot), read the file via ProjectSettings.globalize_path("res://").path_join("platform/engine-patches/0001-p3-output-gamut.patch") with FileAccess.open. Then run scripts/check-test-count.sh and scripts/gate.sh, both green.

Terminal window
git add platform/engine-patches tests/test_engine_patch_p3.gd tests/test_engine_patch_p3.gd.uid
git commit --file=<msgfile> # "feat(hdr): engine patch 0001 Viewport.output_gamut (P3) on the tvOS fork + parity test"
git push origin hdr-colour-space

Task 2: iOS on the patched 4.8 (templates + export switch)

Section titled “Task 2: iOS on the patched 4.8 (templates + export switch)”

Files:

  • Engine: ~/Claude/godot-tvos/godot-48 (the same commit as Task 1; no new branch).
  • Modify: scripts/dev/install-dev.sh:125-131 (iOS branch), scripts/release/build-ios.sh:8, platform/tvos/CLAUDE.md (the “iOS uses stock Godot + stock Xcode” line), platform/engine-patches/README.md.

Interfaces:

  • Consumes: the Task 1 commit on tvos-4.8dev, and its editor ~/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64.

  • Produces:

    • ~/Library/Application Support/Godot/export_templates/4.8.dev/ios.zip (a full generate_bundle zip);
    • env var BH_IOS_GODOT, defaulting to the 4.8 editor.
  • Step 1: Build gate: does 4.8 build iOS at all?

Terminal window
cd ~/Claude/godot-tvos/godot-48 && git log --oneline -1 # the Task 1 commit
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_debug arch=arm64 -j10
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_release arch=arm64 generate_bundle=yes -j10
ls -la bin/ | grep -i "ios" # expect libgodot.ios.template_{debug,release}.arm64.a and godot_ios.zip
unzip -l bin/godot_ios.zip | grep "libgodot.a"

If either build fails, or godot_ios.zip is missing its device slices, STOP the 4.8 iOS path. Report the error to the controller and go to Fallback B below. Do not patch the shared apple_embedded code to make iOS build; that belongs to the 4.8 fix agent.

  • Step 2: Install the template
Terminal window
T="$HOME/Library/Application Support/Godot/export_templates/4.8.dev"
[ -f "$T/ios.zip" ] && cp "$T/ios.zip" "$T/ios.zip.bak-2026-10-11-pre-p3"
cp ~/Claude/godot-tvos/godot-48/bin/godot_ios.zip "$T/ios.zip"
# unzip -p "$T/ios.zip" libgodot.ios.release.xcframework/ios-arm64/libgodot.a | strings | grep -c "srgb_to_p3_matrix" # DOES NOT WORK: RD shaders are zlib-compressed in *.glsl.gen.h (_code_compressed[]); use check-p3-shaders (below)

The simulator slices are whatever generate_bundle produced (possibly none). Nobody runs the simulator here; note it in the README.

  • Step 3: Switch the iOS export to the 4.8 editor

scripts/dev/install-dev.sh, iOS branch. Replace the 2026-09-30 comment block and line 130 with:

Terminal window
# iOS packs: the patched Godot 4.8 editor (tvos-4.8dev + engine patch 0001, platform/engine-patches/),
# whose runtime is the 4.8.dev ios.zip. Editor and runtime MUST come from the same source: the baker
# embeds the editor's engine shaders (BUILD 221 regressed on a fork-baked pack in a stock runtime).
"${BH_IOS_GODOT:-$HOME/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64}" --path . --rendering-method mobile --export-pack "iOS-Dev" build-ios/BulletHeaven.pck > "$EXPORT_LOG" 2>&1 \

scripts/release/build-ios.sh:8: replace /opt/homebrew/bin/godot with "${BH_IOS_GODOT:-$HOME/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64}", and change the header comment to “patched Godot 4.8 (engine patch 0001) export”.

platform/tvos/CLAUDE.md: replace “iOS uses stock Godot + stock Xcode” with “iOS uses the same patched Godot 4.8 as tvOS (~/Claude/godot-tvos/godot-48, templates 4.8.dev/ios.zip; platform/engine-patches/README.md) + stock Xcode. Fallback: BH_IOS_GODOT=/opt/homebrew/bin/godot (stock 4.7.1; P3 unavailable).”

  • Step 4: Rebuild the iOS Xcode shell with one full export

A 4.7.1 shell cannot link a 4.8 libgodot.a reliably: the 4.8 Swift @main and the project template changed. So regenerate the shell once with a full export, instead of swapping the lib:

Terminal window
cd <THIS WORKTREE>/platform/tvos # NEVER the main checkout: its build-ios is what main's stock-4.7.1 iOS installs use, and install-dev.sh uses $ROOT (the worktree it runs from)
mkdir -p ../../.superpowers/build-ios.bak-2026-10-11-pre-48 # gitignored; a platform/tvos/build-ios.bak-* dir is NOT ignored. Skip DerivedData* (disk)
for f in build-ios/*; do case "${f##*/}" in DerivedData*|BulletHeaven.xcarchive|ipa-out) ;; *) cp -R "$f" ../../.superpowers/build-ios.bak-2026-10-11-pre-48/ ;; esac; done
. ../../scripts/lib/godot-export.sh # tolerates ONLY the 4.8 editor's post-export Metal-teardown exit crash; reverts the *.import it rewrites
# --export-release, NOT --export-debug: the Dev shell has always linked the RELEASE template lib (install-dev builds
# -configuration Release; the pre-4.8 shell's libgodot.a == 4.7.1.stable ios.zip's release slice, and its GDExtensions are *.ios.release).
godot_export full $TMPDIR/ios48-export.log "$HOME/Claude/godot-tvos/godot-48/bin/godot.macos.editor.arm64" --path . --rendering-method mobile --export-release "iOS-Dev" build-ios/BulletHeaven.xcodeproj || tail -20 $TMPDIR/ios48-export.log
git checkout -- project.godot
grep -q "Started Baking shaders" $TMPDIR/ios48-export.log && echo baked
cmp build-ios/BulletHeaven.xcframework/ios-arm64/libgodot.a ~/Claude/godot-tvos/godot-48/bin/libgodot.ios.template_release.arm64.a && echo release-lib

Then re-apply what a full export wipes: run scripts/release/apply-ios-metrickit.sh platform/tvos/build-ios, cp platform/tvos/patches/ios-BulletHeaven-Info.plist platform/tvos/build-ios/BulletHeaven/BulletHeaven-Info.plist (the 4.8 export drops the GC controller, Game Mode and local-network keys), and scripts/release/sentry-guard.sh ios-shell platform/tvos. install-dev.sh re-runs MetricKit and the guard on every iOS install. Prove the shell links: xcodebuild -scheme BulletHeaven -configuration Release -destination 'generic/platform=iOS' -derivedDataPath ./DerivedData-check CODE_SIGNING_ALLOWED=NO build in build-ios (delete DerivedData-check after), and run platform/engine-patches/check-p3-shaders.py on the shell lib and the built app. Run bash -n scripts/dev/install-dev.sh scripts/release/build-ios.sh → no output.

  • Step 5: Record and commit

In the README:

  • add the 4.8 iOS row;
  • record the backups (4.8.dev/ios.zip.bak-2026-10-11-pre-p3 if one existed, and platform/tvos/build-ios.bak-2026-10-11-pre-48);
  • record that rollback = BH_IOS_GODOT=/opt/homebrew/bin/godot plus restoring that shell backup.
Terminal window
git add scripts/dev/install-dev.sh scripts/release/build-ios.sh platform/tvos/CLAUDE.md platform/engine-patches/README.md
git commit --file=<msgfile> # "build(ios): export on patched Godot 4.8 (engine patch 0001), same engine as tvOS"
git push origin hdr-colour-space

Fallback B: iOS on a patched 4.7.1 (use ONLY if the Step 1 build gate fails)

Section titled “Fallback B: iOS on a patched 4.7.1 (use ONLY if the Step 1 build gate fails)”

Files:

  • Engine: new worktree ~/Claude/godot-tvos/godot-ios-471, branch ios-p3-4.7.1 off tag 4.7.1-stable.
  • Modify: scripts/dev/install-dev.sh:125-131 (iOS branch), scripts/release/build-ios.sh:8, platform/tvos/CLAUDE.md (the “iOS uses stock Godot + stock Xcode” line), platform/engine-patches/README.md.

Interfaces:

  • Consumes: platform/engine-patches/0001-p3-output-gamut.patch (Task 1).

  • Produces:

    • ~/Claude/godot-tvos/godot-ios-471/bin/godot.macos.editor.arm64 (reports 4.7.1.p3);
    • ~/Library/Application Support/Godot/export_templates/4.7.1.p3/ios.zip;
    • env var BH_IOS_GODOT, which defaults to that editor.
  • Step 1: Create the branch and apply the patch

Terminal window
cd ~/Claude/godot-tvos/godot
git worktree add ../godot-ios-471 -b ios-p3-4.7.1 4.7.1-stable
cd ../godot-ios-471 && git log --oneline -1 # a13da4feb
git am --3way ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/engine-patches/0001-p3-output-gamut.patch
git push lumara ios-p3-4.7.1

The edited lines were verified identical in 4.7.1, so report any conflict instead of improvising.

  • Step 2: Build the editor and the iOS templates with a distinct version status
Terminal window
cd ~/Claude/godot-tvos/godot-ios-471
grep -n 'GODOT_VERSION_STATUS' methods.py # must exist (fork: methods.py:161)
export GODOT_VERSION_STATUS=p3
scons platform=macos target=editor arch=arm64 vulkan=no production=yes -j10
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_release arch=arm64 production=yes -j10
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_debug arch=arm64 production=yes -j10
bin/godot.macos.editor.arm64 --version # 4.7.1.p3.custom_build...
ls -la bin/ | grep libgodot.ios # confirm the exact output names before copying
  • Step 3: Assemble 4.7.1.p3/ios.zip from the stock zip, with only the device libs replaced
Terminal window
S="$HOME/Library/Application Support/Godot/export_templates/4.7.1.stable"
T="$HOME/Library/Application Support/Godot/export_templates/4.7.1.p3"
mkdir -p "$T" && cp "$S/ios.zip" "$T/ios.zip" && printf '4.7.1.p3' > "$T/version.txt"
W=$(mktemp -d); mkdir -p "$W/libgodot.ios.release.xcframework/ios-arm64" "$W/libgodot.ios.debug.xcframework/ios-arm64"
cp bin/libgodot.ios.template_release.arm64.a "$W/libgodot.ios.release.xcframework/ios-arm64/libgodot.a"
cp bin/libgodot.ios.template_debug.arm64.a "$W/libgodot.ios.debug.xcframework/ios-arm64/libgodot.a"
(cd "$W" && zip "$T/ios.zip" libgodot.ios.release.xcframework/ios-arm64/libgodot.a libgodot.ios.debug.xcframework/ios-arm64/libgodot.a)
# unzip -p "$T/ios.zip" libgodot.ios.release.xcframework/ios-arm64/libgodot.a | strings | grep -c "srgb_to_p3_matrix" # DOES NOT WORK: RD shaders are zlib-compressed in *.glsl.gen.h (_code_compressed[]); use check-p3-shaders (below)

If the editor reports a different version string than 4.7.1.p3, name the directory after what --version prints, up to the build-type suffix. The simulator slices stay stock; note this in the README.

  • Step 4: Switch the iOS export to the patched editor

scripts/dev/install-dev.sh, iOS branch. Replace the 2026-09-30 comment block and line 130 with:

Terminal window
# iOS packs: the PATCHED 4.7.1 editor (engine patch 0001, platform/engine-patches/), whose runtime is
# the 4.7.1.p3 template. Editor and runtime MUST come from the same source: the baker embeds the
# editor's engine shaders (BUILD 221 regressed on a fork-baked pack in a stock runtime).
"${BH_IOS_GODOT:-$HOME/Claude/godot-tvos/godot-ios-471/bin/godot.macos.editor.arm64}" --path . --rendering-method mobile --export-pack "iOS-Dev" build-ios/BulletHeaven.pck > "$EXPORT_LOG" 2>&1 \

scripts/release/build-ios.sh:8. Replace /opt/homebrew/bin/godot with "${BH_IOS_GODOT:-$HOME/Claude/godot-tvos/godot-ios-471/bin/godot.macos.editor.arm64}", and change the header comment from “stock Godot export” to “patched 4.7.1 (engine patch 0001) export”.

platform/tvos/CLAUDE.md: replace “iOS uses stock Godot + stock Xcode” with “iOS uses Godot 4.7.1 + engine patch 0001 (~/Claude/godot-tvos/godot-ios-471, templates 4.7.1.p3; platform/engine-patches/README.md) + stock Xcode. The stock-editor fallback is BH_IOS_GODOT=/opt/homebrew/bin/godot (P3 is then unavailable).”

  • Step 5: Refresh the iOS Xcode shell lib

--export-pack never re-copies the engine binary, so refresh it by hand:

Terminal window
SH=~/Claude/bullet-heaven/platform/tvos/build-ios/BulletHeaven.xcframework/ios-arm64
cp "$SH/libgodot.a" "$SH/libgodot.a.bak.20261011-pre-p3"
cp ~/Claude/godot-tvos/godot-ios-471/bin/libgodot.ios.template_release.arm64.a "$SH/libgodot.a"

install-dev.sh builds -configuration Release, hence the template_release lib. bash -n scripts/dev/install-dev.sh scripts/release/build-ios.sh → no output.

  • Step 6: Re-run the probe with the iOS editor, record the hash, commit

Repeat Task 1 Step 8’s probe with ~/Claude/godot-tvos/godot-ios-471/bin/godot.macos.editor.arm64. Expected: has=true get=1.

In the README table:

  • add the ios-p3-4.7.1 commit hash;
  • record the backups (build-ios libgodot.a.bak.20261011-pre-p3);
  • record that rollback = BH_IOS_GODOT=/opt/homebrew/bin/godot plus restoring that shell backup.
Terminal window
git add scripts/dev/install-dev.sh scripts/release/build-ios.sh platform/tvos/CLAUDE.md platform/engine-patches/README.md
git commit --file=<msgfile> # "build(ios): export on patched 4.7.1 (engine patch 0001, templates 4.7.1.p3)"
git push origin hdr-colour-space

Tell Chris: iOS editor and runtime now match by construction (same patched source), but baker coverage is unchanged. It is still the 4.7.1 baker, not the fork’s fuller one (the install-dev.sh 2026-09-30 note).


Task 3: Swatch scene + device proof that canvas negatives survive (controller + Chris)

Section titled “Task 3: Swatch scene + device proof that canvas negatives survive (controller + Chris)”

Files:

  • Create: render/gamut_swatch.gd, tests/test_gamut_swatch.gd
  • Modify: main.gd apply_dev_command (after the tonemap_sdr/tonemap_hdr block, ~line 3700)

Interfaces:

  • Consumes: Viewport.output_gamut (Task 1/2).

  • Produces:

    • dev command {"kind": "gamut_swatch", "on": bool, "p3": bool};
    • GamutSwatch.P3_RED_LINEAR, GamutSwatch.P3_GREEN_LINEAR, GamutSwatch.encoded(c: Color) -> Color.
  • Step 1: Write the failing test

tests/test_gamut_swatch.gd:

extends GutTest
func test_p3_patch_colours_are_the_matrix_primaries_in_encoded_extended_srgb() -> void:
var r := GamutSwatch.encoded(GamutSwatch.P3_RED_LINEAR)
assert_almost_eq(r.srgb_to_linear().r, 1.2249401, 1e-5)
assert_almost_eq(r.srgb_to_linear().g, -0.0420569, 1e-6, "negatives survive Godot's linear segment")
assert_almost_eq(r.g, -0.0420569 * 12.92, 1e-5, "encoded with the engine's c * 12.92 segment")
var g := GamutSwatch.encoded(GamutSwatch.P3_GREEN_LINEAR)
assert_almost_eq(g.srgb_to_linear().r, -0.2249404, 1e-6)
func test_main_toggles_the_swatch_in_the_world_canvas() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
assert_true(m.apply_dev_command({"kind": "gamut_swatch", "on": true, "p3": false}))
var sw: Node = m.get_node_or_null("GamutSwatch")
assert_not_null(sw, "a direct child of main = the tonemapped world canvas, not a HUD CanvasLayer")
assert_true(m.apply_dev_command({"kind": "gamut_swatch", "on": false}))
await get_tree().process_frame
assert_null(m.get_node_or_null("GamutSwatch"))

Run -gselect=test_gamut_swatch.gd → FAIL (GamutSwatch not declared).

  • Step 2: Implement render/gamut_swatch.gd
class_name GamutSwatch
extends Node2D
# Dev-only device proof (spec § 3, "Task 1 swatch"): sRGB red | P3 red over sRGB green | P3 green, drawn
# on main's world canvas (tonemapped, below canvas_max_layer). With Viewport.output_gamut = P3 on an
# HDR screen each pair must DIFFER; with it 0 (or on SDR) each pair must MATCH. Judge by eye only:
# iOS screenshots clip to SDR. draw_rect is PATH_A (the engine linearises it), so the P3 patches are
# the matrix primaries ENCODED with Godot's transfer (negatives take the c * 12.92 segment).
const P3_RED_LINEAR := Color(1.2249401, -0.0420569, -0.0196376)
const P3_GREEN_LINEAR := Color(-0.2249404, 1.0420571, -0.0786361)
const SIZE := 200.0
const GAP := 12.0
static func encoded(c: Color) -> Color:
return c.linear_to_srgb()
func _process(_delta: float) -> void:
var cam := get_viewport().get_camera_2d()
if cam != null:
global_position = cam.get_screen_center_position()
func _draw() -> void:
var cells := [[Color(1, 0, 0), encoded(P3_RED_LINEAR)], [Color(0, 1, 0), encoded(P3_GREEN_LINEAR)]]
for row in cells.size():
var y := -SIZE - GAP * 0.5 + row * (SIZE + GAP)
draw_rect(Rect2(-SIZE - GAP * 0.5, y, SIZE, SIZE), cells[row][0])
draw_rect(Rect2(GAP * 0.5, y, SIZE, SIZE), cells[row][1])
  • Step 3: Wire the dev command in main.gd

Add this after the tonemap_sdr/tonemap_hdr block. It is render-only and needs no run:

if String(cmd.get("kind", "")) == "gamut_swatch":
# P3 device proof (render/gamut_swatch.gd). `p3` writes Viewport.output_gamut DIRECTLY (patched
# engines only) so the proof does not depend on the palette work; _sync_output_gamut writes it
# only on a ColorGamut change, so it will not fight a swatch check mid-way.
var old := get_node_or_null("GamutSwatch")
if old != null:
old.queue_free()
remove_child(old)
if bool(cmd.get("on", false)):
var sw := GamutSwatch.new()
sw.name = "GamutSwatch"
sw.z_index = 4000
add_child(sw)
if ClassDB.class_has_method(&"Viewport", &"set_output_gamut"):
get_viewport().set("output_gamut", 1 if bool(cmd.get("p3", false)) else 0)
return true

Run -gselect=test_gamut_swatch.gd → PASS. Then scripts/check-test-count.sh and scripts/gate.sh, both green.

  • Step 4: Commit
Terminal window
git add render/gamut_swatch.gd render/gamut_swatch.gd.uid tests/test_gamut_swatch.gd tests/test_gamut_swatch.gd.uid main.gd
git commit --file=<msgfile> # "feat(hdr): P3 swatch dev command (device proof that canvas negatives survive)"
git push origin hdr-colour-space
  • Step 5: Device proof (controller + Chris)
  1. Read docs/reference/builds-and-releases.md and use skill bh-worktree-device-install (it covers BUILD bumps and the one-merger rule). Do a Dev install of this branch to the ATV (patched 4.8 template from Task 1) and the iPhone (patched 4.8 from Task 2, or 4.7.1 if Fallback B was taken), both BAKED (no --no-bake).
  2. In a run with HDR output on, send through the console MCP send_command:
    • {"kind":"gamut_swatch","on":true,"p3":false}, then
    • {"kind":"gamut_swatch","on":true,"p3":true}.
  3. Chris judges by eye:
    • with p3:false, each pair must match;
    • with p3:true, P3 red and green must be visibly deeper than the sRGB ones on both devices.
  4. Also run skill bh-fresh-install-stall-check on the iPhone. The new tonemap/blit code is baked.
  5. Also confirm the iPhone draws menu controls and the ship. If it does not, that is the A17 godot#124088 symptom: stop and report.

GATE: if the pairs match with p3:true, the premise fails: canvas negatives are being clipped somewhere not yet patched. Stop and report to Chris before Task 4. If they differ, record the result in memory bullet-heaven-hdr-output and continue.

Bring Findings 1-8 to Chris at this checkpoint.


Task 4: ColorGamut (maths, decision, cache) + the two tunables

Section titled “Task 4: ColorGamut (maths, decision, cache) + the two tunables”

Swatch contention (Task 3 review L2): _sync_output_gamut must respect an active P3 swatch: while main.get_node_or_null("GamutSwatch") is up, do not write Viewport.output_gamut (or update the swatch’s saved _swatch_prev_gamut instead), and write it through main.gamut_output, so the swatch’s release does not restore a stale value.

Files:

  • Create: render/color_gamut.gd, tests/test_color_gamut.gd
  • Modify:
    • render/color_profile_hdr.gd (append after line 65)
    • render/color_profile.gd (next to SDR_ONLY_PREFIX)
    • tests/test_color_profiles.gd:26-36 and :79-85
    • net/tunables_registry.gd (after the ColorProfileHdr.GLASS_BACKDROP_CAP entry)
    • tools/tunable_docs/docs.d/color_profiles.json
    • control/src/tunable_docs.js (regenerated)

Interfaces:

  • Consumes: HdrTier.PATH_A/PATH_B/PATH_R (render/hdr_tier.gd:23), ColorProfileHdr.

  • Produces (exact names later tasks use):

    • enum { SRGB, P3 }, enum { ENCODED, LINEAR }, const ENGINE_PROPERTY := &"output_gamut", const ENGINE_SETTER := &"set_output_gamut".
    • static func widen(c: Color, amount: float, encoding: int = ENCODED) -> Color.
    • static func out(c: Color, path: int) -> Color: identity when inactive; cached.
    • static func decide(engaged: bool, capable: bool, is_on: bool) -> bool; static func active() -> bool.
    • static func refresh(engaged: bool, capable: bool, cs_active: bool) -> bool: true on a relevant change.
    • static var generation: int, static var override: int (-1 follow, 0 off, 1 on), static func on() -> bool, static func set_on(v: bool) -> void.
    • static func engine_capable() -> bool, static func reset_for_tests() -> void.
    • Maths helpers: srgb_to_p3, p3_to_srgb, linear_to_oklab, oklab_to_linear, max_chroma. All take and return PackedFloat64Array of 3, because Vector3 and Color are float32 and would break the 1e-5 round trip.
    • ColorProfileHdr.GAMUT_P3: bool, ColorProfileHdr.GAMUT_WIDEN: float, ColorProfile.HDR_ONLY_PREFIX := "GAMUT_".
  • Step 1: Write the failing tests

tests/test_color_gamut.gd:

extends GutTest
func after_each() -> void:
ColorGamut.reset_for_tests()
Tunables.reset()
func _v(a: float, b: float, c: float) -> PackedFloat64Array:
return PackedFloat64Array([a, b, c])
func _lab_of(c: Color) -> PackedFloat64Array: # c in LINEAR
return ColorGamut.linear_to_oklab(_v(c.r, c.g, c.b))
func _hue_deg(lab: PackedFloat64Array) -> float:
return rad_to_deg(atan2(lab[2], lab[1]))
func _inside(lin: Color, target: int, eps := 1e-4) -> bool:
var v := _v(lin.r, lin.g, lin.b)
if target == ColorGamut.P3:
v = ColorGamut.srgb_to_p3(v)
for k in 3:
if v[k] < -eps or v[k] > 1.0 + eps:
return false
return true
# ---- maths ---------------------------------------------------------------------------------------
func test_p3_red_anchor() -> void:
var p := ColorGamut.srgb_to_p3(_v(1.2249401, -0.0420569, -0.0196376))
assert_almost_eq(p[0], 1.0, 1e-6)
assert_almost_eq(p[1], 0.0, 1e-6)
assert_almost_eq(p[2], 0.0, 1e-6)
func test_matrices_invert_each_other() -> void:
for v: PackedFloat64Array in [_v(1, 0, 0), _v(0.2, 0.7, 0.1), _v(0.9, 0.9, 0.9)]:
var back := ColorGamut.p3_to_srgb(ColorGamut.srgb_to_p3(v))
for k in 3:
assert_almost_eq(back[k], v[k], 2e-6)
func test_oklab_round_trip_within_1e5() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 1234
for i in 200:
var v := _v(rng.randf_range(-0.1, 1.2), rng.randf_range(-0.1, 1.2), rng.randf_range(-0.1, 1.2))
var back := ColorGamut.oklab_to_linear(ColorGamut.linear_to_oklab(v))
for k in 3:
assert_almost_eq(back[k], v[k], 1e-5, "sample %d ch %d" % [i, k])
func test_oklab_white_is_l1_neutral() -> void:
var lab := ColorGamut.linear_to_oklab(_v(1, 1, 1))
assert_almost_eq(lab[0], 1.0, 1e-4)
assert_almost_eq(lab[1], 0.0, 1e-4)
assert_almost_eq(lab[2], 0.0, 1e-4)
# ---- widen ---------------------------------------------------------------------------------------
func test_amount_zero_is_identity_and_neutrals_do_not_move() -> void:
for c: Color in [Color(1, 0, 0), Color(0.3, 0.8, 0.2, 0.5)]:
assert_eq(ColorGamut.widen(c, 0.0), c)
for c: Color in [Color(0.5, 0.5, 0.5), Color.WHITE, Color.BLACK]:
assert_eq(ColorGamut.widen(c, 1.0), c, "neutral %s" % c)
func test_edge_inputs_land_outside_srgb_and_inside_p3() -> void:
# Pure sRGB blue and yellow gain nothing at constant OKLCH L and hue (gamut geometry), so only
# their P3 membership is asserted.
for c: Color in [Color(1, 0, 0), Color(0, 1, 0), Color(0, 1, 1), Color(1, 0, 1)]:
var w := ColorGamut.widen(c, 1.0, ColorGamut.LINEAR)
assert_true(min(w.r, min(w.g, w.b)) < -1e-3, "%s leaves sRGB: %s" % [c, w])
assert_true(_inside(w, ColorGamut.P3), "%s stays inside P3: %s" % [c, w])
for c: Color in [Color(0, 0, 1), Color(1, 1, 0)]:
assert_true(_inside(ColorGamut.widen(c, 1.0, ColorGamut.LINEAR), ColorGamut.P3))
func test_hue_and_lightness_hold_on_a_360_sweep() -> void:
for deg in range(0, 360, 5):
var h := deg_to_rad(float(deg))
var c_max := ColorGamut.max_chroma(0.7, h, ColorGamut.SRGB)
var lin := ColorGamut.oklab_to_linear(_v(0.7, 0.9 * c_max * cos(h), 0.9 * c_max * sin(h)))
var src := Color(lin[0], lin[1], lin[2])
var w := ColorGamut.widen(src, 1.0, ColorGamut.LINEAR)
var a := _lab_of(src)
var b := _lab_of(w)
assert_almost_eq(b[0], a[0], 1e-3, "L at %d deg" % deg)
assert_lt(absf(angle_difference(deg_to_rad(_hue_deg(a)), deg_to_rad(_hue_deg(b)))), deg_to_rad(0.5), "hue at %d deg" % deg)
func test_mid_chroma_keeps_its_relative_position() -> void:
var h := deg_to_rad(30.0)
var cs := ColorGamut.max_chroma(0.65, h, ColorGamut.SRGB)
var cp := ColorGamut.max_chroma(0.65, h, ColorGamut.P3)
var lin := ColorGamut.oklab_to_linear(_v(0.65, 0.5 * cs * cos(h), 0.5 * cs * sin(h)))
var lab := _lab_of(ColorGamut.widen(Color(lin[0], lin[1], lin[2]), 1.0, ColorGamut.LINEAR))
assert_almost_eq(sqrt(lab[1] * lab[1] + lab[2] * lab[2]) / cp, 0.5, 2e-3)
func test_encoded_and_linear_agree_and_alpha_is_untouched() -> void:
var enc := Color(1.0, 0.32, 0.46, 0.4) # ElementPalette.NEUTRAL with alpha
var a := ColorGamut.widen(enc, 0.7, ColorGamut.ENCODED).srgb_to_linear()
var b := ColorGamut.widen(enc.srgb_to_linear(), 0.7, ColorGamut.LINEAR)
for k in 3:
assert_almost_eq(a[k], b[k], 1e-5)
assert_eq(ColorGamut.widen(enc, 1.0).a, enc.a, "alpha passes through bit-exact (float32, so not the literal 0.4)")
func test_overbright_scales_through() -> void:
var c := Color(1.0, 0.1, 0.05)
var w1 := ColorGamut.widen(c, 1.0, ColorGamut.LINEAR)
var w2 := ColorGamut.widen(c * 2.0, 1.0, ColorGamut.LINEAR)
for k in 3:
assert_almost_eq(w2[k], w1[k] * 2.0, 1e-4)
# ---- decision + cache ----------------------------------------------------------------------------
func test_active_truth_table_including_not_capable() -> void:
for e in [false, true]:
for c in [false, true]:
for o in [false, true]:
assert_eq(ColorGamut.decide(e, c, o), e and c and o, "engaged=%s capable=%s on=%s" % [e, c, o])
func test_refresh_reports_only_relevant_changes() -> void:
var g0 := ColorGamut.generation
assert_false(ColorGamut.refresh(false, true, false), "inactive -> inactive: nothing to do")
assert_true(ColorGamut.refresh(true, true, false), "turns active")
assert_eq(ColorGamut.generation, g0 + 1)
assert_false(ColorGamut.refresh(true, true, false), "steady state")
ColorProfileHdr.GAMUT_WIDEN = 0.5
assert_true(ColorGamut.refresh(true, true, false), "dial moved")
assert_true(ColorGamut.refresh(true, true, true), "PATH_B encoding moved")
ColorGamut.set_on(false)
assert_true(ColorGamut.refresh(true, true, true), "row off")
ColorProfileHdr.GAMUT_WIDEN = 0.25
assert_false(ColorGamut.refresh(true, true, true), "dial moves while inactive: no re-apply")
func test_out_is_identity_when_inactive_and_path_aware_when_active() -> void:
var red := Color(1, 0, 0)
assert_eq(ColorGamut.out(red, HdrTier.PATH_A), red)
ColorGamut.refresh(true, true, false)
assert_eq(ColorGamut.out(red, HdrTier.PATH_A), ColorGamut.widen(red, 1.0, ColorGamut.ENCODED))
assert_eq(ColorGamut.out(red, HdrTier.PATH_R), ColorGamut.widen(red, 1.0, ColorGamut.LINEAR))
assert_eq(ColorGamut.out(red, HdrTier.PATH_B), ColorGamut.widen(red, 1.0, ColorGamut.LINEAR), "cs off: raw linear")
ColorGamut.refresh(true, true, true)
assert_eq(ColorGamut.out(red, HdrTier.PATH_B), ColorGamut.widen(red, 1.0, ColorGamut.ENCODED), "cs on: cs_in decodes it")
func test_cache_rebuilds_when_the_dial_moves() -> void:
ColorGamut.refresh(true, true, false)
var full := ColorGamut.out(Color(1, 0, 0), HdrTier.PATH_A)
ColorProfileHdr.GAMUT_WIDEN = 0.0
ColorGamut.refresh(true, true, false)
assert_eq(ColorGamut.out(Color(1, 0, 0), HdrTier.PATH_A), Color(1, 0, 0), "0 = the sRGB source")
assert_ne(full, Color(1, 0, 0))
func test_engine_capable_matches_classdb() -> void:
assert_eq(ColorGamut.engine_capable(), ClassDB.class_has_method(&"Viewport", ColorGamut.ENGINE_SETTER))
# ---- UI exclusion is structural ------------------------------------------------------------------
func _gd_files(dir: String, out: Array[String]) -> void:
for f in DirAccess.get_files_at(dir):
if f.ends_with(".gd"):
out.append(dir.path_join(f))
for d in DirAccess.get_directories_at(dir):
_gd_files(dir.path_join(d), out)
func test_nothing_under_ui_widens() -> void:
var files: Array[String] = []
_gd_files("res://ui", files)
assert_gt(files.size(), 10, "the scan found ui/")
var re := RegEx.create_from_string(r"ColorGamut\.(widen|out)\(")
for f in files:
assert_null(re.search(FileAccess.get_file_as_string(f)), "%s widens a colour: UI stays sRGB" % f)

Update tests/test_color_profiles.gd:

  • In test_every_sdr_value_has_an_hdr_counterpart_and_back (lines 26-36), the second loop becomes:
for n in hdr:
if n.begins_with(ColorProfile.HDR_ONLY_PREFIX):
continue # the P3 gamut: HDR-only by definition (SDR has no wide-gamut output)
assert_has(sdr, n, "ColorProfileHdr.%s has no SDR counterpart" % n)
assert_false(n.begins_with(ColorProfile.SDR_ONLY_PREFIX), "%s: LDR_* values are SDR-only" % n)
  • Add "GAMUT_P3": 1.0, "GAMUT_WIDEN": 1.0 to HDR_SEEDS (line 79-85). float(true) == 1.0 keeps test_hdr_seeds numeric.

Run -gselect=test_color_gamut.gd and -gselect=test_color_profiles.gd → FAIL (ColorGamut not declared).

  • Step 2: Add the tunables

Append to render/color_profile_hdr.gd (after line 65):

# Display P3 output (docs/superpowers/specs/2026-10-11-hdr-p3-palette-design.md). HDR-ONLY (prefix
# ColorProfile.HDR_ONLY_PREFIX): SDR has no wide-gamut output. GAMUT_P3 is the kill switch (the engine's
# Viewport.output_gamut AND every widened source follow ColorGamut.active()); GAMUT_WIDEN dials the
# palette from 0 (the sRGB source colour) to 1 (the P3 edge). Under AgX, P3 on + WIDEN 0 is still not
# today's image (AgX's own out-of-709 output reaches the screen): "today" is GAMUT_P3 off.
static var GAMUT_P3: bool = true
static var GAMUT_WIDEN: float = 1.0

In render/color_profile.gd, next to const SDR_ONLY_PREFIX := "LDR_":

const HDR_ONLY_PREFIX := "GAMUT_" # HDR-profile values with no SDR twin (wide-gamut output)

Before relying on it, grep render/color_profile.gd for SDR_ONLY_PREFIX. If ColorProfile.value() or a pairing helper also skips SDR_ONLY_PREFIX, add the matching HDR_ONLY_PREFIX skip there, so an SDR-mode lookup of GAMUT_* returns the HDR value instead of erroring.

net/tunables_registry.gd, after the ColorProfileHdr.GLASS_BACKDROP_CAP entry:

{"key": "ColorProfileHdr.GAMUT_P3", "script": COLOR_HDR, "name": "GAMUT_P3", "type": "bool"},
{"key": "ColorProfileHdr.GAMUT_WIDEN", "script": COLOR_HDR, "name": "GAMUT_WIDEN", "min": 0.0, "max": 1.0},

tools/tunable_docs/docs.d/color_profiles.json. Add two entries, keeping the file valid JSON:

"ColorProfileHdr.GAMUT_P3": {
"label": "HDR wide gamut (Display P3)",
"desc": "HDR look (HDR output on): lets saturated gameplay colours reach the Display P3 edge. HUD and UI never change. Off = sRGB colours, today's look.",
"src": "code",
"group": "Glow & colour grade"
},
"ColorProfileHdr.GAMUT_WIDEN": {
"label": "HDR wide gamut amount",
"desc": "HDR look, wide gamut on: how far saturated gameplay colours move toward the P3 edge. 0 = the sRGB colour, 1 = the full P3 edge. Hue and brightness stay the same.",
"src": "code",
"group": "Glow & colour grade",
"unit": "frac"
},

Then run node tools/tunable_docs/generate.mjs.

  • Step 3: Implement render/color_gamut.gd
class_name ColorGamut
extends RefCounted
# Display P3 wide gamut for GAMEPLAY colour sources (spec docs/superpowers/specs/2026-10-11-hdr-p3-palette-design.md).
# Pure maths (OKLab/OKLCH, linear sRGB <-> linear Display P3, gamut-boundary search) plus the ONE decision,
# active() = HDR output engaged AND engine capable AND on(), that drives BOTH the engine
# (Viewport.output_gamut, set by main._sync_output_gamut) and every widened source, so the game never
# emits P3 colours the engine would clip. Render-only, never read by /sim. ui/ must never call widen()
# or out() (tests/test_color_gamut.gd greps it): UI shows sRGB (ElementPalette.color_for_srgb).
# Maths runs in PackedFloat64Array: Vector3/Color are float32 and break a 1e-5 round trip.
# Never call Color.h/.s/.v on a result (negative channels make HSV meaningless).
enum { SRGB, P3 }
enum { ENCODED, LINEAR } # what a Color's channels hold: Godot's sRGB-encoded form, or linear
const ENGINE_PROPERTY := &"output_gamut"
const ENGINE_SETTER := &"set_output_gamut" # ClassDB has no class_has_property; probe the setter
const NEUTRAL_C := 1e-4 # OKLCH chroma below this = a neutral: never moved
const SEARCH_STEPS := 32 # binary search on [0, 0.5] chroma: 0.5 / 2^32, far below float32
const CACHE_CAP := 4096 # per path; cleared when exceeded (random per-entity tints would grow it)
static var override := -1 # -1 = follow ColorProfileHdr.GAMUT_P3; 0 / 1 = the Debug row's session choice
static var generation := 0 # bumps on every relevant change; owners of cached colours compare it
static var _active := false
static var _amount := 0.0
static var _cs_active := false
static var _cache: Array[Dictionary] = [{}, {}, {}] # indexed by HdrTier.PATH_A / PATH_B / PATH_R
static func on() -> bool:
return ColorProfileHdr.GAMUT_P3 if override < 0 else override == 1
static func set_on(v: bool) -> void:
override = 1 if v else 0
static func engine_capable() -> bool:
return ClassDB.class_has_method(&"Viewport", ENGINE_SETTER)
static func decide(engaged: bool, capable: bool, is_on: bool) -> bool:
return engaged and capable and is_on
static func active() -> bool:
return _active
# Every frame from main._sync_output_gamut. True = something a widened colour depends on changed
# (active, the dial, or PATH_B's encoding while active): caches are dropped and generation bumps.
static func refresh(engaged: bool, capable: bool, cs_active: bool) -> bool:
var a := decide(engaged, capable, on())
var amt := clampf(ColorProfileHdr.GAMUT_WIDEN, 0.0, 1.0)
var changed := a != _active or (a and (amt != _amount or cs_active != _cs_active))
_active = a
_amount = amt
_cs_active = cs_active
if changed:
for d: Dictionary in _cache:
d.clear()
generation += 1
return changed
static func reset_for_tests() -> void:
override = -1
_active = false
_amount = 0.0
_cs_active = false
for d: Dictionary in _cache:
d.clear()
# The widened colour for a source drawn on `path` (HdrTier.PATH_*), or `c` itself when inactive.
static func out(c: Color, path: int) -> Color:
if not _active or _amount <= 0.0:
return c
var d: Dictionary = _cache[path]
var hit: Variant = d.get(c)
if hit != null:
return hit
var enc := LINEAR
if path == HdrTier.PATH_A or (path == HdrTier.PATH_B and _cs_active):
enc = ENCODED
var w := widen(c, _amount, enc)
if d.size() >= CACHE_CAP:
d.clear()
d[c] = w
return w
# Keep OKLCH hue + lightness; move chroma from its position relative to the sRGB boundary to the same
# relative position on the P3 boundary, blended by `amount`. Overbright colours (max channel > 1) widen
# their unit-brightness chromaticity and scale back. Returns the same encoding it was given.
static func widen(c: Color, amount: float, encoding: int = ENCODED) -> Color:
if amount <= 0.0:
return c
var lc := c.srgb_to_linear() if encoding == ENCODED else c
var k := maxf(lc.r, maxf(lc.g, lc.b))
if k <= 0.0:
return c
var scale := maxf(k, 1.0)
var lab := linear_to_oklab(PackedFloat64Array([lc.r / scale, lc.g / scale, lc.b / scale]))
var ch := sqrt(lab[1] * lab[1] + lab[2] * lab[2])
if ch < NEUTRAL_C:
return c
var h := atan2(lab[2], lab[1])
var c_srgb := max_chroma(lab[0], h, SRGB)
if c_srgb < NEUTRAL_C:
return c
var rel := minf(ch / c_srgb, 1.0)
var c_new := lerpf(ch, rel * max_chroma(lab[0], h, P3), clampf(amount, 0.0, 1.0))
var o := oklab_to_linear(PackedFloat64Array([lab[0], c_new * cos(h), c_new * sin(h)]))
var out_lin := Color(o[0] * scale, o[1] * scale, o[2] * scale, c.a)
if encoding == ENCODED:
out_lin = out_lin.linear_to_srgb()
out_lin.a = c.a
return out_lin
# Largest OKLCH chroma at (L, h) still inside `target` (unit cube in that space's linear RGB).
static func max_chroma(l_ok: float, h: float, target: int) -> float:
var lo := 0.0
var hi := 0.5
for _i in SEARCH_STEPS:
var mid := (lo + hi) * 0.5
var lin := oklab_to_linear(PackedFloat64Array([l_ok, mid * cos(h), mid * sin(h)]))
if _inside(lin, target):
lo = mid
else:
hi = mid
return lo
static func _inside(lin: PackedFloat64Array, target: int) -> bool:
var v := srgb_to_p3(lin) if target == P3 else lin
for i in 3:
if v[i] < -1e-9 or v[i] > 1.0 + 1e-9:
return false
return true
static func srgb_to_p3(l: PackedFloat64Array) -> PackedFloat64Array:
return PackedFloat64Array([
0.8224621 * l[0] + 0.1775380 * l[1],
0.0331941 * l[0] + 0.9668058 * l[1],
0.0170827 * l[0] + 0.0723974 * l[1] + 0.9105199 * l[2]])
static func p3_to_srgb(p: PackedFloat64Array) -> PackedFloat64Array:
return PackedFloat64Array([
1.2249401 * p[0] - 0.2249404 * p[1],
-0.0420569 * p[0] + 1.0420571 * p[1],
-0.0196376 * p[0] - 0.0786361 * p[1] + 1.0982735 * p[2]])
static func linear_to_oklab(c: PackedFloat64Array) -> PackedFloat64Array:
var l := _cbrt(0.4122214708 * c[0] + 0.5363325363 * c[1] + 0.0514459929 * c[2])
var m := _cbrt(0.2119034982 * c[0] + 0.6806995451 * c[1] + 0.1073969566 * c[2])
var s := _cbrt(0.0883024619 * c[0] + 0.2817188376 * c[1] + 0.6299787005 * c[2])
return PackedFloat64Array([
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s])
static func oklab_to_linear(lab: PackedFloat64Array) -> PackedFloat64Array:
var l := lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2]
var m := lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2]
var s := lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2]
l = l * l * l
m = m * m * m
s = s * s * s
return PackedFloat64Array([
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s])
static func _cbrt(x: float) -> float:
return signf(x) * pow(absf(x), 1.0 / 3.0)

Color indexing (w[k]) in the tests is valid GDScript (index 0-3 = r, g, b, a). If -gselect shows a parse warning-as-error on d.get(c), keep the Variant annotation as written (CLAUDE.md: Dictionary.get() results are Variant).

  • Step 4: Run the tests to verify they pass

Run -gselect=test_color_gamut.gd → PASS. Run -gselect=test_color_profiles.gd → PASS, including test_every_profile_value_is_a_registered_live_tunable: LiveScope’s "ColorProfileHdr." prefix (net/live_scope.gd:285) already maps the new keys to HOOK_ENV. Then scripts/check-test-count.sh and scripts/gate.sh, both green.

  • Step 5: Commit
Terminal window
git add render/color_gamut.gd render/color_gamut.gd.uid tests/test_color_gamut.gd tests/test_color_gamut.gd.uid \
render/color_profile_hdr.gd render/color_profile.gd tests/test_color_profiles.gd net/tunables_registry.gd \
tools/tunable_docs/docs.d/color_profiles.json control/src/tunable_docs.js
git commit --file=<msgfile> # "feat(hdr): ColorGamut (OKLCH widen to P3, active() decision, path-aware cache) + GAMUT_P3/GAMUT_WIDEN"
git push origin hdr-colour-space

Task 5: Coupling: main drives the engine + the Debug row, change note, console A/B

Section titled “Task 5: Coupling: main drives the engine + the Debug row, change note, console A/B”

Files:

  • Modify:
    • main.gd _sync_hdr_output (2313-2321), _ready (capability probe), _note_console_change (3743-3751)
    • ui/debug_settings_panel.gd GRAPHICS_TOGGLES (72-105), NO_REPUSH_GRAPHICS (108), live_graphics_state (111-120), GRAPHICS_DESCS (~175), apply_graphics_toggle (after 358)
    • tools/console_mcp/lib/hdr_ab.mjs:25
  • Test: tests/test_color_gamut_main.gd (new), plus the existing tests/test_debug_console_parity.gd (unchanged; it must still pass with the new row)

Interfaces:

  • Consumes: ColorGamut.refresh/active/engine_capable/set_on/on/override (Task 4).

  • Produces:

    • main._gamut_capable: bool (a test seam);
    • main._sync_output_gamut(want: bool) -> bool;
    • graphics row "hdr_gamut".
    • _apply_color_profile() now also runs on a ColorGamut change, which is where Tasks 6-8 hook their rebuilds.
  • Step 1: Write the failing tests

tests/test_color_gamut_main.gd:

extends GutTest
var _was_hdr_override := -1
func before_each() -> void:
_was_hdr_override = HdrOutput.override
func after_each() -> void:
HdrOutput.override = _was_hdr_override
ColorGamut.reset_for_tests()
Tunables.reset()
func _boot() -> Node:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
return m
func _hdr_device(m: Node, gamut_capable: bool) -> void:
var w := Window.new()
autofree(w)
m._hdr_output_capable = true
m._hdr_window_override = w
m._hdr_supported_override = true
m._gamut_capable = gamut_capable
m.quality_manager.hdr_capable = true
m._apply_color_profile()
func test_gamut_follows_hdr_output_and_the_row() -> void:
var m: Node = await _boot()
_hdr_device(m, true)
m._process(1.0 / 60.0)
assert_false(ColorGamut.active(), "HDR output default off")
HdrOutput.override = 1
m._process(1.0 / 60.0)
assert_true(ColorGamut.active(), "same frame as the HDR request (GAMUT_P3 defaults on)")
DebugSettingsPanel.apply_graphics_toggle("hdr_gamut", false, null)
m._process(1.0 / 60.0)
assert_false(ColorGamut.active(), "the row turns it off live")
assert_eq(DebugSettingsPanel.live_graphics_state("hdr_gamut"), false)
func test_not_capable_engine_never_activates() -> void:
var m: Node = await _boot()
_hdr_device(m, false)
HdrOutput.override = 1
m._process(1.0 / 60.0)
assert_false(ColorGamut.active(), "stock/web/Android templates: no Viewport.output_gamut")
func test_a_gamut_change_reapplies_the_colour_profile() -> void:
var m: Node = await _boot()
_hdr_device(m, true)
HdrOutput.override = 1
m._process(1.0 / 60.0)
var g := ColorGamut.generation
ColorProfileHdr.GAMUT_WIDEN = 0.5
m._process(1.0 / 60.0)
assert_eq(ColorGamut.generation, g + 1, "the dial re-applies next frame")
func test_debug_row_is_registered_and_not_repushed() -> void:
assert_true(DebugSettingsPanel.has_toggle(DebugSettingsPanel.GRAPHICS_TOGGLES, "hdr_gamut"))
assert_true("hdr_gamut" in DebugSettingsPanel.NO_REPUSH_GRAPHICS)
DebugSettingsPanel.apply_graphics_toggle("hdr_gamut", true, null)
assert_eq(ColorGamut.override, 1)

Run -gselect=test_color_gamut_main.gd → FAIL (_gamut_capable missing).

  • Step 2: Implement the main drive

In main.gd, next to _hdr_output_capable’s declaration, add:

# Engine patch 0001 present (Viewport.output_gamut): stock / web / Android templates say false.
# A var, not a call per frame, so a headless test can stand in for a patched device.
var _gamut_capable := false

In _ready, where _hdr_output_capable is set, add _gamut_capable = ColorGamut.engine_capable().

Replace the tail of _sync_hdr_output (lines 2319-2321) with:

# The colour profile follows the same decision (HdrLook), re-applied in this frame on a change.
var look_changed := HdrLook.update(want, win.get_output_max_linear_value() if want and win != null else 1.0)
# The P3 gamut rides the same `want` (ColorGamut.active()): engine + palette never disagree.
var gamut_changed := _sync_output_gamut(want)
if look_changed or gamut_changed:
_apply_color_profile()
# Display P3 (render/color_gamut.gd): ONE decision drives Viewport.output_gamut AND every widened
# source. Writes the engine property only on a change and only on a patched engine, always through
# gamut_output (the Task 3 seam a test fakes). While the P3 swatch is up it owns the property: the
# decision goes into _swatch_prev_gamut instead, so the swatch's release restores the CURRENT value,
# not the stale one its first ON saved (Task 3 review L2).
func _sync_output_gamut(want: bool) -> bool:
if not ColorGamut.refresh(want, _gamut_capable, ColorSpace.active):
return false
var g := 1 if ColorGamut.active() else 0
if get_node_or_null("GamutSwatch") != null and _swatch_prev_gamut >= 0:
_swatch_prev_gamut = g
elif _gamut_capable:
gamut_output.write(get_viewport(), g)
return true

Swatch contention test (required, in tests/test_color_gamut_main.gd): swap m.gamut_output for a fake store (as Task 3’s swatch tests do), turn the swatch on with p3: false (so the swatch holds 0 while the decision becomes 1, otherwise the assert cannot fail), flip ColorGamut active (HDR on), assert the fake still holds 0 (the swatch’s value), then turn the swatch off and assert the fake now holds the ColorGamut.active() value, not the pre-swatch one. Also pin HDR-off-after-on -> fake holds 0.

Task 1 review notes for this step:

  • The patched engine runs the P3 tonemap branch whenever output_gamut == 1 on a use_hdr_2d target, HDR output on or not. So output_gamut must go back to 0 the frame HDR disengages; the code above does that because ColorGamut.active() requires HDR engaged. A test must pin it (HDR off after on -> property 0).
  • output_gamut is per viewport. Only the root viewport gets it. SubViewports with their own Environment (ship3d, ACES) keep clamping to Rec.709, so widened colours fed into them gain nothing there. That is accepted, not a bug; don’t widen sources that only feed those SubViewports.

In _note_console_change, extend the hdr_output suffix condition so the hdr_gamut row also explains a no-op:

var fx := String(cmd.get("effect", ""))
if String(cmd.get("kind", "")) == "graphics" and (fx == "hdr_output" or fx == "hdr_gamut"):
  • Step 3: Implement the Debug row

ui/debug_settings_panel.gd:

  • In GRAPHICS_TOGGLES, after the colour_space_fix row: ["hdr_gamut", "HDR wide gamut P3 (A/B)"],, with the comment # Display P3 palette (render/color_gamut.gd). Live; also the remote tunable ColorProfileHdr.GAMUT_P3.
  • NO_REPUSH_GRAPHICS becomes ["hdr_output", "colour_space_fix", "hdr_gamut"].
  • live_graphics_state: add
if effect == "hdr_gamut":
return ColorGamut.on() # with no row press, a remote-config patch (ColorProfileHdr.GAMUT_P3) moves it
  • GRAPHICS_DESCS: add
"hdr_gamut": "With HDR output on, lets saturated gameplay colours reach the Display P3 edge (deeper reds, greens, magentas). HUD and UI never change. Needs a P3-capable build and an HDR screen. Off = sRGB colours. Live.",
  • apply_graphics_toggle match, after colour_space_fix:
"hdr_gamut":
ColorGamut.set_on(enabled) # main._sync_output_gamut applies it next frame

tools/console_mcp/lib/hdr_ab.mjs:25: export const TOGGLES = ['hdr_output', 'colour_space_fix', 'hdr_gamut', 'alpha'];. Check phaseCommand and flagOf handle a plain graphics toggle the same way as colour_space_fix. Then run cd tools/console_mcp && npm test → green.

  • Step 4: Run the tests to verify they pass

Run -gselect=test_color_gamut_main.gd, -gselect=test_debug_console_parity.gd and -gselect=test_color_space.gd → PASS. Then scripts/check-test-count.sh and scripts/gate.sh, both green.

  • Step 5: Commit
Terminal window
git add main.gd ui/debug_settings_panel.gd tools/console_mcp/lib/hdr_ab.mjs tests/test_color_gamut_main.gd tests/test_color_gamut_main.gd.uid
git commit --file=<msgfile> # "feat(hdr): ColorGamut.active() drives Viewport.output_gamut; hdr_gamut Debug row + console A/B"
git push origin hdr-colour-space

Task 6: Palette chokepoints: ElementPalette, its callers, main’s LUTs

Section titled “Task 6: Palette chokepoints: ElementPalette, its callers, main’s LUTs”

Files:

  • Modify:
    • render/element_palette.gd:42-60
    • main.gd:1300-1306 (LUT build, extracted into _build_element_luts), :2969-3063 (_build_enemy_type_colors, _base_color_for_type, _swarmer_color), :1488, :4157-4161, _apply_color_profile (2268-2283)
    • content/bestiary_loader.gd:71
    • plus every call site in the table in Step 3
  • Test: tests/test_element_palette_gamut.gd (new)

Interfaces:

  • Consumes: ColorGamut.out(c, path), ColorGamut.generation, HdrTier.PATH_*.

  • Produces:

    • ElementPalette.color_for_srgb(content, idx) -> Color and ElementPalette.weapon_color_srgb(content, idx) -> Color: today’s exact behaviour.
    • ElementPalette.color_for(content, idx, path: int = HdrTier.PATH_A) -> Color and ElementPalette.weapon_color(content, idx, path: int = HdrTier.PATH_A) -> Color: the source passed through ColorGamut.out.
    • ElementPalette.vivid_out(c: Color, path: int) -> Color = ColorGamut.out(vivid(c), path).
    • vivid() stays pure sRGB.
  • Step 1: Write the failing tests

tests/test_element_palette_gamut.gd:

extends GutTest
func after_each() -> void:
ColorGamut.reset_for_tests()
Tunables.reset()
func _content() -> ContentDB:
return ContentDB.load_default()
func test_srgb_accessor_is_todays_colour_and_widened_one_is_identity_when_inactive() -> void:
var c := _content()
for k in c.element_count():
var s := ElementPalette.color_for_srgb(c, k)
assert_eq(s, ElementPalette.vivid(Color.from_string(String(c.element_at(k).get("color", "")), ElementPalette.NEUTRAL)))
assert_eq(ElementPalette.color_for(c, k), s, "inactive: byte-identical")
func test_active_widens_by_path() -> void:
var c := _content()
ColorGamut.refresh(true, true, false)
var fire := c.element_index("fire")
var s := ElementPalette.color_for_srgb(c, fire)
assert_eq(ElementPalette.color_for(c, fire), ColorGamut.out(s, HdrTier.PATH_A))
assert_eq(ElementPalette.color_for(c, fire, HdrTier.PATH_R), ColorGamut.out(s, HdrTier.PATH_R))
assert_ne(ElementPalette.color_for(c, fire), s)
func test_main_luts_rebuild_on_a_gamut_change() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
var before: Color = m._element_color_lut[0]
ColorGamut.refresh(true, true, false)
m._apply_color_profile()
assert_eq(m._element_color_lut[0], ColorGamut.out(before, HdrTier.PATH_B), "MultiMesh LUT: PATH_B")
assert_eq(m._element_color_vivid[0],
ColorGamut.out(ElementPalette.vivid(ElementPalette.color_for_srgb(m.sim.content, 0)), HdrTier.PATH_B),
"vivid from the sRGB source, THEN widen")
func test_console_caps_and_codex_stay_srgb() -> void:
var c := _content()
ColorGamut.refresh(true, true, false)
var info := BestiaryLoader.entry_for(c, "swarmer")
assert_eq(info["element_color"], ElementPalette.color_for_srgb(c, c.element_index(String(c.enemy("swarmer").get("element", "")))))

Before running, open content/bestiary_loader.gd around line 60 and ContentDB for the exact public names of the entry builder and the default loader. Replace BestiaryLoader.entry_for and ContentDB.load_default with the real ones. If the real ones differ, mirror how tests/test_bestiary*.gd builds an entry. Run -gselect=test_element_palette_gamut.gd → FAIL (color_for_srgb missing).

  • Step 2: Implement the ElementPalette chokepoints

Rename the current weapon_color body to weapon_color_srgb and make its inner fallbacks call color_for_srgb. Rename the current color_for body to color_for_srgb. Then add:

# ── Display P3 (render/color_gamut.gd) ───────────────────────────────────────────────────────────
# color_for / weapon_color = the sRGB source, widened for the draw path it feeds (HdrTier.PATH_A
# modulate/draw_*/source_color/Line2D, PATH_B MultiMesh/cs_in uniform, PATH_R particle/Light2D/raw
# uniform). Identity unless ColorGamut.active(). UI and text (Codex, console caps) use the *_srgb
# twins. Never call .h/.s/.v on a widened result: run vivid()/HSV maths on the sRGB value first.
static func color_for(content: ContentDB, element_idx: int, path: int = HdrTier.PATH_A) -> Color:
return ColorGamut.out(color_for_srgb(content, element_idx), path)
static func weapon_color(content: ContentDB, element_idx: int, path: int = HdrTier.PATH_A) -> Color:
return ColorGamut.out(weapon_color_srgb(content, element_idx), path)
static func vivid_out(c: Color, path: int) -> Color:
return ColorGamut.out(vivid(c), path)
  • Step 3: Migrate every call site

The path is decided by how the colour reaches the GPU. Sinks marked “verified” were read while writing this plan. For each row marked “trace”, open the consumer and apply the rule: modulate / draw_* / Line2D / source_color uniform → PATH_A; MultiMesh set_instance_color or a buffer colour, or a uniform the shader passes through cs_in() → PATH_B; particle color, Light2D.color, or a raw uniform with no cs_in → PATH_R. Where a caller post-processes the colour with lerp/lightened, use the _srgb accessor, do the maths, then wrap the result in ColorGamut.out(..., path).

Call site Change Path
content/bestiary_loader.gd:71 color_for_srgb (Codex UI) sRGB
render/codex_preview.gd:49, :63 unchanged (vivid on sRGB input) sRGB
main.gd:4160 _element_color_caps color_for_srgb (console text) sRGB
main.gd:1300-1305 LUTs see Step 4 B (verified: enemy/proj MultiMesh)
main.gd:3012 _build_enemy_type_colors vivid_out(..., HdrTier.PATH_B) B
main.gd:3026 ColorGamut.out((ElementPalette.vivid(BOSS_COLOR)).lightened(0.15) if enraged else ElementPalette.vivid(BOSS_COLOR), HdrTier.PATH_B) B
main.gd:3032, :3035, :3060, :3063 vivid_out(..., HdrTier.PATH_B) B
main.gd:1305 _proj_color color_for(..., HdrTier.PATH_B) B
main.gd:1488 shard Sprite2D color_for(...) (PATH_A; it feeds modulate) A (verified)
fx/fx_manager.gd:845, 863, 867, 885, 891, 900 → _spawn color_for(...) A (verified: s.modulate, fx_manager.gd:1010)
fx/fx_manager.gd:673 _ev_color add a path := HdrTier.PATH_A param and pass it to both calls; consumers at :713, :729, :743, :751 pass theirs trace each consumer
fx/fx_manager.gd:430, :642 RailgunMarkLook.tint weapon_color(..., HdrTier.PATH_B) B (railgun_mark shader uses cs_in)
fx/weapons/railgun_mark_look.gd:32 default tint unchanged (overwritten by FxManager) n/a
fx/weapons/chalice_pool_look.gd:57 weapon_color(..., HdrTier.PATH_B) B (chalice_pool uses cs_in)
fx/weapons/momentum_charge_look.gd:41 weapon_color(..., HdrTier.PATH_B) B (momentum_* use cs_in)
fx/weapons/orbit_shards_look.gd:120-123 color_for_srgb, lerp with EH_CORE as today, then ColorGamut.out(tint, <path>) trace ("tint" dict → the orbit look’s material)
fx/death_dissolve.gd:48 color_for(..., HdrTier.PATH_B) B (death_dissolve shader uses cs_in)
fx/enemy_aura_look.gd:170 _color_cache color_for(...); also clear _color_cache when ColorGamut.generation differs from a stored _gamut_gen A (verified: draw_multiline_colors)
fx/gpu_burst.gd:71, :76 color_for(..., HdrTier.PATH_R) R (particles)
render/hero_lights.gd:80 color_for(..., HdrTier.PATH_R) R (Light2D.color)
render/dash_telegraph_renderer.gd:55-57 color_for_srgb, lerp to white as today, then ColorGamut.out(core, HdrTier.PATH_A) and ColorGamut.out(col, HdrTier.PATH_A) trace (expected A: draw calls)
render/dimension_hazard_renderer.gd:71 color_for(...) trace (expected A)
render/weapon_pickup_renderer.gd:36 color_for(...) A (verified: draw_circle)
render/zone_renderer.gd:578, :581 color_for(...) A (verified: source_color uniforms fire_col/shock_col)
render/zone_renderer.gd:678 color_for(..., HdrTier.PATH_B) B (verified: _zone_buf MultiMesh colour)
render/player_renderer.gd:125 ElementPalette.PLAYER_HALO ColorGamut.out(ElementPalette.PLAYER_HALO, <path>) at the use trace

Any owner that stores a colour in a member at build time must also re-read it when ColorGamut.generation changes, with the same _gamut_gen check pattern as enemy_aura_look. Grep each touched file for colour members set in _ready/build.

  • Step 4: Rebuild main’s LUTs on a gamut change

Extract main.gd:1300-1306 into:

# Element colour LUTs (MultiMesh: PATH_B). vivid() runs on the sRGB source, THEN ColorGamut widens:
# HSV maths on a widened colour is meaningless. Rebuilt per run AND on every ColorGamut change.
func _build_element_luts() -> void:
if sim == null:
return
_element_color_lut.resize(sim.content.element_count())
_element_color_vivid.resize(sim.content.element_count())
for k in range(sim.content.element_count()):
var srgb := ElementPalette.color_for_srgb(sim.content, k)
_element_color_lut[k] = ColorGamut.out(srgb, HdrTier.PATH_B)
_element_color_vivid[k] = ElementPalette.vivid_out(srgb, HdrTier.PATH_B)
_proj_color = ElementPalette.color_for(sim.content, sim.pulse_element_idx, HdrTier.PATH_B)
_build_enemy_type_colors()

Call it where the old block was. At the end of _apply_color_profile, add:

# Display P3: every cached gameplay colour follows ColorGamut (render/color_gamut.gd).
if _gamut_built != ColorGamut.generation:
_gamut_built = ColorGamut.generation
_build_element_luts()

Declare var _gamut_built := -1 next to _element_color_lut. Fix the stale comment at main.gd:289-293 so it says “vivid(srgb) then widened”, not “ALREADY vivified twice”.

  • Step 5: Run the tests to verify they pass

Run -gselect=test_element_palette_gamut.gd → PASS. Then scripts/check-test-count.sh and scripts/gate.sh, both green; the determinism baselines must be unchanged. Also run git grep -n "ElementPalette\.\(color_for\|weapon_color\)(" -- ui/: no output.

  • Step 6: Commit
Terminal window
git add render/element_palette.gd main.gd content/bestiary_loader.gd fx render tests/test_element_palette_gamut.gd tests/test_element_palette_gamut.gd.uid
git commit --file=<msgfile> # "feat(hdr): ElementPalette P3 chokepoints (path-aware), sRGB accessors for UI/caps, LUT rebuild on gamut change"
git push origin hdr-colour-space

Task 7: Looks constants + backdrop accents

Section titled “Task 7: Looks constants + backdrop accents”

Files: see the table. Each row was found by grepping colour consts in non-UI render/fx files. Uses outside the listed line need a grep of the const name in that file.

Interfaces:

  • Consumes: ColorGamut.out, ColorGamut.generation, HdrTier.PATH_*; main._apply_color_profile runs arena_bg.refresh_hdr() and far_stars.refresh_hdr() on every gamut change (Task 5).
  • Produces: no new API.

Recipe (identical for every row):

  1. At each USE of the const, replace CONST with ColorGamut.out(CONST, HdrTier.PATH_x), with the path chosen by the Task 6 Step 3 rule.
  2. Inside a per-entity loop, hoist it to a local before the loop. If the value is stored in a member, material parameter or MultiMesh at build time, re-apply it when ColorGamut.generation changes (a _gamut_gen member checked in the owner’s existing per-frame update, or in its refresh_hdr() for the backdrop).
  3. Leave alone, by the spec’s UI rule: HUD/feedback colours (fx/screen_feedback.gd BORDER_COLOR/VIGNETTE_COLOR, render/shaders/low_hp_border.gdshader, render/shaders/threat_aggregate_glow.gdshader, elite_bar_renderer NAME_COL) and near-neutral consts (OKLCH chroma < 0.08, e.g. HOT whites).
File : const Expected path (confirm with the rule)
render/arena_background.gd GALAXY_CORE/ARM/OUTER_COLOR (lines 34/68/35 → galaxy_shader_params 622-624) B (arena_galaxy uses cs_in; SubViewport follows root HDR, 394-395)
render/arena_background.gd LINE_COLOR/BORDER_COLOR (20-21), STARFIELD_COLOR* trace
render/nebula_bg.gd COL_ACCENT (43 → :113, cs_in) B, ONLY while the nebula SubViewport is HDR (colour_space_fix on, 164-167). Otherwise pass the sRGB value: the 8-bit target clips negatives
render/far_stars.gd tint (:200) B (far_stars uses cs_in)
fx/boss/warden_fx.gd DRONE_TINT trace (MultiMesh → B)
fx/thruster_trail.gd COLOR R (particles)
fx/weapons/orbit_shards_look.gd EH_CORE (uniform eh_core) B (orbit_shards uses cs_in)
fx/weapons/scar_tissue_meter_look.gd TINT/HOT trace
fx/weapons/turret_body_look.gd TINT/EVO_TINT/WARN_TINT trace
render/elite_fx_renderer.gd WEB_COLOR/WARP_COLOR trace
render/funzo_dash_afterimage.gd GHOST_COLOR A (modulate)
render/funzo_living_shadow.gd LIGHT_COLOR R (Light2D)
render/funzo_reality_tear.gd SHARD_COLOR trace
render/hero_lights.gd PLAYER_COLOR R (Light2D)
render/player_renderer.gd HEAL_GLOW/RING_COLOR A (modulate)
render/suppressor_renderer.gd, render/tackler_renderer.gd colour consts trace
render/story_map_renderer.gd WALL_COL/GATE_COL/SECRET_COL trace
render/warp_afterimages.gd TINT, render/warp_effect.gd COL trace
render/zone_renderer.gd FZ_COL (source_color) A
fx/fx_manager.gd MARK_POP_COL same path as the _ev_color consumer at :751
uniforms set from GDScript: render/funzo_zone_field.gdshader col, render/shaders/funzo_lit_shell.gdshader tint (both source_color → A), render/weapon_zone_field.gdshader fire_col/shock_col (done in Task 6 via zone_renderer) A
  • Step 1: Write the failing test

tests/test_color_gamut_looks.gd:

extends GutTest
func after_each() -> void:
ColorGamut.reset_for_tests()
Tunables.reset()
func _boot() -> Node:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
return m
func _galaxy_param(m: Node, p: String) -> Variant:
for c in m.arena_bg.get_children():
if c.has_meta("galaxy_vp") and not c.is_queued_for_deletion():
return ((c.get_child(0) as ColorRect).material as ShaderMaterial).get_shader_parameter(p)
return null
func test_galaxy_accents_widen_on_a_gamut_change_and_return() -> void:
var m: Node = await _boot()
var was := VisualLook.galaxy_look
VisualLook.galaxy_look = 1
m.arena_bg.set_variant(ArenaBackground.VARIANT_GALAXY, 1)
var before: Variant = _galaxy_param(m, "col_outer")
ColorGamut.refresh(true, true, false)
m._apply_color_profile()
assert_ne(_galaxy_param(m, "col_outer"), before, "outer accent widened")
ColorGamut.reset_for_tests()
ColorGamut.refresh(false, true, false)
m._apply_color_profile()
assert_eq(_galaxy_param(m, "col_outer"), before, "inactive = byte-identical")
VisualLook.galaxy_look = was

Read how galaxy_shader_params builds its values (render/arena_background.gd:600-630) and compare in that type (Vector3 or Color). Run -gselect=test_color_gamut_looks.gd → FAIL.

  • Step 2: Apply the recipe to every row; extend the test

Do this one file per sub-commit, so the reviewer can reject a single file. For each file whose owner has a reachable instance in a booted survival run, add one assertion of the same shape as the test above (widened when active, equal when inactive). Rows marked DevFlags-only (tackler, stasis web, blight) need no runtime assertion. Record the path you chose in a one-line comment at the use: # P3: PATH_x (<sink>).

  • Step 3: Run the tests, the gate and the roster check

Run scripts/check-test-count.sh and scripts/gate.sh, both green. Open tools/vfx_library/library.tscn windowed under mobile (godot --path . --rendering-method mobile res://tools/vfx_library/library.tscn) and confirm no entry turned black or white, then git checkout -- project.godot audio/bus_layout.tres.

  • Step 4: Commit
Terminal window
git add render fx tests/test_color_gamut_looks.gd tests/test_color_gamut_looks.gd.uid
git commit --file=<msgfile> # "feat(hdr): P3 widening for looks constants + backdrop accents (path-aware, cached)"
git push origin hdr-colour-space

Task 8: Shader-literal colours → profile-fed global uniforms

Section titled “Task 8: Shader-literal colours → profile-fed global uniforms”

Files:

  • Create: render/shaders/gamut_palette.gdshaderinc, render/gamut_globals.gd, tests/test_gamut_globals.gd
  • Modify: project.godot and platform/tvos/project.godot ([shader_globals], ; comments only), main.gd _apply_color_profile, plus the shaders in the table

Interfaces:

  • Consumes: ColorGamut.out, HdrTier.PATH_*.
  • Produces:
    • GamutGlobals.GLOBALS: Dictionary (StringName → [Color literal, int path]);
    • static var setter: Callable (test seam);
    • static func push() -> void.

The literals below were measured with OKLCH (relative chroma ≥ 0.85 and C ≥ 0.09). Near-whites (C < 0.08: every HOT, white_hot, (1.0, 0.97, 0.9)-style core) stay literal. Path rule: if the literal’s value reaches cs_in() in that shader, use B; otherwise R. Files with no cs_in at all are R. Use one global per (literal, path): if the same literal is used on both paths, add a second global with an _r suffix.

Global Literal (sRGB) Shader uses (file:line) Path
gp_warden_rim 0.541, 0.169, 0.886 fx/boss/warden_{bolt:17,burst:15,charge:23,drone:15,lane:17,missile:18,swing:17}.gdshader (const vec3 RIM) B
gp_warden_charge 1.0, 0.55, 0.65 fx/boss/warden_charge.gdshader:40 B
gp_chalice_gold 1.0, 0.78, 0.22 fx/weapons/chalice_pool.gdshader:19 (GOLD) B
gp_void_rim 0.55, 0.2, 1.0 fx/weapons/chalice_pool.gdshader:181, fx/weapons/element_accents.gdshaderinc:79 B (confirm accents)
gp_acc_fire 1.0, 0.45, 0.08 fx/weapons/element_accents.gdshaderinc:50 B (confirm)
gp_acc_ice 0.55, 0.85, 1.0 element_accents.gdshaderinc:57 B (confirm)
gp_acc_blood 0.9, 0.05, 0.12 element_accents.gdshaderinc:72 B (confirm)
gp_pressure 0.55, 0.82, 1.0 fx/weapons/momentum_charge.gdshader:17, momentum_release.gdshader:20, player_bolt.gdshader:123 B
gp_nova_violet 0.42, 0.10, 1.0 fx/weapons/nova_burst.gdshader:41, plasma_nova.gdshader:28 B / R (plasma_nova has no cs_in → gp_nova_violet_r)
gp_nova_magenta 1.0, 0.14, 0.62 nova_burst.gdshader:42, plasma_nova.gdshader:29 B / R
gp_nova_orange 1.0, 0.40, 0.06 nova_burst.gdshader:43, plasma_nova.gdshader:30 B / R
gp_nova_yellow 1.0, 0.80, 0.28 nova_burst.gdshader:44, plasma_nova.gdshader:31 B / R
gp_nova_deep 0.20, 0.25, 1.0 nova_burst.gdshader:54 B
gp_nova_blue 0.35, 0.65, 1.0 nova_burst.gdshader:55 B
gp_ember_hot / gp_ember_red / gp_ember_pink 1.0, 0.85, 0.45 / 1.0, 0.35, 0.1 / 0.95, 0.2, 0.7 nova_burst.gdshader:116-117 B
gp_bolt_orange 1.0, 0.55, 0.15 fx/weapons/player_bolt.gdshader:102 B
gp_rail_red / gp_rail_green / gp_rail_blue 1.0, 0.2, 0.25 / 0.25, 1.0, 0.35 / 0.3, 0.45, 1.0 fx/weapons/railgun_lance.gdshader:202-204 B
gp_scythe_violet / gp_scythe_ghost 0.45, 0.12, 0.85 / 0.45, 1.0, 0.7 fx/weapons/scythe_reap.gdshader:88-89 B
gp_web_strand / gp_web_venom 0.62, 0.22, 1.0 / 0.42, 1.0, 0.22 render/web_look_b.gdshader:18-19 R
gp_web_mesh / gp_web_rim 0.22, 0.92, 1.0 / 1.0, 0.28, 0.82 render/web_look_c.gdshader:16-17 R
gp_carnival 1.0, 0.3, 0.85 render/shaders/funzo_carnival_warp.gdshader:15 R

Uniforms with defaults that GDScript overwrites (orbit_shards eh_core, arena_galaxy col_*, funzo_zone_field col, funzo_lit_shell tint, weapon_zone_field fire_col/shock_col, nebula_bg col_accent) are handled at the GDScript setter (Tasks 6-7), not here.

  • Step 1: Write the failing test

tests/test_gamut_globals.gd:

extends GutTest
var _calls: Dictionary = {}
func before_each() -> void:
_calls.clear()
GamutGlobals.setter = func(n: StringName, v: Variant) -> void: _calls[n] = v
func after_each() -> void:
GamutGlobals.setter = Callable(RenderingServer, "global_shader_parameter_set")
ColorGamut.reset_for_tests()
Tunables.reset()
func _v(c: Color) -> Vector3:
return Vector3(c.r, c.g, c.b)
func test_push_sends_the_literal_when_inactive_and_the_widened_value_when_active() -> void:
GamutGlobals.push()
for g: StringName in GamutGlobals.GLOBALS:
assert_eq(_calls[g], _v(GamutGlobals.GLOBALS[g][0]), "%s inactive = the old literal" % g)
ColorGamut.refresh(true, true, false)
GamutGlobals.push()
for g: StringName in GamutGlobals.GLOBALS:
var e: Array = GamutGlobals.GLOBALS[g]
assert_eq(_calls[g], _v(ColorGamut.out(e[0], int(e[1]))), "%s active" % g)
func _declared(path: String) -> Dictionary:
var out := {}
var re := RegEx.create_from_string(r'(?m)^(gp_\w+)=\{\s*"type": "vec3",\s*"value": Vector3\(([^)]*)\)')
for m: RegExMatch in re.search_all(FileAccess.get_file_as_string(path)):
var p := m.get_string(2).split(",")
out[StringName(m.get_string(1))] = Vector3(float(p[0]), float(p[1]), float(p[2]))
return out
func test_every_global_is_declared_in_both_projects_at_its_literal() -> void:
for proj: String in ["res://project.godot", "res://platform/tvos/project.godot"]:
var d := _declared(proj)
for g: StringName in GamutGlobals.GLOBALS:
assert_true(d.has(g), "%s declares %s (export baker + runtime compile)" % [proj, g])
if d.has(g):
assert_almost_eq(d[g], _v(GamutGlobals.GLOBALS[g][0]), Vector3.ONE * 1e-6, "%s %s" % [proj, g])
func test_include_declares_every_global() -> void:
var src := FileAccess.get_file_as_string("res://render/shaders/gamut_palette.gdshaderinc")
for g: StringName in GamutGlobals.GLOBALS:
assert_true(src.contains("global uniform vec3 %s;" % g), String(g))

If res://platform/tvos/project.godot is unreadable, use the ProjectSettings.globalize_path fallback from Task 1 Step 11. That file is already read by test_every_global_is_declared_in_both_projects_at_its_sdr_value in tests/test_color_profiles.gd: mirror how it opens it.

Run -gselect=test_gamut_globals.gd → FAIL.

  • Step 2: Implement the include, the pusher, and the declarations

render/shaders/gamut_palette.gdshaderinc (one line per table global, in table order):

#ifndef GAMUT_PALETTE
#define GAMUT_PALETTE
// Display P3 gameplay-colour literals (render/gamut_globals.gd). Each = the shader's old sRGB literal
// (declared at that value in [shader_globals] of BOTH project.godot files), widened by ColorGamut while
// ColorGamut.active(). Path B globals are read through cs_in() exactly where the literal was; path R
// globals are used raw. Guarded: element_accents.gdshaderinc and a weapon shader may both include it.
global uniform vec3 gp_warden_rim;
global uniform vec3 gp_warden_charge;
// ... one `global uniform vec3 gp_<name>;` per row of the Task 8 table, including every `_r` twin
#endif

render/gamut_globals.gd:

class_name GamutGlobals
extends RefCounted
# Gameplay-colour shader literals as global uniforms (render/shaders/gamut_palette.gdshaderinc), so
# ColorGamut can widen them. Pushed from main._apply_color_profile (runs on every ColorGamut change).
# Value = [the old sRGB literal, HdrTier path]; inactive pushes the literal back (byte-identical).
const GLOBALS := {
&"gp_warden_rim": [Color(0.541, 0.169, 0.886), HdrTier.PATH_B],
&"gp_warden_charge": [Color(1.0, 0.55, 0.65), HdrTier.PATH_B],
&"gp_chalice_gold": [Color(1.0, 0.78, 0.22), HdrTier.PATH_B],
&"gp_void_rim": [Color(0.55, 0.2, 1.0), HdrTier.PATH_B],
&"gp_acc_fire": [Color(1.0, 0.45, 0.08), HdrTier.PATH_B],
&"gp_acc_ice": [Color(0.55, 0.85, 1.0), HdrTier.PATH_B],
&"gp_acc_blood": [Color(0.9, 0.05, 0.12), HdrTier.PATH_B],
&"gp_pressure": [Color(0.55, 0.82, 1.0), HdrTier.PATH_B],
&"gp_nova_violet": [Color(0.42, 0.10, 1.0), HdrTier.PATH_B],
&"gp_nova_violet_r": [Color(0.42, 0.10, 1.0), HdrTier.PATH_R],
&"gp_nova_magenta": [Color(1.0, 0.14, 0.62), HdrTier.PATH_B],
&"gp_nova_magenta_r": [Color(1.0, 0.14, 0.62), HdrTier.PATH_R],
&"gp_nova_orange": [Color(1.0, 0.40, 0.06), HdrTier.PATH_B],
&"gp_nova_orange_r": [Color(1.0, 0.40, 0.06), HdrTier.PATH_R],
&"gp_nova_yellow": [Color(1.0, 0.80, 0.28), HdrTier.PATH_B],
&"gp_nova_yellow_r": [Color(1.0, 0.80, 0.28), HdrTier.PATH_R],
&"gp_nova_deep": [Color(0.20, 0.25, 1.0), HdrTier.PATH_B],
&"gp_nova_blue": [Color(0.35, 0.65, 1.0), HdrTier.PATH_B],
&"gp_ember_hot": [Color(1.0, 0.85, 0.45), HdrTier.PATH_B],
&"gp_ember_red": [Color(1.0, 0.35, 0.1), HdrTier.PATH_B],
&"gp_ember_pink": [Color(0.95, 0.2, 0.7), HdrTier.PATH_B],
&"gp_bolt_orange": [Color(1.0, 0.55, 0.15), HdrTier.PATH_B],
&"gp_rail_red": [Color(1.0, 0.2, 0.25), HdrTier.PATH_B],
&"gp_rail_green": [Color(0.25, 1.0, 0.35), HdrTier.PATH_B],
&"gp_rail_blue": [Color(0.3, 0.45, 1.0), HdrTier.PATH_B],
&"gp_scythe_violet": [Color(0.45, 0.12, 0.85), HdrTier.PATH_B],
&"gp_scythe_ghost": [Color(0.45, 1.0, 0.7), HdrTier.PATH_B],
&"gp_web_strand": [Color(0.62, 0.22, 1.0), HdrTier.PATH_R],
&"gp_web_venom": [Color(0.42, 1.0, 0.22), HdrTier.PATH_R],
&"gp_web_mesh": [Color(0.22, 0.92, 1.0), HdrTier.PATH_R],
&"gp_web_rim": [Color(1.0, 0.28, 0.82), HdrTier.PATH_R],
&"gp_carnival": [Color(1.0, 0.3, 0.85), HdrTier.PATH_R],
}
static var setter: Callable = Callable(RenderingServer, "global_shader_parameter_set")
static func push() -> void:
for g: StringName in GLOBALS:
var e: Array = GLOBALS[g]
var c := ColorGamut.out(e[0], int(e[1]))
setter.call(g, Vector3(c.r, c.g, c.b))

If the path check in Step 3 moves a row’s path, change it here and in the include at the same time. Drop an _r twin that ends up unused, and add one where needed.

In BOTH project.godot files, append to [shader_globals] (; comments only, values = the literal):

; Display P3 gameplay-colour literals (render/gamut_globals.gd): the old sRGB literal; widened at runtime.
gp_warden_rim={
"type": "vec3",
"value": Vector3(0.541, 0.169, 0.886)
}

Use one block per GLOBALS entry, in the same order.

In main._apply_color_profile, after ShaderGlobals.push(...), add GamutGlobals.push().

  • Step 3: Convert the shaders

Use one mechanical recipe per file:

  1. Add #include "res://render/shaders/gamut_palette.gdshaderinc" beside the file’s existing includes.
  2. A const vec3 NAME = vec3(...) row becomes #define NAME gp_<global>.
  3. An inline literal becomes the global’s name.
  4. Before editing, grep the const name in the file. If it initialises another const, turn that one into a local variable in the function that uses it (a global uniform is not a constant expression).
  5. Confirm the path: B rows must have the value reach cs_in( in that file, and R rows must not. If a row’s path is wrong, switch to (or add) the other global.

Run scripts/check-test-count.sh and scripts/gate.sh, both green (the headless gate does NOT compile shaders).

  • Step 4: Windowed shader compile check under BOTH renderers
Terminal window
cd ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space
for rm in mobile gl_compatibility; do
perl -e 'alarm 120; exec @ARGV' godot --path . --rendering-method $rm res://tools/vfx_library/library.tscn > $TMPDIR/p3-shaders-$rm.log 2>&1; true
grep -iE "shader.*(error|fail)|global.*uniform|gp_" $TMPDIR/p3-shaders-$rm.log | head
done
git checkout -- project.godot audio/bus_layout.tres

Expected: no compile errors under either renderer. The vfx library instantiates every listed effect. If a touched shader has no library entry (funzo_carnival_warp, web_look_b/c), also boot a run with that look forced on (VisualLook.web_look label b/c through the web_look dev command), or add the entry. Then run skill bh-fresh-install-stall-check at the Task 9 install.

  • Step 5: Commit
Terminal window
git add render/shaders/gamut_palette.gdshaderinc render/gamut_globals.gd render/gamut_globals.gd.uid project.godot platform/tvos/project.godot main.gd fx render tests/test_gamut_globals.gd tests/test_gamut_globals.gd.uid
git commit --file=<msgfile> # "feat(hdr): gameplay shader literals -> P3-widened global uniforms (both project.godot)"
git push origin hdr-colour-space

Task 9: Device verification, perf ABBA, Chris’s A/B, persist (controller + Chris)

Section titled “Task 9: Device verification, perf ABBA, Chris’s A/B, persist (controller + Chris)”

Files:

  • Modify: render/color_profile_hdr.gd (seeds, only if Chris picks values ≠ 1/true), tests/test_color_profiles.gd HDR_SEEDS (to match), docs/architecture/rendering-and-performance.md (new “Display P3 output” section), docs/RELEASE-1.0.1.md (add + tick the item)

  • Step 1: Baked Dev installs

Read docs/reference/builds-and-releases.md, then use skill bh-worktree-device-install:

  • ATV: the patched 4.8 template, editor and shell from Task 1.
  • iPhone: the patched 4.8 build from Task 2 (or 4.7.1 under Fallback B). Must be BAKED: the A17 godot#124088 check is that the menu controls and the ship draw.

Then run skill bh-fresh-install-stall-check on both.

  • Step 2: Perf ABBA

Run the console MCP ab_test with toggle: "hdr_gamut", rounds: 3 or more, seconds_per_side: 20, on ATV and iPhone in a run with HDR output on.

Then compare render_cpu_ms and tick_ms for the two sides with telemetry_query (never samples.sim_ms; it includes the autopilot). Budget: ≤ 0.1 ms render_cpu_ms. Report the numbers.

  • Step 3: Chris’s eyeball A/B

Use send_command (or the hdr_ab script with toggle: hdr_gamut), one variable at a time, holding each state 15-20 s in a real run:

  1. graphics hdr_gamut off, then on.
  2. set_tunables {"ColorProfileHdr.GAMUT_WIDEN": 0}, then 0.5, then 1.
  3. With the Linear tonemapper for a clean comparison: {"kind":"tonemap_hdr","mode":"linear"} vs AgX.

Trust the on-device “Console: …” change note, not /status (it lags 10-20 s). Judge by eye only: iOS screenshots clip to SDR.

  • Step 4: Persist Chris’s picks

  • If Chris picks values other than GAMUT_P3 = true / GAMUT_WIDEN = 1.0, change the seeds in render/color_profile_hdr.gd and HDR_SEEDS.

  • Draft a remote-config patch (docs/architecture/remote-config.md) so installed builds get the values without a rebuild. Publishing it needs Chris’s explicit OK.

  • Write the docs/architecture/rendering-and-performance.md section (with Starlight frontmatter unchanged). It must cover:

    • the engine property;
    • the active() coupling;
    • the A/B/R path rule;
    • the gp_* globals plus their both-projects declaration rule;
    • “UI never widens”;
    • the AgX note (Finding 2).
  • Add a one-line pointer row to the CLAUDE.md read-on-demand table only if the doc section is new territory; prefer the existing Rendering row.

  • Update memory bullet-heaven-hdr-output with the outcome.

Run scripts/check-test-count.sh and scripts/gate.sh, both green.

Terminal window
git add render/color_profile_hdr.gd tests/test_color_profiles.gd docs/architecture/rendering-and-performance.md docs/RELEASE-1.0.1.md
git commit --file=<msgfile> # "docs(hdr): Display P3 output architecture; persist Chris's P3 picks"
git push origin hdr-colour-space

Task 10: bh-reviewer, then merge (Chris’s OK)

Section titled “Task 10: bh-reviewer, then merge (Chris’s OK)”
  • Step 1: Dispatch bh-reviewer on hdr-colour-space vs main. It re-runs scripts/gate.sh and must return MERGE. Fix every concrete defect it names, each in its own commit, then re-run it.
  • Step 2: Confirm the three determinism files are unchanged (git diff main -- tests/test_determinism*.gd → empty).
  • Step 3: Ask Chris for the merge OK. On a yes, use skill bh-merge-reviewed-branch: one merger at a time, BUILD bump rules per docs/reference/builds-and-releases.md, then git push origin main right after the merge. Tick the docs/RELEASE-1.0.1.md item with the merge commit.

  • Spec coverage:

    Spec item Where
    engine property + tonemap/glow/blit Task 1 (mobile shader; Finding 1)
    platforms Tasks 1-2
    ColorGamut maths + widen Task 4
    active() coupling Tasks 4-5
    tunables + docs.d + LiveScope + change note + Debug row Tasks 4-5
    ElementPalette + sRGB accessor Task 6
    looks + backdrop Task 7
    ShaderGlobals colour uniforms none exist: ShaderGlobals.GLOBALS holds only scalars (Finding 5); colour literals become GamutGlobals, Task 8
    shader audit Task 8
    UI grep ratchet Task 4
    cache rebuild Tasks 4-6
    GUT list Tasks 4-8
    determinism every gate run plus Task 10 Step 2
    engine compile check + probe Task 1 Step 8
    swatch Task 3
    device / perf / eyeball / persist Task 9
    bh-reviewer + merge Task 10
  • Deliberate deviations, flagged to Chris at the Task 3 gate:

    • The clustered tonemap.glsl is unpatched.
    • “Widen 0 = today” holds only with GAMUT_P3 off.
  • Placeholder scan:

    • “trace” rows give a deterministic rule plus the verified sink where one was read. They are not guesses.
    • The two names BestiaryLoader.entry_for / ContentDB.load_default in Task 6 Step 1 are explicitly marked “replace with the real name”, with the file to read.
  • Type consistency:

    • ColorGamut.out(c, path), refresh(engaged, capable, cs_active), decide, generation, override, ENGINE_PROPERTY, HdrTier.PATH_A/B/R are used identically in Tasks 3-8.
    • ElementPalette.color_for(content, idx, path) / color_for_srgb / weapon_color(content, idx, path) / weapon_color_srgb / vivid_out(c, path) are identical in Tasks 6-7.
    • GamutGlobals.GLOBALS / setter / push() are identical in Task 8.
  • Already done: the spec (commit 7fb22cac) and this plan, both on branch hdr-colour-space. No engine, template or game code has changed yet.
  • Authorized by the original task brief:
    • fork commits plus git push lumara (never origin);
    • local template-zip repacks and editor rebuilds, with backups;
    • pushes of hdr-colour-space;
    • Dev installs to Chris’s ATV and iPhone.
  • Needs Chris’s same-day OK:
    • publishing a remote-config patch;
    • any store or TestFlight build;
    • the merge to main;
    • the Godot 4.8 cherry-pick (Task 1 Step 10), which happens only if he picks 4.8.
  • Open questions for Chris, at the Task 3 gate:
    • Findings 1-8;
    • whether “widen 0 under AgX still differs” is acceptable, or whether he wants an extra P3-off-for-AgX option.
  • Environment:
    • The Xcode shells in the MAIN checkout’s platform/tvos/build*/ are shared by all worktrees (Tasks 1 and 2 swap their libgodot.a).
    • A background ATV 4.8 A/B agent may be using the fork’s bin/ concurrently. Check ps/git -C ~/Claude/godot-tvos/godot status before rebuilding.
  • Chris’s answers (2026-10-11): iOS moves to 4.8 too (behind the Task 2 Step 1 build/device gate; fallback 4.7.1.p3). AgX on P3 primaries, which the Task 1 code already is (Finding 2).
  • Base decision (controller, 2026-10-11): Chris picked Godot 4.8 (GPU-bound A/B 41.7 vs 33.1 fps). tvos-4.8dev is primary for tvOS AND iOS (iOS behind the Task 2 Step 1 build gate). The 4.7 fork is a fallback only if the 4.7 repeat run reverses the result.
  • Dependency: a separate agent is fixing the 4.8 build/export (Xcode export template, the debug lib in 4.8.dev/tvos.zip, editor paths in the scripts). Task 1 waits for it to finish. No one edits godot-48 concurrently.
  • Next action: Task 1 Step 1 (fork preflight on godot-48), once the 4.8 fix agent has reported done.