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 skillbh-sdd-concurrentbefore Task 1, and dispatch code tasks asbh-implementerwithmodel: "opus"(memorybullet-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, likeuse_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 theCAMetalLayer. 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 ANDGAMUT_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.
Global Constraints
Section titled “Global Constraints”- Render-only. Nothing under
/simchanges. The three seed-1234 baselines (tests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gd) must not move, andscripts/gate.shproves 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 behindoutput_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/callsColorGamut.widenorColorGamut.out, and a GUT grep test enforces it. - UI that shows an element colour uses
ElementPalette.color_for_srgb. - Layers above
canvas_max_layerskip the tonemap anyway.
- Nothing under
- Tunables:
ColorProfileHdr.GAMUT_P3: bool = trueis the kill switch.ColorProfileHdr.GAMUT_WIDEN: float = 1.0is the dial.- Both are HDR-profile only, with no SDR counterpart.
- Both are registered tunables with a
tools/tunable_docs/docs.dlabel, the LiveScopeHOOK_ENVhook, and the on-device change note. control/src/tunable_docs.jsis GENERATED: runnode tools/tunable_docs/generate.mjsand never hand-edit it.
- Debug Settings row parity:
hdr_gamut= “HDR wide gamut P3 (A/B)” (memorybullet-heaven-debug-settings-console-parity). - Never call
Color.h/.s/.v(orfrom_hsvon 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 asc * 12.92andc / 12.92. - Fork rules:
- Never push to
origin(someone else’s upstream). Push only to thelumararemote. - A fork change reaches a build only after the template zip is repackaged AND the Xcode shell’s
libgodot.ais refreshed (--export-packnever 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).
- Never push to
- The headless gate never compiles shaders. Every edited
.gdshader/.gdshaderincneeds a windowed compile check under BOTH--rendering-method mobileand--rendering-method gl_compatibility. Afterwards rungit 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; setGUT_LOG=<own path>when worktrees run gates concurrently). - Narrow a run with
-gselect=<exact_name>.gd. - Every new
docs/**/*.mdneeds Starlighttitle:frontmatter.
- Never pull a whole GUT run into context. Use
- 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, branchhdr-colour-space). git pull --rebasebefore each commit.- Commit with
git commit --file=<msgfile>, ending with the Co-Authored-By and Claude-Session lines. git push origin hdr-colour-space.
- Work in this worktree (
- Secrets: never put secret values in files or the transcript, and never Read
~/.secrets.
Matrices (verbatim in Tasks 1, 2 and 4)
Section titled “Matrices (verbatim in Tasks 1, 2 and 4)”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.
- The game tonemaps through
tonemap_mobile.glsl, nottonemap.glsl.- Why:
platform/tvos/project.godotsets.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 AgXmin(output_max)at:230. - The spec’s
tonemap.glsl:253/424are 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.
- Why:
- AgX ships negative components today, and the blit clips them.
tonemap_agxends “simply return the color, even if it has negative components” (tonemap_mobile.glsl:232-236). BCSSATURATION1.35 can also push in-sRGB colours negative, because the BCSlinear_to_srgbtakes the linear branch for negatives.- So
GAMUT_P3on withGAMUT_WIDEN0 is NOT byte-identical to today under AgX: the P3 blit lets those values through, up to the P3 edge. “Today’s look” isGAMUT_P3off. - 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→709IS 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.
- No
ui/file callsElementPalette. The Codex path iscontent/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. - 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_inuniforms) gets an encoded result only whileColorSpace.active, otherwise linear. - PATH_R (particles,
Light2D.color, raw uniforms) gets linear. - So
ColorGamut.out(c, path)caches per path, and its cache keys onColorSpace.active.
- PATH_A (modulate, draw_*,
- 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). - The nebula SubViewport is 8-bit unless
colour_space_fixis on (render/nebula_bg.gd:164-167). WideningCOL_ACCENTis therefore wasted there, so it widens only when that SubViewport is HDR (Task 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:13still name the 4.7 editor until the 4.8 fix agent switches them). - iOS today: Today iOS exports with stock
/opt/homebrew/bin/godot4.7.1 (install-dev.sh:130,build-ios.sh:8) and the stock4.7.1.stable/ios.zip. - A patched runtime with a stock-baked pack is the BUILD 221 mismatch in reverse.
- tvOS and iOS:
- 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, HEAD655cdf8b9fwhen 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 switchinginstall-dev.sh:111/build-tvos.sh:13off the 4.7 editor path. - Start Task 1 from its finished HEAD and re-run the line check in Task 1 Step 1.
- That agent owns the Xcode export template, the debug lib in
- Fallback: the 4.7 fork
~/Claude/godot-tvos/godot, branchtvos-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/iosis present, thegenerate_bundlescons builder is present (platform/ios/SCsub:49,platform_ios_builders.py), andmisc/dist/apple_embedded_xcodecarrieslibgodot.ios.{debug,release}.xcframework. - NOT verified: no iOS lib has ever been built from this branch. Its tvOS commits touch the shared
apple_embeddedcode (“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.deveditor 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.
- Verified in the source:
- iOS fallback (only if the 4.8 iOS build fails or misbehaves on device): branch
ios-p3-4.7.1off4.7.1-stable(a13da4feb), built withGODOT_VERSION_STATUS=p3into its own4.7.1.p3template dir (methods.py:161-162). See Task 2 “Fallback B”.
File map
Section titled “File map”| 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),:293and:313(glow clamps), plus an include near the top.servers/rendering/renderer_rd/shaders/blit.glsl:27,:73and: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:246and:337.servers/rendering/renderer_rd/renderer_scene_render_rd.cpp:752and:957.servers/rendering/renderer_rd/renderer_compositor_rd.h:91;servers/rendering/renderer_rd/renderer_compositor_rd.cpp:114and:300.servers/rendering/storage/texture_storage.h:167;servers/rendering/renderer_rd/storage_rd/texture_storage.h:378and:803, plustexture_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 NOclass_has_property; it is a parse error, found in Task 1).- The constant names
srgb_to_p3_matrix/p3_to_srgb_matrixingamut_p3_inc.glsl.
- GDScript
-
Step 1: Preflight the fork
cd ~/Claude/godot-tvos/godot-48git branch --show-current # tvos-4.8devgit 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: STOPpgrep -fl "scons|godot.macos.editor" || echo idle # no concurrent build in this treegrep -n "color = max(vec3(0.0), color);" servers/rendering/renderer_rd/shaders/effects/tonemap_mobile.glsl # 258grep -n "uint pad1" servers/rendering/renderer_rd/shaders/blit.glsl # 27, 73If 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), addint output_gamut = 0;. - After line 268, add
void viewport_set_output_gamut(RID p_viewport, int p_gamut);andint 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), addint output_gamut = 0;. - After line 568, add
void set_output_gamut(int p_gamut);andint 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, afterbool convert_to_srgb = false;(line 209), adduint32_t output_gamut = 0;. tone_mapper.cpp: after line 246 AND after line 337, addtonemap_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 addblit.push_constant.output_gamut = texture_storage->render_target_get_output_gamut(p_render_targets[i].render_target);, and after line 300 (boot image) addblit.push_constant.output_gamut = 0;. -
Fix any remaining
pad[...]reference the compiler flags. -
Step 7: Build the tvOS template and the fork editor
cd ~/Claude/godot-tvos/godot-48cp bin/godot.macos.editor.arm64 bin/godot.macos.editor.arm64.2026-10-11-pre-p3.bakcp bin/libgodot.tvos.template_release.arm64.a bin/libgodot.tvos.template_release.arm64.a.bak.20261011-pre-p3cp bin/libgodot.tvos.template_debug.arm64.a bin/libgodot.tvos.template_debug.arm64.a.bak.20261011-pre-p3DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=tvos target=template_release arch=arm64 -j10DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=tvos target=template_debug arch=arm64 -j10scons 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_matrixline, found in Task 1).stringsfinds nothing because the engine stores RD shader sources zlib-compressed (*.glsl.gen.h,_code_compressed[]). Instead: decompress the regeneratedblit.glsl.gen.h/tonemap_mobile.glsl.gen.harrays, confirm the text containssrgb_to_p3_matrixandoutput_gamut, then confirm those exact compressed byte arrays occur in each binary (editor, template libs, or the zip member viaunzip -p). Task 2 turns this intoplatform/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):
P=$(mktemp -d); printf '[application]\nconfig/name="p3probe"\n' > "$P/project.godot"cat > "$P/probe.gd" <<'EOF'extends SceneTreefunc _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 P3PROBEExpected: 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:
cd ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/tvosperl -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; truegit checkout -- project.godot audio/bus_layout.tres 2>/dev/null; truegrep -iE "tonemap|blit|shader.*(error|fail)|SPIR|metal.*compil" $TMPDIR/p3-boot.log | headExpected: 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
cd ~/Claude/godot-tvos/godot-48git add servers/rendering scene/maingit commit -m "Dark Cosmos 0001: Viewport.output_gamut (Display P3) through mobile tonemap + HDR blit"git push lumara tvos-4.8devT="$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-patchesgit format-patch -1 HEAD --stdout > ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/engine-patches/0001-p3-output-gamut.patchDo 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 thetemplate 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
cd ~/Claude/godot-tvos/godot && git branch --show-current && git status --short && git log --oneline -1 # tvos-render-1080p, efa009712egit 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.ziphas 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.
git add platform/engine-patches tests/test_engine_patch_p3.gd tests/test_engine_patch_p3.gd.uidgit commit --file=<msgfile> # "feat(hdr): engine patch 0001 Viewport.output_gamut (P3) on the tvOS fork + parity test"git push origin hdr-colour-spaceTask 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 fullgenerate_bundlezip);- env var
BH_IOS_GODOT, defaulting to the 4.8 editor.
-
Step 1: Build gate: does 4.8 build iOS at all?
cd ~/Claude/godot-tvos/godot-48 && git log --oneline -1 # the Task 1 commitDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_debug arch=arm64 -j10DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_release arch=arm64 generate_bundle=yes -j10ls -la bin/ | grep -i "ios" # expect libgodot.ios.template_{debug,release}.arm64.a and godot_ios.zipunzip -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
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:
# 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:
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.loggit checkout -- project.godotgrep -q "Started Baking shaders" $TMPDIR/ios48-export.log && echo bakedcmp build-ios/BulletHeaven.xcframework/ios-arm64/libgodot.a ~/Claude/godot-tvos/godot-48/bin/libgodot.ios.template_release.arm64.a && echo release-libThen 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-p3if one existed, andplatform/tvos/build-ios.bak-2026-10-11-pre-48); - record that rollback =
BH_IOS_GODOT=/opt/homebrew/bin/godotplus restoring that shell backup.
git add scripts/dev/install-dev.sh scripts/release/build-ios.sh platform/tvos/CLAUDE.md platform/engine-patches/README.mdgit commit --file=<msgfile> # "build(ios): export on patched Godot 4.8 (engine patch 0001), same engine as tvOS"git push origin hdr-colour-spaceFallback 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, branchios-p3-4.7.1off tag4.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(reports4.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
cd ~/Claude/godot-tvos/godotgit worktree add ../godot-ios-471 -b ios-p3-4.7.1 4.7.1-stablecd ../godot-ios-471 && git log --oneline -1 # a13da4febgit am --3way ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-space/platform/engine-patches/0001-p3-output-gamut.patchgit push lumara ios-p3-4.7.1The 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
cd ~/Claude/godot-tvos/godot-ios-471grep -n 'GODOT_VERSION_STATUS' methods.py # must exist (fork: methods.py:161)export GODOT_VERSION_STATUS=p3scons platform=macos target=editor arch=arm64 vulkan=no production=yes -j10DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_release arch=arm64 production=yes -j10DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_debug arch=arm64 production=yes -j10bin/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.zipfrom the stock zip, with only the device libs replaced
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:
# 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:
SH=~/Claude/bullet-heaven/platform/tvos/build-ios/BulletHeaven.xcframework/ios-arm64cp "$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.1commit hash; - record the backups (
build-ioslibgodot.a.bak.20261011-pre-p3); - record that rollback =
BH_IOS_GODOT=/opt/homebrew/bin/godotplus restoring that shell backup.
git add scripts/dev/install-dev.sh scripts/release/build-ios.sh platform/tvos/CLAUDE.md platform/engine-patches/README.mdgit commit --file=<msgfile> # "build(ios): export on patched 4.7.1 (engine patch 0001, templates 4.7.1.p3)"git push origin hdr-colour-spaceTell 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.gdapply_dev_command(after thetonemap_sdr/tonemap_hdrblock, ~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.
- dev command
-
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 GamutSwatchextends 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.0const 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 trueRun -gselect=test_gamut_swatch.gd → PASS. Then scripts/check-test-count.sh and scripts/gate.sh, both green.
- Step 4: Commit
git add render/gamut_swatch.gd render/gamut_swatch.gd.uid tests/test_gamut_swatch.gd tests/test_gamut_swatch.gd.uid main.gdgit 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)
- Read
docs/reference/builds-and-releases.mdand use skillbh-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). - 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}.
- 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.
- with
- Also run
skill bh-fresh-install-stall-checkon the iPhone. The new tonemap/blit code is baked. - 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_gamutmust respect an active P3 swatch: whilemain.get_node_or_null("GamutSwatch")is up, do not writeViewport.output_gamut(or update the swatch’s saved_swatch_prev_gamutinstead), and write it throughmain.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 toSDR_ONLY_PREFIX)tests/test_color_profiles.gd:26-36and:79-85net/tunables_registry.gd(after theColorProfileHdr.GLASS_BACKDROP_CAPentry)tools/tunable_docs/docs.d/color_profiles.jsoncontrol/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 returnPackedFloat64Arrayof 3, becauseVector3andColorare 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.0toHDR_SEEDS(line 79-85).float(true) == 1.0keepstest_hdr_seedsnumeric.
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 = truestatic var GAMUT_WIDEN: float = 1.0In 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 ColorGamutextends 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 setterconst NEUTRAL_C := 1e-4 # OKLCH chroma below this = a neutral: never movedconst SEARCH_STEPS := 32 # binary search on [0, 0.5] chroma: 0.5 / 2^32, far below float32const 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 choicestatic var generation := 0 # bumps on every relevant change; owners of cached colours compare itstatic var _active := falsestatic var _amount := 0.0static var _cs_active := falsestatic 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
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.jsgit commit --file=<msgfile> # "feat(hdr): ColorGamut (OKLCH widen to P3, active() decision, path-aware cache) + GAMUT_P3/GAMUT_WIDEN"git push origin hdr-colour-spaceTask 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.gdGRAPHICS_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 existingtests/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 aColorGamutchange, 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 := falseIn _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 trueSwatch 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 == 1on ause_hdr_2dtarget, HDR output on or not. Sooutput_gamutmust go back to 0 the frame HDR disengages; the code above does that becauseColorGamut.active()requires HDR engaged. A test must pin it (HDR off after on -> property 0). output_gamutis 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 thecolour_space_fixrow:["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_GRAPHICSbecomes["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 itGRAPHICS_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_togglematch, aftercolour_space_fix:
"hdr_gamut": ColorGamut.set_on(enabled) # main._sync_output_gamut applies it next frametools/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
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.uidgit commit --file=<msgfile> # "feat(hdr): ColorGamut.active() drives Viewport.output_gamut; hdr_gamut Debug row + console A/B"git push origin hdr-colour-spaceTask 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-60main.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) -> ColorandElementPalette.weapon_color_srgb(content, idx) -> Color: today’s exact behaviour.ElementPalette.color_for(content, idx, path: int = HdrTier.PATH_A) -> ColorandElementPalette.weapon_color(content, idx, path: int = HdrTier.PATH_A) -> Color: the source passed throughColorGamut.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
ElementPalettechokepoints
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
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.uidgit 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-spaceTask 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_profilerunsarena_bg.refresh_hdr()andfar_stars.refresh_hdr()on every gamut change (Task 5). - Produces: no new API.
Recipe (identical for every row):
- At each USE of the const, replace
CONSTwithColorGamut.out(CONST, HdrTier.PATH_x), with the path chosen by the Task 6 Step 3 rule. - 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.generationchanges (a_gamut_genmember checked in the owner’s existing per-frame update, or in itsrefresh_hdr()for the backdrop). - Leave alone, by the spec’s UI rule: HUD/feedback colours (
fx/screen_feedback.gdBORDER_COLOR/VIGNETTE_COLOR,render/shaders/low_hp_border.gdshader,render/shaders/threat_aggregate_glow.gdshader,elite_bar_rendererNAME_COL) and near-neutral consts (OKLCH chroma < 0.08, e.g.HOTwhites).
| 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 = wasRead 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
git add render fx tests/test_color_gamut_looks.gd tests/test_color_gamut_looks.gd.uidgit commit --file=<msgfile> # "feat(hdr): P3 widening for looks constants + backdrop accents (path-aware, cached)"git push origin hdr-colour-spaceTask 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.godotandplatform/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#endifrender/gamut_globals.gd:
class_name GamutGlobalsextends 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:
- Add
#include "res://render/shaders/gamut_palette.gdshaderinc"beside the file’s existing includes. - A
const vec3 NAME = vec3(...)row becomes#define NAME gp_<global>. - An inline literal becomes the global’s name.
- 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). - 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
cd ~/Claude/bullet-heaven/.claude/worktrees/hdr-colour-spacefor 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 | headdonegit checkout -- project.godot audio/bus_layout.tresExpected: 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
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.uidgit commit --file=<msgfile> # "feat(hdr): gameplay shader literals -> P3-widened global uniforms (both project.godot)"git push origin hdr-colour-spaceTask 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.gdHDR_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:
graphics hdr_gamutoff, then on.set_tunables {"ColorProfileHdr.GAMUT_WIDEN": 0}, then0.5, then1.- 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 inrender/color_profile_hdr.gdandHDR_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.mdsection (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-outputwith the outcome.
Run scripts/check-test-count.sh and scripts/gate.sh, both green.
git add render/color_profile_hdr.gd tests/test_color_profiles.gd docs/architecture/rendering-and-performance.md docs/RELEASE-1.0.1.mdgit commit --file=<msgfile> # "docs(hdr): Display P3 output architecture; persist Chris's P3 picks"git push origin hdr-colour-spaceTask 10: bh-reviewer, then merge (Chris’s OK)
Section titled “Task 10: bh-reviewer, then merge (Chris’s OK)”- Step 1: Dispatch
bh-revieweronhdr-colour-spacevsmain. It re-runsscripts/gate.shand 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 perdocs/reference/builds-and-releases.md, thengit push origin mainright after the merge. Tick thedocs/RELEASE-1.0.1.mditem with the merge commit.
Self-review (done while writing)
Section titled “Self-review (done while writing)”-
Spec coverage:
Spec item Where engine property + tonemap/glow/blit Task 1 (mobile shader; Finding 1) platforms Tasks 1-2 ColorGamutmaths +widenTask 4 active()couplingTasks 4-5 tunables + docs.d + LiveScope + change note + Debug row Tasks 4-5 ElementPalette+ sRGB accessorTask 6 looks + backdrop Task 7 ShaderGlobalscolour uniformsnone exist: ShaderGlobals.GLOBALSholds only scalars (Finding 5); colour literals becomeGamutGlobals, Task 8shader 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.glslis unpatched. - “Widen 0 = today” holds only with
GAMUT_P3off.
- The clustered
-
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_defaultin 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/Rare 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.
Execution handoff
Section titled “Execution handoff”- Already done: the spec (commit
7fb22cac) and this plan, both on branchhdr-colour-space. No engine, template or game code has changed yet. - Authorized by the original task brief:
- fork commits plus
git push lumara(neverorigin); - local template-zip repacks and editor rebuilds, with backups;
- pushes of
hdr-colour-space; - Dev installs to Chris’s ATV and iPhone.
- fork commits plus
- 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 theirlibgodot.a). - A background ATV 4.8 A/B agent may be using the fork’s
bin/concurrently. Checkps/git -C ~/Claude/godot-tvos/godot statusbefore rebuilding.
- The Xcode shells in the MAIN checkout’s
- 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.8devis 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 editsgodot-48concurrently. - Next action: Task 1 Step 1 (fork preflight on
godot-48), once the 4.8 fix agent has reported done.