Colour-blind mode (presets + shape cues) implementation plan
AMENDMENTS (Chris, 2026-10-20, accepted in chat) — these OVERRIDE any task text below that disagrees.
- 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
CvdPaletteHdrvalue 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 (useCvdSimwithhdr=trueto 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.- 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 fonthas_charcheck + 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 skillbh-sdd-concurrentbefore Task 1 and runsdd-worktree-leak-guardafter 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 inCvdPaletteSdr/CvdPaletteHdr, which have the same static var names (theColorProfileSdr/Hdrpattern), andHdrLook.activepicks 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.outstays 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).
Global Constraints
Section titled “Global Constraints”-
Off is today’s game, pixel for pixel. With
CvdPalette.preset == OFFandCUES_FOR_ALL == false:- every changed function returns exactly what it returns today;
- no new node draws;
- the ring layer is hidden.
/simis not touched at all, so the three pinned determinism files must pass UNCHANGED. Never edit a pinned hash. -
Render-only. No
/simfile readsCvdPalette. -
Two colour systems. Every preset colour exists in BOTH
CvdPaletteSdrandCvdPaletteHdr, 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 BYPASSElementPalette.vivid(). -
Tunables. Every palette value is a registered
"type": "color","local": truetunable:- a
net/tunables_registry.gdentry; - a label in
tools/tunable_docs/docs.d/cvd_palette.json; - a
net/live_scope.gdhook prefix.
control/src/tunable_docs.jsis GENERATED (node tools/tunable_docs/generate.mjs); never hand-edit it. - a
-
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_ALLis 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.
- row label:
-
Gate before every commit:
bash scripts/gate.sh. Usescripts/check-test-count.shfor quick loops, and never pull a raw GUT run into context. After adding aclass_name, rungodot --headless --path . --importfirst, thengit checkout -- project.godot audio/bus_layout.tres. If worktrees run gates concurrently, setGUT_LOG=<own path>. -
Worktree. Work in
.claude/worktrees/colourblind-mode(branchcolourblind-mode, offorigin/main), never in the main checkout. Commit withgit 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 map
Section titled “File map”| 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:
-
CvdPaletteconstants and state:OFF/DEUTAN/PROTAN/TRITAN,IDS,LABELS,preset,generation,CUES_FOR_ALL. -
CvdPalettefunctions: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
CvdPaletteis not declared yet.
- Run:
-
Step 3: Add
ColorGamut.bump_generation()torender/color_gamut.gd, directly afterrefresh():
# 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 prefixesDEUTAN_,PROTAN_andTRITAN_. 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:
- It holds
class_name CvdPaletteSdrextends 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.gdhas the same names withclass_name CvdPaletteHdrand INDEPENDENTLY AUTHORED values (amendment 1: headroom/P3 mapping of the SDR pick, then the best-separating choice underCvdSimwithhdr=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 CvdPaletteextends 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 := OFFstatic var generation := 0 # bumps on a preset / profile change / live value editstatic 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:- linearise with
c.srgb_to_linear(); - if
hdr, applyColorGamut.widen(lin, 1.0, ColorGamut.LINEAR); - unless
kind == "normal", apply the matrix; - clamp each channel to 0..1.
- linearise with
static func oklab_l(c: Color) -> float.static func de(a, b, kind, hdr) -> float: the Euclidean distance ofColorGamut.linear_to_oklabresults ×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, thengit 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.
- Run
-
Step 8: Gate and commit. Run
bash scripts/gate.sh(expect PASS), thengit addthe new files and commit with messagecvd: 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 existingcontrol/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"andconst 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
ColorProfileHdrblock):
- Add script consts next to
python3 - <<'EOF'import renames = 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 toconst 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
ColorProfilelines (live at :418-419):
- In
"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):
- In
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, liveNEW (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.jsonwith one entry per key, same shape ascolor_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)".
- Write
-
Step 7: Regenerate. Run
node tools/tunable_docs/generate.mjs, thennode --test control/test/tunable-docs.test.mjs(expect PASS), then rerun the GUT file (expect PASS). -
Step 8: Gate and commit.
bash scripts/gate.shmust 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_presetdefaults to"off";- it round-trips
"protan"throughto_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:
- Extend
- In
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 placeholderif that test waits differently.-
Also add
CvdPalette.set_preset(CvdPalette.OFF)to the file’safter_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)):
- Field, after
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"-
/simpurity:sim/meta_state.gdmust not reference a render class. If the purity review or test flagsCvdPalette.IDS, inline the id list as aconst CVD_IDSin meta_state instead, and add a test asserting thatCVD_IDS == CvdPalette.IDS. -
Step 4: Add the SettingsPanel row (
ui/settings_panel.gd).- Field:
var cvd_preset := "off"aftervar reduce_fx := false, andvar _cvd_btn: Buttonaftervar _reduce_btn: Button. - Row: after
_reduce_btn = _row_btn("reduce_fx", box)add_cvd_btn = _row_btn("colour_vision", box). - Add
_cvd_btnto 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:
- Field:
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)_inputleft/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
_emitrefreshes 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: afterp.reduce_fx = ComfortFx.reduceaddp.cvd_preset = CvdPalette.id()._on_setting_changed: after the"reduce_fx":arm add:
- Boot, after
"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, thengit checkout -- project.godot audio/bus_layout.tres. - Run both test files. Expected: PASS.
- Run
-
Step 7: Gate and commit.
bash scripts/gate.shmust 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.shmust 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):ringis null untilenable_ring(true);- after it,
ring.visible, its material isfx/additive_mat.tres(a covered material, the hdr_alpha rule), and its texture isRingTexture.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’svisible_instance_countmust be 2 (the skippeda == 0slot 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 equalColorGamut.out(CvdPaletteSdr.PROTAN_THREAT, HdrTier.PATH_B). With Off, it equals today’s_base_color_for_typeresult.
-
Step 2: Run to verify it fails.
-
Step 3: Create
render/ring_texture.gd. It isclass_name RingTexture, modelled onrender/glow_texture.gd(read it first and copy its Image → ImageTexture pattern, mipmaps, andshared()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: MultiMeshInstance2Dandvar _ring_color := Color.WHITE. - Add:
- 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(), addfit_capacity(ring.multimesh, n)whenring != null and ring.visible. -
In the per-instance write loop, for the same
jslots the core writes, writering.multimesh.set_instance_transform_2d(j, Transform2D(0.0, <the core's translation>))(unrotated: it is a circle) andset_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 <= 0skip identical. -
Check:
configure()re-runs onHOOK_ENEMY_PROJ(main :1491). Make sure a reconfigure keepsring(re-add it ifconfigurefrees children; today it replacesmultimesh/halo/hotcore, so read it and handle that). -
Step 5: Change
main._enemy_proj_colors.- Cache in
_build_element_luts(), after the LUT loop:
- Cache in
_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] = cNEW:
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] = cwith 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_genagainstCvdPalette.generation. On a change, callenemy_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.shmust 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)isclampi(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’scoluniform ==ColorGamut.out(CvdPaletteSdr.DEUTAN_THREAT, HdrTier.PATH_A).
-
-
Step 2: Run to verify it fails.
-
Step 3: Write
render/cvd_cues.gd:
class_name CvdCuesextends 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 tunablestatic var DASH := 10.0 # px; local tunablestatic 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
poisonelement viaCvdPalette.element_srgb("poison", ...)→ColorGamut.out(..., PATH_A), and the ring is THREAT (spec H3), with today’s* fracalphas 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 becomesCvdPalette.threat_out(HdrTier.PATH_A, alpha). Whilecues_on(), usedraw_dashed_line(origin, end_pos, col, width, CvdCues.DASH, true, true)instead. Check the Godot 4.7draw_dashed_linesignature; the last args arealignedandantialiased. 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’swarn_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, whilecues_on(), callCvdCues.draw_teeth(self, pos, radius, Color(rc.r, rc.g, rc.b, 0.4 + 0.5 * frac)).
- Extract the ring colour into
-
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.gdFunZo.- 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 withColorGamut.out(fz_col_srgb(), HdrTier.PATH_A). The existing_fz_gamut_genpoll already re-pushes on the bump. - Teeth: the quad uses a ShaderMaterial, so add ONE child
Node2D(_fz_teeth, materialfx/additive_mat.tres, created lazily on the firstcues_on()frame). Its_drawloops the live FunZo zones (the same source the quads read) and callsCvdCues.draw_teeth; it callsqueue_redraw()only whilecues_on()and zones exist. - Do NOT touch the funzo
.gdshader.
- Add
-
Step 8: Run, gate, and commit. Run
tests/test_cvd_hostile.gd(expect PASS), thenbash scripts/gate.sh(expect PASS). Commit message:cvd: hostile zones/telegraphs to THREAT + hazard teeth + dashed telegraphs.
Task 7: Dashed shock aura ring
Section titled “Task 7: Dashed shock aura ring”model: sonnet
Files:
-
Modify:
fx/enemy_aura_look.gd -
Test:
tests/test_cvd_hostile.gd(append) -
Step 1: Write the failing test.
- Build an
EnemyAuraLookwith a sim, and call_runs(sim, shock_el), whereshock_elis the element whose biblestatus == "shock"(find it by scanningcontent.element_at(k).get("status")). - With
cues_on(), the odd-index segments of every run havea == 0. - With Off, the result is identical to today’s (hot/dim only).
- A non-shock element is never dashed.
- Build an
-
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.shmust 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 checktests/test_stats_sheet_fit.gdstill passes at everyShopLayoutFixtureprofile. -
Step 1: Write the failing tests (
tests/test_cvd_ui.gd).- HUD:
- With a preset, call
update_hudonce at 25% HP._hp_fill_sb.bg_color(alpha aside) equalsColorGamut.ui_out(CvdPaletteSdr.<P>_HP_LO). - With Off, it equals
Tokens.c_src(&"danger.hot")widened (today). _hp_notchesis null with Off. With a preset, it is visible and has 2 tick x positions atBAR_W * 0.3andBAR_W * 0.6.
- With a preset, call
- 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.
- With a preset AND with Off, the legend text starts with
- HUD:
-
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 theHP_BACKING_Hcheck, add:if _hp_cvd_gen != CvdPalette.generation: _refresh_hp_fills(); _sync_hp_notches() -
Notches: a small
Controlchild ofbar_backing(_hp_notches). Its_drawdraws two 2 px vertical ticks at 30% and 60% ofBAR_W, inTokens.c(&"text.bright", 0.55), bound withTokenBind.redraw. It is visible only whileCvdPalette.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’scol: StringNameparameter aVariant(live :586). Its body passes it on to_label, which already takes aVariant. -
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 markerhue = Tokens.c(GOOD)becomeshue = _role(GOOD) if CvdPalette.active() else Tokens.c(GOOD)). -
Prefix
_mark()onto the value text wherechanged(live :654) and on theaft != havecrystal 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.shmust PASS. Commit message:cvd: HP bands + notches, stats-sheet roles + changed-row marker.
Task 9: Remote Console preset switch
Section titled “Task 9: Remote Console preset switch”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 intests/test_cvd_palette.gdfor the dev command -
Step 1: Write the failing tests.
- GUT:
main.apply_dev_command({"kind": "cvd_preset", "preset": "tritan"})returns true and setsCvdPalette.preset == TRITAN. An unknown preset leaves it unchanged and returns true. It works withsim == null(menu). - guard.test.mjs: add
'cvd_preset'to the safe-kinds list in the test at :13. - panel.test.mjs: extend the
lookCapstest at :440 withcvd_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'}.
- GUT:
-
Step 2: Run to verify they fail. Run
node --test control/test/guard.test.mjs control/test/panel.test.mjsand the GUT file. -
Step 3: Change
main.gd.- In
apply_dev_command, next to thecolor_profilearm:
- In
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 thedev_commandslist (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) addout["look:cvd"] = CvdPalette.id() # the console's "Colour vision" selector. -
Step 4: Change the control panel.
guard.js: add'cvd_preset',toSAFE_KINDSwith a comment// colour-blind preset: look-only.client.jslookCaps(): after thecolor_profilesline 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.
- Commit message:
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/READMEcomment header incvd.py(no new .md) -
Modify:
tools/ui_capture/capture.gd(opt-incvd+cvd_hdrscenes) -
Test:
tools/cvd_sim/test_cvd.py(pythonunittest, 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).deof a colour with itself is 0.- The protan simulation of
#ff0000and#00ff00is closer (ΔE) than their normal-vision distance. - Parity with GDScript: the
min dEof the DEUTAN SDR element table printed bycvd.pyequals the valuetests/test_cvd_palette.gdmeasures, 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, andreport(name, cols, viv). - Seed it from this reference implementation (already used to make the spec tables):
- It holds the same maths as
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.4def 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 x100def 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-gdmode that regex-readsrender/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(liverender/color_gamut.gd:139-176) for--hdr. -
Step 3: Write
tools/cvd_sim/sheet.pyandpalette_opt.py.sheet.py(PIL + numpy). Usage:python3 -I tools/cvd_sim/sheet.py <in.png> <out.png>.- Linearise the image.
- Apply each matrix per pixel.
- Re-encode.
- 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 varlines to paste.
-
Step 4: Add the
ui_capturescene. Intools/ui_capture/capture.gd, follow the opt-in pattern ofelitefx(live :166-181), gated by_listed("cvd"). For each preset in[DEUTAN, PROTAN, TRITAN, OFF]:- Call
CvdPalette.set_preset(p), thenmain._on_cvd_changed(). - 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, ...), livesim/elemental_system.gd:367); - a bomb (
sim._spawn_fire_hazard(), livesim/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.
- Wait in sim time (poll
sim.run_time, never rendered frames: CLAUDE.md render-harness rule). - Call
_capture("cvd_" + CvdPalette.id()).
A second listed id,
cvd_hdr, repeats this withHdrLook.set_override_label("hdr")andmain._sync_hdr_output(). Restore the override and preset at the end. - Call
-
Step 5: Run it at iPhone, iPad and TV, under both renderers:
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.tscnUI_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.tscngit checkout -- project.godot audio/bus_layout.tresfor f in $TMPDIR/cvd-mobile/*.png $TMPDIR/cvd-gl/*.png; do python3 -I tools/cvd_sim/sheet.py "$f" "${f%.png}.sheet.png"; doneExpected: 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"toEXPECTED_EFFECTSintests/test_vfx_library_manifest.gd. Run it; expected to fail (missing entries). -
Step 2: Add the manifest entries. Add three
_entry(...)calls inlibrary_manifest.gd, following theenemy_projectile/bomb_telegraph/elemental_auraentries:- 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 = truefor the sample only and restores it on exit.
- status REVIEW, rating 3 in
-
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 throughElementPalette.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.shmust 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
.gdshaderchanged, so confirm that withgit diff origin/main --stat -- '*.gdshader' '*.gdshaderinc'(expected empty). If anything is listed, runtools/shader_check/compile_check.gdundermobileandgl_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 appand reinstall first (stale state). Say first that this wipes the Dev save.
- Step 3: Fresh-install stall check. Run skill
bh-fresh-install-stall-checkon ATV with a preset chosen. - Step 4: Perf. Run a 10-minute Survival soak with PROTAN against Off on ATV.
tick_msandrender_cpu_msp95 must be within noise; the ring adds one draw call. - Step 5: Visual QA. Dispatch
bh-visual-qaon 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_presetselector. - Then he reviews the HDR tables on an HDR display, tuning values live (
set_tunablesonCvdPaletteHdr.*) 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}.gddefaults, then rerun the Task 1 floor tests. A failed floor goes back to Chris with the number.
- Chris reviews the SDR tables on iPhone/ATV via the console
- 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.
Execution handoff
Section titled “Execution handoff”- 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.
- pushing to
- 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-modeofforigin/main.