Skip to content

Colour-blind mode (presets + shape cues) implementation plan

AMENDMENTS (Chris, 2026-10-20, accepted in chat) — these OVERRIDE any task text below that disagrees.

  1. HDR values are authored independently, never copied from SDR (memory rule: two full colour systems, no derived-HDR shortcuts). Task 1 Step 4: author each CvdPaletteHdr value for the HDR path — start from a headroom/P3 mapping of the SDR pick, then choose the value that best separates the pairs under that CVD kind AFTER P3 widening on the linear canvas (use CvdSim with hdr=true to compare candidates). Each value gets a one-line comment with its min-ΔE under its kind. Add a test that fails if the HDR table is value-for-value identical to SDR. Chris still reviews both on hardware; the values are a considered first pass, not a seed.
  2. Clearer stats-sheet legend + ▸ marker for EVERYONE, not only under a preset. The changed-row ▸ marker and the legend copy apply with preset Off too (this is the one deliberate exception to “Off is today’s game pixel for pixel”; it ships visibly in the reviewed 1.1 build, so Apple 2.3.1 is fine). Legend copy, all modes: "▸ marks what %s changes" (preview title) / "▸ marks what this card changes"; the colour stays the role colour of the active palette. Keep the font has_char check + ASCII > fallback.

Colour-blind mode (presets + shape cues) Implementation Plan

Section titled “Colour-blind mode (presets + shape cues) Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. In this repo also read skill bh-sdd-concurrent before Task 1 and run sdd-worktree-leak-guard after every dispatch.

Goal: add a Settings “Colour vision” row (Off / Deuteranopia / Protanopia / Tritanopia).

  • A preset remaps only the gameplay-critical colours: element table, one THREAT hue for hostile shots and hazards, HP bands, and stats-sheet roles.
  • A preset also turns on redundant shape cues: a hostile ring on enemy shots, hazard teeth, dashed telegraphs, a dashed shock aura, HP notches, and a stats marker.
  • Every colour has independent SDR and HDR values, all live Remote Console tunables.

Architecture:

  • Palette classes. A render-only CvdPalette (render/cvd_palette.gd) holds the preset and answers colour lookups. The values live in CvdPaletteSdr / CvdPaletteHdr, which have the same static var names (the ColorProfileSdr/Hdr pattern), and HdrLook.active picks one.
  • Rebuild trigger. A preset change calls a new ColorGamut.bump_generation(), so every existing colour-cache owner rebuilds through the generation poll it already has.
  • Remap points. Colours are swapped at their source: ElementPalette.color_for_srgb, main._enemy_proj_colors, the hazard renderers, and the HUD/stats-sheet roles. ColorGamut.out stays the single, last widening step.
  • Cues are GDScript draw_* calls and one extra MultiMesh layer. No task edits a shader.

Tech Stack: Godot 4.7 typed GDScript, GUT; node (tunable docs generator, control tests); Python 3 + Pillow + numpy (verification sheets only, not shipped).

Spec: docs/superpowers/specs/2026-10-20-colourblind-mode-design.md (audit, palettes, floors, veto list).

  • Off is today’s game, pixel for pixel. With CvdPalette.preset == OFF and CUES_FOR_ALL == false:

    • every changed function returns exactly what it returns today;
    • no new node draws;
    • the ring layer is hidden.

    /sim is not touched at all, so the three pinned determinism files must pass UNCHANGED. Never edit a pinned hash.

  • Render-only. No /sim file reads CvdPalette.

  • Two colour systems. Every preset colour exists in BOTH CvdPaletteSdr and CvdPaletteHdr, under the same name. A parity test enforces this. HDR values are authored independently for the HDR path (amendment 1); Chris reviews both on hardware.

  • Widening. ColorGamut.out(c, path) is applied once, last, on an sRGB source. Never mix colours that are already widened. Preset element colours BYPASS ElementPalette.vivid().

  • Tunables. Every palette value is a registered "type": "color", "local": true tunable:

    • a net/tunables_registry.gd entry;
    • a label in tools/tunable_docs/docs.d/cvd_palette.json;
    • a net/live_scope.gd hook prefix.

    control/src/tunable_docs.js is GENERATED (node tools/tunable_docs/generate.mjs); never hand-edit it.

  • The preset is a PLAYER PREF, not a tunable. It is MetaState.cvd_preset, one of the strings "off" / "deutan" / "protan" / "tritan"; anything missing or invalid becomes "off". This is the ComfortFx precedent. The field is additive, so there is no schema bump.

  • Apple 2.3.1. CUES_FOR_ALL is a dev A/B only. Cues reach store players only through the reviewed Settings row.

  • No telemetry of the preset. Collecting it would need a privacy page + ASC label update, which is out of scope.

  • Player-facing copy:

    • row label: Colour vision: Off / Colour vision: Deuteranopia / Colour vision: Protanopia / Colour vision: Tritanopia;
    • stats legend, every mode (amendment 2): ▸ marks what X changes.
  • Gate before every commit: bash scripts/gate.sh. Use scripts/check-test-count.sh for quick loops, and never pull a raw GUT run into context. After adding a class_name, run godot --headless --path . --import first, then git checkout -- project.godot audio/bus_layout.tres. If worktrees run gates concurrently, set GUT_LOG=<own path>.

  • Worktree. Work in .claude/worktrees/colourblind-mode (branch colourblind-mode, off origin/main), never in the main checkout. Commit with git commit --file=<msgfile>.

  • Models: model tags are per task. bh-implementer is pinned to Sonnet; pass model: "opus" only where a task says opus.

File Status Task
render/cvd_palette.gd new (class_name CvdPalette) 1
render/cvd_palette_sdr.gd, render/cvd_palette_hdr.gd new (CvdPaletteSdr, CvdPaletteHdr) 1
render/color_gamut.gd bump_generation() 1
tools/cvd_sim/cvd_sim.gd new (no class_name, preloaded by tests) 1
tests/test_cvd_palette.gd new 1, 4
net/tunables_registry.gd, net/live_scope.gd, tools/tunable_docs/docs.d/cvd_palette.json, control/src/tunable_docs.js (generated) edit / new 2
sim/meta_state.gd, ui/settings_panel.gd, main.gd edit 3
tests/test_settings_panel.gd, tests/test_meta_state.gd edit 3
render/element_palette.gd, main.gd (_build_element_luts) edit 4
render/ring_texture.gd (new), render/swarm_renderer.gd, main.gd (_enemy_proj_colors) edit 5
tests/test_cvd_hostile.gd new 5, 6
render/dimension_hazard_renderer.gd, render/bomb_renderer.gd, render/lancer_telegraph_renderer.gd, render/zone_renderer.gd, render/cvd_cues.gd (new) edit 6
fx/enemy_aura_look.gd edit 7
ui/hud.gd, ui/stats_sheet.gd, tests/test_cvd_ui.gd (new) edit 8
main.gd (dev command + caps), control/src/guard.js, control/src/panel/client.js, control/src/panel/tooltips.js, control/test/*.test.mjs edit 9
tools/cvd_sim/cvd.py, sheet.py, palette_opt.py, tools/ui_capture/capture.gd (cvd scene) new / edit 10
tools/vfx_library/library_manifest.gd, tests/test_vfx_library_manifest.gd, docs/architecture/rendering-and-performance.md, docs/architecture/ui-tokens.md edit 11
(device verification, no code) 12

Task 1: CvdPalette core, SDR/HDR tables, floor + parity tests

Section titled “Task 1: CvdPalette core, SDR/HDR tables, floor + parity tests”

model: sonnet

Files:

  • Create: render/cvd_palette.gd, render/cvd_palette_sdr.gd, render/cvd_palette_hdr.gd, tools/cvd_sim/cvd_sim.gd, tests/test_cvd_palette.gd
  • Modify: render/color_gamut.gd

Interfaces:

  • CvdPalette constants and state: OFF/DEUTAN/PROTAN/TRITAN, IDS, LABELS, preset, generation, CUES_FOR_ALL.

  • CvdPalette functions: id(), from_id(s), active(), cues_on(), set_preset(p), bump(), sync_profile(profile), element_srgb(id, fallback), threat_srgb(), threat_out(path, alpha), ui_srgb(role, fallback).

  • ColorGamut.bump_generation().

  • Step 1: Write the failing tests in tests/test_cvd_palette.gd (extends GutTest, tabs):

extends GutTest
# Colour-blind mode (docs/superpowers/specs/2026-10-20-colourblind-mode-design.md § Method). The
# preset tables must keep every used element apart under the target deficiency, AND keep every
# element away from THREAT, in BOTH colour systems. Off must change nothing.
const CvdSim := preload("res://tools/cvd_sim/cvd_sim.gd")
const SDR_PATH := "res://render/cvd_palette_sdr.gd"
const HDR_PATH := "res://render/cvd_palette_hdr.gd"
# Elements referenced by shipped weapons/enemies/dimensions. "sound" is in the bible but no weapon
# or enemy carries it (spec § Method); it keeps a table entry and is left out of the floor.
const USED := ["fire", "cold", "lightning", "poison", "void", "aether", "kinetic", "light",
"decay", "time", "blood", "psychic", "nano"]
const KIND := {CvdPalette.DEUTAN: "deutan", CvdPalette.PROTAN: "protan", CvdPalette.TRITAN: "tritan"}
func after_each() -> void:
CvdPalette.set_preset(CvdPalette.OFF)
CvdPalette.sync_profile(ColorProfile.SDR)
CvdPalette.CUES_FOR_ALL = false
func _statics(path: String) -> Array[String]:
var out: Array[String] = []
var re := RegEx.create_from_string("(?m)^static var ([A-Za-z_][A-Za-z0-9_]*)")
for m in re.search_all(FileAccess.get_file_as_string(path)):
out.append(m.get_string(1))
return out
func test_sdr_and_hdr_tables_have_the_same_names() -> void:
var sdr := _statics(SDR_PATH)
var hdr := _statics(HDR_PATH)
assert_eq(sdr.size(), 60, "20 values per preset x 3 presets")
for n in sdr:
assert_has(hdr, n, "CvdPaletteSdr.%s has no HDR twin" % n)
for n in hdr:
assert_has(sdr, n, "CvdPaletteHdr.%s has no SDR twin" % n)
func test_off_returns_the_fallback_and_cues_are_off() -> void:
assert_false(CvdPalette.active())
assert_false(CvdPalette.cues_on())
var fb := Color(0.1, 0.2, 0.3)
assert_eq(CvdPalette.element_srgb("fire", fb), fb)
assert_eq(CvdPalette.ui_srgb("UI_GOOD", fb), fb)
func test_ids_round_trip_and_bad_ids_are_off() -> void:
for p in [CvdPalette.OFF, CvdPalette.DEUTAN, CvdPalette.PROTAN, CvdPalette.TRITAN]:
CvdPalette.set_preset(p)
assert_eq(CvdPalette.from_id(CvdPalette.id()), p)
assert_eq(CvdPalette.from_id("nonsense"), CvdPalette.OFF)
assert_eq(CvdPalette.from_id(""), CvdPalette.OFF)
func test_set_preset_bumps_the_gamut_generation() -> void:
var g := ColorGamut.generation
CvdPalette.set_preset(CvdPalette.PROTAN)
assert_gt(ColorGamut.generation, g, "every colour cache owner polls ColorGamut.generation")
var g2 := ColorGamut.generation
CvdPalette.set_preset(CvdPalette.PROTAN)
assert_eq(ColorGamut.generation, g2, "same preset: no rebuild")
func test_profile_flip_bumps_only_while_active() -> void:
var g := ColorGamut.generation
CvdPalette.sync_profile(ColorProfile.HDR)
assert_eq(ColorGamut.generation, g, "Off: the HDR flip has nothing to rebuild")
CvdPalette.sync_profile(ColorProfile.SDR)
CvdPalette.set_preset(CvdPalette.DEUTAN)
g = ColorGamut.generation
CvdPalette.sync_profile(ColorProfile.HDR)
assert_gt(ColorGamut.generation, g)
func test_cues_for_all_turns_cues_on_with_preset_off() -> void:
CvdPalette.CUES_FOR_ALL = true
assert_true(CvdPalette.cues_on())
assert_false(CvdPalette.active(), "the colours stay Off")
func _floors(profile: int) -> void:
CvdPalette.sync_profile(profile)
for p: int in KIND:
CvdPalette.set_preset(p)
var cols: Array[Color] = []
for e: String in USED:
cols.append(CvdPalette.element_srgb(e, Color.BLACK))
cols.append(ElementPalette.GEM)
cols.append(ElementPalette.PLAYER_HALO)
var hdr := profile == ColorProfile.HDR
var tag := "%s %s" % [KIND[p], "hdr" if hdr else "sdr"]
assert_gte(CvdSim.min_pair_de(cols, KIND[p], hdr), 5.5, "%s: used elements under %s" % [tag, KIND[p]])
assert_gte(CvdSim.min_pair_de(cols, "normal", hdr), 5.0, "%s: normal vision" % tag)
var threat := CvdPalette.threat_srgb()
for c in cols:
assert_gte(CvdSim.de(c, threat, KIND[p], hdr), 10.0, "%s: element vs THREAT" % tag)
var hp: Array[Color] = []
for r in ["HP_HI", "HP_MID", "HP_LO"]:
hp.append(CvdPalette.ui_srgb(r, Color.BLACK))
assert_gte(CvdSim.min_pair_de(hp, KIND[p], hdr), 20.0, "%s: HP bands" % tag)
var ui: Array[Color] = [CvdPalette.ui_srgb("UI_GOOD", Color.BLACK), CvdPalette.ui_srgb("UI_EVOLVE", Color.BLACK)]
assert_gte(CvdSim.min_pair_de(ui, KIND[p], hdr), 15.0, "%s: stats roles" % tag)
func test_sdr_tables_meet_the_floors() -> void:
_floors(ColorProfile.SDR)
func test_hdr_tables_meet_the_floors_after_p3_widening() -> void:
_floors(ColorProfile.HDR)
func test_every_used_element_is_readable_on_black() -> void:
for profile in [ColorProfile.SDR, ColorProfile.HDR]:
CvdPalette.sync_profile(profile)
for p: int in KIND:
CvdPalette.set_preset(p)
for e: String in USED:
assert_gte(CvdSim.oklab_l(CvdPalette.element_srgb(e, Color.BLACK)), 0.62, "%s %s" % [KIND[p], e])
  • Step 2: Run it to verify it fails.

    • Run: godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_cvd_palette.gd -gexit 2>&1 | tail -15
    • Expected: parse errors, because CvdPalette is not declared yet.
  • Step 3: Add ColorGamut.bump_generation() to render/color_gamut.gd, directly after refresh():

# A source colour changed under every cache owner without the gamut moving (colour-blind preset,
# CvdPalette.set_preset): drop the out() caches and bump, so each owner's generation poll rebuilds.
static func bump_generation() -> void:
for d: Dictionary in _cache:
d.clear()
generation += 1
  • Step 4: Create render/cvd_palette_sdr.gd.
    • It holds class_name CvdPaletteSdr / extends RefCounted, with a header comment pointing at the spec and saying “SDR values; the HDR twin is cvd_palette_hdr.gd, same names”.
    • It has 60 static var NAME := Color("#rrggbb") lines: 20 per preset with prefixes DEUTAN_, PROTAN_ and TRITAN_. The names are FIRE COLD LIGHTNING POISON VOID AETHER KINETIC LIGHT DECAY TIME SOUND BLOOD PSYCHIC NANO THREAT HP_HI HP_MID HP_LO UI_GOOD UI_EVOLVE.
    • The values are exactly the spec § Palettes tables. Example of the first lines:
class_name CvdPaletteSdr
extends RefCounted
# Colour-blind presets, SDR values (docs/superpowers/specs/2026-10-20-colourblind-mode-design.md
# § Palettes). The HDR twin is cvd_palette_hdr.gd with the same names (tests/test_cvd_palette.gd).
# Every value is a local colour tunable (net/tunables_registry.gd). Render-only.
static var DEUTAN_FIRE := Color("#ff9080")
static var DEUTAN_COLD := Color("#38e0ca")
static var DEUTAN_LIGHTNING := Color("#e0c200")
# ... (all 20 DEUTAN_, then PROTAN_, then TRITAN_)
static var DEUTAN_THREAT := Color("#ff1f9a")
static var DEUTAN_HP_HI := Color("#2e9bff")
static var DEUTAN_HP_MID := Color("#ffe600")
static var DEUTAN_HP_LO := Color("#ff2a55")
static var DEUTAN_UI_GOOD := Color("#4db8ff")
static var DEUTAN_UI_EVOLVE := Color("#ffd21f")
  • render/cvd_palette_hdr.gd has the same names with class_name CvdPaletteHdr and INDEPENDENTLY AUTHORED values (amendment 1: headroom/P3 mapping of the SDR pick, then the best-separating choice under CvdSim with hdr=true, each with a min-ΔE comment), header “HDR/P3 values: authored for the HDR path 2026-10-20; Chris reviews on HDR hardware”. Plus a test that the two tables differ.

  • Step 5: Create render/cvd_palette.gd:

class_name CvdPalette
extends RefCounted
# Colour-blind mode (docs/superpowers/specs/2026-10-20-colourblind-mode-design.md). The player's
# preset (Settings "Colour vision", MetaState.cvd_preset; NOT a tunable, the ComfortFx precedent)
# and the lookups render code swaps its gameplay-critical colours through. Off = every lookup
# returns the caller's fallback. Values: CvdPaletteSdr / CvdPaletteHdr (HdrLook.active picks).
# Render-only, never read by /sim.
enum { OFF, DEUTAN, PROTAN, TRITAN }
const IDS: Array[String] = ["off", "deutan", "protan", "tritan"]
const LABELS: Array[String] = ["Off", "Deuteranopia", "Protanopia", "Tritanopia"]
const _PREFIX: Array[String] = ["", "DEUTAN_", "PROTAN_", "TRITAN_"]
const _SDR := preload("res://render/cvd_palette_sdr.gd")
const _HDR := preload("res://render/cvd_palette_hdr.gd")
static var preset := OFF
static var generation := 0 # bumps on a preset / profile change / live value edit
static var CUES_FOR_ALL := false # dev A/B: shape cues with preset Off (local tunable; Apple 2.3.1: never a store flip)
static var _profile := ColorProfile.SDR
static func id() -> String:
return IDS[preset]
static func from_id(s: String) -> int:
var i := IDS.find(s)
return i if i >= 0 else OFF
static func active() -> bool:
return preset != OFF
static func cues_on() -> bool:
return preset != OFF or CUES_FOR_ALL
static func set_preset(p: int) -> void:
p = clampi(p, OFF, TRITAN)
if p == preset:
return
preset = p
bump()
# Every colour-cache owner polls ColorGamut.generation already (main LUTs, GamutGlobals,
# EnemyAuraLook, ZoneRenderer, elite fx): one bump rebuilds them all.
static func bump() -> void:
generation += 1
ColorGamut.bump_generation()
# main._apply_color_profile calls this with HdrLook.active: the HDR table is a separate value set.
static func sync_profile(profile: int) -> void:
if profile == _profile:
return
_profile = profile
if active():
bump()
static func _value(name: String) -> Variant:
var s: GDScript = _HDR if _profile == ColorProfile.HDR else _SDR
return s.get(_PREFIX[preset] + name)
# An element's sRGB colour under the preset, by bible element id. NOT run through vivid(): the
# tables rely on lightness separation that vivid() (sat >= 0.92, value >= 0.9) would undo.
static func element_srgb(element_id: String, fallback: Color) -> Color:
if not active():
return fallback
var v: Variant = _value(element_id.to_upper())
return v if v is Color else fallback
static func threat_srgb() -> Color:
var v: Variant = _value("THREAT") if active() else null
return v if v is Color else Color(1.0, 0.05, 0.05) # content-colour: never drawn while Off
# THREAT for a draw path, widened once, with the caller's alpha.
static func threat_out(path: int, alpha: float) -> Color:
var c := ColorGamut.out(threat_srgb(), path)
c.a = alpha
return c
# HP_HI / HP_MID / HP_LO / UI_GOOD / UI_EVOLVE.
static func ui_srgb(role: String, fallback: Color) -> Color:
if not active():
return fallback
var v: Variant = _value(role)
return v if v is Color else fallback
  • Step 6: Create tools/cvd_sim/cvd_sim.gd.

    • It has no class_name and extends RefCounted.
    • It holds the Machado 2009 severity-1.0 matrices from the spec § Method as const M := {"protan": [...], "deutan": [...], "tritan": [...]}.
    • Functions:
      • static func simulate(c: Color, kind: String, hdr: bool) -> PackedFloat64Array:
        1. linearise with c.srgb_to_linear();
        2. if hdr, apply ColorGamut.widen(lin, 1.0, ColorGamut.LINEAR);
        3. unless kind == "normal", apply the matrix;
        4. clamp each channel to 0..1.
      • static func oklab_l(c: Color) -> float.
      • static func de(a, b, kind, hdr) -> float: the Euclidean distance of ColorGamut.linear_to_oklab results ×100.
      • static func min_pair_de(cols: Array[Color], kind, hdr) -> float.
    • Comment that it mirrors tools/cvd_sim/cvd.py (Task 10).
  • Step 7: Import, run, and fix the values until the tests pass.

    • Run godot --headless --path . --import, then git checkout -- project.godot audio/bus_layout.tres.
    • Rerun the Step 2 command. Expected: all pass.
    • If a floor fails by less than 0.3, the cause is GDScript-vs-Python OKLab rounding. Report the measured value; never lower the floor silently.
  • Step 8: Gate and commit. Run bash scripts/gate.sh (expect PASS), then git add the new files and commit with message cvd: CvdPalette core + SDR/HDR preset tables + floor tests.


Task 2: Register the 121 palette values as live tunables

Section titled “Task 2: Register the 121 palette values as live tunables”

model: sonnet

Files:

  • Modify: net/tunables_registry.gd, net/live_scope.gd, main.gd (live hook block only)

  • Create: tools/tunable_docs/docs.d/cvd_palette.json

  • Regenerate: control/src/tunable_docs.js

  • Test: tests/test_cvd_palette.gd (append), plus the existing control/test/tunable-docs.test.mjs

  • Step 1: Write the failing test (append to tests/test_cvd_palette.gd):

func test_every_palette_value_is_a_local_colour_tunable() -> void:
var keys := {}
for e: Dictionary in TunablesRegistry.ENTRIES:
keys[String(e["key"])] = e
for n in _statics(SDR_PATH):
for cls in ["CvdPaletteSdr.", "CvdPaletteHdr."]:
var k: String = cls + n
assert_true(keys.has(k), "%s is not registered" % k)
if keys.has(k):
assert_eq(String(keys[k].get("type", "")), "color", k)
assert_true(bool(keys[k].get("local", false)), "%s: per-device, not in the patch hash" % k)
assert_true(keys.has("CvdPalette.CUES_FOR_ALL"))
func test_a_palette_edit_classifies_to_the_cvd_hook() -> void:
assert_eq(LiveScope.hook_for("CvdPaletteSdr.DEUTAN_FIRE"), LiveScope.HOOK_CVD)
assert_eq(LiveScope.hook_for("CvdPaletteHdr.TRITAN_THREAT"), LiveScope.HOOK_CVD)
assert_eq(LiveScope.hook_for("CvdPalette.CUES_FOR_ALL"), LiveScope.HOOK_CVD)

Before writing these, check the real names of the registry const and the LiveScope lookup (/usr/bin/grep -n "^const\|^static func" net/tunables_registry.gd net/live_scope.gd). Use those names; ENTRIES and hook_for are placeholders if the file names them differently.

  • Step 2: Run it to verify it fails (keys not registered).

  • Step 3: Add the registry entries.

    • Add script consts next to const COLOR_SDR := "res://render/color_profile_sdr.gd" (line 51): const CVD := "res://render/cvd_palette.gd", const CVD_SDR := "res://render/cvd_palette_sdr.gd" and const CVD_HDR := "res://render/cvd_palette_hdr.gd".
    • Generate the 120 colour lines with this one-off (run it from the worktree root and paste the output after the ColorProfileHdr block):
Terminal window
python3 - <<'EOF'
import re
names = re.findall(r'(?m)^static var ([A-Z_]+)', open('render/cvd_palette_sdr.gd').read())
for cls, const in (("CvdPaletteSdr", "CVD_SDR"), ("CvdPaletteHdr", "CVD_HDR")):
for n in names:
print(f'\t{{"key": "{cls}.{n}", "script": {const}, "name": "{n}", "type": "color", "local": true}},')
EOF
  • Add the bool by hand: {"key": "CvdPalette.CUES_FOR_ALL", "script": CVD, "name": "CUES_FOR_ALL", "type": "bool", "local": true},

  • Step 4: Add the LiveScope hook.

    • In net/live_scope.gd, next to const HOOK_ENEMY_PROJ := "enemy_proj", add: const HOOK_CVD := "cvd" # CvdPalette.bump(): colour-blind palette value / cue switch (main._on_cvd_changed)
    • In the prefix map, after the two ColorProfile lines (live at :418-419):
"ColorProfileSdr.": HOOK_ENV,
"ColorProfileHdr.": HOOK_ENV,

add:

"CvdPaletteSdr.": HOOK_CVD,
"CvdPaletteHdr.": HOOK_CVD,
"CvdPalette.": HOOK_CVD,
  • Check how prefix matching handles "CvdPalette." vs "CvdPaletteSdr.". All three map to the same hook, so the match order does not matter.

  • Step 5: Handle the hook in main.

    • In main.gd’s live-hook block, OLD (live :1488-1490):
if hooks.has(LiveScope.HOOK_ENV):
HdrTier.invalidate() # the edited static may be an HdrTier source: recompute its fed value
_apply_color_profile() # a ColorProfileSdr/Hdr edit: environment + shader globals, live

NEW (append directly after it):

if hooks.has(LiveScope.HOOK_CVD):
_on_cvd_changed() # a colour-blind palette value or the cue A/B: rebuild every colour cache
  • Add the function near _apply_color_profile:
# Colour-blind mode: a preset change (Settings / console) or a live palette edit. CvdPalette.bump()
# moves ColorGamut.generation; _apply_color_profile rebuilds the LUTs and re-pushes GamutGlobals.
func _on_cvd_changed() -> void:
CvdPalette.bump()
_apply_color_profile()
  • Step 6: Write the docs labels.

    • Write tools/tunable_docs/docs.d/cvd_palette.json with one entry per key, same shape as color_profiles.json: label, desc, "src": "code", "group": "Glow & colour grade".
    • Generate it with a Python one-off over the same names. Label pattern: "Deuteranopia: fire (SDR)". Desc: "Colour-blind preset colour (SDR look). Used in place of the element/threat/HP/stats colour while this preset is chosen in Settings. Keep it readable on black."
    • The HDR desc says “(HDR look, before P3 widening)”.
    • CUES_FOR_ALL: "Colour-blind shape cues for everyone (dev A/B)".
  • Step 7: Regenerate. Run node tools/tunable_docs/generate.mjs, then node --test control/test/tunable-docs.test.mjs (expect PASS), then rerun the GUT file (expect PASS).

  • Step 8: Gate and commit. bash scripts/gate.sh must PASS. Commit message: cvd: palette values as live local colour tunables (HOOK_CVD).


Task 3: Player pref, Settings row, boot + profile wiring

Section titled “Task 3: Player pref, Settings row, boot + profile wiring”

model: opus (cross-cutting: main.gd boot/settings/profile path, MetaState save, SettingsPanel nav)

Files:

  • Modify: sim/meta_state.gd, ui/settings_panel.gd, main.gd

  • Test: tests/test_settings_panel.gd, tests/test_meta_state.gd

  • Step 1: Write the failing tests.

    • In tests/test_meta_state.gd, add:
      • cvd_preset defaults to "off";
      • it round-trips "protan" through to_dict() / from_dict() (use the file’s existing round-trip helper);
      • {"cvd_preset": "purple"} and {"cvd_preset": 3} both load as "off".
    • In tests/test_settings_panel.gd:
      • Extend test_dpad_walks_every_row_and_a_presses_a_toggle’s expected keys (live): ["music_volume", "sfx_volume", "hp_bar_always", "haptics", "reduce_fx", "drone_nudge", "done"] becomes ["music_volume", "sfx_volume", "hp_bar_always", "haptics", "reduce_fx", "colour_vision", "drone_nudge", "done"].
      • Add:
func test_colour_vision_cycles_with_a_and_steps_with_left_right() -> void:
var p := await _panel()
watch_signals(p)
assert_eq(p._cvd_btn.text, "Colour vision: Off")
p._cvd_btn.emit_signal("pressed")
assert_signal_emitted_with_parameters(p, "setting_changed", ["colour_vision", "deutan"])
assert_eq(p._cvd_btn.text, "Colour vision: Deuteranopia")
for _i in p.row_keys().find("colour_vision"):
_press(p, _joy(JOY_BUTTON_DPAD_DOWN))
await wait_physics_frames(1) # the nav debounce (NAV_DEBOUNCE_MS) is wall-clock: see the volume test for its wait
_press(p, _joy(JOY_BUTTON_DPAD_LEFT))
assert_eq(p._cvd_btn.text, "Colour vision: Off", "left steps back")
_press(p, _joy(JOY_BUTTON_DPAD_LEFT))
assert_eq(p._cvd_btn.text, "Colour vision: Tritanopia", "wraps")
func test_boot_applies_the_saved_colour_vision() -> void:
CvdPalette.set_preset(CvdPalette.TRITAN)
var m := await _boot()
assert_eq(CvdPalette.preset, CvdPalette.OFF, "fresh save: boot copied meta.cvd_preset over a stale static")
Copy the debounce wait from the existing left/right volume test; the line above is a placeholder
if that test waits differently.
  • Also add CvdPalette.set_preset(CvdPalette.OFF) to the file’s after_each.

  • test_fits_every_device_profile (live :315) covers the new row automatically through _all_buttons(), so that test needs no change.

  • Step 2: Run both files to verify they fail.

  • Step 3: Add the MetaState field.

    • Field, after var reduce_fx: bool = false # mirrors ComfortFx.reduce (live :96): var cvd_preset: String = "off" # mirrors CvdPalette.preset (Settings "Colour vision"); "off"/"deutan"/"protan"/"tritan"
    • to_dict(): OLD "music_volume": music_volume, "sfx_volume": sfx_volume, "reduce_fx": reduce_fx, "drone_nudge": drone_nudge, "fullscreen": fullscreen, NEW "music_volume": music_volume, "sfx_volume": sfx_volume, "reduce_fx": reduce_fx, "cvd_preset": cvd_preset, "drone_nudge": drone_nudge, "fullscreen": fullscreen,
    • Load, after reduce_fx = bool(d.get("reduce_fx", false)):
var cv: Variant = d.get("cvd_preset", "off") # older saves: Off
cvd_preset = String(cv) if cv is String and CvdPalette.IDS.has(String(cv)) else "off"
  • /sim purity: sim/meta_state.gd must not reference a render class. If the purity review or test flags CvdPalette.IDS, inline the id list as a const CVD_IDS in meta_state instead, and add a test asserting that CVD_IDS == CvdPalette.IDS.

  • Step 4: Add the SettingsPanel row (ui/settings_panel.gd).

    • Field: var cvd_preset := "off" after var reduce_fx := false, and var _cvd_btn: Button after var _reduce_btn: Button.
    • Row: after _reduce_btn = _row_btn("reduce_fx", box) add _cvd_btn = _row_btn("colour_vision", box).
    • Add _cvd_btn to the _all_buttons() list, after _reduce_btn.
    • Label: after _reduce_btn.text = on_off_label("Reduce shake & flashes", reduce_fx) add _cvd_btn.text = cvd_label(cvd_preset), with:
static func cvd_label(preset_id: String) -> String:
return "Colour vision: " + CvdPalette.LABELS[CvdPalette.from_id(preset_id)]
func _step_cvd(dir: int) -> void:
var n := CvdPalette.IDS.size()
cvd_preset = CvdPalette.IDS[(CvdPalette.from_id(cvd_preset) + dir + n) % n]
_emit("colour_vision", cvd_preset)
  • _activate, OLD:
"reduce_fx":
reduce_fx = not reduce_fx
_emit(key, reduce_fx)

NEW:

"reduce_fx":
reduce_fx = not reduce_fx
_emit(key, reduce_fx)
"colour_vision":
_step_cvd(1)
  • _input left/right, OLD:
var k := _keys[_sel]
if k == "music_volume" or k == "sfx_volume":
_step(k, h)

NEW:

var k := _keys[_sel]
if k == "music_volume" or k == "sfx_volume":
_step(k, h)
elif k == "colour_vision":
_ui_nav()
_step_cvd(h)
  • Check that _emit refreshes labels. If it does not, call _refresh_labels() in _step_cvd.

  • Step 5: Wire main.gd.

    • Boot, after ComfortFx.reduce = meta.reduce_fx # Settings "Reduce shake & flashes" (live :469): CvdPalette.set_preset(CvdPalette.from_id(meta.cvd_preset)) # Settings "Colour vision"
    • _open_settings: after p.reduce_fx = ComfortFx.reduce add p.cvd_preset = CvdPalette.id().
    • _on_setting_changed: after the "reduce_fx": arm add:
"colour_vision":
CvdPalette.set_preset(CvdPalette.from_id(String(value)))
_on_cvd_changed()
if meta != null:
meta.cvd_preset = CvdPalette.id()
MetaStore.save_prefs(meta)
  • _apply_color_profile(), OLD (live :3213-3215):
# Display P3: every cached gameplay colour follows ColorGamut (render/color_gamut.gd).
if _gamut_built != ColorGamut.generation:
_build_element_luts()

NEW:

CvdPalette.sync_profile(HdrLook.active) # colour-blind presets: the HDR table is its own value set
# Display P3: every cached gameplay colour follows ColorGamut (render/color_gamut.gd).
if _gamut_built != ColorGamut.generation:
_build_element_luts()
  • Step 6: Run the tests.

    • Run godot --headless --path . --import, then git checkout -- project.godot audio/bus_layout.tres.
    • Run both test files. Expected: PASS.
  • Step 7: Gate and commit. bash scripts/gate.sh must PASS. Commit message: cvd: Settings "Colour vision" row + saved pref + boot/profile wiring.


Task 4: Element remap at source (vivid bypass)

Section titled “Task 4: Element remap at source (vivid bypass)”

model: opus (cross-cutting: main.gd LUTs feed enemies, bullets, auras, zones, charger)

Files:

  • Modify: render/element_palette.gd, main.gd (_build_element_luts)

  • Test: tests/test_cvd_palette.gd (append)

  • Step 1: Write the failing tests:

func _content() -> ContentDB:
var c := ContentDB.new()
c.load_bible() # check the real loader name in sim/content_db.gd; other tests build one the same way
return c
func test_off_element_colours_are_unchanged() -> void:
var c := _content()
var fire := c.element_index("fire")
var before := ElementPalette.color_for_srgb(c, fire)
CvdPalette.set_preset(CvdPalette.OFF)
assert_eq(ElementPalette.color_for_srgb(c, fire), before)
func test_a_preset_returns_the_table_value_without_vivid() -> void:
var c := _content()
CvdPalette.set_preset(CvdPalette.PROTAN)
assert_eq(ElementPalette.color_for_srgb(c, c.element_index("decay")), CvdPaletteSdr.PROTAN_DECAY,
"exact table value: vivid() would push its saturation and undo the lightness spacing")
assert_eq(ElementPalette.vivid_srgb_for(c, c.element_index("decay")), CvdPaletteSdr.PROTAN_DECAY)
  • Step 2: Run to verify it fails.

  • Step 3: Change render/element_palette.gd. OLD (live):

static func color_for_srgb(content: ContentDB, element_idx: int) -> Color:
if content == null or element_idx < 0:
return NEUTRAL
var e := content.element_at(element_idx)
var hex: String = e.get("color", "")
if hex == "":
return NEUTRAL
return vivid(Color.from_string(hex, NEUTRAL))

NEW:

static func color_for_srgb(content: ContentDB, element_idx: int) -> Color:
if content == null or element_idx < 0:
return NEUTRAL
var e := content.element_at(element_idx)
var hex: String = e.get("color", "")
if hex == "":
return NEUTRAL
var cvd := _cvd(e)
if cvd.a >= 0.0:
return cvd # colour-blind preset: the table value as-is (no vivid: it would undo the spacing)
return vivid(Color.from_string(hex, NEUTRAL))
# The preset's colour for this element, or alpha -1 when Off / not in the table.
static func _cvd(e: Dictionary) -> Color:
if not CvdPalette.active():
return Color(0, 0, 0, -1)
return CvdPalette.element_srgb(String(e.get("id", "")), Color(0, 0, 0, -1))
# The main LUT's "vivid" entry: vivid() of the element colour, or the preset value as-is.
static func vivid_srgb_for(content: ContentDB, element_idx: int) -> Color:
var srgb := color_for_srgb(content, element_idx)
return srgb if CvdPalette.active() else vivid(srgb)

Before editing, read vivid_out in the same file. vivid_srgb_for must equal what vivid_out feeds into ColorGamut.out while Off.

  • Step 4: Change main._build_element_luts. OLD (live :3228-3231):
for k in range(sim.content.element_count()):
var srgb := ElementPalette.color_for_srgb(sim.content, k)
_element_color_lut[k] = ColorGamut.out(srgb, HdrTier.PATH_B)
_element_color_vivid[k] = ElementPalette.vivid_out(srgb, HdrTier.PATH_B)

NEW:

for k in range(sim.content.element_count()):
var srgb := ElementPalette.color_for_srgb(sim.content, k)
_element_color_lut[k] = ColorGamut.out(srgb, HdrTier.PATH_B)
# Colour-blind preset: the table value is final; the double-vivid would undo its spacing.
_element_color_vivid[k] = ColorGamut.out(srgb, HdrTier.PATH_B) if CvdPalette.active() \
else ElementPalette.vivid_out(srgb, HdrTier.PATH_B)
  • Grep for other vivid( callers on element colours: /usr/bin/grep -rn "vivid(\|vivid_out(" render fx ui main.gd

  • Give each caller the same bypass, or list it in the commit message as deliberately left alone (with the reason).

  • Step 5: Add a main-level test. In a headless main harness (the pattern in tests/test_settings_panel.gd’s _boot()), set a preset, call _on_cvd_changed(), and assert that _element_color_lut[fire] == ColorGamut.out(CvdPaletteSdr.<PRESET>_FIRE, HdrTier.PATH_B). This proves that the generation bump rebuilt the LUT.

  • Step 6: Gate and commit. bash scripts/gate.sh must PASS. Commit message: cvd: element table remap at the ElementPalette source (vivid bypass).


Task 5: THREAT on enemy projectiles + hostile ring cue

Section titled “Task 5: THREAT on enemy projectiles + hostile ring cue”

model: opus (SwarmRenderer is shared by every swarm; new MultiMesh pipeline variant)

Files:

  • Create: render/ring_texture.gd, tests/test_cvd_hostile.gd

  • Modify: render/swarm_renderer.gd, main.gd (_enemy_proj_colors, enemy proj renderer setup)

  • Step 1: Write the failing tests (tests/test_cvd_hostile.gd):

    • SwarmRenderer.new() + configure(4.0, Color.WHITE):
      • ring is null until enable_ring(true);
      • after it, ring.visible, its material is fx/additive_mat.tres (a covered material, the hdr_alpha rule), and its texture is RingTexture.shared();
      • enable_ring(false) hides it.
    • Sync a 3-entity pool with colours [c, Color(0,0,0,0), c] while the ring is on. The ring’s visible_instance_count must be 2 (the skipped a == 0 slot gets no ring).
    • RingTexture.build(64): the centre pixel alpha is 0, and a pixel at radius 0.8 has alpha > 0.5 (hollow).
    • Main-level (the _boot() harness): with preset PROTAN, fire a shooter projectile and call _enemy_proj_colors(). Entry 0 must equal ColorGamut.out(CvdPaletteSdr.PROTAN_THREAT, HdrTier.PATH_B). With Off, it equals today’s _base_color_for_type result.
  • Step 2: Run to verify it fails.

  • Step 3: Create render/ring_texture.gd. It is class_name RingTexture, modelled on render/glow_texture.gd (read it first and copy its Image → ImageTexture pattern, mipmaps, and shared() cache). Alpha is a soft annulus: smoothstep(0.62, 0.74, r) * (1.0 - smoothstep(0.86, 1.0, r)) where r is the distance from the centre / half size, on white RGB.

  • Step 4: Add the ring to render/swarm_renderer.gd.

    • Add var ring: MultiMeshInstance2D and var _ring_color := Color.WHITE.
    • Add:
# Colour-blind mode cue (spec § Cues): a hollow ring around each hostile shot, one extra draw call.
# Built lazily so Off adds no node. Added BEFORE ColorSpace.stock_layers re-scans in configure().
func enable_ring(on: bool, color: Color = Color.WHITE, radius: float = 8.0) -> void:
_ring_color = color
if ring == null:
if not on:
return
ring = MultiMeshInstance2D.new()
ring.multimesh = _make_mm(radius)
ring.texture = RingTexture.shared()
ring.material = preload("res://fx/additive_mat.tres")
ring.show_behind_parent = true
add_child(ring)
_cs_layers = ColorSpace.stock_layers(self) # the new layer joins the colour_space_fix swap
_cs_gen = -1
ring.visible = on
  • In sync(), add fit_capacity(ring.multimesh, n) when ring != null and ring.visible.

  • In the per-instance write loop, for the same j slots the core writes, write ring.multimesh.set_instance_transform_2d(j, Transform2D(0.0, <the core's translation>)) (unrotated: it is a circle) and set_instance_color(j, Color(_ring_color, _ring_color.a * col.a)).

  • After the loop, set ring.multimesh.visible_instance_count = j.

  • Read the live loop first and quote it in the commit. Keep the col.a <= 0 skip identical.

  • Check: configure() re-runs on HOOK_ENEMY_PROJ (main :1491). Make sure a reconfigure keeps ring (re-add it if configure frees children; today it replaces multimesh/halo/hotcore, so read it and handle that).

  • Step 5: Change main._enemy_proj_colors.

    • Cache in _build_element_luts(), after the LUT loop:
_threat_proj = ColorGamut.out(CvdPalette.threat_srgb(), HdrTier.PATH_B)
_threat_proj_homing = ColorGamut.out(CvdPalette.threat_srgb().lightened(0.3), HdrTier.PATH_B) # mixed in sRGB, widened once

(declare both vars next to _proj_color).

  • OLD (live :4304-4309):
var c := ENEMY_PROJ_COLOR if tid < 0 else _base_color_for_type(tid, sim.enemy_proj.source_element[i])
# Homing missiles: boost brightness to make them visually distinct and threatening
if sim.enemy_proj.is_homing[i] == 1:
c = c.lightened(0.3) # brighten the missile color
elif sim.enemy_proj.is_homing[i] == 2:
c = Color(1.6, 0.55, 1.9) # content-colour: Warden drone tint; Warden swarm drone: overbright magenta, blooms, reads as "not a bullet"
_enemy_proj_colors_buf[i] = c

NEW:

var c := ENEMY_PROJ_COLOR if tid < 0 else _base_color_for_type(tid, sim.enemy_proj.source_element[i])
# Homing missiles: boost brightness to make them visually distinct and threatening
if sim.enemy_proj.is_homing[i] == 1:
c = _threat_proj_homing if cvd else c.lightened(0.3) # brighten the missile color
elif sim.enemy_proj.is_homing[i] == 2:
c = Color(1.6, 0.55, 1.9) # content-colour: Warden drone tint; Warden swarm drone: overbright magenta, blooms, reads as "not a bullet"
elif cvd:
c = _threat_proj # colour-blind preset: every hostile shot is THREAT (spec H1)
_enemy_proj_colors_buf[i] = c

with var cvd := CvdPalette.active() hoisted above the loop, next to hide_warden.

  • Ring toggle: where main syncs per frame before enemy_proj_renderer.sync(...), compare a cached _cvd_ring_gen against CvdPalette.generation. On a change, call enemy_proj_renderer.enable_ring(CvdPalette.cues_on(), CvdPalette.threat_out(HdrTier.PATH_B, 0.7), ENEMY_BULLET_WID * 1.6).

  • Register CvdPalette.CUES_FOR_ALL’s ring alpha 0.7 and radius factor 1.6 as tunables (CvdPalette.RING_ALPHA, CvdPalette.RING_SCALE, local) in the same commit, with docs.d labels.

  • Step 6: Run. Import, then run tests/test_cvd_hostile.gd. Expected: PASS.

  • Step 7: Gate and commit. bash scripts/gate.sh must PASS. Commit message: cvd: THREAT hue on enemy shots + hollow hostile ring cue. In the commit body, note: “new MultiMesh+texture variant: Task 12 baked-iPhone A17 check”.


Task 6: Hostile zones and telegraphs (remap + teeth + dashes)

Section titled “Task 6: Hostile zones and telegraphs (remap + teeth + dashes)”

model: sonnet

Files:

  • Create: render/cvd_cues.gd (class_name CvdCues: static draw helpers)

  • Modify: render/dimension_hazard_renderer.gd, render/bomb_renderer.gd, render/lancer_telegraph_renderer.gd, render/zone_renderer.gd

  • Test: tests/test_cvd_hostile.gd (append)

  • Step 1: Write the failing tests.

    • CvdCues.teeth_points(Vector2.ZERO, 100.0, 24, 6.0) returns 49 points (closed). Even points lie at radius 100, odd points at 106.

    • CvdCues.tooth_count(r) is clampi(int(r / 6.0), 16, 32).

    • Each renderer gets an exposed pure colour picker so the test needs no draw capture:

      • DimensionHazardRenderer.slow_colors() -> Array[Color] (fill, ring);
      • poison_colors(frac);
      • BombRenderer.ring_color(bm, frac);
      • LancerTelegraphRenderer.charge_color(alpha).

      With Off, each returns today’s literal exactly. With DEUTAN, ring/charge = CvdPalette.threat_out(HdrTier.PATH_A, <today's alpha>).

    • ZoneRenderer: with DEUTAN, after _process (or the method that holds the live :957-962 block), each funzo quad’s col uniform == ColorGamut.out(CvdPaletteSdr.DEUTAN_THREAT, HdrTier.PATH_A).

  • Step 2: Run to verify it fails.

  • Step 3: Write render/cvd_cues.gd:

class_name CvdCues
extends RefCounted
# Colour-blind mode shape cues (docs/superpowers/specs/2026-10-20-colourblind-mode-design.md § Cues).
# Pure helpers; callers draw only while CvdPalette.cues_on().
static var TOOTH_DEPTH := 6.0 # px; local tunable
static var DASH := 10.0 # px; local tunable
static var GAP := 8.0 # px; local tunable
static func tooth_count(r: float) -> int:
return clampi(int(r / 6.0), 16, 32)
# A closed sawtooth just outside a circle: even points on r, odd points on r + depth.
static func teeth_points(c: Vector2, r: float, n: int, depth: float) -> PackedVector2Array:
var pts := PackedVector2Array()
for k in range(n * 2 + 1):
var a := TAU * float(k) / float(n * 2)
pts.append(c + Vector2.from_angle(a) * (r + (depth if k % 2 == 1 else 0.0)))
return pts
static func draw_teeth(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
ci.draw_polyline(teeth_points(c, r, tooth_count(r), TOOTH_DEPTH), col, 2.0, true)

Register TOOTH_DEPTH / DASH / GAP as local tunables, with docs.d labels in cvd_palette.json and a "CvdCues.": HOOK_CVD prefix.

  • Step 4: Change dimension_hazard_renderer.gd.
    • Slow zone, OLD (live):
draw_circle(pos, r, SLOW_FILL)
draw_arc(pos, r, 0.0, TAU, 48, SLOW_RING, 2.0, true)

NEW:

var sc := slow_colors()
draw_circle(pos, r, sc[0])
draw_arc(pos, r, 0.0, TAU, 48, sc[1], 2.0, true)
if CvdPalette.cues_on():
CvdCues.draw_teeth(self, pos, r, sc[1])

with

# Colour-blind preset: the hostile slow zone is THREAT (spec H2); Off = today's literals.
static func slow_colors() -> Array[Color]:
if not CvdPalette.active():
return [SLOW_FILL, SLOW_RING]
return [CvdPalette.threat_out(HdrTier.PATH_A, SLOW_FILL.a), CvdPalette.threat_out(HdrTier.PATH_A, SLOW_RING.a)]
  • Poison: the same treatment. The fill is the preset poison element via CvdPalette.element_srgb("poison", ...) → ColorGamut.out(..., PATH_A), and the ring is THREAT (spec H3), with today’s * frac alphas kept.

  • Light-area laser warn: the charging branch draw_line(origin, end_pos, Color(light_col.r, light_col.g, light_col.b, alpha), width, true). Under a preset, the colour becomes CvdPalette.threat_out(HdrTier.PATH_A, alpha). While cues_on(), use draw_dashed_line(origin, end_pos, col, width, CvdCues.DASH, true, true) instead. Check the Godot 4.7 draw_dashed_line signature; the last args are aligned and antialiased. The firing branch is unchanged (solid = “now”).

  • Step 5: Change bomb_renderer.gd.

    • Extract the ring colour into static func ring_color(bm: Dictionary, frac: float) -> Color, which returns today’s warn_ring.lerp(full_ring, frac) while Off.
    • Under a preset, the ring is CvdPalette.threat_out(HdrTier.PATH_A, 1.0); the fill keeps its lerp (fill growth is the redundant imminence cue, spec H5).
    • After the outer draw_arc, while cues_on(), call CvdCues.draw_teeth(self, pos, radius, Color(rc.r, rc.g, rc.b, 0.4 + 0.5 * frac)).
  • Step 6: Change lancer_telegraph_renderer.gd. OLD (live :36-42):

elif charge >= 0.0:
# Growing telegraph during charge: brightens toward fire
var alpha: float = 0.15 + charge * 0.65
var width: float = 1.0 + charge * 4.0
draw_line(pos, end_pos, Color(1.0, 0.95, 0.6, alpha), width, true)
# Small endpoint circle as aiming marker
draw_circle(end_pos, 5.0 + charge * 6.0, Color(1.0, 0.95, 0.6, alpha * 0.7))

NEW:

elif charge >= 0.0:
# Growing telegraph during charge: brightens toward fire
var alpha: float = 0.15 + charge * 0.65
var width: float = 1.0 + charge * 4.0
var cc := charge_color(alpha)
if CvdPalette.cues_on():
draw_dashed_line(pos, end_pos, cc, width, CvdCues.DASH, true, true) # dashed = "not yet" (spec H6)
else:
draw_line(pos, end_pos, cc, width, true)
# Small endpoint circle as aiming marker
draw_circle(end_pos, 5.0 + charge * 6.0, Color(cc, alpha * 0.7))

with static func charge_color(alpha: float) -> Color returning Color(1.0, 0.95, 0.6, alpha) (keep the # content-colour: comment) while Off, and CvdPalette.threat_out(HdrTier.PATH_A, alpha) under a preset. The fire branch is unchanged.

  • Step 7: Change zone_renderer.gd FunZo.

    • Add static func fz_col_srgb() -> Color: return CvdPalette.threat_srgb() if CvdPalette.active() else FZ_COL.
    • Replace ColorGamut.out(FZ_COL, HdrTier.PATH_A) at live :822 and :959 with ColorGamut.out(fz_col_srgb(), HdrTier.PATH_A). The existing _fz_gamut_gen poll already re-pushes on the bump.
    • Teeth: the quad uses a ShaderMaterial, so add ONE child Node2D (_fz_teeth, material fx/additive_mat.tres, created lazily on the first cues_on() frame). Its _draw loops the live FunZo zones (the same source the quads read) and calls CvdCues.draw_teeth; it calls queue_redraw() only while cues_on() and zones exist.
    • Do NOT touch the funzo .gdshader.
  • Step 8: Run, gate, and commit. Run tests/test_cvd_hostile.gd (expect PASS), then bash scripts/gate.sh (expect PASS). Commit message: cvd: hostile zones/telegraphs to THREAT + hazard teeth + dashed telegraphs.


model: sonnet

Files:

  • Modify: fx/enemy_aura_look.gd

  • Test: tests/test_cvd_hostile.gd (append)

  • Step 1: Write the failing test.

    • Build an EnemyAuraLook with a sim, and call _runs(sim, shock_el), where shock_el is the element whose bible status == "shock" (find it by scanning content.element_at(k).get("status")).
    • With cues_on(), the odd-index segments of every run have a == 0.
    • With Off, the result is identical to today’s (hot/dim only).
    • A non-shock element is never dashed.
  • Step 2: Run to verify it fails.

  • Step 3: Change _runs. OLD (live :166-171):

for lit in range(SEGS + 1):
var run := PackedColorArray()
run.resize(SEGS)
for k in range(SEGS):
run[k] = hot if k < lit else dim
out.append(run)

NEW:

# Colour-blind cue (spec H7): shock (damage vulnerability) dashes, so it never reads by hue alone.
var dashed := CvdPalette.cues_on() and String(sim.content.element_at(el).get("status", "")) == "shock"
for lit in range(SEGS + 1):
var run := PackedColorArray()
run.resize(SEGS)
for k in range(SEGS):
run[k] = Color(0, 0, 0, 0) if dashed and k % 2 == 1 else (hot if k < lit else dim) # content-colour: dash gap
out.append(run)

The _runs_cache is already cleared on ColorGamut.generation (live :126-129), and CvdPalette.bump() moves it, so a cue toggle lands.

  • Step 4: Gate and commit. bash scripts/gate.sh must PASS. Commit message: cvd: dashed shock aura ring.

Task 8: HUD HP bands + notches, stats-sheet roles + marker

Section titled “Task 8: HUD HP bands + notches, stats-sheet roles + marker”

model: sonnet

Files:

  • Modify: ui/hud.gd, ui/stats_sheet.gd

  • Test: tests/test_cvd_ui.gd (new). Also check tests/test_stats_sheet_fit.gd still passes at every ShopLayoutFixture profile.

  • Step 1: Write the failing tests (tests/test_cvd_ui.gd).

    • HUD:
      • With a preset, call update_hud once at 25% HP. _hp_fill_sb.bg_color (alpha aside) equals ColorGamut.ui_out(CvdPaletteSdr.<P>_HP_LO).
      • With Off, it equals Tokens.c_src(&"danger.hot") widened (today).
      • _hp_notches is null with Off. With a preset, it is visible and has 2 tick x positions at BAR_W * 0.3 and BAR_W * 0.6.
    • Stats sheet:
      • With a preset AND with Off, the legend text starts with "▸ marks what " (amendment 2; ">" when the font lacks ▸).
      • A changed stat row’s value text starts with "▸ ".
      • Its font colour override equals ColorGamut.ui_out(CvdPaletteSdr.<P>_UI_GOOD).
      • With Off, changed rows carry the marker too (amendment 2).
      • NeonTheme.mono_font().has_char(0x25B8) decides ▸ vs >; assert the chosen glyph is the one the font has.
  • Step 2: Run to verify it fails.

  • Step 3: Change the HUD (ui/hud.gd).

    • _refresh_hp_fills, OLD (live):
func _refresh_hp_fills() -> void:
_hp_col_hi = Tokens.c_src(&"accent.main")
_hp_col_mid = Tokens.c_src(&"gold.main")
_hp_col_lo = Tokens.c_src(&"danger.hot")

NEW:

func _refresh_hp_fills() -> void:
_hp_cvd_gen = CvdPalette.generation
# Colour-blind preset: the bands come from the preset (spec § Palettes); Off = the tokens.
_hp_col_hi = CvdPalette.ui_srgb("HP_HI", Tokens.c_src(&"accent.main"))
_hp_col_mid = CvdPalette.ui_srgb("HP_MID", Tokens.c_src(&"gold.main"))
_hp_col_lo = CvdPalette.ui_srgb("HP_LO", Tokens.c_src(&"danger.hot"))
  • At the top of update_hud, next to the HP_BACKING_H check, add: if _hp_cvd_gen != CvdPalette.generation: _refresh_hp_fills(); _sync_hp_notches()

  • Notches: a small Control child of bar_backing (_hp_notches). Its _draw draws two 2 px vertical ticks at 30% and 60% of BAR_W, in Tokens.c(&"text.bright", 0.55), bound with TokenBind.redraw. It is visible only while CvdPalette.cues_on(), created lazily, and laid out in _layout_hp_bars. Do the same for _hp_group2 (co-op pilot 2).

  • Step 4: Change the stats sheet (ui/stats_sheet.gd).

    • Add a role helper:
# Colour-blind preset: GOOD / EVOLVE come from the preset (Color, content_colour meta) instead of the
# token; Off returns the token name, so _label binds it exactly as today.
func _role(key: StringName) -> Variant:
if not CvdPalette.active():
return key
var role := "UI_GOOD" if key == GOOD else ("UI_EVOLVE" if key == EVOLVE else "")
if role == "":
return key
return ColorGamut.ui_out(CvdPalette.ui_srgb(role, Tokens.c_src(key)))
func _mark() -> String:
if not CvdPalette.cues_on():
return ""
return "▸ " if NeonTheme.mono_font().has_char(0x25B8) else "> "
  • Legend, OLD (live :631): legend = ("Green = what %s changes" % _preview_title) if _preview_title != "" else "Green = what this card changes" NEW:
# amendment 2: marker + legend for everyone, not only under a preset
var mark := _mark().strip_edges()
legend = ("%s marks what %s changes" % [mark, _preview_title]) if _preview_title != "" else "%s marks what this card changes" % mark
  • Make _stat_row’s col: StringName parameter a Variant (live :586). Its body passes it on to _label, which already takes a Variant.

  • Wrap each GOOD/EVOLVE use in _role(...). These are at live :632 (legend_col), :655, :711, :843-845 (weapon names), :853 (rung chip), and :984 (rung marker hue = Tokens.c(GOOD) becomes hue = _role(GOOD) if CvdPalette.active() else Tokens.c(GOOD)).

  • Prefix _mark() onto the value text where changed (live :654) and on the aft != have crystal value (:711).

  • Step 5: Run. Run tests/test_cvd_ui.gd (expect PASS), and the stats-sheet and HUD fit tests at every device profile (expect PASS). The ▸ prefix widens a row, so the worst-case content (Chalice/Chronowell crystal rows) must still fit.

  • Step 6: Gate and commit. bash scripts/gate.sh must PASS. Commit message: cvd: HP bands + notches, stats-sheet roles + changed-row marker.


model: sonnet

Files:

  • Modify: main.gd (apply_dev_command, caps, flags), control/src/guard.js, control/src/panel/client.js, control/src/panel/tooltips.js

  • Test: control/test/guard.test.mjs, control/test/panel.test.mjs, plus a GUT test in tests/test_cvd_palette.gd for the dev command

  • Step 1: Write the failing tests.

    • GUT: main.apply_dev_command({"kind": "cvd_preset", "preset": "tritan"}) returns true and sets CvdPalette.preset == TRITAN. An unknown preset leaves it unchanged and returns true. It works with sim == null (menu).
    • guard.test.mjs: add 'cvd_preset' to the safe-kinds list in the test at :13.
    • panel.test.mjs: extend the lookCaps test at :440 with cvd_presets: ['off','deutan','protan','tritan']. The expected kinds become ['color_profile', 'tonemap_sdr', 'tonemap_hdr', 'cvd_preset'] (check where the new entry lands in the list order). A segRow click sends {kind:'cvd_preset', preset:'protan'}.
  • Step 2: Run to verify they fail. Run node --test control/test/guard.test.mjs control/test/panel.test.mjs and the GUT file.

  • Step 3: Change main.gd.

    • In apply_dev_command, next to the color_profile arm:
if String(cmd.get("kind", "")) == "cvd_preset":
# Colour-blind preset, session-only from the console (the saved pref is Settings'): look-only.
var want := String(cmd.get("preset", ""))
if CvdPalette.IDS.has(want):
CvdPalette.set_preset(CvdPalette.from_id(want))
_on_cvd_changed()
return true
  • Caps: add "cvd_preset" to the dev_commands list (live :5491, next to "color_profile"), and "cvd_presets": CvdPalette.IDS, next to "color_profiles": HdrLook.OVERRIDE_NAMES, (:5514).

  • Flags: after out["look:profile"] = HdrLook.override_label() (:5444) add out["look:cvd"] = CvdPalette.id() # the console's "Colour vision" selector.

  • Step 4: Change the control panel.

    • guard.js: add 'cvd_preset', to SAFE_KINDS with a comment // colour-blind preset: look-only.
    • client.js lookCaps(): after the color_profiles line add: if(Array.isArray(caps.cvd_presets)) list = list.concat([{wid:'cvd', looks:caps.cvd_presets, kind:'cvd_preset', label:'Colour vision'}]);
    • renderLooks(): add an arm: }else if(entry.kind === 'cvd_preset'){ segRow(seg, 'look:'+wid, opts, (preset)=>({kind:'cvd_preset', preset}), (b)=>tip(b,'visuals-cvd-preset'));
    • tooltips.js: add 'visuals-cvd-preset': {icon:'◐', title:'Colour vision', body:'Colour-blind preset on the device: off / deutan / protan / tritan. Remaps the gameplay-critical colours (element, threat, HP, stats) and turns on the shape cues. Session only; the saved choice is the player\'s Settings row.'},
  • Step 5: Run and commit. Run the node tests and GUT (expect PASS), then bash scripts/gate.sh (expect PASS).

    • Commit message: cvd: console Colour vision selector (cvd_preset).
    • Do NOT deploy the control Worker. That is an outward step and needs Chris’s OK; Task 12 asks for it.

Task 10: Verification tooling a reviewer can SEE

Section titled “Task 10: Verification tooling a reviewer can SEE”

model: sonnet

Files:

  • Create: tools/cvd_sim/cvd.py, tools/cvd_sim/sheet.py, tools/cvd_sim/palette_opt.py, tools/cvd_sim/README comment header in cvd.py (no new .md)

  • Modify: tools/ui_capture/capture.gd (opt-in cvd + cvd_hdr scenes)

  • Test: tools/cvd_sim/test_cvd.py (python unittest, run directly; it is not in the gate)

  • Step 1: Write the failing Python test (tools/cvd_sim/test_cvd.py):

    • simulate((1,0,0), 'protan') matches the Machado protan matrix row sums applied to linear red (to 1e-6).
    • de of a colour with itself is 0.
    • The protan simulation of #ff0000 and #00ff00 is closer (ΔE) than their normal-vision distance.
    • Parity with GDScript: the min dE of the DEUTAN SDR element table printed by cvd.py equals the value tests/test_cvd_palette.gd measures, within 0.3. Hard-code the GDScript value from Task 1’s run in a comment.
  • Step 2: Write tools/cvd_sim/cvd.py.

    • It holds the same maths as cvd_sim.gd: the Machado 2009 severity-1.0 matrices on linear sRGB, OKLab ΔE ×100, vivid() for the “today” baseline, and report(name, cols, viv).
    • Seed it from this reference implementation (already used to make the spec tables):
import itertools, math, sys, colorsys
# Machado, Oliveira, Fernandes 2009, severity 1.0, applied to LINEAR sRGB.
M = {
'protan': [[0.152286,1.052583,-0.204868],[0.114503,0.786281,0.099216],[-0.003882,-0.048116,1.051998]],
'deutan': [[0.367322,0.860646,-0.227968],[0.280085,0.672501,0.047413],[-0.011820,0.042940,0.968881]],
'tritan': [[1.255528,-0.076749,-0.178779],[-0.078411,0.930809,0.147602],[0.004733,0.691367,0.303900]],
}
def s2l(c): return c/12.92 if c<=0.04045 else ((c+0.055)/1.055)**2.4
def hexc(h):
h=h.lstrip('#'); return tuple(int(h[i:i+2],16)/255 for i in (0,2,4))
def lin(rgb): return [s2l(x) for x in rgb]
def sim(rgbl,k):
if k=='normal': return rgbl
m=M[k]; return [max(0,min(1,sum(m[i][j]*rgbl[j] for j in range(3)))) for i in range(3)]
def oklab(l):
r,g,b=l
L=(0.4122214708*r+0.5363325363*g+0.0514459929*b)**(1/3)
m_=(0.2119034982*r+0.6806995451*g+0.1073969566*b)**(1/3)
s=(0.0883024619*r+0.2817188376*g+0.6299787005*b)**(1/3)
return (0.2104542553*L+0.7936177850*m_-0.0040720468*s,1.9779984951*L-2.4285922050*m_+0.4505937099*s,0.0259040371*L+0.7827717662*m_-0.8086757660*s)
def de(a,b): return math.dist(a,b)*100 # OKLab dE x100
def vivid(rgb):
h,s,v=colorsys.rgb_to_hsv(*rgb)
if s<=0.02: return rgb
return colorsys.hsv_to_rgb(h,min(1,max(s*1.7,0.92)),max(v,0.9))
  • Add a --from-gd mode that regex-reads render/cvd_palette_{sdr,hdr}.gd, so re-tuned values can be re-scored without hand-copying.

  • Mirror the HDR path by porting ColorGamut.widen / max_chroma (live render/color_gamut.gd:139-176) for --hdr.

  • Step 3: Write tools/cvd_sim/sheet.py and palette_opt.py.

    • sheet.py (PIL + numpy). Usage: python3 -I tools/cvd_sim/sheet.py <in.png> <out.png>.
      1. Linearise the image.
      2. Apply each matrix per pixel.
      3. Re-encode.
      4. Write a 2x2 sheet labelled normal / protan / deutan / tritan.
    • palette_opt.py: the simulated-annealing seed search from the spec § Method.
      • Inputs: a target deficiency and the current tables.
      • Objective: maximise min ΔE under target + normal, with an L ≥ 0.62 constraint, a ≥ 10.5 THREAT distance, and a hue-drift penalty.
      • Output: the static var lines to paste.
  • Step 4: Add the ui_capture scene. In tools/ui_capture/capture.gd, follow the opt-in pattern of elitefx (live :166-181), gated by _listed("cvd"). For each preset in [DEUTAN, PROTAN, TRITAN, OFF]:

    1. Call CvdPalette.set_preset(p), then main._on_cvd_changed().
    2. Build the tableau around sim.player.pos:
      • two shooters firing (enemy bullets) next to the player’s own bolts of the same element;
      • a blood slow zone + a poison timed zone (spawn via sim.dimension_hazards; read its API);
      • a Nova reaction zone (sim.elemental_system.spawn_zone(sim, pos, fire_idx, ...), live sim/elemental_system.gd:367);
      • a bomb (sim._spawn_fire_hazard(), live sim/sim.gd:2546);
      • a charging Lancer (sim._spawn_one(EnemyPool.TYPE_LANCER, ...));
      • burn + shock auras on two swarmers;
      • sim.player.hp = sim.player.max_hp * 0.25.
    3. Wait in sim time (poll sim.run_time, never rendered frames: CLAUDE.md render-harness rule).
    4. Call _capture("cvd_" + CvdPalette.id()).

    A second listed id, cvd_hdr, repeats this with HdrLook.set_override_label("hdr") and main._sync_hdr_output(). Restore the override and preset at the end.

  • Step 5: Run it at iPhone, iPad and TV, under both renderers:

Terminal window
UI_CAP_OUT=$TMPDIR/cvd-mobile UI_CAP_SIZES=iphone,ipad,tv UI_CAP_SCENES=cvd,cvd_hdr \
godot --path . --rendering-method mobile --always-on-top res://tools/ui_capture/capture.tscn
UI_CAP_OUT=$TMPDIR/cvd-gl UI_CAP_SIZES=iphone,ipad,tv UI_CAP_SCENES=cvd \
godot --path . --rendering-method gl_compatibility --always-on-top res://tools/ui_capture/capture.tscn
git checkout -- project.godot audio/bus_layout.tres
for f in $TMPDIR/cvd-mobile/*.png $TMPDIR/cvd-gl/*.png; do python3 -I tools/cvd_sim/sheet.py "$f" "${f%.png}.sheet.png"; done

Expected: 8 scenes x 3 sizes (mobile) + 4 x 3 (gl). In every cvd_<preset> sheet’s matching column, the enemy and player bullets, the hazards and the friendly zone are visibly different by colour AND by shape. In cvd_off, the hazards are exactly today’s.

  • Step 6: Commit (no gate change: the python test is run by hand and recorded in the commit body). Run bash scripts/gate.sh (expect PASS, because capture.gd parses). Commit message: cvd: CVD simulation sheets + ui_capture cvd tableau.

Task 11: VFX library entries + architecture docs

Section titled “Task 11: VFX library entries + architecture docs”

model: sonnet

Files:

  • Modify: tools/vfx_library/library_manifest.gd, tests/test_vfx_library_manifest.gd, docs/architecture/rendering-and-performance.md, docs/architecture/ui-tokens.md

  • Step 1: Write the failing test. Add "cvd_hostile_ring", "cvd_hazard_teeth" and "cvd_shock_dash" to EXPECTED_EFFECTS in tests/test_vfx_library_manifest.gd. Run it; expected to fail (missing entries).

  • Step 2: Add the manifest entries. Add three _entry(...) calls in library_manifest.gd, following the enemy_projectile / bomb_telegraph / elemental_aura entries:

    • status REVIEW, rating 3 in RATINGS;
    • the source file of each cue;
    • a description naming the spec;
    • a preview that sets CvdPalette.CUES_FOR_ALL = true for the sample only and restores it on exit.
  • Step 3: Update the docs.

    • rendering-and-performance.md: a “Colour-blind mode” section covering:
      • CvdPalette, its sources and the generation bump;
      • the no-vivid rule;
      • the ring layer;
      • teeth/dashes;
      • “a new hostile colour must go through CvdPalette.threat_out, a new element colour through ElementPalette.color_for_srgb”.
    • ui-tokens.md: the HP/stats roles are overridden by the preset; the tokens are untouched.
    • Both docs already have frontmatter; keep it.
  • Step 4: Gate and commit. bash scripts/gate.sh must PASS (including the docs-site node tests). Commit message: cvd: VFX library cue entries + architecture docs.


Task 12: Device verification and Chris’s review (no code)

Section titled “Task 12: Device verification and Chris’s review (no code)”

model: opus (judgement + device coordination)

  • Step 1: Shader compile check (sanity). No .gdshader changed, so confirm that with git diff origin/main --stat -- '*.gdshader' '*.gdshaderinc' (expected empty). If anything is listed, run tools/shader_check/compile_check.gd under mobile and gl_compatibility.
  • Step 2: Baked iPhone A17 check. The ring layer is a new MultiMesh + texture pipeline variant, which falls under the godot#124088 baker class.
    • Install a BAKED Dev build on the A17 iPhone.
    • Confirm the menu controls, ship and HUD draw with a preset on and off.
    • On a blank draw, devicectl device uninstall app and reinstall first (stale state). Say first that this wipes the Dev save.
  • Step 3: Fresh-install stall check. Run skill bh-fresh-install-stall-check on ATV with a preset chosen.
  • Step 4: Perf. Run a 10-minute Survival soak with PROTAN against Off on ATV. tick_ms and render_cpu_ms p95 must be within noise; the ring adds one draw call.
  • Step 5: Visual QA. Dispatch bh-visual-qa on the Task 10 captures and sheets (iPhone, iPad, TV).
  • Step 6: Chris’s review.
    • Chris reviews the SDR tables on iPhone/ATV via the console cvd_preset selector.
    • Then he reviews the HDR tables on an HDR display, tuning values live (set_tunables on CvdPaletteHdr.*) and noting the approved values.
    • Ask before the control Worker deploy that the selector needs (outward step).
    • Persist the approved values into cvd_palette_{sdr,hdr}.gd defaults, then rerun the Task 1 floor tests. A failed floor goes back to Chris with the number.
  • Step 7: Close out.
    • Add the 1.2 release list entry.
    • Close issue #78 with the commit list.
    • Tick AAA audit gap 5 in docs/research/2026-10-15-aaa-tooling-gap-audit.md.
  • Decided by Chris (2026-10-20): presets + shape cues, not a full-screen filter. The remap covers gameplay-critical hues only; the neon art style is not remapped. Cues are always on with a preset. Every preset gets SDR and HDR values, live-tunable where feasible.
  • Still open: the spec’s “Decisions for Chris’s veto” list (11 items). None has been ruled on. Get Chris’s veto pass on the spec BEFORE Task 1 starts.
  • Authorized: local code, tests and commits in the plan’s own worktree.
  • NOT authorized (needs a same-day OK):
    • pushing to origin/main;
    • the control Worker deploy (Task 9’s selector);
    • Dev installs on Chris’s devices (Task 12);
    • any remote-config patch.
  • Done so far: the spec and this plan were committed on branch spec-colourblind (not pushed). No code exists yet. The seed palettes came from scratchpad scripts that are not committed; Task 10 re-creates them (the core maths is quoted above).
  • Line numbers quoted as “live :N” were checked against main at 73328c66 (2026-10-20). Re-grep before editing, because main moves fast.
  • Next action: Chris vetoes the spec decisions, then execute Task 1 subagent-driven in .claude/worktrees/colourblind-mode off origin/main.