HDR P3 wide gamut (engine blit) implementation plan
HDR P3 Wide Gamut Implementation Plan
Section titled “HDR P3 Wide Gamut Implementation Plan”SUPERSEDED (2026-10-11) by
docs/superpowers/specs/2026-10-11-hdr-p3-palette-design.md(palette-authored P3). Chris rejected this blit reinterpretation as a hack (hue skew, hits UI). Do not execute.
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 consult skillbh-sdd-concurrentbefore Task 1.
Goal: With HDR output on, let saturated neon reach Display P3 on the Apple TV and iPhone. A live tunable ColorProfileHdr.GAMUT_EXPAND (0 = today’s look, bit-identical) drives this, and it can be A/B’d from the console.
Architecture:
- Final blit (engine). In the linear-HDR branch of the final blit (
blit.glsl,COLOR_SPACE_REC709_LINEAR), whengamut_expand > 0:- Saturated colours are re-read as P3 primaries, weighted by saturation, so neutrals don’t move.
- The result is clamped to [0, headroom] in P3, not Rec.709. Negatives can then survive into the
kCGColorSpaceExtendedLinearSRGBlayer. - The value reaches the engine through a project setting,
rendering/hdr/gamut_expand, read withGLOBAL_GET_CACHED. It rides in the blit push constant’s spare pad slot, so the struct size is unchanged.
- One patch, two bases.
- tvOS: the
godot-tvosfork (4.7.dev). - iOS: a new branch on upstream 4.7.1-stable. That’s the engine iOS already ships, and its editor still bakes the iOS shaders, so iOS gains only this one delta.
- macOS and web stay stock and are unaffected: the setting doesn’t exist there, and the game never writes it.
- tvOS: the
- Game side. The game pushes the active profile’s value after every colour-profile apply, only when the engine reports support. This uses the same hook as
ShaderGlobals.push.
Tech Stack: Godot 4.7 C++/GLSL (engine), typed GDScript + GUT (game), Node (console MCP hdr_ab).
Spec:
docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md: Rollout item 7, “Phase 3: P3 / wide gamut … behind its own A/B”.docs/research/2026-10-08-hdr-colour-best-practice.md: § 3 “Gamut” and § 5 lever 8.
Scope decision (Chris, 2026-10-11): ATV + iOS. iOS today exports with stock 4.7.1 templates (platform/tvos/CLAUDE.md “iOS uses stock Godot”), so the iOS half needs its own patched template build. This plan builds it from 4.7.1-stable + this patch, not from the 4.7.dev tvOS fork. The fork branches from upstream master of ~2026-03-30, so moving iOS onto it would also swap months of upstream fixes out of the shipping iOS engine. The stock 4.7.1 editor that bakes iOS shaders would then mismatch the runtime too.
Global Constraints
Section titled “Global Constraints”- Render-only: nothing under
/simchanges and the determinism baselines must not move. gamut_expand == 0must take the exact stock code path in the blit: anif/else, with theelsebranch holding the original line unmodified. This is the bundled default for both profiles.- HDR-only: the effect exists only in the
COLOR_SPACE_REC709_LINEARbranch. SDR output (an 8-bit sRGB target) is untouched. - The tunable must be console-live (CLAUDE.md “Every new tuning value that CAN be a Remote Console tunable SHOULD be one”).
- Registry entry and
tools/tunable_docs/docs.d/color_profiles.jsonlabel in the same task. control/src/tunable_docs.jsis GENERATED; never hand-edit it.
- Registry entry and
- Fork rules (
platform/tvos/CLAUDE.md):- Never push to
origin(upstream stuartcarnie). - Push every fork commit to the
lumararemote. - Fork changes reach a build only after the template zip is repackaged AND the Xcode shell’s
libgodot.ais refreshed (--export-packnever re-copies it).
- Never push to
- No store/TestFlight build in this plan. Dev installs only, via
bh-deploy/bh-worktree-device-install. Readdocs/reference/builds-and-releases.mdbefore any BUILD bump or Dev install. - Don’t flip the default away from 0.0 without Chris’s on-device verdict on BOTH the ATV and the iPhone.
- After any GDScript change: run
scripts/check-test-count.sh(never a raw GUT run) and the gatescripts/gate.sh. - The headless gate never compiles shaders. The blit is an engine shader, so the only compile check is a runtime boot of a patched binary (Task 2 Step 7).
Matrices (used verbatim in Tasks 1 and 2)
Section titled “Matrices (used verbatim in Tasks 1 and 2)”These are linear, D65 matrices. GLSL mat3(a, b, c) takes columns.
| row 0 | row 1 | row 2 | |
|---|---|---|---|
SRGB_TO_P3 |
0.8224621, 0.1775380, 0.0000000 | 0.0331941, 0.9668058, 0.0000000 | 0.0170827, 0.0723974, 0.9105199 |
P3_TO_SRGB |
1.2249401, −0.2249404, 0.0000000 | −0.0420569, 1.0420571, 0.0000000 | −0.0196376, −0.0786361, 1.0982735 |
Sanity checks:
- Each row sums to 1 (±3e-7), so white maps to white.
- P3 pure red in extended linear sRGB is
P3_TO_SRGB * (1,0,0) = (1.2249401, −0.0420569, −0.0196376).
Task 1: HdrGamut reference maths + engine-support detection + push
Section titled “Task 1: HdrGamut reference maths + engine-support detection + push”The GDScript copy of the blit maths serves two purposes:
- It is the test oracle Task 2’s patch is pinned against.
- It holds the game-side push.
Files:
- Create:
render/hdr_gamut.gd - Create:
tests/test_hdr_gamut.gd
Interfaces:
-
Produces (used by Tasks 2 and 4):
class_name HdrGamutconst SETTING := "rendering/hdr/gamut_expand"const SRGB_TO_P3: Array[Vector3](3 rows)const P3_TO_SRGB: Array[Vector3](3 rows)static func mul(rows: Array[Vector3], c: Vector3) -> Vector3static func blit(c: Vector3, strength: float, max_value: float) -> Vector3, a mirror of the shaderstatic func supported() -> boolstatic var setter: Callable(test spy seam)static func reset_support_cache() -> voidstatic func push(value: float) -> voidstatic var last_pushed: float
-
Step 1: Write the failing tests
extends GutTest
# HDR P3 gamut expansion (docs/superpowers/plans/2026-10-11-hdr-p3-gamut.md). HdrGamut.blit mirrors the# engine blit's REC709_LINEAR branch; tests/test_hdr_gamut_patch.gd pins the engine patch to these constants.
var _was_setter: Callable
func before_each() -> void: _was_setter = HdrGamut.setter HdrGamut.reset_support_cache()
func after_each() -> void: HdrGamut.setter = _was_setter HdrGamut.reset_support_cache()
func _near(a: Vector3, b: Vector3, eps := 1e-5) -> bool: return absf(a.x - b.x) < eps and absf(a.y - b.y) < eps and absf(a.z - b.z) < eps
func test_matrices_are_inverses() -> void: for c: Vector3 in [Vector3(1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, 1), Vector3(0.3, 0.7, 0.2)]: var round_trip := HdrGamut.mul(HdrGamut.P3_TO_SRGB, HdrGamut.mul(HdrGamut.SRGB_TO_P3, c)) assert_true(_near(round_trip, c, 2e-6), "round trip %s -> %s" % [c, round_trip])
func test_white_maps_to_white() -> void: assert_true(_near(HdrGamut.mul(HdrGamut.SRGB_TO_P3, Vector3.ONE), Vector3.ONE, 1e-6)) assert_true(_near(HdrGamut.mul(HdrGamut.P3_TO_SRGB, Vector3.ONE), Vector3.ONE, 1e-6))
func test_strength_zero_is_the_stock_clamp() -> void: assert_eq(HdrGamut.blit(Vector3(0.5, 0.2, 0.1), 0.0, 4.0), Vector3(0.5, 0.2, 0.1)) assert_eq(HdrGamut.blit(Vector3(6.0, -0.5, 1.0), 0.0, 4.0), Vector3(4.0, 0.0, 1.0))
func test_neutral_greys_do_not_move() -> void: for v: float in [0.0, 0.18, 1.0, 3.0]: assert_true(_near(HdrGamut.blit(Vector3(v, v, v), 1.0, 4.0), Vector3(v, v, v)), "grey %s" % v)
func test_full_saturation_red_becomes_p3_red() -> void: var out := HdrGamut.blit(Vector3(1, 0, 0), 1.0, 4.0) assert_true(_near(out, Vector3(1.2249401, -0.0420569, -0.0196376)), "got %s" % out) assert_lt(out.y, 0.0, "the negative must survive: that's the whole point")
func test_half_strength_is_halfway() -> void: var out := HdrGamut.blit(Vector3(0, 1, 0), 0.5, 4.0) var full := HdrGamut.mul(HdrGamut.P3_TO_SRGB, Vector3(0, 1, 0)) assert_true(_near(out, Vector3(0, 1, 0).lerp(full, 0.5)), "got %s" % out)
func test_clamps_in_p3_to_headroom() -> void: var out := HdrGamut.blit(Vector3(9, 0, 0), 1.0, 4.0) var p3 := HdrGamut.mul(HdrGamut.SRGB_TO_P3, out) assert_lte(p3.x, 4.0 + 1e-5) assert_gte(p3.y, -1e-5) assert_gte(p3.z, -1e-5)
func test_stock_engine_reports_unsupported_and_push_is_a_no_op() -> void: # The stock 4.7.1 binary running GUT has no such setting; the patched engines GLOBAL_DEF it. var calls: Array = [] HdrGamut.setter = func(k: String, v: Variant) -> void: calls.append([k, v]) assert_false(HdrGamut.supported()) HdrGamut.push(0.8) assert_eq(calls, [], "never write the setting on an engine that lacks it (it would fake support)")
func test_push_writes_the_clamped_value_when_supported() -> void: var calls: Array = [] HdrGamut.setter = func(k: String, v: Variant) -> void: calls.append([k, v]) HdrGamut._supported = 1 HdrGamut.push(1.7) HdrGamut.push(-1.0) assert_eq(calls, [[HdrGamut.SETTING, 1.0], [HdrGamut.SETTING, 0.0]]) assert_eq(HdrGamut.last_pushed, 0.0)
func test_no_project_file_declares_the_setting() -> void: # A declared key would make has_setting() true on the STOCK engine and fake support. for path: String in ["res://project.godot", "res://platform/tvos/project.godot"]: var text := FileAccess.get_file_as_string(path) assert_false(text.contains("gamut_expand"), path)- Step 2: Run to verify it fails
Run: scripts/check-test-count.sh. Expected: FAIL, because HdrGamut isn’t declared (parse errors in test_hdr_gamut.gd).
- Step 3: Implement
render/hdr_gamut.gd
class_name HdrGamutextends RefCounted
# HDR P3 wide gamut (plan docs/superpowers/plans/2026-10-11-hdr-p3-gamut.md; research# docs/research/2026-10-08-hdr-colour-best-practice.md § 3). The PATCHED engines (tvOS fork, iOS# 4.7.1+patch, platform/engine-patches/) read SETTING every frame in the final blit's linear-HDR branch:# 0 = the stock per-channel clamp to [0, headroom]; > 0 re-reads saturated colours as P3 primaries# (weighted by saturation, so neutrals stay) and clamps in P3, so negatives reach the extended-linear-sRGB# layer. Stock engines (macOS, web, GUT) lack the setting: supported() is false and push() never writes it# (a write would create the key and fake support). blit() mirrors the shader for tests only.
const SETTING := "rendering/hdr/gamut_expand"
# Linear D65 matrices, ROWS. tests/test_hdr_gamut_patch.gd pins the engine patch's GLSL to these.const SRGB_TO_P3: Array[Vector3] = [Vector3(0.8224621, 0.1775380, 0.0), Vector3(0.0331941, 0.9668058, 0.0), Vector3(0.0170827, 0.0723974, 0.9105199)]const P3_TO_SRGB: Array[Vector3] = [Vector3(1.2249401, -0.2249404, 0.0), Vector3(-0.0420569, 1.0420571, 0.0), Vector3(-0.0196376, -0.0786361, 1.0982735)]
static var setter: Callable = Callable(ProjectSettings, "set_setting")static var last_pushed: float = 0.0# -1 = not probed yet. Probed BEFORE the first write (push never writes on an unsupported engine).static var _supported: int = -1
static func mul(rows: Array[Vector3], c: Vector3) -> Vector3: return Vector3(rows[0].dot(c), rows[1].dot(c), rows[2].dot(c))
static func blit(c: Vector3, strength: float, max_value: float) -> Vector3: if strength <= 0.0: return c.clamp(Vector3.ZERO, Vector3.ONE * max_value) var mx := maxf(maxf(c.x, c.y), c.z) var mn := minf(minf(c.x, c.y), c.z) var sat := clampf((mx - mn) / mx, 0.0, 1.0) if mx > 1e-5 else 0.0 var out := c.lerp(mul(P3_TO_SRGB, c), strength * sat) return mul(P3_TO_SRGB, mul(SRGB_TO_P3, out).clamp(Vector3.ZERO, Vector3.ONE * max_value))
static func supported() -> bool: if _supported < 0: _supported = 1 if ProjectSettings.has_setting(SETTING) else 0 return _supported == 1
static func reset_support_cache() -> void: _supported = -1 last_pushed = 0.0
static func push(value: float) -> void: if not supported(): return last_pushed = clampf(value, 0.0, 1.0) setter.call(SETTING, last_pushed)- Step 4: Run to verify it passes
Run: godot --headless --path . --import, then git checkout -- project.godot audio/bus_layout.tres (the new class_name needs the class cache), then scripts/check-test-count.sh. Expected: PASS, and the count goes up by 10.
- Step 5: Commit
git add render/hdr_gamut.gd render/hdr_gamut.gd.uid tests/test_hdr_gamut.gdgit commit --file=<msgfile> # "feat(hdr): HdrGamut reference maths + engine-support probe for P3 gamut"Task 2: Engine patch on 4.7.1-stable → iOS templates + runtime compile check
Section titled “Task 2: Engine patch on 4.7.1-stable → iOS templates + runtime compile check”Files (engine, outside this repo):
- New worktree
~/Claude/godot-tvos/godot-ios-471, branchios-p3-4.7.1. - Modify:
servers/rendering/renderer_rd/shaders/blit.glsl - Modify:
servers/rendering/renderer_rd/renderer_compositor_rd.h - Modify:
servers/rendering/renderer_rd/renderer_compositor_rd.cpp
Files (repo):
- Create:
platform/engine-patches/0001-hdr-gamut-expand.patch - Create:
platform/engine-patches/README.md - Create:
tests/test_hdr_gamut_patch.gd
Interfaces:
-
Consumes: Task 1’s
HdrGamut.SRGB_TO_P3,HdrGamut.P3_TO_SRGB,HdrGamut.SETTING. -
Produces:
- The patch file Task 3 applies to the tvOS fork.
- A patched
ios.zipin~/Library/Application Support/Godot/export_templates/4.7.1.stable/. ~/Claude/godot-tvos/godot-ios-471/bin/godot.macos.editor.arm64(patched Mac editor, for the compile check and Mac EDR eyeballing).
-
Step 1: Get 4.7.1-stable into the fork repo and branch from it
cd ~/Claude/godot-tvos/godotgit fetch https://github.com/godotengine/godot.git refs/tags/4.7.1-stable:refs/tags/4.7.1-stable --no-tagsgit rev-parse '4.7.1-stable^{commit}' # MUST start with a13da4feb (= `godot --version` of the stock 4.7.1)git worktree add ~/Claude/godot-tvos/godot-ios-471 -b ios-p3-4.7.1 4.7.1-stableIf the hash doesn’t match, STOP and report it. The stock editor that bakes iOS shaders must be the same version as the template.
- Step 2: Verify the patch surface is what this plan assumes
In ~/Claude/godot-tvos/godot-ios-471:
renderer_compositor_rd.hBlitPushConstantendsfloat output_max_value; uint32_t pad[2];blit.glslhas two push-constant blocks endingfloat output_max_value; uint pad1; uint pad2;blit.glslhas the linecolor.rgb = clamp(color.rgb, vec3(0.0), vec3(data.output_max_value));renderer_compositor_rd.cppsetsblit.push_constant.output_max_valuein exactly two places:blit_render_targets_to_screenand the boot-image blit.
Run: grep -n "pad\|output_max_value" servers/rendering/renderer_rd/renderer_compositor_rd.h servers/rendering/renderer_rd/shaders/blit.glsl servers/rendering/renderer_rd/renderer_compositor_rd.cpp. If any of these differ, stop and report.
- Step 3: Edit the push constant (header)
In renderer_compositor_rd.h, replace the struct’s tail:
float reference_multiplier; // 4 - 84 float output_max_value; // 4 - 88 float gamut_expand; // 4 - 92 (Dark Cosmos: P3 gamut expansion, 0 = stock clamp) uint32_t pad; // 4 - 96 (padding to reach 16-byte boundary)- Step 4: Edit
blit.glsl
In BOTH push-constant blocks, replace uint pad1; with float gamut_expand; and keep uint pad2;.
Add the matrices after the #include/helper section, before void main() in the fragment stage:
// Dark Cosmos (platform/engine-patches/0001-hdr-gamut-expand.patch): linear D65 Rec.709 <-> Display P3.// mat3() takes COLUMNS. Pinned against HdrGamut (render/hdr_gamut.gd) by tests/test_hdr_gamut_patch.gd.const mat3 SRGB_TO_P3 = mat3(vec3(0.8224621, 0.0331941, 0.0170827), vec3(0.1775380, 0.9668058, 0.0723974), vec3(0.0, 0.0, 0.9105199));const mat3 P3_TO_SRGB = mat3(vec3(1.2249401, -0.0420569, -0.0196376), vec3(-0.2249404, 1.0420571, -0.0786361), vec3(0.0, 0.0, 1.0982735));Replace the single clamp line in the COLOR_SPACE_REC709_LINEAR branch (keep the upstream comment above it) with:
if (data.gamut_expand > 0.0) { // Dark Cosmos P3: re-read saturated colours as P3 primaries (neutrals untouched), then clamp // in P3 so negatives reach the extended-linear-sRGB layer as wide-gamut colour. float mx = max(max(color.r, color.g), color.b); float mn = min(min(color.r, color.g), color.b); float sat = mx > 1e-5 ? clamp((mx - mn) / mx, 0.0, 1.0) : 0.0; color.rgb = mix(color.rgb, P3_TO_SRGB * color.rgb, data.gamut_expand * sat); color.rgb = P3_TO_SRGB * clamp(SRGB_TO_P3 * color.rgb, vec3(0.0), vec3(data.output_max_value)); } else { color.rgb = clamp(color.rgb, vec3(0.0), vec3(data.output_max_value)); }- Step 5: Edit
renderer_compositor_rd.cpp
Register the setting once, in the RendererCompositorRD constructor body:
// Dark Cosmos P3 gamut expansion (0 = stock). Its existence is how the game detects a patched engine. GLOBAL_DEF(PropertyInfo(Variant::FLOAT, "rendering/hdr/gamut_expand", PROPERTY_HINT_RANGE, "0,1,0.01"), 0.0);In blit_render_targets_to_screen, next to the other per-frame reads (after reference_multiplier):
const float gamut_expand = CLAMP(float(GLOBAL_GET_CACHED(float, "rendering/hdr/gamut_expand")), 0.0f, 1.0f);and, beside blit.push_constant.output_max_value = output_max_value; in that loop:
blit.push_constant.gamut_expand = gamut_expand;In the boot-image blit (the second output_max_value site), add blit.push_constant.gamut_expand = 0.0f; so the boot splash stays stock. Fix any pad[...] references the compiler flags.
- Step 6: Commit the engine change and export the patch into the repo
cd ~/Claude/godot-tvos/godot-ios-471git add -A servers/rendering/renderer_rd && git commit -m "Dark Cosmos: HDR P3 gamut expansion in the final blit (rendering/hdr/gamut_expand)"git format-patch -1 HEAD --stdout > ~/Claude/bullet-heaven/.claude/worktrees/<this task's worktree>/platform/engine-patches/0001-hdr-gamut-expand.patchgit push lumara ios-p3-4.7.1- Step 7: Build the macOS editor and boot the game with it (the shader compile check)
cd ~/Claude/godot-tvos/godot-ios-471scons platform=macos target=editor arch=arm64 vulkan=no production=yes -j10 # ~4+ mincd <repo worktree>/platform/tvosperl -e 'alarm 60; exec @ARGV' ~/Claude/godot-tvos/godot-ios-471/bin/godot.macos.editor.arm64 \ --path . --rendering-method mobile --quit-after 600 2>&1 | tee $TMPDIR/p3-boot.log | tail -20git checkout -- project.godot audio/bus_layout.tres 2>/dev/null; truegrep -i -E "blit|shader.*(error|fail)|SPIR|Metal.*compile" $TMPDIR/p3-boot.logExpected: the game window draws frames, and the grep finds no blit/shader compile error. A broken blit gives a black window plus errors on the first frame.
- Step 8: Build the iOS templates and repackage
ios.zip
cd ~/Claude/godot-tvos/godot-ios-471DEVELOPER_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 -j10ls -la bin/ | grep libgodot.ios # confirm the exact output names before copyingT="$HOME/Library/Application Support/Godot/export_templates/4.7.1.stable"cp "$T/ios.zip" "$T/ios.zip.bak-2026-10-11-pre-p3"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 "rendering/hdr/gamut_expand" # >= 1The simulator slices stay stock. Nobody runs the simulator here; note it in the README.
- Step 9: Write
platform/engine-patches/README.mdand the patch-parity test
The README must cover:
- What each patch does.
- Which base each patch is applied to: the tvOS fork branch
tvos-render-1080p; iOS onios-p3-4.7.1= 4.7.1-stablea13da4feb. - The rebuild + repackage commands from Step 8 and Task 3.
- The backups:
ios.zip.bak-2026-10-11-pre-p3,tvos.zip.bak-2026-10-11-pre-p3. - Rollback: restore the backup zip AND the shell
libgodot.a. - When upgrading Godot, re-apply these patches or iOS silently loses P3.
tests/test_hdr_gamut_patch.gd:
extends GutTest
# The engine patch (platform/engine-patches/0001-hdr-gamut-expand.patch) must use the exact matrices and# setting name HdrGamut (render/hdr_gamut.gd) tests against. GLSL mat3() takes COLUMNS.
const PATCH := "res://platform/engine-patches/0001-hdr-gamut-expand.patch"
func _glsl_mat_rows(text: String, name: String) -> Array[Vector3]: var re := RegEx.create_from_string("const mat3 " + name + r" = mat3\(([^;]+)\);") var m := re.search(text) assert_not_null(m, "patch defines " + name) 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, name + " has 9 numbers") # Columns -> rows. return [Vector3(nums[0], nums[3], nums[6]), Vector3(nums[1], nums[4], nums[7]), Vector3(nums[2], nums[5], nums[8])]
func test_patch_matrices_match_hdr_gamut() -> void: var text := FileAccess.get_file_as_string(PATCH) assert_false(text.is_empty(), "patch file exists") for pair: Array in [["SRGB_TO_P3", HdrGamut.SRGB_TO_P3], ["P3_TO_SRGB", HdrGamut.P3_TO_SRGB]]: var rows := _glsl_mat_rows(text, pair[0]) for i in 3: assert_almost_eq(rows[i], (pair[1] as Array[Vector3])[i], Vector3.ONE * 1e-7, "%s row %d" % [pair[0], i])
func test_patch_uses_the_setting_hdr_gamut_probes() -> void: assert_true(FileAccess.get_file_as_string(PATCH).contains('"' + HdrGamut.SETTING + '"'))
func test_patch_keeps_the_stock_clamp_as_the_zero_path() -> void: assert_true(FileAccess.get_file_as_string(PATCH).contains( "color.rgb = clamp(color.rgb, vec3(0.0), vec3(data.output_max_value));"))Check that res://platform/... is readable from the root project. platform/ may be excluded by a .gdignore; if so, read it via ProjectSettings.globalize_path("res://") + "platform/engine-patches/..." with FileAccess.open. Run scripts/check-test-count.sh: PASS.
- Step 10: Commit (repo)
git add platform/engine-patches/ tests/test_hdr_gamut_patch.gd tests/test_hdr_gamut_patch.gd.uidgit commit --file=<msgfile> # "feat(hdr): engine patch for P3 gamut in the final blit + parity test; iOS templates rebuilt"Task 3: Apply the same patch to the tvOS fork → tvOS template
Section titled “Task 3: Apply the same patch to the tvOS fork → tvOS template”Files (engine): ~/Claude/godot-tvos/godot on branch tvos-render-1080p. The same three files as Task 2.
Files (repo): platform/engine-patches/README.md (record the fork commit hash).
Interfaces:
-
Consumes:
platform/engine-patches/0001-hdr-gamut-expand.patch(Task 2). -
Produces: a repackaged
~/Library/Application Support/Godot/export_templates/4.7.dev/tvos.zipand a refreshed tvOS shell lib. -
Step 1: Apply
cd ~/Claude/godot-tvos/godot && git status --short # only the two untracked libgodot.tvos.*.xcframework dirs are expectedgit am --3way <repo worktree>/platform/engine-patches/0001-hdr-gamut-expand.patchgit log --oneline -1 && git push lumara tvos-render-1080pOn a conflict, resolve by hand to exactly Task 2’s code. The fork’s blit lines are identical to 4.7-stable per the research doc, so a conflict is unexpected; report it if one happens.
- Step 2: Build and repackage (skill
bh-appstore-release§ 0.55)
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=tvos target=template_release arch=arm64 -j10T="$HOME/Library/Application Support/Godot/export_templates/4.7.dev"cp "$T/tvos.zip" "$T/tvos.zip.bak-2026-10-11-pre-p3"W=$(mktemp -d); mkdir -p "$W/libgodot.tvos.release.xcframework/tvos-arm64"cp bin/libgodot.tvos.template_release.arm64.a "$W/libgodot.tvos.release.xcframework/tvos-arm64/libgodot.a"(cd "$W" && zip "$T/tvos.zip" libgodot.tvos.release.xcframework/tvos-arm64/libgodot.a)unzip -p "$T/tvos.zip" libgodot.tvos.release.xcframework/tvos-arm64/libgodot.a | strings | grep -c "rendering/hdr/gamut_expand" # >= 1Also verify that the residency fix (4e0000a2f7) and EDR (efa0097) are still ancestors of HEAD: git merge-base --is-ancestor 4e0000a2f7 HEAD && git merge-base --is-ancestor efa009712e HEAD && echo ok.
- Step 3: Record and commit (repo)
Add the fork commit hash to platform/engine-patches/README.md, then:
git commit --file=<msgfile> # "docs(engine-patches): P3 patch applied to godot-tvos <hash>"Task 4: Game side: tunable, push, status/caps, console A/B
Section titled “Task 4: Game side: tunable, push, status/caps, console A/B”Files:
- Modify:
render/color_profile_hdr.gd: addstatic var GAMUT_EXPAND: float = 0.0 - Modify:
render/color_profile_sdr.gd: addstatic var GAMUT_EXPAND: float = 0.0, for profile key parity. The SDR target is 8-bit sRGB, so it has no engine effect there; say so in a comment. - Modify:
tests/test_color_profiles.gd: add"GAMUT_EXPAND": 0.0to BOTH seed-value dictionaries (lines ~49 and ~85). - Modify:
net/tunables_registry.gd: add{"key": "ColorProfileHdr.GAMUT_EXPAND", "script": COLOR_HDR, "name": "GAMUT_EXPAND", "min": 0.0, "max": 1.0}after the HDRALPHA_GAMMAentry (~line 1072), and the SDR twin after line 1024 only if an existing registry parity test demands it. - Modify:
tools/tunable_docs/docs.d/color_profiles.json: add the label, then regeneratecontrol/src/tunable_docs.jswith the generator the gate names (never hand-edit it). - Modify:
main.gd_apply_color_profile()(~2252): after theShaderGlobals.push(...)line, addHdrGamut.push(float(ColorProfile.script_for(HdrLook.active).get("GAMUT_EXPAND"))). - Modify:
render/hdr_output.gdstatus(): add"gamut_supported": HdrGamut.supported(), "gamut_expand": HdrGamut.last_pushedtoout’s initial dictionary. - Modify:
main.gdbuild_caps(): add"gamut_expand": HdrGamut.supported()beside"build". - Modify:
tools/console_mcp/lib/hdr_ab.mjs,tools/console_mcp/hdr_ab.mjs,tools/console_mcp/test/hdr_ab.test.mjs: add toggle'gamut'. - Test:
tests/test_hdr_gamut.gd(extend),tests/test_hdr_output.gd(extend).
Interfaces:
-
Consumes:
HdrGamut.push(value),HdrGamut.supported(),HdrGamut.last_pushed(Task 1). -
Produces: tunable key
ColorProfileHdr.GAMUT_EXPAND; console togglenode tools/console_mcp/hdr_ab.mjs --toggle gamut [--strength 1.0]. -
Step 1: Write the failing tests
Append to tests/test_hdr_gamut.gd:
func test_profiles_seed_gamut_off() -> void: Tunables.reset() assert_eq(ColorProfileHdr.GAMUT_EXPAND, 0.0, "0 = stock blit until Chris's verdict") assert_eq(ColorProfileSdr.GAMUT_EXPAND, 0.0)
func test_gamut_is_a_live_tunable() -> void: assert_eq(Tunables.apply({"ColorProfileHdr.GAMUT_EXPAND": 0.6}), []) assert_eq(ColorProfileHdr.GAMUT_EXPAND, 0.6) Tunables.reset()To show that a live edit reaches the engine, mirror the existing ALPHA_GAMMA live-edit tests at tests/test_quality_manager.gd:250-275. They drive a real main through Tunables.apply and the HOOK_ENV re-apply, then read a spy. Add test_live_gamut_edit_pushes_through_apply_color_profile there with the same setup:
HdrGamut._supported = 1HdrGamut.setter= a recording lambda- HDR profile active
- apply
{"ColorProfileHdr.GAMUT_EXPAND": 0.6} - assert the spy’s last call is
[HdrGamut.SETTING, 0.6] - switch to the SDR profile and assert the last call is
[HdrGamut.SETTING, 0.0]
Restore setter and reset_support_cache() in that file’s after_each.
Append to tests/test_hdr_output.gd:
func test_status_reports_gamut_support() -> void: var s := HdrOutput.status(null, false) assert_true(s.has("gamut_supported") and s.has("gamut_expand")) assert_false(s["gamut_supported"], "GUT runs the stock engine")In tools/console_mcp/test/hdr_ab.test.mjs:
test('gamut toggles the P3 expansion through set_tunables', () => { assert.deepEqual(phaseCommand('gamut', true, { strength: 0.7 }), { kind: 'set_tunables', tunables: { 'ColorProfileHdr.GAMUT_EXPAND': 0.7 } }); assert.deepEqual(phaseCommand('gamut', false), { kind: 'set_tunables', tunables: { 'ColorProfileHdr.GAMUT_EXPAND': 0.0 } }); assert.deepEqual(phaseCommand('gamut', true), { kind: 'set_tunables', tunables: { 'ColorProfileHdr.GAMUT_EXPAND': 1.0 } }); assert.ok(TOGGLES.includes('gamut'));});- Step 2: Run to verify they fail
Run scripts/check-test-count.sh and node --test tools/console_mcp/test/hdr_ab.test.mjs. Expected: FAIL (missing static, missing keys, unknown toggle).
- Step 3: Implement
- Add the statics in both profiles, each with a comment block. HDR comment:
# P3 wide gamut (render/hdr_gamut.gd): 0 = stock per-channel clamp; 1 = saturated colours fully re-read as P3 primaries. Needs a PATCHED engine (tvOS fork / iOS 4.7.1+patch); a no-op elsewhere. - Add the registry entry.
- Add the docs.d entry:
Then regenerate"ColorProfileHdr.GAMUT_EXPAND": {"label": "HDR wide gamut (P3)","desc": "HDR output on, Apple TV/iPhone engine builds only: pushes saturated neon past sRGB into Display P3. 0 = off (today's look), 1 = full. Neutral colours never move.","src": "code","group": "Glow & colour grade"}
tunable_docs.jsusing the command indocs/architecture/remote-config.md/ skillbh-tunable-batch. - Add the
main.gdpush line, then the status and caps keys. - In
lib/hdr_ab.mjs:TOGGLES = ['hdr_output', 'colour_space_fix', 'alpha', 'gamut']- in
phaseCommand(toggle, on, { gamma = 2.2, strength = 1.0 } = {})addif (toggle === 'gamut') return { kind: 'set_tunables', tunables: { 'ColorProfileHdr.GAMUT_EXPAND': on ? strength : 0.0 } }; - in the CLI
hdr_ab.mjs, parse--strengthand pass it through, mirroring how--gammais parsed and passed. Make the restore-state message mention gamut the way it does alpha.
- Check the LiveScope hook:
ColorProfileHdr.*keys must already map toHOOK_ENV(seenet/live_scope.gd:30). If the mapping is per-key rather than per-script, addGAMUT_EXPANDto it.
- Step 4: Run to verify they pass
Run scripts/check-test-count.sh, node --test tools/console_mcp/test/, then scripts/gate.sh. Expected: all green, with determinism untouched.
- Step 5: Commit
git add render/color_profile_hdr.gd render/color_profile_sdr.gd net/tunables_registry.gd \ tools/tunable_docs/docs.d/color_profiles.json control/src/tunable_docs.js main.gd render/hdr_output.gd \ tests/test_hdr_gamut.gd tests/test_hdr_output.gd tests/test_quality_manager.gd tests/test_color_profiles.gd \ tools/console_mcp/lib/hdr_ab.mjs tools/console_mcp/hdr_ab.mjs tools/console_mcp/test/hdr_ab.test.mjsgit commit --file=<msgfile> # "feat(hdr): ColorProfileHdr.GAMUT_EXPAND live tunable, engine push, status/caps, hdr_ab --toggle gamut"Task 5: Dev installs + docs, then Chris’s A/B
Section titled “Task 5: Dev installs + docs, then Chris’s A/B”Files:
-
Modify:
docs/architecture/rendering-and-performance.md: new § “HDR wide gamut (P3)”. Cover the mechanism, the two engine bases, the tunable, support detection, rollback, and that the effect is invisible in iOS screenshots (SDR), so it can only be judged by eye. -
Modify:
platform/tvos/CLAUDE.md:33: “iOS uses stock Godot” → “iOS uses stock Godot 4.7.1 +platform/engine-patches/(repackaged4.7.1.stable/ios.zip) + stock Xcode”. -
Modify:
.claude/skills/bh-appstore-release/SKILL.md§ 1: one line saying the iOS template is patched, with a pointer toplatform/engine-patches/README.md. -
Step 1: Docs + commit. Write the three edits above and commit. The § heading in the rendering doc is already Starlight-safe; no new docs file is needed.
-
Step 2: Refresh both Xcode shells’ engine libs.
install-dev.shuses--export-pack, which never re-copies the engine lib:- back up
platform/tvos/build/BulletHeaven.xcframework/tvos-arm64/libgodot.a, then copy the Task 3 lib over it; - back up
platform/tvos/build-ios/BulletHeaven.xcframework/ios-arm64/libgodot.a, then copy the Task 2template_debugortemplate_releaselib over it. Use whichever template variant the iOS-Dev install path links; readbh-deployto confirm.
Verify each with
strings <lib> | grep -c rendering/hdr/gamut_expand. - back up
-
Step 3: BUILD bump + Dev installs on the ATV and the iPhone 15 Pro Max, via skill
bh-deploy(orbh-worktree-device-installfrom this worktree). Followdocs/reference/builds-and-releases.mdfor the BUILD-number rules.- Confirm
/capsshowsgamut_expand: trueon both devices. - Confirm
/status hdr.gamut_supported: true. - iPhone: also confirm haptics still load and the app survives a task switch. Dev 301 crashed on task switch with HDR on, cause still open, so record whether it still happens. Don’t attribute it to this change without a crash log.
- Confirm
-
Step 4: Fresh-install stall check with skill
bh-fresh-install-stall-checkon the iPhone, because the engine binary changed. -
Step 5: Chris’s A/B (Chris must be present; ATV first, then iPhone).
- Use HDR output on, the persisted AgX look, a real run or the freeze scene.
- One variable only:
node tools/console_mcp/hdr_ab.mjs --device atv --toggle gamut --strength 1.0, holding each state 15-20 s. - Then try
--strength 0.5. - Ask Chris to watch for:
- greens and reds (the biggest P3 gain);
- hue skew on orange/yellow (Railspike, fire);
- halos and glow edges for fringing;
- UI/HUD colours (the blit applies to the whole frame, HUD included).
- Record the verdict in memory
bullet-heaven-hdr-output. - Do not change the 0.0 seed in this plan. Persisting a value is a follow-up seed commit plus a remote-config patch, and needs Chris’s OK.
-
Step 6: Review + merge path. Have
bh-reviewerreview the branch, merge intohdr-colour-space(notmain), then push. Merginghdr-colour-spacetomainstays a separate Chris call (memorybullet-heaven-hdr-output).
Rollback
Section titled “Rollback”- iOS:
cp ios.zip.bak-2026-10-11-pre-p3 ios.zip, then restore thebuild-iosshell lib backup. - tvOS: do the same with
tvos.zip.bak-2026-10-11-pre-p3and thebuild/shell lib. - The game side is inert on a stock engine (
supported()false), so it needs no rollback.
Self-review notes
Section titled “Self-review notes”- Spec item 7 is covered by Tasks 2-3 (engine) and Task 4 (A/B).
- Research § 3 step 1 (negative clamp) and step 2 (gamut expand) both land in the blit. The research proposed putting step 2 “at the end of the tonemap pass”. The blit is later and carries no AgX/BCS ordering risk, and it covers the HUD too. That HUD coverage is a deliberate trade-off for Chris to judge in Step 5.
- Glow negatives (research risk) can’t occur: expansion happens after glow and tonemap, in the blit.
Execution handoff
Section titled “Execution handoff”- Scope ruling (Chris, 2026-10-11): “ATV + iOS from fork” was chosen over ATV-only or skipping P3.
- The planning session then based iOS on 4.7.1-stable + patch, not on the 4.7.dev tvOS fork. Reasons: the fork base is upstream master of ~2026-03-30, the stock 4.7.1 editor bakes the iOS shaders, and
godot --version=4.7.1.stable.official.a13da4feb. - If Chris objects, the only change is Task 2’s base.
- The planning session then based iOS on 4.7.1-stable + patch, not on the 4.7.dev tvOS fork. Reasons: the fork base is upstream master of ~2026-03-30, the stock 4.7.1 editor bakes the iOS shaders, and
- macOS and web stay stock (not in scope). Task 2 Step 7’s patched Mac editor is only a compile check, plus optional Mac XDR eyeballing.
- Verified during planning:
- Fork Metal layer =
kCGColorSpaceExtendedLinearSRGB+ RGBA16F (drivers/metal/rendering_context_driver_metal.cpp:195). - Clamp at
blit.glsl:169. BlitPushConstanthasuint32_t pad[2]spare.MaterialStorage::global_shader_parameter_getis editor-only (errors at runtime), which is why the plan uses a project setting.GLOBAL_GET_CACHEDis spin-locked and already used by the RD renderers.- The fork checkout
~/Claude/godot-tvos/godot(branchtvos-render-1080p, HEADefa009712e) is clean apart from two untrackedlibgodot.tvos.*.xcframeworkdirs. - No
4.7*tags exist in the fork repo yet, so Task 2 Step 1 fetches the tag.
- Fork Metal layer =
- Authorized: fork commits +
git push lumara, local template zip repackaging (with backups), Dev installs to Chris’s ATV/iPhone, and pushinghdr-colour-space. - Needs Chris’s same-day OK: any store/TestFlight build, any non-zero seed or remote-config patch, and merging
hdr-colour-spacetomain. - Task 5 Step 5 needs Chris present.
- Nothing was done outside this plan file during planning.
- The main checkout (on
main) has uncommitted edits to.claude/skills/bh-overnight-audit-loop/SKILL.mdandplatform/tvos/CLAUDE.mdfrom another session. Leave them alone. - Next action: start SDD Task 1 in a new worktree branched off
hdr-colour-space(checkbh-sdd-concurrentfirst).