Skip to content

Unremapped-alpha fix batch — implementation plan

Unremapped-Alpha Fix Batch Implementation Plan

Section titled “Unremapped-Alpha Fix Batch 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.

Goal: Give the six measured unremapped partial-alpha draws on the linear root canvas (enemy Nova glow, far stars, low-HP border, death dissolve, weapon-zone fade, glass focus glow) the hdr_alpha remap, each behind its own switchable, paired SDR/HDR live tunable, measured before/after with a real-render capture, and judged by Chris on device.

Architecture: The template is the merged player hull-glow fix (e8e482e9, parents 30e490ba / a51c0512): an alpha remap that is an exact no-op on an 8-bit root (hdr_alpha_gamma == 1.0 early-out), switched by a bool static in BOTH ColorProfileSdr and ColorProfileHdr (registered, HOOK_ENV, live). One draw (the Nova glow, a null-material MultiMesh) is fixed by a material swap like the hull glow. The other five are saved shaders, so each gets a gated remap x > 0.5 ? hdr_alpha(a) : a, with the switch delivered as a new global shader uniform pushed by the existing ShaderGlobals.push (so a live edit lands through main._apply_color_profile, with no per-renderer plumbing and nothing that depends on a ticking sim). A new dev harness, tools/alpha_fix_capture/, measures each item’s light contribution (frame shown minus hidden, linear luminance) in the frozen console freeze scene under 8-bit SDR / linear SDR / HDR, switch off and on.

Tech Stack: Godot 4.7 typed GDScript, canvas_item shaders, GUT, Node node --test (tunable docs), the dark-cosmos-console MCP for the device A/B.

Spec: docs/research/2026-10-12-unremapped-alpha-audit.md (the audit; its “Recommended fix batch” items 1-3 minus the Funzo shaders are this plan’s scope). Background: docs/architecture/rendering-and-performance.md § “A linear canvas blends alpha in LINEAR light”.

  • SDR is NOT frozen (Chris 2026-10-12): every fix ships ON in BOTH profiles. On an 8-bit root (L1+ without HDR output, web / gl_compatibility) every remap must be an exact no-op. The harness proves it per item: the S8r row’s contribution must equal the S8 row’s exactly (maxdiff=0.000000).
    • Note 2026-10-13 (Task 8): two exceptions shipped OFF, not ON. WEAPON_ZONE_REMAP and GLASS_GLOW_REMAP are seeded false in both profiles (pinned by SHIPPED_OFF in tests/test_alpha_remap_switches.gd): measured, the weapon-zone remap over-steepens the life fade (0.36 / 0.11 of 8-bit in SDR) and the glass-glow remap over-corrects the focus glow to ~0.1x of 8-bit light with a too-tight falloff. Both stay switchable for the on-device A/B. The low-HP border also ships alpha-only (the both-factor form measured 0.47).
  • Every fix is a switchable registered live tunable with independent ColorProfileSdr + ColorProfileHdr values: type: "bool", both seeded true (except WEAPON_ZONE_REMAP and GLASS_GLOW_REMAP, which ship false; see the SDR note above), registered in net/tunables_registry.gd, labelled in tools/tunable_docs/docs.d/color_profiles.json, control/src/tunable_docs.js regenerated with node tools/tunable_docs/generate.mjs (a hand edit fails the gate). tests/test_color_profiles.gd pins pairing (test_every_sdr_value_has_an_hdr_counterpart_and_back), the SDR bundle (SDR_BUNDLED, size-checked), registration + HOOK_ENV (test_every_profile_value_is_a_registered_live_tunable) and the [shader_globals] declarations (test_every_global_is_declared_in_both_projects_at_its_sdr_value).
  • Saved materials only (A17 shader-baker bug godot#124088): never ShaderMaterial.new() in shipped dirs; new materials are saved .tres preloaded.
  • Global uniforms are declared in [shader_globals] of BOTH project.godot and platform/tvos/project.godot (type float, value 1.0 = the SDR value), with ; comments only (a # line is glued onto the next key).
  • The headless gate never compiles shaders. After every shader edit run, FOREGROUND: godot --path . --rendering-method mobile -s res://tools/shader_check/compile_check.gd 2>&1 | grep -E "ERROR|WARNING|compile_check" and the same with --rendering-method gl_compatibility. A clean run prints only the two compile_check: lines. Then git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot only after committing your own intended project.godot edits (or re-apply them). compile_check.gd discovers every saved material whose shader names hdr_alpha.gdshaderinc, so each touched shader is covered once it includes it.
  • Determinism untouched: every change is render-side (/render, /fx, /ui, main.gd render wiring). Nothing under /sim changes. The gate’s three determinism tests must pass unchanged.
  • Gate: GUT_LOG=$TMPDIR/alpha-fix-<task>.log scripts/check-test-count.sh (own log path: other worktrees may run gates concurrently), then scripts/gate.sh before reporting done. Never pull a raw GUT run into context. One GUT script: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=<file>.gd -gexit 2>&1 | grep -E "\[Failed\]|Passing|Failing|Tests|Asserts" | tail -15.
  • Fresh worktree: run godot --headless --path . --import once first, then git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot.
  • Commits: git commit --file=<msgfile>, message ending with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> and Claude-Session: https://claude.ai/code/session_01PMFWuj1oet3a3KRztVziEC.
  • Copy rule: this is code and dev docs, not marketing copy; no em-dash rule applies, but keep comments plain.
  • Before dispatching Task 1, run the bh-sdd-concurrent check (another session may be on main; this repo routinely has several).
  • Integration branch: alpha-fix-batch, worktree /Users/chris/Claude/bullet-heaven/.claude/worktrees/alpha-fix-batch, created by the controller off origin/alpha-fix-plan (which carries this plan): git -C /Users/chris/Claude/bullet-heaven fetch -q origin && git -C /Users/chris/Claude/bullet-heaven worktree add -b alpha-fix-batch /Users/chris/Claude/bullet-heaven/.claude/worktrees/alpha-fix-batch origin/alpha-fix-plan
  • Implementers: agent bh-implementer, model: "opus", one per task, each in its OWN worktree the controller creates off the current alpha-fix-batch tip: .claude/worktrees/alpha-fix-t<N> on branch alpha-fix-t<N>. Give the implementer that absolute path; do NOT also set the Agent tool’s isolation: "worktree" (it would land in a harness worktree off main). Run the sdd-worktree-leak-guard checklist after every dispatch.
  • Reviewer: agent bh-reviewer per task, on alpha-fix-t<N> vs alpha-fix-batch. On MERGE, the controller fast-forwards (or merges) alpha-fix-t<N> into alpha-fix-batch. Tasks are strictly sequential: Tasks 3-7 all append to the same shared lists (SDR_BUNDLED, ShaderGlobals.GLOBALS, the push-list assert, the registry, docs.d, the rendering-doc table).
  • Do not merge alpha-fix-batch to main until Task 8’s device A/B is judged by Chris. Merging to main follows docs/reference/builds-and-releases.md (bh-merge-reviewed-branch).

In scope (the audit’s recommended batch, items 1-3):

# item file path class fix
1 Enemy Nova bucket glow render/archetype_renderer.gd:440-445 (mm_glow, no material) null-material MultiMesh, mix material swap to a saved cs_mix copy
2 Far stars render/shaders/far_stars.gdshader:53 saved ShaderMaterial, blend_add, alpha falloff gated hdr_alpha on COLOR.a
3 Low-HP border render/shaders/low_hp_border.gdshader:23-24 saved, blend_add, vec4(col * a, a) gated remap on the alpha only (both factors measured at half the 8-bit light)
4 Death dissolve render/shaders/death_dissolve.gdshader:19 saved, blend_add, alpha gated hdr_alpha on alpha
5 Weapon zones (Firestorm / Thunder Wake) render/weapon_zone_field.gdshader:144,211 saved, blend_add, alpha = life fade gated hdr_alpha(fade) (the alpha part only)
6 Glass focus glow (level-up cards, pause menu, every GlassPanel) ui/theme/glass_common.gdshaderinc:34-35 saved ShaderMaterials glass_clear / glass_frost / glass_snapshot gated hdr_alpha on the GLOW alpha only

Explicitly OUT of scope, with recommendations (listed again in the rendering doc by Task 8):

  • Glass grey body (core 1.7-2.3x, “milky cards”). NOT fixed by the remap. Cause: glass_surface’s authored sRGB constants (mix(base, vec3(1.0), mix(0.13, 0.035, t)), the rim 0.22 + 0.4 * focus, the top edge 0.25, the bottom shade) land unconverted in a linear buffer, and glass_clear’s body_a 0.72 is a DARK tint (the audit’s dark-overlay class, where pow(a, γ) goes the wrong way). Recommendation: a separate design + plan: (a) a new global ui_canvas_linear (1.0 while the root is linear, pushed next to hdr_alpha_gamma by ShaderGlobals.push_alpha), (b) in glass_surface, convert the sheen / rim / edge mix weights to their linear-canvas equivalents when it is set (the mix happens on linear values, so the 8-bit look needs the weight applied to sRGB-decoded values; simplest exact form: decode base and body to sRGB space, mix, re-encode), (c) glass_clear’s 0.72 tint via the inverse remap 1 - pow(1 - a, γ). Shader edit on every menu: A17 baker check required. Chris decides whether it is worth it after seeing Task 6’s glow-only result.
  • RGB-falloff family (every fx/weapons/*.gdshader and fx/boss/warden_*.gdshader: COLOR = vec4(col * falloff, 1.0), incl. Orbit shards and Rift Bolt): alpha is 1.0, so hdr_alpha cannot reach it. Needs a per-shader hdr_falloff(w) on the intensity scalar and Chris’s eye (these looks were tuned on the Mac’s mobile renderer, which is a linear canvas at L0).
  • Dark dims / veils (pause dim ui/pause_menu.gd, level-up veil ui/level_up_panel.gd, fx/screen_feedback.gd VignetteFrame, outlines, surface.scrim): need the inverse remap 1 - pow(1 - a, γ), a second helper. Never give them hdr_alpha.
  • Covered backdrop / zones under-shoot (arena backdrop 0.37x, zones 0.47x of the 8-bit light: a^γ is exact only over black). Candidate: a per-layer gamma below 2.2 on the backdrop; Chris’s eye on L0 SDR.
  • Funzo shaders (render/funzo_*.gdshader): the audit’s item 3 lists them, but funzo_zone_field carries alpha past 1 into rgb (rendering doc § “Not covered”), so it needs its own design.
  • Drone panel glow (ui/drone_panel.gd:82 via DroneSprites.draw, render/drone_sprites.gd:42): the glow and the body are drawn with draw_texture_rect on the SAME CanvasItem, so a remap material would also hit the body. Needs the glow moved to its own CanvasItem first.
  • Default-material draw_* / StyleBoxFlat shadows (HUD, hex slots), threat radar, swarm cores, Warden ring bodies: leave (audit § “Leave as is” or no shader hook).
file change task
tools/alpha_fix_capture/alpha_fix_capture.gd + .tscn create: the measurement harness 1
.gitignore add tools/alpha_fix_capture/out/ 1
docs/architecture/rendering-and-performance.md new subsection + per-task numbers 1-8
render/shaders/nova_glow_mix_mat.tres create: saved ShaderMaterial on cs_mix.gdshader 2
render/archetype_renderer.gd Nova glow list + refresh_nova_glow_remap() 2
main.gd (_apply_color_profile, line 2545) call enemy_renderer.refresh_nova_glow_remap() 2
tests/test_nova_glow_remap.gd create 2
render/color_profile_sdr.gd, render/color_profile_hdr.gd one bool switch per task 2-7
net/tunables_registry.gd two rows per task 2-7
tools/tunable_docs/docs.d/color_profiles.json, control/src/tunable_docs.js two labels per task, regenerate 2-7
tests/test_color_profiles.gd SDR_BUNDLED + the SDR push list 2-7
render/shader_globals.gd bool push as float; one global per shader task 3-7
project.godot, platform/tvos/project.godot one [shader_globals] declaration per shader task 3-7
tests/test_alpha_remap_switches.gd create (Task 3), one row per later task 3-7
render/shaders/far_stars.gdshader gated remap 3
render/shaders/low_hp_border.gdshader gated remap 4
render/shaders/death_dissolve.gdshader gated remap 5
render/weapon_zone_field.gdshader gated remap 6
ui/theme/glass_common.gdshaderinc gated remap on the glow 7

Shared recipe P: add one paired profile switch <NAME>

Section titled “Shared recipe P: add one paired profile switch <NAME>”

Every task 2-7 adds exactly one switch. The per-task values (name, comment, labels, descriptions, global) are given literally in each task; apply them with these steps. Each anchor below was verified against origin/main at 7772c7b8.

P1. render/color_profile_sdr.gd. Task 2 inserts this block directly after the line static var GLASS_BACKDROP_CAP: float = 10000.0 (line 86); Tasks 3-7 append their two lines at the END of the block:

# Unremapped-alpha fix batch (2026-10-12, docs/superpowers/plans/2026-10-12-unremapped-alpha-fix-batch.md):
# one switch per layer that drew partial alpha on the linear root canvas without the hdr_alpha remap.
# true = remapped (an exact no-op on an 8-bit root: hdr_alpha_gamma is 1.0 there); false = the pre-fix draw.

then, per task:

# <the task's one-line comment>
static var <NAME>: bool = true

P2. render/color_profile_hdr.gd. Same block and lines, inserted after static var GLASS_BACKDROP_CAP: float = 1.0 (line 70).

P3. net/tunables_registry.gd. Insert after the row {"key": "ColorProfileSdr.GLASS_BACKDROP_CAP", ...} (line 1101; Tasks 3-7: after the previous switch’s SDR row):

{"key": "ColorProfileSdr.<NAME>", "script": COLOR_SDR, "name": "<NAME>", "type": "bool"},

and after the row {"key": "ColorProfileHdr.GLASS_BACKDROP_CAP", ...} (line 1152; Tasks 3-7: after the previous switch’s HDR row):

{"key": "ColorProfileHdr.<NAME>", "script": COLOR_HDR, "name": "<NAME>", "type": "bool"},

LiveScope already maps the ColorProfileSdr. / ColorProfileHdr. prefixes to HOOK_ENV (net/live_scope.gd:348-349), so nothing else is needed for the live path.

P4. tools/tunable_docs/docs.d/color_profiles.json. Insert after the closing }, of the "ColorProfileHdr.GLASS_BACKDROP_CAP" entry (line 700; Tasks 3-7: after the previous switch’s HDR entry):

"ColorProfileSdr.<NAME>": {
"label": "<SDR label>",
"desc": "<SDR desc>",
"src": "code",
"group": "Glow & colour grade"
},
"ColorProfileHdr.<NAME>": {
"label": "<HDR label>",
"desc": "<HDR desc>",
"src": "code",
"group": "Glow & colour grade"
},

(No unit on a bool, as the HERO_HULL_GLOW_REMAP entries.) Then:

Terminal window
node -e "JSON.parse(require('fs').readFileSync('tools/tunable_docs/docs.d/color_profiles.json','utf8'))" && node tools/tunable_docs/generate.mjs && node --test control/test/tunable-docs.test.mjs 2>&1 | grep -E "^# (pass|fail)"

Expected: # pass N and # fail 0.

P5. tests/test_color_profiles.gd SDR_BUNDLED. Insert one line directly ABOVE "LDR_GLOW_INTENSITY": 0.9, "LDR_GLOW_BLOOM": 0.2,:

"<NAME>": 1.0,

(test_sdr_bundled_values_are_todays_look compares float(value), so true reads 1.0. HDR_SEEDS needs no entry: an HDR value absent there must equal the SDR value, and both are true.)

P6 (shader tasks 3-7 only): the global. (a) render/shader_globals.gd: append &"<global>": "<NAME>" as the LAST entry of GLOBALS. (b) Both project.godot and platform/tvos/project.godot: insert directly after the hdr_alpha_gamma={ ... } block (root file line 91-94, tvOS line 90-93; Tasks 4-7: after the previous switch’s block):

; Unremapped-alpha fix batch switch (ColorProfile*.<NAME>, render/shader_globals.gd): 1.0 = remap on (the SDR seed).
<global>={
"type": "float",
"value": 1.0
}

(c) tests/test_color_profiles.gd test_push_sends_the_profiles_values: append [&"<global>", 1.0] to the END of the exact SDR list (the one that currently ends [&"glass_cap", 10000.0], [&"hdr_alpha_gamma", 2.2]]). (d) Add a row to SWITCHED in tests/test_alpha_remap_switches.gd (created by Task 3).

Terminal window
mkdir -p "$TMPDIR/afc" && AF_OUT="$TMPDIR/afc" AF_ONLY=<item> perl -e 'alarm 1200; exec @ARGV' \
godot --path . --rendering-method mobile --always-on-top --resolution 320x180 \
res://tools/alpha_fix_capture/alpha_fix_capture.tscn 2>&1 | grep "\[afc\]"
git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot # after committing your own edits to them

Run it FOREGROUND with --always-on-top (the audit’s first run stalled 10 minutes when another window covered Godot’s: macOS stopped presenting frames). Copy the [afc] <item> <tag> ... lines into the task’s row of the rendering-doc table (Task 1 creates it). Acceptance for every fix task:

  • S8r prints maxdiff=0.000000 (exact no-op on an 8-bit root),
  • floor=0.000000 on every row (the frozen frame is stable, so the numbers are real),
  • SLr fringe is nearer 1.00 than SL fringe (the falloff shape moves toward the 8-bit look). If it does not, STOP and report: the item is not the class the audit says.

Task 1: Measurement harness tools/alpha_fix_capture + baseline numbers

Section titled “Task 1: Measurement harness tools/alpha_fix_capture + baseline numbers”

The audit’s own harness (tools/alpha_audit_capture/) was never committed and its worktree is gone, so this rebuilds a focused one from tools/player_glow_capture (same _set_profile / _set_canvas path through main’s real HDR hooks). It measures all six items now; the r (switch-on) rows are identical to the off rows until each fix lands (the switch statics don’t exist yet; Object.set on a missing property is a silent no-op). That makes this task’s output the BEFORE column for every item.

Files:

  • Create: tools/alpha_fix_capture/alpha_fix_capture.gd
  • Create: tools/alpha_fix_capture/alpha_fix_capture.tscn
  • Modify: .gitignore (after the line tools/player_glow_capture/out/)
  • Modify: docs/architecture/rendering-and-performance.md (new subsection, directly before ### HDR brightness sources (HDR/SDR colour systems step 3))

Interfaces:

  • Produces: item ids nova_glow, far_stars, low_hp, dissolve, weapon_zone, glass; the switch names they toggle (ENEMY_NOVA_GLOW_REMAP, FAR_STARS_REMAP, LOW_HP_BORDER_REMAP, DISSOLVE_REMAP, WEAPON_ZONE_REMAP, GLASS_GLOW_REMAP), which Tasks 2-7 MUST use verbatim; the [afc] output line format.

  • Consumes: main.enemy_renderer._shells / ._shell_kinds (render/archetype_renderer.gd:205-217), main.far_stars._mm (render/far_stars.gd:45), main.screen_fx._border / .set_hp_fraction() / .advance() (fx/screen_feedback.gd:114,186), main.death_dissolve._sprites / ._age / .spawn_at() / DeathDissolve.LIFE, ZoneRenderer.new_weapon_zone_layer() / .weapon_zone_instance() / .weapon_zone_list() (render/zone_renderer.gd:494-529), main._toggle_pause() / main.pause_menu, GlassPanel.backdrop() (ui/theme/glass_panel.gd:105).

  • Step 1: Create the scene file tools/alpha_fix_capture/alpha_fix_capture.tscn:

[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/alpha_fix_capture/alpha_fix_capture.gd" id="1"]
[node name="AlphaFixCapture" type="Node"]
script = ExtResource("1")
  • Step 2: Create the harness tools/alpha_fix_capture/alpha_fix_capture.gd:
extends Node
# Unremapped-alpha fix batch: per-item light contribution, switch off vs on -- DEV ONLY (2026-10-12).
# Plan: docs/superpowers/plans/2026-10-12-unremapped-alpha-fix-batch.md. Audit:
# docs/research/2026-10-12-unremapped-alpha-audit.md (its own harness was never committed).
# Boots the real main.tscn into a 1920x1080 SubViewport (real WorldEnvironment, colour profiles, L0,
# colour_space_fix OFF = store default), starts the console freeze scene, then per item stages it,
# sets Engine.time_scale = 0 (stops shader TIME, particles, tweens: the noise floor is then 0) and per
# row grabs the frame with the item shown and hidden (RenderingServer visibility). Contribution =
# shown - hidden, linear luminance (8-bit frames sRGB-decoded first). Rows:
# S8 8-bit canvas, SDR profile, switch OFF: the reference (L1+ tiers, web)
# S8r 8-bit canvas, SDR, switch ON: must equal S8 exactly (maxdiff 0: the remap is a no-op there)
# SL / SLr linear canvas, SDR profile (Metal L0, tvOS from boot), off / on
# H / Hr linear canvas, HDR profile at AF_HEADROOM (default 4.93, the ATV), off / on
# (+ per-item EXTRA rows, e.g. HDR gain candidates)
# Per row: total (sum of contribution), x8 = total / S8 total, core / fringe = the same ratio over the
# pixels where S8's contribution is >= 30% of its peak (core) or 1-10% of it (fringe: the soft falloff),
# floor = max |diff| between two grabs of the shown frame, maxdiff = max |contribution - S8's|.
# AF_OUT=/abs/dir [AF_ONLY=item,item] [AF_HEADROOM=2.67] perl -e 'alarm 1200; exec @ARGV' \
# godot --path . --rendering-method mobile --always-on-top --resolution 320x180 \
# res://tools/alpha_fix_capture/alpha_fix_capture.tscn 2>&1 | grep "\[afc\]"
# Then: git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot
const SIZE := Vector2i(1920, 1080)
const SETTLE := 20
const ORDER := ["nova_glow", "far_stars", "low_hp", "dissolve", "weapon_zone", "glass"]
# item -> its switch, a bool static in BOTH ColorProfileSdr and ColorProfileHdr (Tasks 2-7).
const SWITCH := {
"nova_glow": "ENEMY_NOVA_GLOW_REMAP",
"far_stars": "FAR_STARS_REMAP",
"low_hp": "LOW_HP_BORDER_REMAP",
"dissolve": "DISSOLVE_REMAP",
"weapon_zone": "WEAPON_ZONE_REMAP",
"glass": "GLASS_GLOW_REMAP",
}
# [tag, hdr profile, linear canvas, switch on, {HDR-profile overrides}]
const ROWS := [
["S8", false, false, false, {}],
["S8r", false, false, true, {}],
["SL", false, true, false, {}],
["SLr", false, true, true, {}],
["H", true, true, false, {}],
["Hr", true, true, true, {}],
]
# The audit: in HDR the Nova swap over-tightens (the gain was tuned against the washed glow), so the
# HDR gain is re-measured with the switch on. Candidates only: the seed stays 0.5 until Chris picks.
const EXTRA := {
"nova_glow": [
["Hr.75", true, true, true, {"ENEMY_NOVA_GLOW": 0.75}],
["Hr1.0", true, true, true, {"ENEMY_NOVA_GLOW": 1.0}],
],
}
var vp: SubViewport
var main
var _fake_win: Window
var _wz: MultiMeshInstance2D
var _srgb_lut := PackedFloat32Array()
func _ready() -> void:
for k in 256:
var c := k / 255.0
_srgb_lut.append(c / 12.92 if c <= 0.04045 else pow((c + 0.055) / 1.055, 2.4))
vp = SubViewport.new()
vp.size = SIZE
vp.use_hdr_2d = true
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
vp.handle_input_locally = false
add_child(vp)
main = preload("res://main.tscn").instantiate()
vp.add_child(main)
await _frames(2)
seed(1234)
main._on_mode_chosen("survival")
main._capturing = true
if main.telemetry != null:
main.telemetry._enabled = false
main.sim.player.dev_invuln = true
main.quality_manager.auto = false
main.quality_manager.set_level(0)
main.apply_dev_command({"kind": "graphics", "effect": "colour_space_fix", "on": false})
await _frames(30)
main.apply_dev_command({"kind": "freeze_scene", "on": true})
var waited := 0
while (main.freeze_scene == null or not main.freeze_scene.frozen) and waited < 900:
await get_tree().process_frame
waited += 1
print("[afc] frozen=%s after %d frames" % [main.freeze_scene != null and main.freeze_scene.frozen, waited])
await _frames(60)
var h_env := OS.get_environment("AF_HEADROOM")
var h := float(h_env) if h_env != "" else 4.93
var only := OS.get_environment("AF_ONLY")
var items: Array = ORDER if only == "" else Array(only.split(","))
for item: String in items:
if not SWITCH.has(item):
print("[afc] unknown item %s" % item)
continue
await _measure(item, h)
Engine.time_scale = 1.0
if _fake_win != null:
_fake_win.free()
get_tree().quit()
func _measure(item: String, h: float) -> void:
var sw: String = SWITCH[item]
var rows: Array = ROWS.duplicate()
rows.append_array(EXTRA.get(item, []))
# Saved so every row starts from the shipped values and the run leaves them as it found them.
var saved := {}
for mode in [ColorProfile.SDR, ColorProfile.HDR]:
var s: Object = ColorProfile.script_for(mode)
saved[[mode, sw]] = s.get(sw)
for r: Array in rows:
for k: String in (r[4] as Dictionary):
saved[[mode, k]] = s.get(k)
await _setup(item)
var ref := PackedFloat32Array()
var ref_total := 0.0
var core_ref := 0.0
var fringe_ref := 0.0
var core := PackedByteArray()
var fringe := PackedByteArray()
for r: Array in rows:
for key: Array in saved:
ColorProfile.script_for(int(key[0])).set(String(key[1]), saved[key])
ColorProfile.script_for(ColorProfile.SDR).set(sw, bool(r[3])) # a missing static: silent no-op
ColorProfile.script_for(ColorProfile.HDR).set(sw, bool(r[3]))
for k: String in (r[4] as Dictionary):
ColorProfile.script_for(ColorProfile.HDR).set(k, r[4][k])
Engine.time_scale = 1.0
_set_profile(bool(r[1]), h)
_set_canvas(bool(r[2]))
await _frames(5) # main._sync_hdr_output lands the profile flip
main._apply_color_profile() # statics edited with no HOOK_ENV: re-feed HdrTier + push the globals
if item == "nova_glow": # the frozen sim never reaches sync(), which re-feeds the gain boosts
main.enemy_renderer.sync(main.sim.enemies, main._enemy_colors())
if item == "glass": # the snapshot glass captured the previous canvas: reopen on this one
await _teardown(item)
await _setup(item)
Engine.time_scale = 0.0
await _frames(SETTLE)
var srgb := not bool(r[2])
var full := _lum(await _grab(), srgb)
var again := _lum(await _grab(), srgb)
var rids := _canvas_items(item)
for rid: RID in rids:
RenderingServer.canvas_item_set_visible(rid, false)
await _frames(4)
var bare := _lum(await _grab(), srgb)
for rid: RID in rids:
RenderingServer.canvas_item_set_visible(rid, true)
await _frames(4)
var n := full.size()
var c := PackedFloat32Array()
c.resize(n)
var floor_d := 0.0
var total := 0.0
for i in n:
c[i] = full[i] - bare[i]
total += c[i]
floor_d = maxf(floor_d, absf(full[i] - again[i]))
if String(r[0]) == "S8":
ref = c
ref_total = total
var peak := 0.0
for i in n:
peak = maxf(peak, c[i])
core.resize(n)
fringe.resize(n)
for i in n:
core[i] = 1 if c[i] >= 0.3 * peak else 0
fringe[i] = 1 if c[i] >= 0.01 * peak and c[i] <= 0.10 * peak else 0
if core[i] == 1:
core_ref += c[i]
if fringe[i] == 1:
fringe_ref += c[i]
var core_s := 0.0
var fringe_s := 0.0
var maxdiff := 0.0
for i in n:
if core[i] == 1:
core_s += c[i]
if fringe[i] == 1:
fringe_s += c[i]
maxdiff = maxf(maxdiff, absf(c[i] - ref[i]))
print("[afc] %-11s %-6s total=%.1f x8=%.2f core=%.2f fringe=%.2f floor=%.6f maxdiff=%.6f rids=%d" % [
item, r[0], total, total / maxf(ref_total, 1e-6), core_s / maxf(core_ref, 1e-6),
fringe_s / maxf(fringe_ref, 1e-6), floor_d, maxdiff, rids.size()])
for key: Array in saved:
ColorProfile.script_for(int(key[0])).set(String(key[1]), saved[key])
Engine.time_scale = 1.0
main._apply_color_profile()
await _teardown(item)
# Stage the item on screen (around the player, which the freeze scene centres on).
func _setup(item: String) -> void:
var p: Vector2 = main.sim.player.pos
match item:
"low_hp":
main.sim.player.hp = main.sim.player.max_hp * 0.1
main.screen_fx.set_hp_fraction(0.1)
main.screen_fx.advance(0.0) # shows the border now (a frozen frame may skip main's call)
"dissolve":
for k in 6:
main.death_dissolve.spawn_at(p + Vector2.from_angle(TAU * k / 6.0) * 110.0, Color(0.35, 0.9, 1.0))
for i in main.death_dissolve._sprites.size():
if main.death_dissolve._age[i] >= 0.0:
main.death_dissolve._age[i] = 0.3 * DeathDissolve.LIFE # advance(0) then holds progress 0.3
"weapon_zone":
# frac 0.12: the shader's life fade (smoothstep(0, 0.25, frac)) is ~0.4, where the alpha
# remap acts. At frac >= 0.25 the alpha is 1.0 and the remap is a no-op by construction.
var d := {"pos": p + Vector2(160.0, 0.0), "radius": 70.0, "kind": "firestorm", "frac": 0.12}
_wz = ZoneRenderer.new_weapon_zone_layer(false)
var inst := ZoneRenderer.weapon_zone_instance(d, 0)
_wz.multimesh.set_instance_transform_2d(0, inst[0])
_wz.multimesh.set_instance_custom_data(0, inst[1])
_wz.multimesh.visible_instance_count = 1
var m: ShaderMaterial = _wz.material
m.set_shader_parameter("zones", ZoneRenderer.weapon_zone_list([d]))
m.set_shader_parameter("zone_count", 1)
_wz.visible = true
main.zone_renderer.add_child(_wz)
"glass":
main._toggle_pause()
await _frames(30) # focus tween + the one-shot snapshot blur, at time_scale 1
await _frames(4)
func _teardown(item: String) -> void:
Engine.time_scale = 1.0
match item:
"low_hp":
main.sim.player.hp = main.sim.player.max_hp
main.screen_fx.set_hp_fraction(1.0)
main.screen_fx.advance(0.0)
"dissolve":
main.death_dissolve.advance(DeathDissolve.LIFE)
"weapon_zone":
if _wz != null:
_wz.queue_free()
_wz = null
"glass":
if main._paused_for_menu:
main._toggle_pause()
await _frames(4)
func _canvas_items(item: String) -> Array[RID]:
var out: Array[RID] = []
match item:
"nova_glow":
var ar: ArchetypeRenderer = main.enemy_renderer
for sh in ar._shells.size():
var shells: Array = ar._shells[sh]
for si in shells.size():
if ar._shell_kinds[sh][si] == &"ENEMY_NOVA_GLOW":
out.append((shells[si] as CanvasItem).get_canvas_item())
"far_stars":
out.append(main.far_stars._mm.get_canvas_item())
"low_hp":
out.append(main.screen_fx._border.get_canvas_item())
"dissolve":
for i in main.death_dissolve._sprites.size():
if main.death_dissolve._age[i] >= 0.0:
out.append(main.death_dissolve._sprites[i].get_canvas_item())
"weapon_zone":
if _wz != null:
out.append(_wz.get_canvas_item())
"glass":
if main.pause_menu != null:
_glass_rids(main.pause_menu, out)
return out
func _glass_rids(n: Node, out: Array[RID]) -> void:
if n is GlassPanel:
out.append((n as GlassPanel).backdrop().get_canvas_item())
for ch in n.get_children():
_glass_rids(ch, out)
# Linear luminance per pixel (an 8-bit frame is sRGB: decoded through the LUT).
func _lum(img: Image, srgb: bool) -> PackedFloat32Array:
var f := img.get_data().to_float32_array()
var n := f.size() / 4
var out := PackedFloat32Array()
out.resize(n)
for i in n:
var r := f[i * 4]
var g := f[i * 4 + 1]
var b := f[i * 4 + 2]
if srgb:
r = _srgb_lut[clampi(roundi(r * 255.0), 0, 255)]
g = _srgb_lut[clampi(roundi(g * 255.0), 0, 255)]
b = _srgb_lut[clampi(roundi(b * 255.0), 0, 255)]
out[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b
return out
# As tools/player_glow_capture: main's own HDR path, with a detached window standing in for an HDR screen.
func _set_profile(hdr: bool, h: float) -> void:
if _fake_win == null:
_fake_win = Window.new()
HdrOutput.set_on(true)
main._hdr_output_capable = true
main._hdr_window_override = _fake_win
main._hdr_supported_override = hdr
var reported := _fake_win.get_output_max_linear_value()
HdrLook.PEAK_CALIBRATION = h / maxf(reported, 1.0e-3)
HdrLook.override = 1 if hdr else 0
func _set_canvas(linear: bool) -> void:
vp.use_hdr_2d = linear
ShaderGlobals.push_alpha(HdrLook.active, linear)
func _grab() -> Image:
await RenderingServer.frame_post_draw
var img := vp.get_texture().get_image()
img.convert(Image.FORMAT_RGBAF)
return img
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
  • Step 3: Ignore its output dir. In .gitignore, directly after the line tools/player_glow_capture/out/, add tools/alpha_fix_capture/out/.

  • Step 4: Import, then run the harness for all items (recipe M, without AF_ONLY).

Run: godot --headless --path . --import > /dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot, then recipe M with no AF_ONLY. Expected: [afc] frozen=true, then 6 rows per item (8 for nova_glow), every row floor=0.000000, rids > 0 for every item (nova_glow: one per Nova bucket; glass: one per GlassPanel in the pause menu). Every r row equals its off row (S8r maxdiff=0.000000; SLr == SL; Hr == H), because no switch exists yet. SL x8 should land in the audit’s range for the items it measured (Nova glow 2.6-4x, far stars ~5x); a large miss means the staging differs from the audit’s scenes, which is fine but must be stated in the doc. If floor is not 0 on a row, STOP and report it (an un-frozen clock: the numbers would be noise). If rids=0 for an item, STOP: the staging failed (e.g. the pause menu did not open; _toggle_pause returns early when sim.game_over, _paused_for_levelup, _paused_for_dimension_choice, _story_won or a content modal is open).

  • Step 5: Record the baseline in the rendering doc. Insert directly before ### HDR brightness sources (HDR/SDR colour systems step 3) in docs/architecture/rendering-and-performance.md:
### Unremapped-alpha fix batch (2026-10-12)
Plan `docs/superpowers/plans/2026-10-12-unremapped-alpha-fix-batch.md`, audit
`docs/research/2026-10-12-unremapped-alpha-audit.md`. Six draws that blended partial alpha on the linear
root canvas without the `hdr_alpha` remap each get one, behind a paired switch
`ColorProfile{Sdr,Hdr}.<NAME>_REMAP` (bool, both `true`; false = the pre-fix draw; an exact no-op on an
8-bit root). Measured with `tools/alpha_fix_capture` (real `main.tscn`, freeze scene, L0, colour_space_fix
OFF, Engine.time_scale 0 so the noise floor is exactly 0; contribution = item shown minus hidden, linear
luminance, 1920x1080, Mac mobile renderer, ATV headroom 4.93). x8 = total light / the 8-bit SDR total;
core / fringe = the same ratio over the pixels holding >= 30% / 1-10% of the 8-bit peak. Before = the
switch off, after = on, same build. Re-measure:
`AF_OUT=<dir> [AF_ONLY=item,...] godot --path . --rendering-method mobile --always-on-top --resolution 320x180 res://tools/alpha_fix_capture/alpha_fix_capture.tscn`,
then restore `project.godot`, `audio/bus_layout.tres`, `platform/tvos/project.godot`.
| item (switch) | S8 total | SL x8 (core / fringe) | SLr x8 (core / fringe) | H x8 (core / fringe) | Hr x8 (core / fringe) |
|---|---|---|---|---|---|
| Nova glow (`ENEMY_NOVA_GLOW_REMAP`) | <S8 total> | <SL> | pending (Task 2) | <H> | pending (Task 2) |
| far stars (`FAR_STARS_REMAP`) | <S8 total> | <SL> | pending (Task 3) | <H> | pending (Task 3) |
| low-HP border (`LOW_HP_BORDER_REMAP`) | <S8 total> | <SL> | pending (Task 4) | <H> | pending (Task 4) |
| death dissolve (`DISSOLVE_REMAP`) | <S8 total> | <SL> | pending (Task 5) | <H> | pending (Task 5) |
| weapon zone, fade 0.4 (`WEAPON_ZONE_REMAP`) | <S8 total> | <SL> | pending (Task 6) | <H> | pending (Task 6) |
| glass glow, pause menu (`GLASS_GLOW_REMAP`) | <S8 total> | <SL> | pending (Task 7) | <H> | pending (Task 7) |

Replace every <...> with the numbers Step 4 printed (format 2.57 (1.52 / 3.18)). The pending cells are filled by Tasks 2-7.

  • Step 6: Gate and commit.

Run: GUT_LOG=$TMPDIR/alpha-fix-t1.log scripts/check-test-count.sh (the docs-site test needs the new doc text to parse; nothing else changed). Expected: pass. Commit tools/alpha_fix_capture/, .gitignore, the rendering doc, message feat(tools): alpha_fix_capture harness + unremapped-alpha baseline (+ attribution lines).


Task 2: Enemy Nova bucket glow gets the remap material (ENEMY_NOVA_GLOW_REMAP)

Section titled “Task 2: Enemy Nova bucket glow gets the remap material (ENEMY_NOVA_GLOW_REMAP)”

The Nova glow quad (_build_nova_bucket, render/archetype_renderer.gd:440-445) draws with NO material, so it skips the remap. Fix: a saved material on the existing cs_mix.gdshader (mix blend, cs_in vertex conversion that is an identity while cs_linearize is off, hdr_alpha last), in its OWN .tres so ColorSpace.swap_layers (which matches the ColorSpace.CS_MIX instance by identity, render/color_space.gd:109-120) never swaps it back to null. Two traps: (1) ColorSpace.stock_layers() collects null-material layers at build time (render/archetype_renderer.gd:405), so the Nova glows are kept out of _cs_layers and handled by the new refresh alone; (2) enemy_renderer.sync() does NOT run while the sim clock is stopped (main.gd:2812-2814), so the refresh is driven from main._apply_color_profile (profile flip, live HOOK_ENV edit, run start), like far_stars.refresh_hdr().

Files:

  • Create: render/shaders/nova_glow_mix_mat.tres
  • Modify: render/archetype_renderer.gd (consts near line 201, _build_nova_bucket 423-454, end of _build_meshes 405-406, apply_color_space 410-411)
  • Modify: main.gd:2558-2559 (_apply_color_profile)
  • Modify: recipe P files (P1-P5; no P6, no shader edit)
  • Test: tests/test_nova_glow_remap.gd (create)
  • Modify: docs/architecture/rendering-and-performance.md (the Task 1 table row + a paragraph)

Interfaces:

  • Consumes: HdrTier.value(name: String) -> Variant (render/hdr_tier.gd, added by 30e490ba), ColorSpace.CS_MIX.

  • Produces: ColorProfileSdr.ENEMY_NOVA_GLOW_REMAP: bool = true, ColorProfileHdr.ENEMY_NOVA_GLOW_REMAP: bool = true; ArchetypeRenderer.refresh_nova_glow_remap() -> void; ArchetypeRenderer._nova_glows: Array[MultiMeshInstance2D].

  • Step 1: Write the failing test tests/test_nova_glow_remap.gd:

extends GutTest
# The enemies' Nova bucket glow (ArchetypeRenderer._build_nova_bucket) drew with NO material, so on a
# linear root canvas its soft falloff blended in linear light (audit 2026-10-12: fringe 3-7x the 8-bit
# look). ColorProfile{Sdr,Hdr}.ENEMY_NOVA_GLOW_REMAP puts a saved cs_mix copy (mix blend + hdr_alpha) on it.
const MAT := "res://render/shaders/nova_glow_mix_mat.tres"
const SHADER := "res://render/shaders/cs_mix.gdshader"
var _saved_nova := true
var _s: Variant
var _h: Variant
func before_each() -> void:
_saved_nova = VisualLook.nova
VisualLook.nova = true
_s = ColorProfileSdr.ENEMY_NOVA_GLOW_REMAP
_h = ColorProfileHdr.ENEMY_NOVA_GLOW_REMAP
HdrTier.reset()
func after_each() -> void:
VisualLook.nova = _saved_nova
ColorProfileSdr.ENEMY_NOVA_GLOW_REMAP = _s
ColorProfileHdr.ENEMY_NOVA_GLOW_REMAP = _h
HdrTier.invalidate()
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
func _ar() -> ArchetypeRenderer:
var a := ArchetypeRenderer.new()
add_child_autofree(a)
return a
func test_every_nova_glow_carries_the_saved_remap_material_by_default() -> void:
var a := _ar()
assert_gt(a._nova_glows.size(), 0, "the Nova buckets built their glow layers")
for g in a._nova_glows:
var m := g.material as ShaderMaterial
assert_not_null(m, "SDR default: remapped")
assert_eq(m.resource_path, MAT)
func test_the_material_is_saved_on_cs_mix_and_is_not_the_cs_mix_instance() -> void:
var m: Material = load(MAT)
assert_true(m is ShaderMaterial)
assert_eq((m as ShaderMaterial).shader.resource_path, SHADER, "mix blend + cs_in + hdr_alpha")
assert_ne(m, ColorSpace.CS_MIX, "its own resource, so ColorSpace.swap_layers never matches it")
func test_nova_glows_are_kept_out_of_the_colour_space_swap_list() -> void:
var a := _ar()
for g in a._nova_glows:
assert_false(a._cs_layers.has(g), "refresh_nova_glow_remap owns the Nova glow's material")
a.apply_color_space(true)
for g in a._nova_glows:
assert_eq((g.material as ShaderMaterial).resource_path, MAT, "the fix swap leaves the remap on")
func test_switch_off_restores_the_pre_fix_draw_in_both_colour_space_states() -> void:
var a := _ar()
ColorProfileSdr.ENEMY_NOVA_GLOW_REMAP = false
a.refresh_nova_glow_remap()
for g in a._nova_glows:
assert_null(g.material, "off + colour_space_fix off: no material, as shipped")
a.apply_color_space(true)
for g in a._nova_glows:
assert_eq(g.material, ColorSpace.CS_MIX, "off + fix on: cs_mix, what swap_layers gave it before")
a.apply_color_space(false)
for g in a._nova_glows:
assert_null(g.material)
func test_the_hdr_profile_reads_its_own_switch() -> void:
var a := _ar()
ColorProfileHdr.ENEMY_NOVA_GLOW_REMAP = false
HdrTier.invalidate()
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
a.refresh_nova_glow_remap()
for g in a._nova_glows:
assert_null(g.material, "HDR off while SDR stays on")
func test_main_refreshes_it_on_every_colour_profile_apply() -> void:
# sync() never runs while the sim clock is stopped (main.gd: the run_time gate), so a live edit
# during a pause / the freeze scene must land through _apply_color_profile.
var src := FileAccess.get_file_as_string("res://main.gd")
var i := src.find("func _apply_color_profile()")
var j := src.find("\nfunc ", i + 10)
assert_true(src.substr(i, j - i).contains("enemy_renderer.refresh_nova_glow_remap()"))
func test_both_switches_are_registered_live_bool_tunables() -> void:
for prof: String in ["ColorProfileSdr", "ColorProfileHdr"]:
var key := "%s.ENEMY_NOVA_GLOW_REMAP" % prof
assert_eq(LiveScope.tunable_hook(key), LiveScope.HOOK_ENV, key)
var found := false
for e: Dictionary in TunablesRegistry.ENTRIES:
if String(e["key"]) == key:
found = true
assert_eq(String(e.get("type", "")), "bool", key)
assert_true(found, key)
  • Step 2: Run it to verify it fails. Run: the single-script command with -gselect=test_nova_glow_remap.gd. Expected: FAIL / parse error (ENEMY_NOVA_GLOW_REMAP, _nova_glows, refresh_nova_glow_remap don’t exist).

  • Step 3: Create the material render/shaders/nova_glow_mix_mat.tres:

[gd_resource type="ShaderMaterial" load_steps=2 format=3]
[ext_resource type="Shader" path="res://render/shaders/cs_mix.gdshader" id="1"]
[resource]
shader = ExtResource("1")
  • Step 4: Add the switch (recipe P1-P5) with:

  • <NAME> = ENEMY_NOVA_GLOW_REMAP

  • comment (both profiles): # The enemies' Nova bucket glow (ArchetypeRenderer): the saved nova_glow_mix material (cs_mix + hdr_alpha).

  • SDR label SDR enemy Nova glow alpha remap, desc SDR look: draw the glow behind every Nova-look enemy with the alpha remap, so on a linear canvas (Metal full quality, Apple TV) its edge falls off like 8-bit, not a haze. Off = the pre-fix look. No effect on an 8-bit canvas.

  • HDR label HDR enemy Nova glow alpha remap, desc HDR look: draw the glow behind every Nova-look enemy with the alpha remap, so its edge falls off like the SDR look instead of a wide haze. Off = the pre-fix look. Re-tune HDR enemy Nova glow with it on.

  • Step 5: Implement in render/archetype_renderer.gd.

After const _SMEAR_MAT: ShaderMaterial = preload("res://render/shaders/enemy_smear_mat.tres") (line 201) add:

# The Nova bucket glow's alpha-remap material (unremapped-alpha batch, 2026-10-12): cs_mix.gdshader (mix
# blend, cs_in, hdr_alpha last) in its OWN saved resource, so ColorSpace.swap_layers (which matches the
# CS_MIX instance) never swaps it back to null. Saved: the export shader baker compiles it.
const _NOVA_GLOW_MAT: ShaderMaterial = preload("res://render/shaders/nova_glow_mix_mat.tres")

After var _cs_layers: Array[MultiMeshInstance2D] = [] (line 252) add:

var _nova_glows: Array[MultiMeshInstance2D] = [] # every Nova bucket glow layer (refresh_nova_glow_remap)
var _nova_cs_on := false # the last colour_space_fix state apply_color_space() was given

In _build_nova_bucket, after mm_core.add_child(mm_glow) (line 445) add _nova_glows.append(mm_glow).

Replace the two lines at the end of _build_meshes (405-406):

_cs_layers = ColorSpace.stock_layers(self)
_cs_gen = -1 # a rebuild re-applies the colour_space_fix swap on the next sync

with:

# The Nova glows are NOT in the colour_space_fix swap list: refresh_nova_glow_remap owns their material
# (remap on: the saved remap material in every colour-space state; off: what the swap gave them).
_cs_layers.clear()
for l in ColorSpace.stock_layers(self):
if not _nova_glows.has(l):
_cs_layers.append(l)
refresh_nova_glow_remap()
_cs_gen = -1 # a rebuild re-applies the colour_space_fix swap on the next sync

Replace apply_color_space (410-411):

func apply_color_space(on: bool) -> void:
ColorSpace.swap_layers(_cs_layers, on)
_nova_cs_on = on
refresh_nova_glow_remap()
# The active profile's ENEMY_NOVA_GLOW_REMAP (read through HdrTier.value, the profile the gains came
# from). On: the saved remap material. Off: the pre-fix draw (null, or cs_mix while colour_space_fix is
# on, exactly what ColorSpace.swap_layers gave it). Called at build, on every colour_space_fix swap and
# from main._apply_color_profile: sync() does not run while the sim clock is stopped.
func refresh_nova_glow_remap() -> void:
var on := bool(HdrTier.value("ENEMY_NOVA_GLOW_REMAP"))
var mat: Material = _NOVA_GLOW_MAT if on else (ColorSpace.CS_MIX if _nova_cs_on else null)
for g in _nova_glows:
g.material = mat
  • Step 6: Wire main. In main.gd _apply_color_profile, directly after:
if far_stars != null and is_instance_valid(far_stars):
far_stars.refresh_hdr()

add:

if enemy_renderer != null and is_instance_valid(enemy_renderer):
enemy_renderer.refresh_nova_glow_remap() # unremapped-alpha batch: the switch is live, sim frozen or not
  • Step 7: Run the tests. -gselect=test_nova_glow_remap.gd, then -gselect=test_color_profiles.gd, then -gselect=test_prewarm. Expected: all pass. (Prewarm family 19 builds a real ArchetypeRenderer, so its Nova glows now link the cs_mix pipeline with the new material; the shader is unchanged and already baked.)

  • Step 8: Measure (recipe M, AF_ONLY=nova_glow). Expected: S8r maxdiff=0.000000; SLr fringe near 1 (audit measured the swap at 1.13 freeze / 1.17 live); Hr fringe well below H (audit: 0.28-0.71, over-tight). Fill the Nova row’s SLr / Hr cells, and add under the table:

**Nova glow (Task 2).** `render/shaders/nova_glow_mix_mat.tres` (a separate saved copy on `cs_mix.gdshader`,
so `ColorSpace.swap_layers` never matches it); `ArchetypeRenderer.refresh_nova_glow_remap()` owns the
glow's material and runs from `main._apply_color_profile` (the frozen-sim path) and every colour-space swap.
HDR gain candidates with the remap on (switch ON, `ENEMY_NOVA_GLOW` HDR): 0.5 (seed) <Hr>, 0.75 <Hr.75>,
1.0 <Hr1.0>. The seed is NOT changed here: Chris picks on the ATV in Task 8 (`set_tunables`
`ColorProfileHdr.ENEMY_NOVA_GLOW`), then the value is committed.
  • Step 9: Gate and commit. GUT_LOG=$TMPDIR/alpha-fix-t2.log scripts/check-test-count.sh, then scripts/gate.sh. No shader edited, so no compile check is needed (the .tres reuses cs_mix.gdshader); run it anyway if compile_check is cheap for you: it now discovers nova_glow_mix_mat.tres. Commit: fix(render): enemy Nova glow gets the alpha remap (switchable, SDR + HDR).

Task 3: Remap-switch plumbing + far stars (FAR_STARS_REMAP, global far_stars_remap)

Section titled “Task 3: Remap-switch plumbing + far stars (FAR_STARS_REMAP, global far_stars_remap)”

Introduces the shader-switch mechanism the remaining tasks reuse: a bool profile static pushed as a FLOAT global (ShaderGlobals.push gains a bool-to-float conversion), and tests/test_alpha_remap_switches.gd, a table-driven test every later task adds a row to.

Files:

  • Modify: render/shader_globals.gd (push, lines 27-34; GLOBALS, 18-20)
  • Modify: render/shaders/far_stars.gdshader (include after line 5; fragment() line 53)
  • Modify: recipe P files (P1-P6)
  • Test: tests/test_alpha_remap_switches.gd (create), tests/test_color_profiles.gd
  • Modify: rendering doc (row + paragraph)

Interfaces:

  • Produces: ShaderGlobals.push sends a bool profile value as 1.0 / 0.0; global far_stars_remap; ColorProfile{Sdr,Hdr}.FAR_STARS_REMAP: bool = true; tests/test_alpha_remap_switches.gd const SWITCHED: Dictionary (path -> [global, profile static, exact remap snippet]).

  • Step 1: Write the failing test tests/test_alpha_remap_switches.gd:

extends GutTest
# Unremapped-alpha fix batch (docs/superpowers/plans/2026-10-12-unremapped-alpha-fix-batch.md): saved
# shaders that drew partial alpha on the linear root without hdr_alpha get a GATED remap
# `<global> > 0.5 ? hdr_alpha(a) : a`. The global is a bool profile switch (both profiles, true)
# pushed as a float by ShaderGlobals.push. hdr_alpha() is an exact no-op on an 8-bit root (gamma 1.0).
const INCLUDE := "#include \"res://render/shaders/hdr_alpha.gdshaderinc\""
# path -> [global, profile static, the exact remap expression the shader must contain]
const SWITCHED := {
"res://render/shaders/far_stars.gdshader": ["far_stars_remap", "FAR_STARS_REMAP",
"far_stars_remap > 0.5 ? hdr_alpha(fa) : fa"],
}
var _pushed: Array = []
func _spy(name: StringName, v: Variant) -> void:
_pushed.append([name, v])
func after_each() -> void:
Tunables.reset()
HdrTier.reset()
func test_each_shader_includes_hdr_alpha_and_gates_it_on_its_global() -> void:
for p: String in SWITCHED:
var src := FileAccess.get_file_as_string(p)
var row: Array = SWITCHED[p]
assert_true(src.contains(INCLUDE), "%s includes hdr_alpha" % p)
assert_true(src.contains("global uniform float %s;" % row[0]), "%s declares %s" % [p, row[0]])
assert_true(src.contains(String(row[2])), "%s applies the gated remap" % p)
func test_each_global_reads_its_profile_switch() -> void:
for p: String in SWITCHED:
var row: Array = SWITCHED[p]
assert_eq(ShaderGlobals.GLOBALS.get(StringName(row[0]), ""), String(row[1]), String(row[0]))
func test_switches_ship_on_in_both_profiles() -> void:
for p: String in SWITCHED:
var n := String(SWITCHED[p][1])
assert_eq(ColorProfile.value(ColorProfile.SDR, n), true, "SDR %s" % n)
assert_eq(ColorProfile.value(ColorProfile.HDR, n), true, "HDR %s" % n)
func test_a_bool_switch_is_pushed_as_a_float_and_follows_the_active_profile() -> void:
var was: Callable = ShaderGlobals.setter
ShaderGlobals.setter = _spy
for p: String in SWITCHED:
var row: Array = SWITCHED[p]
var g := StringName(row[0])
var n := String(row[1])
ColorProfile.script_for(ColorProfile.HDR).set(n, false)
_pushed.clear()
HdrTier.refresh(ColorProfile.SDR, 1.0, false)
ShaderGlobals.push(ColorProfile.SDR, true)
var sdr: Array = _pushed.filter(func(e: Array) -> bool: return e[0] == g)
assert_eq(sdr, [[g, 1.0]], "SDR on: 1.0")
assert_eq(typeof(sdr[0][1]), TYPE_FLOAT, "a float, the declared type")
_pushed.clear()
HdrTier.refresh(ColorProfile.HDR, 4.93, false)
ShaderGlobals.push(ColorProfile.HDR, true)
assert_eq(_pushed.filter(func(e: Array) -> bool: return e[0] == g), [[g, 0.0]], "HDR off: 0.0")
ColorProfile.script_for(ColorProfile.HDR).set(n, true)
ShaderGlobals.setter = was
func test_the_remap_is_an_exact_no_op_on_an_8bit_root() -> void:
assert_eq(ShaderGlobals.alpha_gamma(ColorProfile.SDR, false), 1.0)
assert_eq(ShaderGlobals.alpha_gamma(ColorProfile.HDR, false), 1.0)
var inc := FileAccess.get_file_as_string("res://render/shaders/hdr_alpha.gdshaderinc")
assert_true(inc.contains("hdr_alpha_gamma == 1.0"), "uniform early-out returns `a` exactly")
func test_the_switches_are_registered_live_bool_tunables() -> void:
for p: String in SWITCHED:
for prof: String in ["ColorProfileSdr", "ColorProfileHdr"]:
var key := "%s.%s" % [prof, SWITCHED[p][1]]
assert_eq(LiveScope.tunable_hook(key), LiveScope.HOOK_ENV, key)
var found := false
for e: Dictionary in TunablesRegistry.ENTRIES:
if String(e["key"]) == key:
found = true
assert_eq(String(e.get("type", "")), "bool", key)
assert_true(found, key)
  • Step 2: Run it to verify it fails. -gselect=test_alpha_remap_switches.gd. Expected: FAIL (no include / global / static).

  • Step 3: Bool-to-float push. In render/shader_globals.gd, replace the push body’s else branch:

else:
setter.call(g, HdrTier.gain(key) if HdrTier.SOURCES.has(key) else p.get(String(key)))

with:

else:
var v: Variant = HdrTier.gain(key) if HdrTier.SOURCES.has(key) else p.get(String(key))
# A bool profile switch (the unremapped-alpha batch) drives a float global: 1.0 / 0.0.
setter.call(g, (1.0 if v else 0.0) if v is bool else v)
  • Step 4: Add the switch (recipe P1-P6) with:

  • <NAME> = FAR_STARS_REMAP, <global> = far_stars_remap

  • comment: # The far star layer (render/shaders/far_stars.gdshader): hdr_alpha on its dot / streak alpha (global far_stars_remap).

  • SDR label SDR far stars alpha remap, desc SDR look: the alpha remap on the far star dots and warp streaks, so on a linear canvas (Metal full quality, Apple TV) they keep the 8-bit size and softness instead of a bright halo. Off = the pre-fix look. No effect on an 8-bit canvas.

  • HDR label HDR far stars alpha remap, desc HDR look: the alpha remap on the far star dots and warp streaks, so their soft edge does not spread into a halo. Off = the pre-fix look.

  • P6(c): the SDR push list in test_push_sends_the_profiles_values becomes [[&"border_halo", 2.2], [&"wfx_hot", 1.0], [&"wfx_accent", 1.0], [&"boss_hot", 1.0], [&"dissolve_edge", 1.0], [&"glass_cap", 10000.0], [&"hdr_alpha_gamma", 2.2], [&"far_stars_remap", 1.0]].

  • Step 5: The shader. In render/shaders/far_stars.gdshader, after line 5 #include "res://render/shaders/color_space.gdshaderinc" add:

#include "res://render/shaders/hdr_alpha.gdshaderinc"
// Unremapped-alpha batch (2026-10-12): ColorProfile{Sdr,Hdr}.FAR_STARS_REMAP via ShaderGlobals.
global uniform float far_stars_remap;

and replace line 53:

COLOR = vec4(v_rgb * cs_in(tint), max(dot_a, streak_a) * v_alpha);

with:

float fa = max(dot_a, streak_a) * v_alpha;
COLOR = vec4(v_rgb * cs_in(tint), far_stars_remap > 0.5 ? hdr_alpha(fa) : fa);

(Keeps cs_in(tint) for tests/test_color_space_shaders.gd’s uniform-read check.)

  • Step 6: Run the tests. -gselect=test_alpha_remap_switches.gd, -gselect=test_color_profiles.gd, -gselect=test_far_stars.gd, -gselect=test_color_space_shaders.gd. Expected: all pass.

  • Step 7: Compile check under mobile AND gl_compatibility (Global Constraints). Expected: only the two compile_check: lines. Commit your project.godot / platform/tvos/project.godot edits BEFORE the restoring git checkout.

  • Step 8: Measure (recipe M, AF_ONLY=far_stars). Expected: S8r maxdiff=0.000000; SLr x8 and fringe well below SL (audit: 4.95x, fringe 12.4). Fill the row; add one line under the table: **Far stars (Task 3).** gated in far_stars.gdshader; ShaderGlobals.push sends bool switches as 1.0 / 0.0 floats.

  • Step 9: Gate and commit. GUT_LOG=$TMPDIR/alpha-fix-t3.log scripts/check-test-count.sh, scripts/gate.sh. Commit: fix(render): far stars get the alpha remap (switchable); bool profile switches push as float globals.


Task 4: Low-HP border (LOW_HP_BORDER_REMAP, global low_hp_border_remap)

Section titled “Task 4: Low-HP border (LOW_HP_BORDER_REMAP, global low_hp_border_remap)”

low_hp_border.gdshader:23-24 writes vec4(col * a, a) with blend_add, so the canvas adds col * a * a. Over black with a pure 2.2 decode the 8-bit falloff would be a^4.4 (both factors remapped), but in-scene the 8-bit add is in sRGB-encoded space over a non-black destination with the sRGB toe, so it is shallower. As shipped (fix round 1, measured): remap the ALPHA only, ar = hdr_alpha(a), vec4(col * a, ar) (a^3.2): SLr 1.21 (1.36 / 0.94); the both-factor form measured 0.47 (0.69 / 0.21) and was rejected. (The colour’s own encoding is the colour_space_fix domain, untouched.)

Files:

  • Modify: render/shaders/low_hp_border.gdshader (after line 3; lines 23-24)
  • Modify: recipe P files (P1-P6)
  • Test: tests/test_alpha_remap_switches.gd (add a SWITCHED row), tests/test_color_profiles.gd
  • Modify: rendering doc (row + line)

Interfaces:

  • Consumes: Task 3’s push conversion and SWITCHED table.

  • Produces: global low_hp_border_remap; ColorProfile{Sdr,Hdr}.LOW_HP_BORDER_REMAP: bool = true.

  • Step 1: Write the failing test. Add to SWITCHED in tests/test_alpha_remap_switches.gd:

"res://render/shaders/low_hp_border.gdshader": ["low_hp_border_remap", "LOW_HP_BORDER_REMAP",
"float ar = low_hp_border_remap > 0.5 ? hdr_alpha(a) : a;"],

and append this test (alpha only; the rgb factor keeps a):

func test_low_hp_border_remaps_the_alpha_only() -> void:
var src := FileAccess.get_file_as_string("res://render/shaders/low_hp_border.gdshader")
assert_true(src.contains("COLOR = vec4(cs_in(glow_color) * a, ar);"),
"alpha only: the 8-bit add is in encoded space over the scene; both factors measured at half the 8-bit light")
  • Step 2: Run it to verify it fails. -gselect=test_alpha_remap_switches.gd. Expected: FAIL on the new row.

  • Step 3: Add the switch (recipe P1-P6) with:

  • <NAME> = LOW_HP_BORDER_REMAP, <global> = low_hp_border_remap

  • comment: # The low-HP screen border (render/shaders/low_hp_border.gdshader): hdr_alpha on its glow alpha (global low_hp_border_remap).

  • SDR label SDR low-HP border alpha remap, desc SDR look: the alpha remap on the red low-health border glow, so on a linear canvas (Metal L0, ATV) it hugs the screen edge like 8-bit instead of washing inward. Off = the pre-fix look.

  • HDR label HDR low-HP border alpha remap, desc HDR look: the alpha remap on the red low-health border glow, so it hugs the screen edge instead of washing inward. Off = the pre-fix look.

  • P6(c): append [&"low_hp_border_remap", 1.0] to the SDR push list.

  • Step 4: The shader. In render/shaders/low_hp_border.gdshader, after line 3 #include "res://render/shaders/color_space.gdshaderinc" add:

#include "res://render/shaders/hdr_alpha.gdshaderinc"
// Unremapped-alpha batch (2026-10-12): ColorProfile{Sdr,Hdr}.LOW_HP_BORDER_REMAP via ShaderGlobals.
global uniform float low_hp_border_remap;

and replace lines 23-24:

float a = glow * mist * alpha;
COLOR = vec4(cs_in(glow_color) * a, a);

with:

float a = glow * mist * alpha;
// Remap the alpha only. The 8-bit canvas adds in sRGB-encoded space over the scene (with the sRGB
// toe), so its falloff is shallower than (a * a)^2.2: remapping the rgb factor too measured at about
// half the 8-bit light (alpha_fix_capture 2026-10-12, docs/architecture/rendering-and-performance.md).
float ar = low_hp_border_remap > 0.5 ? hdr_alpha(a) : a;
COLOR = vec4(cs_in(glow_color) * a, ar);
  • Step 5: Run the tests. -gselect=test_alpha_remap_switches.gd, -gselect=test_color_profiles.gd, -gselect=test_color_space_shaders.gd, -gselect=test_screen_feedback. Expected: all pass.

  • Step 6: Compile check (mobile + gl_compatibility). Expected: clean.

  • Step 7: Measure (recipe M, AF_ONLY=low_hp). Expected: S8r maxdiff=0.000000; SLr fringe nearer 1 than SL. Fill the row; one line under the table naming the alpha-only remap and the rejected both-factor numbers.

  • Step 8: Gate and commit. GUT_LOG=$TMPDIR/alpha-fix-t4.log scripts/check-test-count.sh, scripts/gate.sh. Commit: fix(render): low-HP border gets the alpha remap (switchable).


Task 5: Death dissolve (DISSOLVE_REMAP, global dissolve_remap)

Section titled “Task 5: Death dissolve (DISSOLVE_REMAP, global dissolve_remap)”

death_dissolve.gdshader:19 writes vec4(col, a * tint.a * (1.0 - progress * 0.25)) with blend_add, where a carries the GlowTexture’s soft alpha falloff: the plain alpha class (canvas adds col * alpha), so remapping the alpha alone is the exact falloff match.

Files:

  • Modify: render/shaders/death_dissolve.gdshader (after line 4; line 19)
  • Modify: recipe P files (P1-P6)
  • Test: tests/test_alpha_remap_switches.gd (row), tests/test_color_profiles.gd
  • Modify: rendering doc

Interfaces:

  • Produces: global dissolve_remap; ColorProfile{Sdr,Hdr}.DISSOLVE_REMAP: bool = true. (Distinct from the existing dissolve_edge global / DISSOLVE_EDGE gain.)

  • Step 1: Write the failing test. Add to SWITCHED:

"res://render/shaders/death_dissolve.gdshader": ["dissolve_remap", "DISSOLVE_REMAP",
"dissolve_remap > 0.5 ? hdr_alpha(da) : da"],
  • Step 2: Run it to verify it fails. Expected: FAIL on the new row.

  • Step 3: Add the switch (recipe P1-P6) with:

  • <NAME> = DISSOLVE_REMAP, <global> = dissolve_remap

  • comment: # The enemy death dissolve (render/shaders/death_dissolve.gdshader): hdr_alpha on its disc alpha (global dissolve_remap).

  • SDR label SDR death dissolve alpha remap, desc SDR look: the alpha remap on the enemy death dissolve disc, so on a linear canvas (Metal full quality, Apple TV) its soft edge matches 8-bit instead of a wider glow. Off = the pre-fix look. No effect on an 8-bit canvas.

  • HDR label HDR death dissolve alpha remap, desc HDR look: the alpha remap on the enemy death dissolve disc, so its soft edge does not spread. Off = the pre-fix look.

  • P6(c): append [&"dissolve_remap", 1.0] to the SDR push list.

  • Step 4: The shader. In render/shaders/death_dissolve.gdshader, after line 4 #include "res://render/shaders/hdr_globals.gdshaderinc" add:

#include "res://render/shaders/hdr_alpha.gdshaderinc"
// Unremapped-alpha batch (2026-10-12): ColorProfile{Sdr,Hdr}.DISSOLVE_REMAP via ShaderGlobals.
global uniform float dissolve_remap;

and replace line 19:

COLOR = vec4(col, a * tint.a * (1.0 - progress * 0.25));

with:

float da = a * tint.a * (1.0 - progress * 0.25);
COLOR = vec4(col, dissolve_remap > 0.5 ? hdr_alpha(da) : da);
  • Step 5: Run the tests. -gselect=test_alpha_remap_switches.gd, -gselect=test_color_profiles.gd, -gselect=test_color_space_shaders.gd, -gselect=test_death_sequence.gd, -gselect=test_prewarm_coverage.gd. Expected: all pass.

  • Step 6: Compile check (mobile + gl_compatibility). Expected: clean.

  • Step 7: Measure (recipe M, AF_ONLY=dissolve). Expected: S8r maxdiff=0.000000; SLr fringe nearer 1 than SL. Fill the row.

  • Step 8: Gate and commit. GUT_LOG=$TMPDIR/alpha-fix-t5.log scripts/check-test-count.sh, scripts/gate.sh. Commit: fix(render): death dissolve gets the alpha remap (switchable).


Task 6: Weapon zones, the alpha part (WEAPON_ZONE_REMAP, global weapon_zone_remap)

Section titled “Task 6: Weapon zones, the alpha part (WEAPON_ZONE_REMAP, global weapon_zone_remap)”

weapon_zone_field.gdshader:211 writes vec4(edge_rgb + body_rgb, fade); fade = smoothstep(0.0, 0.25, v_frac) (line 144) is the zone’s LIFE fade: 1.0 for the first 75% of its life, ramping out over the last 25%. All of the spatial falloff (rim, tongues, halo, embers) is in RGB, the RGB-falloff class the remap cannot reach (out of scope). So this task fixes only the fade-out tail: on the linear canvas a dying zone currently lingers brighter than on 8-bit. State this in the doc; do not oversell it.

Files:

  • Modify: render/weapon_zone_field.gdshader (after line 2 render_mode blend_add;; line 211)
  • Modify: recipe P files (P1-P6)
  • Test: tests/test_alpha_remap_switches.gd (row), tests/test_color_profiles.gd
  • Modify: rendering doc (row + update the “Not covered” bullet that names weapon_zone_field.gdshader)

Interfaces:

  • Produces: global weapon_zone_remap; ColorProfile{Sdr,Hdr}.WEAPON_ZONE_REMAP: bool = true.

  • Step 1: Write the failing test. Add to SWITCHED:

"res://render/weapon_zone_field.gdshader": ["weapon_zone_remap", "WEAPON_ZONE_REMAP",
"weapon_zone_remap > 0.5 ? hdr_alpha(fade) : fade"],
  • Step 2: Run it to verify it fails. Expected: FAIL on the new row.

  • Step 3: Add the switch (recipe P1-P6) with:

  • <NAME> = WEAPON_ZONE_REMAP, <global> = weapon_zone_remap

  • comment: # Firestorm / Thunder Wake zones (render/weapon_zone_field.gdshader): hdr_alpha on the life fade only (global weapon_zone_remap).

  • SDR label SDR weapon zone fade alpha remap, desc SDR look: the alpha remap on Firestorm and Thunder Wake zones' fade-out, so on a linear canvas (Metal full quality, Apple TV) a dying zone fades like 8-bit instead of lingering bright. Only the last quarter of a zone's life changes. Off = the pre-fix look. No effect on an 8-bit canvas.

  • HDR label HDR weapon zone fade alpha remap, desc HDR look: the alpha remap on Firestorm and Thunder Wake zones' fade-out, so a dying zone does not linger bright. Only the last quarter of a zone's life changes. Off = the pre-fix look.

  • P6(c): append [&"weapon_zone_remap", 1.0] to the SDR push list.

  • Step 4: The shader. In render/weapon_zone_field.gdshader, after line 2 render_mode blend_add; add:

#include "res://render/shaders/hdr_alpha.gdshaderinc"
// Unremapped-alpha batch (2026-10-12): ColorProfile{Sdr,Hdr}.WEAPON_ZONE_REMAP via ShaderGlobals. Only the
// life fade is alpha; the rim / body falloff is in RGB (alpha 1), which the remap cannot reach.
global uniform float weapon_zone_remap;

and replace line 211:

COLOR = vec4(edge_rgb + body_rgb, fade);

with:

COLOR = vec4(edge_rgb + body_rgb, weapon_zone_remap > 0.5 ? hdr_alpha(fade) : fade);
  • Step 5: Run the tests. -gselect=test_alpha_remap_switches.gd, -gselect=test_color_profiles.gd, -gselect=test_zone_renderer_weapon_zones.gd, -gselect=test_perf_cuts.gd, -gselect=test_prewarm_coverage.gd. Expected: all pass.

  • Step 6: Compile check (mobile + gl_compatibility). Expected: clean. This shader runs on the A12 Apple TV that page-faulted on a large uniform array once (see its header comment): the edit adds one scalar global, nothing indexed, but the Task 8 ATV install must include an evolved-Wildfire run.

  • Step 7: Measure (recipe M, AF_ONLY=weapon_zone). The harness stages frac 0.12 (fade ~0.4). Expected: S8r maxdiff=0.000000; SLr x8 below SL. Fill the row (label it “fade 0.4”); in the doc’s “Not covered (known gaps)” bullet, change the weapon_zone_field.gdshader mention to say its alpha (life fade) is now remapped and its RGB falloff is not.

  • Step 8: Gate and commit. GUT_LOG=$TMPDIR/alpha-fix-t6.log scripts/check-test-count.sh, scripts/gate.sh. Commit: fix(render): weapon zone fade-out gets the alpha remap (switchable).


Task 7: Glass focus glow (GLASS_GLOW_REMAP, global glass_glow_remap)

Section titled “Task 7: Glass focus glow (GLASS_GLOW_REMAP, global glass_glow_remap)”

glass_surface (ui/theme/glass_common.gdshaderinc:34-36) returns mix(glow, vec4(body, body_a), inside) where glow = vec4(accent.rgb, g * 0.55) and g = exp(-d / glow_len) * ... is a soft falloff OUTSIDE the panel: the alpha class (audit fringe 7-9x). Remap ONLY the glow alpha. Do NOT remap body_a: it is 1.0 for glass_frost / glass_snapshot (a no-op) and 0.72 for glass_clear, a DARK tint over the scene, where pow(a, γ) thins the darkening, the wrong direction (audit § “Dark overlays”). The grey body (core 1.7-2.3x) is out of scope (see Scope). This is the biggest visible win and the riskiest edit: every menu draws it, so the Task 8 baked-iPhone check is mandatory.

Files:

  • Modify: ui/theme/glass_common.gdshaderinc (top; line 35)
  • Modify: recipe P files (P1-P6)
  • Test: tests/test_alpha_remap_switches.gd (row + one test), tests/test_color_profiles.gd
  • Modify: rendering doc

Interfaces:

  • Produces: global glass_glow_remap; ColorProfile{Sdr,Hdr}.GLASS_GLOW_REMAP: bool = true.

  • Step 1: Write the failing test. Add to SWITCHED (the include file holds the code for all three glass shaders):

"res://ui/theme/glass_common.gdshaderinc": ["glass_glow_remap", "GLASS_GLOW_REMAP",
"glass_glow_remap > 0.5 ? hdr_alpha(g * 0.55) : g * 0.55"],

and append:

func test_glass_remaps_only_the_glow_not_the_dark_body() -> void:
var src := FileAccess.get_file_as_string("res://ui/theme/glass_common.gdshaderinc")
assert_true(src.contains("return mix(glow, vec4(body, body_a), inside);"),
"body_a (0.72 on glass_clear: a DARK tint) is not remapped; pow(a, gamma) would thin the darkening")
assert_eq(src.count("hdr_alpha("), 1, "exactly one remap: the glow's alpha")
for p: String in ["res://ui/theme/glass_clear.gdshader", "res://ui/theme/glass_frost.gdshader",
"res://ui/theme/glass_snapshot.gdshader"]:
assert_true(FileAccess.get_file_as_string(p).contains("#include \"res://ui/theme/glass_common.gdshaderinc\""), p)
  • Step 2: Run it to verify it fails. Expected: FAIL on the new row and test.

  • Step 3: Add the switch (recipe P1-P6) with:

  • <NAME> = GLASS_GLOW_REMAP, <global> = glass_glow_remap

  • comment: # The liquid-glass panels' focus glow (ui/theme/glass_common.gdshaderinc): hdr_alpha on the glow outside the panel only (global glass_glow_remap).

  • SDR label SDR glass glow alpha remap, desc SDR look: the alpha remap on the glow around focused glass panels (level-up cards, pause menu, every menu), so on a linear canvas (Metal full quality, Apple TV menus) it stays a tight halo like 8-bit instead of a wide haze. The panel body is not changed. Off = the pre-fix look. No effect on an 8-bit canvas.

  • HDR label HDR glass glow alpha remap, desc HDR look: the alpha remap on the glow around focused glass panels, so it stays a tight halo instead of a wide haze. The panel body is not changed. Off = the pre-fix look.

  • P6(c): append [&"glass_glow_remap", 1.0] to the SDR push list.

  • Step 4: The include. In ui/theme/glass_common.gdshaderinc, after the two header comment lines (before uniform vec4 accent), add:

#include "res://render/shaders/hdr_alpha.gdshaderinc"
// Unremapped-alpha batch (2026-10-12): ColorProfile{Sdr,Hdr}.GLASS_GLOW_REMAP via ShaderGlobals. The remap
// is on the GLOW's alpha only: body_a is a dark tint (0.72 on glass_clear), where pow(a, gamma) would
// thin the darkening (the wrong direction), and 1.0 on frost / snapshot.
global uniform float glass_glow_remap;

and replace line 35:

vec4 glow = vec4(accent.rgb, g * 0.55);

with:

vec4 glow = vec4(accent.rgb, glass_glow_remap > 0.5 ? hdr_alpha(g * 0.55) : g * 0.55);

(hdr_alpha.gdshaderinc is #ifndef-guarded, so glass_frost / glass_snapshot, which also include hdr_globals, compile it once.)

  • Step 5: Run the tests. -gselect=test_alpha_remap_switches.gd, -gselect=test_color_profiles.gd, -gselect=test_color_space_shaders.gd, -gselect=test_prewarm_coverage.gd, -gselect=test_glass. Expected: all pass.

  • Step 6: Compile check (mobile + gl_compatibility). Expected: clean; compile_check lists glass_frost_mat.tres / glass_snapshot_mat.tres by name and now glass_clear_mat.tres via the hdr_alpha marker.

  • Step 7: Measure (recipe M, AF_ONLY=glass). Expected: S8r maxdiff=0.000000; SLr FRINGE well below SL (audit 7-9x); SLr CORE about equal to SL core (the grey body is unchanged by design). Fill the row and add under the table: **Glass glow (Task 7).** Only the focus glow is remapped; the core column is the grey body, unchanged by design (out of scope: see the plan's Scope section).

  • Step 8: Gate and commit. GUT_LOG=$TMPDIR/alpha-fix-t7.log scripts/check-test-count.sh, scripts/gate.sh. Commit: fix(ui): glass focus glow gets the alpha remap (switchable); body untouched.


Task 8: Docs wrap-up, combined Dev build, on-device A/B (Chris)

Section titled “Task 8: Docs wrap-up, combined Dev build, on-device A/B (Chris)”

Files:

  • Modify: docs/architecture/rendering-and-performance.md (§ “A linear canvas blends alpha in LINEAR light”: the covered-shader sentence and the “Not covered” bullet; the Task 1 subsection: the follow-ups list and Chris’s verdicts)

  • Step 1: Docs. In § “A linear canvas blends alpha in LINEAR light”, after the paragraph that lists the covered shaders (it contains `hero_glow_mix` (the player's Nova hull glow, below). Tests: `tests/test_quality_manager.gd`, about line 629 of the doc), add:

**Switched remaps** (the unremapped-alpha batch, `tests/test_alpha_remap_switches.gd` pins them): `far_stars`,
`low_hp_border` (alpha only), `death_dissolve`, `weapon_zone_field` (life fade only) and the glass focus
glow (`glass_common.gdshaderinc`) apply `<global> > 0.5 ? hdr_alpha(a) : a`, each global a bool
`ColorProfile{Sdr,Hdr}.*_REMAP` pushed as a float by `ShaderGlobals.push`; the enemy Nova glow switches
its material (`nova_glow_mix_mat.tres`). See § "Unremapped-alpha fix batch (2026-10-12)".

Under the Task 1 table add a Follow-ups (not in this batch) list copied from this plan’s Scope “OUT of scope” bullets (glass grey body with its recommendation, RGB-falloff family, dark dims / veils, backdrop / zones under-shoot, Funzo shaders, drone panel glow).

  • Step 2: Whole-branch gate. In the alpha-fix-batch worktree: godot --headless --path . --import > /dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot, then GUT_LOG=$TMPDIR/alpha-fix-final.log scripts/check-test-count.sh and scripts/gate.sh. Run the compile check under both renderers once more. Re-run the harness for all items (recipe M without AF_ONLY) and confirm the table matches the per-task rows. Dispatch bh-reviewer on alpha-fix-batch vs origin/main (whole-branch review). Commit the doc changes.

  • Step 3: Combined Dev build on devices. Use skill bh-worktree-device-install from the alpha-fix-batch worktree: one Dev build on Chris’s iPhone (shader baker ON, A17) and Apple TV. Do not install while a human A/B is live on that device (dark-cosmos-console sessions → human_playing).

  • Step 4: A17 baker check (blocking). On the baked iPhone Dev install: the start menu’s glass and controls draw, the ship draws in a run, the pause menu and a level-up draw. If glass draws but menu controls or the ship do not (godot#124088, nothing in godot.log), STOP: reinstall with the baker off to confirm, and report before any merge.

  • Step 5: Chris’s A/B, SDR and HDR, iPhone + ATV. Chris judges each switch by eye with it toggled live through the console (send_command {"kind": "set_tunables", "tunables": {"ColorProfileSdr.<NAME>": false}}, then true; the same for ColorProfileHdr.<NAME> with HDR output on). Set-tunables edits are live-only (a run start or relaunch resets them). Checklist, each in SDR (HDR output off; record /status tier: the remap only acts at L0 / on the ATV, never on L1+) and HDR:

    • Nova glow (any run). In HDR also try ColorProfileHdr.ENEMY_NOVA_GLOW at 0.5 / 0.75 / 1.0 (Task 2’s candidates).
    • Far stars (any run, plus a warp for the streaks).
    • Low-HP border (take damage below 30% HP; the Debug Settings / console invuln helps).
    • Death dissolve (kill enemies).
    • Weapon zones (evolved Wildfire / Thunder Wake; watch zones dying). On the ATV this is also the A12 page-fault guard run.
    • Glass glow (pause menu, a level-up, the ATV start menu, which is linear from boot).
  • Step 6: Record and land. Write Chris’s verdict per switch per profile into the rendering-doc subsection. A switch Chris rejects ships false in that profile (edit the static, SDR_BUNDLED / the push list where it is the SDR value, the [shader_globals] value when the SDR value changes, and the docs.d desc “Off = …”); an ENEMY_NOVA_GLOW HDR value he picks replaces the 0.5 seed (update HDR_SEEDS in tests/test_color_profiles.gd and the docs). Gate, commit, push alpha-fix-batch, then merge via bh-merge-reviewed-branch (main-merge rules in docs/reference/builds-and-releases.md).


  • Spec coverage: the audit’s batch items 1 (Nova glow; DroneSprites moved to follow-ups with the reason), 2 (glass: glow remap here, grey body explicitly out with a recommendation), 3 (far stars, low-HP border, death dissolve, weapon zones; Funzo moved to follow-ups); the audit’s re-tune note (Task 2 candidates, Chris picks); “measure before/after with a real-render capture” (Task 1 harness + each task’s recipe M); “on-device A/B” (Task 8); out-of-scope follow-ups (Scope + Task 8 Step 1).
  • Names used across tasks: ENEMY_NOVA_GLOW_REMAP, FAR_STARS_REMAP / far_stars_remap, LOW_HP_BORDER_REMAP / low_hp_border_remap, DISSOLVE_REMAP / dissolve_remap, WEAPON_ZONE_REMAP / weapon_zone_remap, GLASS_GLOW_REMAP / glass_glow_remap; the harness’s SWITCH table uses the same static names.
  • Audit corrections found while writing this plan (verified against the code):
    1. The audit’s harness tools/alpha_audit_capture/ no longer exists (never committed; the hdr-alpha-audit worktree is gone), so “commit it with the fix batch” is impossible; Task 1 rebuilds a smaller one.
    2. Recommendation 2 says “hdr_alpha on the glow/body alpha”; the body alpha must NOT be remapped (body_a is 0.72 = a dark tint on glass_clear, the audit’s own dark-overlay class; 1.0 on frost / snapshot, a no-op).
    3. The weapon zone’s alpha is only the life fade (smoothstep(0, 0.25, frac): 1.0 for the first 75% of a zone’s life); the alpha-part fix changes only the fade-out tail.
    4. The low-HP border remaps the alpha only: the both-factor a^4.4 form holds only over black with a pure 2.2 decode and measured at about half the 8-bit light in-scene (Task 4 fix round 1).
    5. ArchetypeRenderer.sync() does not run while the sim clock is stopped (main.gd run_time gate), so a Nova-glow switch applied only from sync() would not land in the pause / freeze scene; Task 2 drives it from main._apply_color_profile.
    6. The drone panel glow (“same treatment for DroneSprites”) shares one CanvasItem with the body (draw_texture_rect twice), so a material cannot target the glow alone.

Execution handoff — resume notes (fresh session)

Section titled “Execution handoff — resume notes (fresh session)”
  • Plan written by a subagent on 2026-10-12 from Chris’s hard requirements (relayed by the launching agent): fix SDR AND HDR (SDR not frozen), exact no-op on 8-bit roots, every fix a paired switchable live tunable, before/after real-render numbers in the rendering doc, final on-device A/B by Chris (iPhone baked + ATV, SDR and HDR). These are in Global Constraints; nothing else was decided in conversation.
  • Already done: this plan committed on branch alpha-fix-plan (pushed to origin). Nothing else changed: no code, no deploys, no device installs, no remote-config patch.
  • Authorised: executing Tasks 1-7 in worktrees and pushing the alpha-fix-* branches. Needs Chris’s same-day OK: Task 8’s device installs (and never while a human A/B is live), any remote-config patch, the merge to main.
  • Open for Chris (Task 8): per-switch SDR/HDR verdicts; the ColorProfileHdr.ENEMY_NOVA_GLOW value (0.5 seed vs 0.75 / 1.0); whether to pursue the glass grey-body follow-up.
  • Gotchas met while planning: the audit’s harness tools/alpha_audit_capture/ does not exist (Task 1 rebuilds one); zsh does not expand --include=*.gd unquoted (use git grep with quoted pathspecs); grep is a gitignore-honouring shim, use /usr/bin/grep.
  • Next action: run the bh-sdd-concurrent check, create the alpha-fix-batch worktree off origin/alpha-fix-plan, then dispatch Task 1 to bh-implementer (model opus) in .claude/worktrees/alpha-fix-t1.