Skip to content

HDR P3 wide gamut (engine blit) 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 skill bh-sdd-concurrent before 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), when gamut_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 kCGColorSpaceExtendedLinearSRGB layer.
    • The value reaches the engine through a project setting, rendering/hdr/gamut_expand, read with GLOBAL_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-tvos fork (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.
  • 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.

  • Render-only: nothing under /sim changes and the determinism baselines must not move.
  • gamut_expand == 0 must take the exact stock code path in the blit: an if/else, with the else branch holding the original line unmodified. This is the bundled default for both profiles.
  • HDR-only: the effect exists only in the COLOR_SPACE_REC709_LINEAR branch. 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.json label in the same task.
    • control/src/tunable_docs.js is GENERATED; never hand-edit it.
  • Fork rules (platform/tvos/CLAUDE.md):
    • Never push to origin (upstream stuartcarnie).
    • Push every fork commit to the lumara remote.
    • Fork changes reach a build only after the template zip is repackaged AND the Xcode shell’s libgodot.a is refreshed (--export-pack never re-copies it).
  • No store/TestFlight build in this plan. Dev installs only, via bh-deploy / bh-worktree-device-install. Read docs/reference/builds-and-releases.md before 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 gate scripts/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).

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 HdrGamut
    • const 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) -> Vector3
    • static func blit(c: Vector3, strength: float, max_value: float) -> Vector3, a mirror of the shader
    • static func supported() -> bool
    • static var setter: Callable (test spy seam)
    • static func reset_support_cache() -> void
    • static func push(value: float) -> void
    • static 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 HdrGamut
extends 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
Terminal window
git add render/hdr_gamut.gd render/hdr_gamut.gd.uid tests/test_hdr_gamut.gd
git 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, branch ios-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.zip in ~/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

Terminal window
cd ~/Claude/godot-tvos/godot
git fetch https://github.com/godotengine/godot.git refs/tags/4.7.1-stable:refs/tags/4.7.1-stable --no-tags
git 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-stable

If 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.h BlitPushConstant ends float output_max_value; uint32_t pad[2];
  • blit.glsl has two push-constant blocks ending float output_max_value; uint pad1; uint pad2;
  • blit.glsl has the line color.rgb = clamp(color.rgb, vec3(0.0), vec3(data.output_max_value));
  • renderer_compositor_rd.cpp sets blit.push_constant.output_max_value in exactly two places: blit_render_targets_to_screen and 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
Terminal window
cd ~/Claude/godot-tvos/godot-ios-471
git 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.patch
git push lumara ios-p3-4.7.1
  • Step 7: Build the macOS editor and boot the game with it (the shader compile check)
Terminal window
cd ~/Claude/godot-tvos/godot-ios-471
scons platform=macos target=editor arch=arm64 vulkan=no production=yes -j10 # ~4+ min
cd <repo worktree>/platform/tvos
perl -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 -20
git checkout -- project.godot audio/bus_layout.tres 2>/dev/null; true
grep -i -E "blit|shader.*(error|fail)|SPIR|Metal.*compile" $TMPDIR/p3-boot.log

Expected: 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
Terminal window
cd ~/Claude/godot-tvos/godot-ios-471
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_release arch=arm64 production=yes -j10
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=ios target=template_debug arch=arm64 production=yes -j10
ls -la bin/ | grep libgodot.ios # confirm the exact output names before copying
T="$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" # >= 1

The simulator slices stay stock. Nobody runs the simulator here; note it in the README.

  • Step 9: Write platform/engine-patches/README.md and 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 on ios-p3-4.7.1 = 4.7.1-stable a13da4feb.
  • 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)
Terminal window
git add platform/engine-patches/ tests/test_hdr_gamut_patch.gd tests/test_hdr_gamut_patch.gd.uid
git 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.zip and a refreshed tvOS shell lib.

  • Step 1: Apply

Terminal window
cd ~/Claude/godot-tvos/godot && git status --short # only the two untracked libgodot.tvos.*.xcframework dirs are expected
git am --3way <repo worktree>/platform/engine-patches/0001-hdr-gamut-expand.patch
git log --oneline -1 && git push lumara tvos-render-1080p

On 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)
Terminal window
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scons platform=tvos target=template_release arch=arm64 -j10
T="$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" # >= 1

Also 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:

Terminal window
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: add static var GAMUT_EXPAND: float = 0.0
  • Modify: render/color_profile_sdr.gd: add static 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.0 to 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 HDR ALPHA_GAMMA entry (~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 regenerate control/src/tunable_docs.js with the generator the gate names (never hand-edit it).
  • Modify: main.gd _apply_color_profile() (~2252): after the ShaderGlobals.push(...) line, add HdrGamut.push(float(ColorProfile.script_for(HdrLook.active).get("GAMUT_EXPAND"))).
  • Modify: render/hdr_output.gd status(): add "gamut_supported": HdrGamut.supported(), "gamut_expand": HdrGamut.last_pushed to out’s initial dictionary.
  • Modify: main.gd build_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 toggle node 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 = 1
  • HdrGamut.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
  1. 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.
  2. Add the registry entry.
  3. Add the docs.d entry:
    "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"
    }
    Then regenerate tunable_docs.js using the command in docs/architecture/remote-config.md / skill bh-tunable-batch.
  4. Add the main.gd push line, then the status and caps keys.
  5. In lib/hdr_ab.mjs:
    • TOGGLES = ['hdr_output', 'colour_space_fix', 'alpha', 'gamut']
    • in phaseCommand(toggle, on, { gamma = 2.2, strength = 1.0 } = {}) add if (toggle === 'gamut') return { kind: 'set_tunables', tunables: { 'ColorProfileHdr.GAMUT_EXPAND': on ? strength : 0.0 } };
    • in the CLI hdr_ab.mjs, parse --strength and pass it through, mirroring how --gamma is parsed and passed. Make the restore-state message mention gamut the way it does alpha.
  6. Check the LiveScope hook: ColorProfileHdr.* keys must already map to HOOK_ENV (see net/live_scope.gd:30). If the mapping is per-key rather than per-script, add GAMUT_EXPAND to 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
Terminal window
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.mjs
git 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/ (repackaged 4.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 to platform/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.sh uses --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 2 template_debug or template_release lib over it. Use whichever template variant the iOS-Dev install path links; read bh-deploy to confirm.

    Verify each with strings <lib> | grep -c rendering/hdr/gamut_expand.

  • Step 3: BUILD bump + Dev installs on the ATV and the iPhone 15 Pro Max, via skill bh-deploy (or bh-worktree-device-install from this worktree). Follow docs/reference/builds-and-releases.md for the BUILD-number rules.

    • Confirm /caps shows gamut_expand: true on 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.
  • Step 4: Fresh-install stall check with skill bh-fresh-install-stall-check on 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-reviewer review the branch, merge into hdr-colour-space (not main), then push. Merging hdr-colour-space to main stays a separate Chris call (memory bullet-heaven-hdr-output).


  • iOS: cp ios.zip.bak-2026-10-11-pre-p3 ios.zip, then restore the build-ios shell lib backup.
  • tvOS: do the same with tvos.zip.bak-2026-10-11-pre-p3 and the build/ shell lib.
  • The game side is inert on a stock engine (supported() false), so it needs no rollback.
  • 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.
  • 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.
  • 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.
    • BlitPushConstant has uint32_t pad[2] spare.
    • MaterialStorage::global_shader_parameter_get is editor-only (errors at runtime), which is why the plan uses a project setting.
    • GLOBAL_GET_CACHED is spin-locked and already used by the RD renderers.
    • The fork checkout ~/Claude/godot-tvos/godot (branch tvos-render-1080p, HEAD efa009712e) is clean apart from two untracked libgodot.tvos.*.xcframework dirs.
    • No 4.7* tags exist in the fork repo yet, so Task 2 Step 1 fetches the tag.
  • Authorized: fork commits + git push lumara, local template zip repackaging (with backups), Dev installs to Chris’s ATV/iPhone, and pushing hdr-colour-space.
  • Needs Chris’s same-day OK: any store/TestFlight build, any non-zero seed or remote-config patch, and merging hdr-colour-space to main.
  • 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.md and platform/tvos/CLAUDE.md from another session. Leave them alone.
  • Next action: start SDD Task 1 in a new worktree branched off hdr-colour-space (check bh-sdd-concurrent first).