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”.
Global Constraints
Section titled “Global Constraints”- 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
S8rrow’s contribution must equal theS8row’s exactly (maxdiff=0.000000).- Note 2026-10-13 (Task 8): two exceptions shipped OFF, not ON.
WEAPON_ZONE_REMAPandGLASS_GLOW_REMAPare seededfalsein both profiles (pinned bySHIPPED_OFFintests/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).
- Note 2026-10-13 (Task 8): two exceptions shipped OFF, not ON.
- Every fix is a switchable registered live tunable with independent
ColorProfileSdr+ColorProfileHdrvalues:type: "bool", both seededtrue(exceptWEAPON_ZONE_REMAPandGLASS_GLOW_REMAP, which shipfalse; see the SDR note above), registered innet/tunables_registry.gd, labelled intools/tunable_docs/docs.d/color_profiles.json,control/src/tunable_docs.jsregenerated withnode tools/tunable_docs/generate.mjs(a hand edit fails the gate).tests/test_color_profiles.gdpins 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.trespreloaded. - Global uniforms are declared in
[shader_globals]of BOTHproject.godotandplatform/tvos/project.godot(typefloat, value1.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 twocompile_check:lines. Thengit checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godotonly after committing your own intendedproject.godotedits (or re-apply them).compile_check.gddiscovers every saved material whose shader nameshdr_alpha.gdshaderinc, so each touched shader is covered once it includes it. - Determinism untouched: every change is render-side (
/render,/fx,/ui,main.gdrender wiring). Nothing under/simchanges. 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), thenscripts/gate.shbefore 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 . --importonce first, thengit 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>andClaude-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.
Execution handoff (read before Task 1)
Section titled “Execution handoff (read before Task 1)”- Before dispatching Task 1, run the
bh-sdd-concurrentcheck (another session may be onmain; 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 offorigin/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 currentalpha-fix-batchtip:.claude/worktrees/alpha-fix-t<N>on branchalpha-fix-t<N>. Give the implementer that absolute path; do NOT also set the Agent tool’sisolation: "worktree"(it would land in a harness worktree off main). Run thesdd-worktree-leak-guardchecklist after every dispatch. - Reviewer: agent
bh-reviewerper task, onalpha-fix-t<N>vsalpha-fix-batch. On MERGE, the controller fast-forwards (or merges)alpha-fix-t<N>intoalpha-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-batchtomainuntil Task 8’s device A/B is judged by Chris. Merging to main followsdocs/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 rim0.22 + 0.4 * focus, the top edge0.25, the bottom shade) land unconverted in a linear buffer, andglass_clear’sbody_a0.72 is a DARK tint (the audit’s dark-overlay class, wherepow(a, γ)goes the wrong way). Recommendation: a separate design + plan: (a) a new globalui_canvas_linear(1.0 while the root is linear, pushed next tohdr_alpha_gammabyShaderGlobals.push_alpha), (b) inglass_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: decodebaseandbodyto sRGB space, mix, re-encode), (c)glass_clear’s 0.72 tint via the inverse remap1 - 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/*.gdshaderandfx/boss/warden_*.gdshader:COLOR = vec4(col * falloff, 1.0), incl. Orbit shards and Rift Bolt): alpha is 1.0, sohdr_alphacannot reach it. Needs a per-shaderhdr_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 veilui/level_up_panel.gd,fx/screen_feedback.gdVignetteFrame, outlines,surface.scrim): need the inverse remap1 - pow(1 - a, γ), a second helper. Never give themhdr_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, butfunzo_zone_fieldcarries alpha past 1 into rgb (rendering doc § “Not covered”), so it needs its own design. - Drone panel glow (
ui/drone_panel.gd:82viaDroneSprites.draw,render/drone_sprites.gd:42): the glow and the body are drawn withdraw_texture_recton 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 map
Section titled “File map”| 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 = trueP2. 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:
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).
Shared recipe M: measure an item
Section titled “Shared recipe M: measure an item”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 themRun 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:
S8rprintsmaxdiff=0.000000(exact no-op on an 8-bit root),floor=0.000000on every row (the frozen frame is stable, so the numbers are real),SLrfringeis nearer 1.00 thanSLfringe(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 linetools/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 := 20const 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: SubViewportvar mainvar _fake_win: Windowvar _wz: MultiMeshInstance2Dvar _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 linetools/player_glow_capture/out/, addtools/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)indocs/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 linearroot 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 an8-bit root). Measured with `tools/alpha_fix_capture` (real `main.tscn`, freeze scene, L0, colour_space_fixOFF, Engine.time_scale 0 so the noise floor is exactly 0; contribution = item shown minus hidden, linearluminance, 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 = theswitch 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_bucket423-454, end of_build_meshes405-406,apply_color_space410-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 by30e490ba),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 := truevar _s: Variantvar _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_remapdon’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, descSDR 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, descHDR 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 givenIn _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 syncwith:
# 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 syncReplace 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 realArchetypeRenderer, so its Nova glows now link thecs_mixpipeline 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;SLrfringe near 1 (audit measured the swap at 1.13 freeze / 1.17 live);Hrfringe well belowH(audit: 0.28-0.71, over-tight). Fill the Nova row’sSLr/Hrcells, 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 theglow'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, thenscripts/gate.sh. No shader edited, so no compile check is needed (the.tresreusescs_mix.gdshader); run it anyway ifcompile_checkis cheap for you: it now discoversnova_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.pushsends aboolprofile value as1.0/0.0; globalfar_stars_remap;ColorProfile{Sdr,Hdr}.FAR_STARS_REMAP: bool = true;tests/test_alpha_remap_switches.gdconstSWITCHED: 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 thepushbody’selsebranch:
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, descSDR 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, descHDR 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_valuesbecomes[[&"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 yourproject.godot/platform/tvos/project.godotedits BEFORE the restoringgit checkout. -
Step 8: Measure (recipe M,
AF_ONLY=far_stars). Expected:S8r maxdiff=0.000000;SLrx8 and fringe well belowSL(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 aSWITCHEDrow),tests/test_color_profiles.gd - Modify: rendering doc (row + line)
Interfaces:
-
Consumes: Task 3’s push conversion and
SWITCHEDtable. -
Produces: global
low_hp_border_remap;ColorProfile{Sdr,Hdr}.LOW_HP_BORDER_REMAP: bool = true. -
Step 1: Write the failing test. Add to
SWITCHEDintests/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, descSDR 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, descHDR 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;SLrfringe nearer 1 thanSL. 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 existingdissolve_edgeglobal /DISSOLVE_EDGEgain.) -
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, descSDR 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, descHDR 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;SLrfringe nearer 1 thanSL. 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 2render_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, descSDR 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, descHDR 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 2render_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 stagesfrac 0.12(fade ~0.4). Expected:S8r maxdiff=0.000000;SLrx8 belowSL. Fill the row (label it “fade 0.4”); in the doc’s “Not covered (known gaps)” bullet, change theweapon_zone_field.gdshadermention 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, descSDR 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, descHDR 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 (beforeuniform 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_checklistsglass_frost_mat.tres/glass_snapshot_mat.tresby name and nowglass_clear_mat.tresvia thehdr_alphamarker. -
Step 7: Measure (recipe M,
AF_ONLY=glass). Expected:S8r maxdiff=0.000000;SLrFRINGE well belowSL(audit 7-9x);SLrCORE about equal toSLcore (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 focusglow (`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 switchesits 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-batchworktree:godot --headless --path . --import > /dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres platform/tvos/project.godot, thenGUT_LOG=$TMPDIR/alpha-fix-final.log scripts/check-test-count.shandscripts/gate.sh. Run the compile check under both renderers once more. Re-run the harness for all items (recipe M withoutAF_ONLY) and confirm the table matches the per-task rows. Dispatchbh-revieweronalpha-fix-batchvsorigin/main(whole-branch review). Commit the doc changes. -
Step 3: Combined Dev build on devices. Use skill
bh-worktree-device-installfrom thealpha-fix-batchworktree: 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-consolesessions→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}}, thentrue; the same forColorProfileHdr.<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/statustier: 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_GLOWat 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).
- Nova glow (any run). In HDR also try
-
Step 6: Record and land. Write Chris’s verdict per switch per profile into the rendering-doc subsection. A switch Chris rejects ships
falsein 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 = …”); anENEMY_NOVA_GLOWHDR value he picks replaces the 0.5 seed (updateHDR_SEEDSintests/test_color_profiles.gdand the docs). Gate, commit, pushalpha-fix-batch, then merge viabh-merge-reviewed-branch(main-merge rules indocs/reference/builds-and-releases.md).
Self-review notes (plan author)
Section titled “Self-review notes (plan author)”- 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’sSWITCHtable uses the same static names. - Audit corrections found while writing this plan (verified against the code):
- The audit’s harness
tools/alpha_audit_capture/no longer exists (never committed; thehdr-alpha-auditworktree is gone), so “commit it with the fix batch” is impossible; Task 1 rebuilds a smaller one. - Recommendation 2 says “
hdr_alphaon the glow/body alpha”; the body alpha must NOT be remapped (body_ais 0.72 = a dark tint onglass_clear, the audit’s own dark-overlay class; 1.0 on frost / snapshot, a no-op). - 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. - The low-HP border remaps the alpha only: the both-factor
a^4.4form 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). ArchetypeRenderer.sync()does not run while the sim clock is stopped (main.gdrun_time gate), so a Nova-glow switch applied only fromsync()would not land in the pause / freeze scene; Task 2 drives it frommain._apply_color_profile.- The drone panel glow (“same treatment for DroneSprites”) shares one CanvasItem with the body (
draw_texture_recttwice), so a material cannot target the glow alone.
- The audit’s harness
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 tomain. - Open for Chris (Task 8): per-switch SDR/HDR verdicts; the
ColorProfileHdr.ENEMY_NOVA_GLOWvalue (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=*.gdunquoted (usegit grepwith quoted pathspecs);grepis a gitignore-honouring shim, use/usr/bin/grep. - Next action: run the
bh-sdd-concurrentcheck, create thealpha-fix-batchworktree offorigin/alpha-fix-plan, then dispatch Task 1 tobh-implementer(model opus) in.claude/worktrees/alpha-fix-t1.