Sound Map Tool Implementation Plan
Sound Map Tool Implementation Plan
Section titled “Sound Map Tool Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a browser-based tool that shows every SFX-tied game event next to its real in-game graphic and a play button for its sound, lets Chris build a candidate pool of alternate takes (uploaded or ElevenLabs generation briefs), and writes an assigned winner directly into the project.
Architecture: A committed JSON manifest (audio/sound_map.json) is the source of truth,
superseding the hand-maintained audio/SOUND_MAP.md. A one-time/re-runnable windowed Godot bake
script renders every event’s real graphic to a PNG thumbnail (weapons via the real weapon-icon
code, enemies/bosses via ArchetypeRenderer, player/UI reusing existing renderers, music via a
placeholder glyph). A static browser app (mirroring the existing tools/design-bible/ pattern)
reads the manifest and renders a filterable grid with play/candidate/assign controls. A small local
Python backend (server.py) handles file uploads and write-back: for events with an existing
sound_key it overwrites the .wav; for SILENT events it inserts a new mapping into
audio_manager.gd.
Tech Stack: GDScript (Godot 4.6.3, bake script), vanilla JS ES modules + node --test (browser
tool, matching tools/design-bible), Python 3 stdlib only (server.py, no external deps), GUT
(manifest/asset integrity test).
Global Constraints
Section titled “Global Constraints”- No changes to
sim/, determinism, or gameplay logic — this is render-adjacent tooling only. No determinism re-pin needed for this plan. - Bake script runs windowed, foreground only — headless Godot cannot read back
_draw()/ MultiMesh/shader output (same constraint astools/ship_preview/tools/ui_sweep). server.pyonly ever writes to:audio/*.wav,audio/candidates/,audio/sound_map.json, and (wire-new mode only) theSOUND_FOR_FX/SOUND_FOR_NAMED_REACTIONdict literals and thekeysarray inside_ready()inaudio/audio_manager.gd. Nothing else. Never auto-commits.- Candidate sound files are committed to git (not gitignored) — see design spec’s Candidate storage decision.
flagged_source_sharedevents (GENERIC because the emission site shares anfx_eventkind, not because no one picked a better sound) never get an enabled “Assign” control — the UI explains why and points atgameplay-programming, per the design spec’s write-back boundary.- Design spec:
docs/superpowers/specs/2026-07-19-sound-map-tool-design.md— read it first for the full rationale; this plan implements it task-by-task.
Task 1: Manifest schema + initial audio/sound_map.json
Section titled “Task 1: Manifest schema + initial audio/sound_map.json”Files:
- Create:
audio/sound_map.json - Create:
tools/sound_map/src/schema.js - Test:
tools/sound_map/tests/schema.test.js - Create:
tools/sound_map/package.json
Interfaces:
-
Produces:
validateManifest(manifest) -> {valid: bool, errors: string[]}(exported fromschema.js), used by every later JS task and by the Task 2 GUT test’s counterpart check. -
Produces:
audio/sound_map.jsonwith the shape defined in the design spec’s Data Model section (schemaVersion,events[]withid/category/label/thumbnail/trigger/sound_key/status/ sound_type/shared_with/candidates[]/assigned_candidate). -
Step 1: Write the failing schema test
import { test } from 'node:test';import assert from 'node:assert/strict';import { validateManifest } from '../src/schema.js';
test('rejects a manifest missing schemaVersion', () => { const { valid, errors } = validateManifest({ events: [] }); assert.equal(valid, false); assert.ok(errors.some(e => e.includes('schemaVersion')));});
test('rejects an event missing required fields', () => { const { valid, errors } = validateManifest({ schemaVersion: 1, events: [{ id: 'x' }], }); assert.equal(valid, false); assert.ok(errors.some(e => e.includes('category')));});
test('rejects a silent event that has a sound_key', () => { const { valid, errors } = validateManifest({ schemaVersion: 1, events: [{ id: 'x', category: 'weapons', label: 'X', thumbnail: 't.png', trigger: 'x', sound_key: 'x_sound', status: 'silent', sound_type: 'impact', shared_with: null, candidates: [], assigned_candidate: null, }], }); assert.equal(valid, false); assert.ok(errors.some(e => e.includes('silent')));});
test('rejects a flagged_source_shared event with no shared_with', () => { const { valid, errors } = validateManifest({ schemaVersion: 1, events: [{ id: 'x', category: 'weapons', label: 'X', thumbnail: 't.png', trigger: 'slash', sound_key: 'melee_swish', status: 'flagged_source_shared', sound_type: 'whoosh-cast', shared_with: null, candidates: [], assigned_candidate: null, }], }); assert.equal(valid, false); assert.ok(errors.some(e => e.includes('shared_with')));});
test('accepts a well-formed manifest', () => { const { valid, errors } = validateManifest({ schemaVersion: 1, events: [{ id: 'weapon.pulse.bolt', category: 'weapons', label: 'Pulse (→ Tesla Coil)', thumbnail: 'thumbnails/weapon.pulse.bolt.png', trigger: 'bolt', sound_key: 'pulse_zap', status: 'done', sound_type: 'zap-cast', shared_with: null, candidates: [], assigned_candidate: null, }], }); assert.deepEqual(errors, []); assert.equal(valid, true);});- Step 2: Run test to verify it fails
Run: cd tools/sound_map && npm test (no src/schema.js yet)
Expected: FAIL — Cannot find module '../src/schema.js'
- Step 3: Write
package.json
{ "name": "sound-map", "private": true, "type": "module", "scripts": { "test": "node --test", "serve": "python3 server.py" }}- Step 4: Write
src/schema.js
export const CATEGORIES = ['weapons', 'enemies', 'bosses', 'player', 'ui', 'music'];export const STATUSES = ['done', 'generic', 'silent', 'flagged_source_shared'];export const REQUIRED_FIELDS = [ 'id', 'category', 'label', 'thumbnail', 'trigger', 'sound_key', 'status', 'sound_type', 'shared_with', 'candidates', 'assigned_candidate',];
export function validateManifest(manifest) { const errors = []; if (typeof manifest !== 'object' || manifest === null) { return { valid: false, errors: ['manifest must be an object'] }; } if (manifest.schemaVersion !== 1) { errors.push('missing or wrong schemaVersion (expected 1)'); } if (!Array.isArray(manifest.events)) { errors.push('missing events array'); return { valid: false, errors }; } for (const ev of manifest.events) { const id = ev && ev.id ? ev.id : '(unknown id)'; for (const field of REQUIRED_FIELDS) { if (!(field in ev)) errors.push(`${id}: missing field "${field}"`); } if (ev.category && !CATEGORIES.includes(ev.category)) { errors.push(`${id}: invalid category "${ev.category}"`); } if (ev.status && !STATUSES.includes(ev.status)) { errors.push(`${id}: invalid status "${ev.status}"`); } if (ev.status === 'silent' && ev.sound_key !== null) { errors.push(`${id}: silent status must have null sound_key`); } if (ev.status === 'flagged_source_shared' && !ev.shared_with) { errors.push(`${id}: flagged_source_shared status must have shared_with set`); } } return { valid: errors.length === 0, errors };}- Step 5: Run test to verify it passes
Run: cd tools/sound_map && npm test
Expected: PASS (5 tests)
- Step 6: Hand-author
audio/sound_map.jsonv1
Transcribe every row from audio/SOUND_MAP.md (Weapons/Enemies/Bosses/Player/UI/Music sections)
into the schema above. Concretely:
id:<category>.<entity-slug>.<event-slug>(e.g.weapon.pulse.bolt,enemy.shooter.ranged_fire,boss.warden.spawn,player.dash,ui.menu_nav,music.menu_theme).trigger: the fx_eventkindstring or named-reaction string fromSOUND_MAP.md’s “Fire event”/“Trigger” column (e.g."bolt","BLINK"). For UI/Music rows with no fx_event (they hook in via directaudio.play()/audio.play_music()calls), use the sound key name itself as the trigger placeholder (e.g."ui_tap","menu_theme") since these never go through wire-new (see Task 11 — write-back for UI/Music is out of scope for v1’s auto-wiring; they still get replace-mode).sound_key: the “Sound file” column with.wav/.mp3stripped, ornullfor SILENT rows.status:done→"done",GENERIC→"generic"(unless it’s a source-shared case — see Task 12, which reclassifies specific rows toflagged_source_sharedafter this initial import),SILENT→"silent",MISWIRED/DOUBLE→"generic"(none exist as of this writing perSOUND_MAP.md’s snapshot line — 0 MISWIRED, 0 DOUBLE).sound_type: copy verbatim fromSOUND_MAP.md’s “Sound type needed” column.thumbnail:"thumbnails/<id>.png"for every row (files don’t exist until Task 4-6’s bake runs — that’s fine, the browser tool handles a missing thumbnail file as a placeholder, see Task 7).shared_with:nullfor now — Task 12 fills this in for the specific rows that need it.candidates: [],assigned_candidate: nullfor every row (nothing curated yet).
Skip SOUND_MAP.md’s “Mixing / Infra” section — it’s not per-event and has no graphic.
- Step 7: Validate the hand-authored manifest
// one-off check, run via: node -e "..."import { readFileSync } from 'node:fs';import { validateManifest } from './tools/sound_map/src/schema.js';const manifest = JSON.parse(readFileSync('audio/sound_map.json', 'utf8'));const { valid, errors } = validateManifest(manifest);console.log(valid ? 'VALID' : errors.join('\n'));Run from repo root: node -e "$(cat <<'EOF' import('./tools/sound_map/src/schema.js').then(async ({validateManifest}) => { const fs = await import('node:fs'); const manifest = JSON.parse(fs.readFileSync('audio/sound_map.json', 'utf8')); const { valid, errors } = validateManifest(manifest); console.log(valid ? 'VALID' : errors.join('\n')); }); EOF )"
Expected: VALID. Fix any reported row and re-run until clean.
- Step 8: Commit
git add audio/sound_map.json tools/sound_map/package.json tools/sound_map/src/schema.js tools/sound_map/tests/schema.test.jsgit commit -m "feat(sound-map): manifest schema + initial audio/sound_map.json import"Task 2: GUT test — every sound_key resolves to a real file
Section titled “Task 2: GUT test — every sound_key resolves to a real file”Files:
- Create:
tests/test_sound_map_manifest.gd
Interfaces:
-
Consumes:
audio/sound_map.json(Task 1). -
No new production code — this is a pure regression guard.
-
Step 1: Write the failing test
extends GutTest
func test_every_sound_key_has_a_real_file() -> void: var f := FileAccess.open("res://audio/sound_map.json", FileAccess.READ) assert_not_null(f, "audio/sound_map.json must exist") var manifest: Dictionary = JSON.parse_string(f.get_as_text()) f.close() var missing: Array[String] = [] for ev in manifest["events"]: var key = ev.get("sound_key") if key == null: continue var found := false for ext in [".wav", ".mp3"]: if FileAccess.file_exists("res://audio/%s%s" % [key, ext]): found = true break if not found: missing.append("%s -> %s" % [ev["id"], key]) assert_eq(missing.size(), 0, "sound_keys with no backing file: %s" % ", ".join(missing))
func test_every_silent_event_has_null_sound_key() -> void: var f := FileAccess.open("res://audio/sound_map.json", FileAccess.READ) var manifest: Dictionary = JSON.parse_string(f.get_as_text()) f.close() for ev in manifest["events"]: if ev.get("status") == "silent": assert_null(ev.get("sound_key"), "%s is silent but has a sound_key" % ev["id"])- Step 2: Run to verify current state
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_sound_map_manifest -gexit
Expected: PASS immediately, since Task 1’s manifest was already hand-validated against real
filenames. If it fails, fix the offending row in audio/sound_map.json (not this test).
- Step 3: Run the full test-count guard
Run: scripts/check-test-count.sh
Expected: exit 0, count includes the new test file.
- Step 4: Commit
git add tests/test_sound_map_manifest.gdgit commit -m "test(sound-map): guard every manifest sound_key against a real audio file"Task 3: Extract WeaponIcon into a reusable public class
Section titled “Task 3: Extract WeaponIcon into a reusable public class”Files:
- Create:
ui/weapon_icon.gd - Modify:
ui/weapon_panel.gd(remove the inner_WeaponIconclass, useWeaponIconinstead) - Test:
tests/test_weapon_icon.gd
Interfaces:
- Produces:
class_name WeaponIcon extends Controlwith a settablekind: Stringproperty and the same_draw()behavior as the old_WeaponIcon— consumed by Task 4’s bake script and byweapon_panel.gd.
Why: ui/weapon_panel.gd’s private _WeaponIcon class already draws “a procedural
mini-recreation of each weapon’s ACTUAL on-field look” (its own header comment) — exactly the
graphic the sound-map tool needs for weapon thumbnails. Extracting it to a public class means the
bake script reuses the real renderer instead of re-implementing weapon glyphs, matching the
design spec’s “bake real static thumbnails” decision.
- Step 1: Write the failing test
extends GutTest
func test_known_kind_has_a_color() -> void: var icon := WeaponIcon.new() icon.kind = "pulse" assert_true(WeaponIcon._COLOR.has("pulse"))
func test_unknown_kind_does_not_crash() -> void: var icon := WeaponIcon.new() icon.kind = "not_a_real_weapon" # _draw() isn't called outside the render loop in a headless test, but constructing + # setting kind must not throw for an unmapped kind (falls through to the ring-only case). assert_eq(icon.kind, "not_a_real_weapon")
func test_setting_kind_twice_to_same_value_is_a_noop() -> void: var icon := WeaponIcon.new() icon.kind = "beam" icon.kind = "beam" # must not error assert_eq(icon.kind, "beam")- Step 2: Run test to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_icon -gexit
Expected: FAIL — WeaponIcon class does not exist yet.
- Step 3: Create
ui/weapon_icon.gd
Move the entire body of ui/weapon_panel.gd’s class _WeaponIcon extends Control (lines 16-114 as
read during planning) into this new file verbatim, with two changes: drop the leading underscore
from the class name and add class_name/extends headers.
class_name WeaponIconextends Control
# Procedural mini-recreation of each weapon's ACTUAL on-field look (2026-07-02) — replaced# AI-generated icon art that didn't match gameplay (e.g. Orbit showed a fantasy ring with# triangle ornaments; the real weapon is 3 small glowing dots circling the player). Each# kind draws a tiny version of its own fx (see sim/weapon_*.gd's fx_events + fx/fx_manager.gd# for the real shapes) so it can never drift out of sync the way separately-authored art can.# Fixed per-KIND colour (not element-tinted — a weapon keeps its identity colour regardless# of which element it's currently carrying; see CLAUDE.md).# Extracted 2026-07-19 from ui/weapon_panel.gd's private _WeaponIcon so tools/sound_map_bake# can reuse the exact same renderer for weapon thumbnails.const SIZE: float = 30.0
const _COLOR := { "blade": Color(0.85, 0.9, 1.0), "pulse": Color(1.0, 0.92, 0.25), "nova": Color(1.0, 0.55, 0.15), "orbit": Color(0.4, 0.9, 1.0), "beam": Color(1.0, 0.92, 0.25), "turret": Color(0.8, 0.85, 0.9), "scatter": Color(1.0, 0.3, 0.35), "blaster": Color(0.75, 0.7, 1.0), "aim": Color(0.95, 0.6, 1.0),}
var kind: String = "": set(v): if kind == v: return kind = v queue_redraw()
func _init() -> void: custom_minimum_size = Vector2(SIZE, SIZE) mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void: var ring_col: Color = _COLOR.get(kind, Color(0.5, 0.6, 0.72)) draw_arc(Vector2.ZERO, SIZE * 0.56, 0, TAU, 28, Color(ring_col.r, ring_col.g, ring_col.b, 0.3 if kind != "" else 0.15), 1.2, true) if kind == "" or not _COLOR.has(kind): return var col: Color = _COLOR[kind] var glow := Color(col.r, col.g, col.b, 0.35) match kind: "blade": draw_arc(Vector2.ZERO, SIZE * 0.42, deg_to_rad(-140), deg_to_rad(-20), 16, glow, 7.0, true) draw_arc(Vector2.ZERO, SIZE * 0.42, deg_to_rad(-140), deg_to_rad(-20), 16, col, 3.0, true) "pulse": var pts := PackedVector2Array([ Vector2(-3, -SIZE * 0.45), Vector2(3, -SIZE * 0.12), Vector2(-4, -SIZE * 0.02), Vector2(4, SIZE * 0.2), Vector2(-2, SIZE * 0.32), Vector2(2, SIZE * 0.45), ]) draw_polyline(pts, glow, 6.0, true) draw_polyline(pts, col, 2.2, true) "nova": draw_arc(Vector2.ZERO, SIZE * 0.38, 0, TAU, 24, glow, 7.0, true) draw_arc(Vector2.ZERO, SIZE * 0.38, 0, TAU, 24, col, 2.5, true) "orbit": for i in range(3): var a := TAU * float(i) / 3.0 var p := Vector2(cos(a), sin(a)) * SIZE * 0.36 draw_circle(p, 5.0, glow) draw_circle(p, 2.4, col) "beam": draw_line(Vector2(-SIZE * 0.4, 0), Vector2(SIZE * 0.4, 0), glow, 8.0, true) draw_line(Vector2(-SIZE * 0.4, 0), Vector2(SIZE * 0.4, 0), col, 3.0, true) "turret": var hex := PackedVector2Array() for i in range(6): var a := TAU * float(i) / 6.0 - PI / 2.0 hex.append(Vector2(cos(a), sin(a)) * SIZE * 0.3) draw_colored_polygon(hex, Color(col.r, col.g, col.b, 0.85)) draw_polyline(hex + PackedVector2Array([hex[0]]), glow, 2.0, true) draw_rect(Rect2(-2.5, -SIZE * 0.48, 5.0, SIZE * 0.22), col) "scatter": var origin := Vector2(0, SIZE * 0.4) for i in range(5): var a := deg_to_rad(-90) + deg_to_rad(-50 + i * 25) var p := origin + Vector2(cos(a), sin(a)) * SIZE * 0.6 draw_circle(p, 3.2, glow) draw_circle(p, 1.5, col) "blaster": var tip := Vector2(SIZE * 0.42, 0) var pts := PackedVector2Array([ tip, Vector2(-SIZE * 0.22, -SIZE * 0.16), Vector2(-SIZE * 0.08, 0), Vector2(-SIZE * 0.22, SIZE * 0.16), ]) draw_colored_polygon(pts, Color(col.r, col.g, col.b, 0.85)) draw_polyline(pts + PackedVector2Array([pts[0]]), glow, 2.0, true) "aim": draw_arc(Vector2.ZERO, SIZE * 0.3, 0, TAU, 20, glow, 5.0, true) draw_arc(Vector2.ZERO, SIZE * 0.3, 0, TAU, 20, col, 2.0, true) draw_line(Vector2(-SIZE * 0.46, 0), Vector2(-SIZE * 0.2, 0), col, 2.0, true) draw_line(Vector2(SIZE * 0.2, 0), Vector2(SIZE * 0.46, 0), col, 2.0, true) draw_line(Vector2(0, -SIZE * 0.46), Vector2(0, -SIZE * 0.2), col, 2.0, true) draw_line(Vector2(0, SIZE * 0.2), Vector2(0, SIZE * 0.46), col, 2.0, true)- Step 4: Update
ui/weapon_panel.gd
Delete the inner class _WeaponIcon extends Control block entirely, and replace every
_WeaponIcon.new() call with WeaponIcon.new() (there are two call sites: the per-slot tile icon
around line 201, and _aim_icon around line 166).
- Step 5: Run test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_icon -gexit
Expected: PASS (3 tests)
- Step 6: Run the full suite to confirm no regression in weapon_panel
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ui_sound_wiring -gexit
(and any other existing weapon-panel-touching test file found via grep -rl WeaponPanel tests/)
Expected: PASS, no change in behavior — this is a pure extraction.
- Step 7: Commit
git add ui/weapon_icon.gd ui/weapon_panel.gd tests/test_weapon_icon.gdgit commit -m "refactor(weapon-panel): extract WeaponIcon to a public class for reuse by the sound-map bake"Task 4: Bake script — weapons category
Section titled “Task 4: Bake script — weapons category”Files:
- Create:
tools/sound_map_bake/bake.gd - Create:
tools/sound_map_bake/bake.tscn(aNode2Droot runningbake.gd, matchingtools/ship_preview/preview.tscn’s structure)
Interfaces:
-
Consumes:
WeaponIcon(Task 3),UpgradeSystem.grant_weapon(sim, pilot, wid)andUpgradeSystem.active_weapon_views(sim, pilot) -> Array(existing,sim/upgrade_system.gd),ContentLoader.load_from_path(path) -> ContentDB(existing, used the same way intools/funzo_lab/preview.gd). -
Produces:
tools/sound_map/thumbnails/weapon.<wid>.<trigger>.pngfor every weapon indata/bible.json’sweaponscategory. -
Step 1: Write
bake.gd’s weapon-category function
extends Node2D
# Windowed batch thumbnail bake for the sound-map tool (see# docs/superpowers/specs/2026-07-19-sound-map-tool-design.md). Renders every SFX-tied event's# REAL graphic — not a re-implementation — and saves it as a PNG. Run WINDOWED (headless can't# read back _draw()/MultiMesh/shader output, same constraint as tools/ship_preview):# godot --path . res://tools/sound_map_bake/bake.tscn --rendering-method forward_plus
const OUT_DIR := "res://tools/sound_map/thumbnails"
func _ready() -> void: DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR)) get_window().size = Vector2i(120, 120) await _bake_weapons() get_tree().quit()
func _bake_weapons() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var pilot := sim.player var weapon_ids: Array = content.data.get("weapons", {}).keys() for wid in weapon_ids: sim.upgrade_system.grant_weapon(sim, pilot, wid) var views: Array = sim.upgrade_system.active_weapon_views(sim, pilot) for view in views: var glyph: String = view.get("glyph", "") var wid: String = view.get("id", "") if glyph == "": continue # no icon defined for this weapon yet (see design spec's Non-goals) var icon := WeaponIcon.new() icon.kind = glyph icon.position = Vector2(60, 60) icon.scale = Vector2(2.0, 2.0) add_child(icon) for _i in range(6): await get_tree().process_frame await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() var event_id := "weapon.%s" % wid var path := "%s/%s.png" % [OUT_DIR, event_id] img.save_png(path) print("BAKED %s" % ProjectSettings.globalize_path(path)) remove_child(icon) icon.queue_free()- Step 2: Create
bake.tscn
A minimal scene with a Node2D root pointing script at bake.gd, same structure as
tools/ship_preview/preview.tscn (copy that file and change the script resource path).
- Step 3: Run the bake and verify output
Run: godot --path . res://tools/sound_map_bake/bake.tscn --rendering-method forward_plus
Expected: console prints one BAKED ... line per weapon with a defined glyph; check
tools/sound_map/thumbnails/ contains non-empty PNGs (ls -la tools/sound_map/thumbnails/).
- Step 4: Check for
project.godotcorruption
Run: git diff project.godot
Expected: no output. If it shows changes, git checkout -- project.godot immediately (per this
project’s documented windowed-run gotcha) and re-run the bake — never commit a changed
project.godot from this step.
- Step 5: Commit
git add tools/sound_map_bake/bake.gd tools/sound_map_bake/bake.tscngit commit -m "feat(sound-map): bake script, weapons category"Note: tools/sound_map/thumbnails/*.png are build output, not source — do NOT commit them here;
Task 6 adds a .gitignore entry once all categories are baking (see Task 6 Step 5).
Task 5: Bake script — enemies + bosses category
Section titled “Task 5: Bake script — enemies + bosses category”Files:
- Modify:
tools/sound_map_bake/bake.gd
Interfaces:
-
Consumes:
EnemyPool.add(p, v, r, d, armor_v, speed_v, contact_v, xp_v, tid, base_el, beh, flank_v, biomass_v) -> int(existing,sim/enemy_pool.gd),ArchetypeRenderer.sync(enemies, type_colors, type_to_shape)(existing,render/archetype_renderer.gd),ElementPalette.color_for (content, element_idx) -> Color(existing),EnemyPool.TYPE_NAMES(existing,sim/enemy_pool.gd). -
Produces:
tools/sound_map/thumbnails/enemy.<type_name>.pngfor every non-bossTYPE_*, andtools/sound_map/thumbnails/boss.<type_name>.pngfor the 5 boss types (Warden/Boss2/FunZo/ Graviton/Eye —TYPE_BOSS/TYPE_BOSS2/TYPE_FUNZO/TYPE_GRAVITON/TYPE_EYE). -
Step 1: Add the enemy/boss bake function
# tools/sound_map_bake/bake.gd (add alongside _bake_weapons)
const BOSS_TYPE_IDS := [ EnemyPool.TYPE_BOSS, EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO, EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE,]
func _bake_enemies_and_bosses() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var archetype := ArchetypeRenderer.new() add_child(archetype) for tid in range(EnemyPool.TYPE_NAMES.size()): if tid == EnemyPool.TYPE_CUSTOM: continue # dev-only type, no fixed identity to render sim.enemies = EnemyPool.new(4) # fresh single-slot-ish pool per capture, avoids stacking sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 18.0, 100.0, 0.0, 0.0, 0.0, 1.0, tid, -1) var colors := PackedColorArray() for i in range(sim.enemies.count): colors.append(ElementPalette.color_for(content, sim.enemies.base_element[i])) archetype.sync(sim.enemies, colors, PackedInt32Array()) archetype.position = Vector2(60, 60) for _i in range(6): await get_tree().process_frame await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() var prefix := "boss" if tid in BOSS_TYPE_IDS else "enemy" var event_id := "%s.%s" % [prefix, EnemyPool.TYPE_NAMES[tid]] var path := "%s/%s.png" % [OUT_DIR, event_id] img.save_png(path) print("BAKED %s" % ProjectSettings.globalize_path(path))- Step 2: Wire it into
_ready()
func _ready() -> void: DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR)) get_window().size = Vector2i(120, 120) await _bake_weapons() await _bake_enemies_and_bosses() get_tree().quit()- Step 3: Run the bake and verify output
Run: godot --path . res://tools/sound_map_bake/bake.tscn --rendering-method forward_plus
Expected: BAKED lines for every weapon, then every enemy/boss type name; spot-check 2-3 PNGs open
as recognizable silhouettes (not blank/black) — if a shape renders solid black, the enemy’s
type_to_shape/color lookup likely needs a real element index instead of -1; check
ArchetypeRenderer.shape_for() and adjust the base_el argument to EnemyPool.add() if so.
- Step 4: Check for
project.godotcorruption
Run: git diff project.godot — expect no output (same guard as Task 4 Step 4).
- Step 5: Commit
git add tools/sound_map_bake/bake.gdgit commit -m "feat(sound-map): bake script, enemies + bosses category"Task 6: Bake script — player, UI, and music categories
Section titled “Task 6: Bake script — player, UI, and music categories”Files:
- Modify:
tools/sound_map_bake/bake.gd - Create:
.gitignoreentry fortools/sound_map/thumbnails/
Interfaces:
-
Consumes:
tools/ship_preview/out/ship_current.png(existing bake output, if present — falls back to a placeholder ifship_previewhasn’t been run) for the player category;ShopIcons.make (key, accent) -> Control(existing,ui/shop_icons.gd) for the UI category. -
Produces:
tools/sound_map/thumbnails/player.<event>.png,tools/sound_map/thumbnails/ui. <event>.png,tools/sound_map/thumbnails/music.<event>.png. -
Step 1: Add the player, UI, and music bake functions
# tools/sound_map_bake/bake.gd (add alongside the two functions above)
# Player events all share one thumbnail (the player ship itself) — reuses ship_preview's own# baked output directly rather than re-rendering, per the design spec.const PLAYER_EVENTS := ["dash", "level_up", "hurt", "death", "gem_pickup", "weapon_pickup", "drone_deploy", "drone_pulse", "overcharge_heal", "powerup_slow", "powerup_freeze", "powerup_nuke", "powerup_heal"]
func _bake_player() -> void: var src := "res://tools/ship_preview/out/ship_current.png" if not FileAccess.file_exists(src): push_warning("tools/ship_preview/out/ship_current.png not found — run tools/ship_preview's default bake first for real player thumbnails; skipping player category this run.") return var img := Image.load_from_file(ProjectSettings.globalize_path(src)) for event in PLAYER_EVENTS: var path := "%s/player.%s.png" % [OUT_DIR, event] img.save_png(path) print("BAKED player.* (%d events, copied from ship_preview)" % PLAYER_EVENTS.size())
# UI events map onto ShopIcons' existing glyph keys where one exists; the rest (menu nav,# panel open/close) get a shared generic "nav" glyph since they aren't tied to a single icon.const UI_EVENTS := { "menu_nav": "star", "levelup_select": "star", "shop_buy": "unlock", "shop_nav": "star", "pause_nav": "star", "panel_close": "star",}
func _bake_ui() -> void: for event in UI_EVENTS: var icon := ShopIcons.make(UI_EVENTS[event], Color(0.4, 0.9, 1.0)) icon.position = Vector2(60, 60) icon.scale = Vector2(2.0, 2.0) add_child(icon) for _i in range(4): await get_tree().process_frame await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() var path := "%s/ui.%s.png" % [OUT_DIR, event] img.save_png(path) remove_child(icon) icon.queue_free() print("BAKED ui.* (%d events)" % UI_EVENTS.size())
# Music has no single graphic — draw a simple placeholder waveform glyph, shared by every# music event, per the design spec's explicit Non-goal (no fake baked render for music).const MUSIC_EVENTS := ["menu_theme", "survival_loop", "boss_theme", "victory_stinger", "defeat_stinger"]
func _bake_music_placeholder() -> void: var placeholder := Node2D.new() add_child(placeholder) var bars := [8, 20, 14, 26, 10, 18, 24, 12] for i in range(bars.size()): var h: float = bars[i] var x: float = 20 + i * 10 placeholder.draw.connect(func(): placeholder.draw_rect(Rect2(x, 60 - h / 2.0, 6, h), Color(0.4, 0.9, 1.0, 0.8)) ) placeholder.queue_redraw() for _i in range(4): await get_tree().process_frame await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() for event in MUSIC_EVENTS: img.save_png("%s/music.%s.png" % [OUT_DIR, event]) print("BAKED music.* (%d events, shared placeholder)" % MUSIC_EVENTS.size()) remove_child(placeholder) placeholder.queue_free()- Step 2: Wire the three functions into
_ready()
func _ready() -> void: DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR)) get_window().size = Vector2i(120, 120) await _bake_weapons() await _bake_enemies_and_bosses() await _bake_player() await _bake_ui() await _bake_music_placeholder() get_tree().quit()- Step 3: Ensure a player source thumbnail exists, then run the full bake
Run once if tools/ship_preview/out/ship_current.png doesn’t exist yet:
godot --path . res://tools/ship_preview/preview.tscn --rendering-method forward_plus
Then: godot --path . res://tools/sound_map_bake/bake.tscn --rendering-method forward_plus
Expected: BAKED lines for all five categories; ls tools/sound_map/thumbnails/ | wc -l roughly
matches the manifest’s event count (some weapons without a glyph are skipped per Task 4).
- Step 4: Check for
project.godotcorruption
Run: git diff project.godot — expect no output.
- Step 5: Gitignore the thumbnail output
Add to .gitignore:
tools/sound_map/thumbnails/(Thumbnails are re-bakeable build output, unlike the candidate sound files which are hand-curated and irreplaceable — that asymmetry is why one is gitignored and the other isn’t.)
- Step 6: Commit
git add tools/sound_map_bake/bake.gd .gitignoregit commit -m "feat(sound-map): bake script, player/UI/music categories + gitignore thumbnail output"Task 7: Browser tool — manifest loading + read-only grid view
Section titled “Task 7: Browser tool — manifest loading + read-only grid view”Files:
- Create:
tools/sound_map/index.html - Create:
tools/sound_map/css/style.css - Create:
tools/sound_map/src/manifest.js - Create:
tools/sound_map/src/app.js - Create:
tools/sound_map/src/views/grid.js - Test:
tools/sound_map/tests/manifest.test.js
Interfaces:
-
Consumes:
audio/sound_map.json(Task 1),validateManifest(Task 1’sschema.js). -
Produces:
groupByCategory(manifest) -> Map<string, Event[]>(exported frommanifest.js), consumed by Task 8’s detail view and Task 13’s filter bar. -
Step 1: Write the failing test
import { test } from 'node:test';import assert from 'node:assert/strict';import { groupByCategory } from '../src/manifest.js';
test('groups events by category preserving CATEGORIES order', () => { const manifest = { schemaVersion: 1, events: [ { id: 'a', category: 'enemies', label: 'A' }, { id: 'b', category: 'weapons', label: 'B' }, { id: 'c', category: 'weapons', label: 'C' }, ], }; const grouped = groupByCategory(manifest); assert.deepEqual([...grouped.keys()], ['weapons', 'enemies']); assert.equal(grouped.get('weapons').length, 2); assert.equal(grouped.get('enemies').length, 1);});
test('empty manifest yields an empty map', () => { const grouped = groupByCategory({ schemaVersion: 1, events: [] }); assert.equal(grouped.size, 0);});- Step 2: Run test to verify it fails
Run: cd tools/sound_map && npm test
Expected: FAIL — groupByCategory not defined.
- Step 3: Write
src/manifest.js
import { CATEGORIES } from './schema.js';
export function groupByCategory(manifest) { const grouped = new Map(); for (const cat of CATEGORIES) { const events = manifest.events.filter(e => e.category === cat); if (events.length > 0) grouped.set(cat, events); } return grouped;}
export async function loadManifest() { const res = await fetch('/audio/sound_map.json'); if (!res.ok) throw new Error(`Failed to load manifest: ${res.status}`); return res.json();}- Step 4: Run test to verify it passes
Run: cd tools/sound_map && npm test
Expected: PASS (7 tests total — 5 from Task 1 + 2 new)
- Step 5: Write the static shell
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <title>Sound Map — Dark Cosmos</title> <link rel="stylesheet" href="css/style.css"></head><body> <header> <h1>Sound Map</h1> <div id="actions"></div> </header> <div id="grid"></div> <script type="module" src="src/app.js"></script></body></html>body { background: #0a0d16; color: #cfe8ff; font-family: system-ui, sans-serif; margin: 0; }header { padding: 16px 24px; border-bottom: 1px solid #1c2740; }h1 { margin: 0; font-size: 18px; letter-spacing: 0.04em; }#grid { padding: 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; }.card { background: #101627; border: 1px solid #22314f; border-radius: 8px; padding: 10px; cursor: pointer; }.card img { width: 100%; height: 90px; object-fit: contain; background: #060810; border-radius: 4px; }.card .label { font-size: 12px; margin-top: 6px; }.card .status { font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; display: inline-block; margin-top: 4px; }.status-done { background: #123a24; color: #6ee7a0; }.status-generic { background: #3a2f12; color: #e7c66e; }.status-silent { background: #3a1212; color: #e76e6e; }.status-flagged_source_shared { background: #241238; color: #b26ee7; }.category-heading { grid-column: 1 / -1; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: #7a8bb0; margin-top: 20px; }export function renderGrid(container, grouped) { container.innerHTML = ''; for (const [category, events] of grouped) { const heading = document.createElement('div'); heading.className = 'category-heading'; heading.textContent = category; container.appendChild(heading); for (const ev of events) { const card = document.createElement('div'); card.className = 'card'; card.dataset.id = ev.id; const img = document.createElement('img'); img.src = ev.thumbnail; img.onerror = () => { img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3C/svg%3E'; }; const label = document.createElement('div'); label.className = 'label'; label.textContent = ev.label; const status = document.createElement('span'); status.className = `status status-${ev.status}`; status.textContent = ev.status; card.append(img, label, status); container.appendChild(card); } }}import { loadManifest, groupByCategory } from './manifest.js';import { renderGrid } from './views/grid.js';
const gridEl = document.getElementById('grid');
loadManifest().then(manifest => { renderGrid(gridEl, groupByCategory(manifest));}).catch(err => { gridEl.textContent = `Failed to load sound map: ${err.message}`;});- Step 6: Serve and manually verify in a browser
Run: cd tools/sound_map && python3 -m http.server 8080 from the repo root is wrong (relative
/audio/sound_map.json fetch needs the repo root as the server root) — instead run from repo root:
python3 -m http.server 8080 then open http://localhost:8080/tools/sound_map/index.html.
Expected: a grid of cards grouped by category, thumbnails showing (or blank if Task 4-6’s bake
hasn’t been re-run since), status badges colored per status.
- Step 7: Commit
git add tools/sound_map/index.html tools/sound_map/css/style.css tools/sound_map/src/manifest.js tools/sound_map/src/app.js tools/sound_map/src/views/grid.js tools/sound_map/tests/manifest.test.jsgit commit -m "feat(sound-map): browser tool skeleton — manifest loading + read-only grid"Task 8: Browser tool — play buttons + candidate pool detail view
Section titled “Task 8: Browser tool — play buttons + candidate pool detail view”Files:
- Create:
tools/sound_map/src/views/detail.js - Modify:
tools/sound_map/src/app.js(wire card click → open detail) - Modify:
tools/sound_map/css/style.css(detail panel styles) - Test:
tools/sound_map/tests/detail.test.js
Interfaces:
-
Consumes:
groupByCategory(Task 7). -
Produces:
renderDetail(container, event, callbacks)wherecallbacks = {onAddCandidate(comment, file), onAssign(candidateId), onClose()}— consumed by Task 9-11 to wire real upload/assign behavior in place of stub callbacks. -
Step 1: Write the failing test (pure DOM-shape assertions, no server needed)
import { test } from 'node:test';import assert from 'node:assert/strict';import { JSDOM } from '../tests/support/mini-dom.js';import { candidateRows } from '../src/views/detail.js';
test('candidateRows lists every candidate plus the live sound as row 0', () => { const ev = { id: 'weapon.pulse.bolt', sound_key: 'pulse_zap', assigned_candidate: null, candidates: [{ id: 'c1', file: 'candidates/weapon.pulse.bolt/c1.wav', label: 'brighter', comment: '' }], }; const rows = candidateRows(ev); assert.equal(rows.length, 2); assert.equal(rows[0].isLive, true); assert.equal(rows[0].file, 'audio/pulse_zap.wav'); assert.equal(rows[1].id, 'c1');});
test('a silent event with no sound_key has no live row', () => { const ev = { id: 'weapon.chalice.cast', sound_key: null, assigned_candidate: null, candidates: [] }; const rows = candidateRows(ev); assert.equal(rows.length, 0);});Since this project has no existing DOM-testing setup and node --test runs without a browser,
skip the DOM-shape assertions above (no JSDOM/mini-dom dependency — that would break the
“dependency-free” convention) and test only the pure logic function candidateRows, which needs
no DOM at all:
// tools/sound_map/tests/detail.test.js (corrected — no DOM import)import { test } from 'node:test';import assert from 'node:assert/strict';import { candidateRows } from '../src/views/detail.js';
test('candidateRows lists the live sound as row 0, then every candidate', () => { const ev = { id: 'weapon.pulse.bolt', sound_key: 'pulse_zap', assigned_candidate: null, candidates: [{ id: 'c1', file: 'candidates/weapon.pulse.bolt/c1.wav', label: 'brighter', comment: '' }], }; const rows = candidateRows(ev); assert.equal(rows.length, 2); assert.equal(rows[0].isLive, true); assert.equal(rows[0].file, 'audio/pulse_zap.wav'); assert.equal(rows[1].id, 'c1'); assert.equal(rows[1].isLive, false);});
test('a silent event with no sound_key has no live row, only candidates', () => { const ev = { id: 'weapon.chalice.cast', sound_key: null, assigned_candidate: null, candidates: [{ id: 'c1', file: null, label: 'brief only', comment: 'ethereal chime' }], }; const rows = candidateRows(ev); assert.equal(rows.length, 1); assert.equal(rows[0].id, 'c1');});
test('a comment-only candidate has no playable file', () => { const ev = { id: 'x', sound_key: null, assigned_candidate: null, candidates: [{ id: 'c1', file: null, label: '', comment: 'brief' }] }; assert.equal(candidateRows(ev)[0].file, null);});- Step 2: Run test to verify it fails
Run: cd tools/sound_map && npm test
Expected: FAIL — candidateRows not defined.
- Step 3: Write
src/views/detail.js
// Pure logic: turns an event's sound_key + candidate pool into a flat row list for rendering.// Row 0 (if a live sound_key exists) is always the current live sound, marked isLive: true.export function candidateRows(ev) { const rows = []; if (ev.sound_key) { rows.push({ id: '__live__', isLive: true, file: `audio/${ev.sound_key}.wav`, label: 'Live', comment: '' }); } for (const c of ev.candidates) { rows.push({ id: c.id, isLive: false, file: c.file, label: c.label, comment: c.comment }); } return rows;}
export function renderDetail(container, event, callbacks) { container.innerHTML = ''; const panel = document.createElement('div'); panel.className = 'detail-panel';
const title = document.createElement('h2'); title.textContent = event.label; panel.appendChild(title);
if (event.status === 'flagged_source_shared') { const note = document.createElement('p'); note.className = 'flag-note'; note.textContent = `Shares its sound with "${event.shared_with}" at the source (same fx_event kind) — needs a gameplay-programming code change before this can get its own sound. Assign is disabled.`; panel.appendChild(note); }
const rows = candidateRows(event); const list = document.createElement('div'); list.className = 'candidate-list'; for (const row of rows) { const rowEl = document.createElement('div'); rowEl.className = 'candidate-row' + (row.isLive ? ' live' : ''); if (row.file) { const btn = document.createElement('button'); btn.textContent = '▶'; btn.onclick = () => new Audio(row.file).play(); rowEl.appendChild(btn); } const label = document.createElement('span'); label.textContent = row.label + (row.comment ? ` — ${row.comment}` : ''); rowEl.appendChild(label); if (!row.isLive && event.status !== 'flagged_source_shared') { const assignBtn = document.createElement('button'); assignBtn.textContent = 'Assign'; assignBtn.onclick = () => callbacks.onAssign(row.id); rowEl.appendChild(assignBtn); } list.appendChild(rowEl); } panel.appendChild(list);
const addForm = document.createElement('div'); addForm.className = 'add-candidate'; const commentInput = document.createElement('input'); commentInput.placeholder = 'Comment / ElevenLabs brief'; const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'audio/*'; const addBtn = document.createElement('button'); addBtn.textContent = '+ Add candidate'; addBtn.onclick = () => callbacks.onAddCandidate(commentInput.value, fileInput.files[0] || null); addForm.append(commentInput, fileInput, addBtn); panel.appendChild(addForm);
const closeBtn = document.createElement('button'); closeBtn.textContent = '× Close'; closeBtn.onclick = () => callbacks.onClose(); panel.appendChild(closeBtn);
container.appendChild(panel);}- Step 4: Run test to verify it passes
Run: cd tools/sound_map && npm test
Expected: PASS (10 tests total)
- Step 5: Wire card clicks in
app.js
// tools/sound_map/src/app.js (replace previous content)import { loadManifest, groupByCategory } from './manifest.js';import { renderGrid } from './views/grid.js';import { renderDetail } from './views/detail.js';
const gridEl = document.getElementById('grid');const detailEl = document.createElement('div');detailEl.id = 'detail';document.body.appendChild(detailEl);
let manifest = null;
function openDetail(id) { const event = manifest.events.find(e => e.id === id); renderDetail(detailEl, event, { onAddCandidate: (comment, file) => console.log('TODO Task 9: upload', comment, file), onAssign: (candidateId) => console.log('TODO Task 10/11: assign', candidateId), onClose: () => { detailEl.innerHTML = ''; }, });}
loadManifest().then(m => { manifest = m; renderGrid(gridEl, groupByCategory(manifest)); gridEl.addEventListener('click', (e) => { const card = e.target.closest('.card'); if (card) openDetail(card.dataset.id); });}).catch(err => { gridEl.textContent = `Failed to load sound map: ${err.message}`;});Add matching CSS:
/* append to tools/sound_map/css/style.css */#detail { position: fixed; top: 0; right: 0; width: 360px; height: 100%; background: #0d1220; border-left: 1px solid #22314f; padding: 16px; overflow-y: auto; }.candidate-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #1c2740; }.candidate-row.live { color: #6ee7a0; }.flag-note { color: #b26ee7; font-size: 12px; }.add-candidate { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }- Step 6: Manually verify in a browser
Repeat Task 7 Step 6’s serve command, click a card, confirm the detail panel opens with a play button for the live sound and an “+ Add candidate” form.
- Step 7: Commit
git add tools/sound_map/src/views/detail.js tools/sound_map/src/app.js tools/sound_map/css/style.css tools/sound_map/tests/detail.test.jsgit commit -m "feat(sound-map): candidate pool detail view with play buttons"Task 9: server.py — static serving + candidate upload
Section titled “Task 9: server.py — static serving + candidate upload”Files:
- Create:
tools/sound_map/server.py - Modify:
tools/sound_map/src/app.js(wireonAddCandidateto a real POST) - Modify:
tools/sound_map/package.json(servescript already points atserver.pyfrom Task 1) - Test:
tools/sound_map/tests/server.test.py
Interfaces:
-
Produces:
POST /api/candidates/<event-id>accepting multipart form data (comment: string,file: binary?) → appends a new entry to that event’scandidates[]inaudio/sound_map.jsonand, if a file was sent, saves it toaudio/candidates/<event-id>/<candidate-id>.<ext>. -
Step 1: Write the failing test
import jsonimport osimport shutilimport tempfileimport threadingimport unittestimport urllib.request
import syssys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))import server
class ServerTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() self.repo_root = os.path.join(self.tmp, 'repo') os.makedirs(os.path.join(self.repo_root, 'audio')) with open(os.path.join(self.repo_root, 'audio', 'sound_map.json'), 'w') as f: json.dump({ 'schemaVersion': 1, 'events': [{ 'id': 'weapon.pulse.bolt', 'category': 'weapons', 'label': 'Pulse', 'thumbnail': 't.png', 'trigger': 'bolt', 'sound_key': 'pulse_zap', 'status': 'done', 'sound_type': 'zap-cast', 'shared_with': None, 'candidates': [], 'assigned_candidate': None, }], }, f) server.REPO_ROOT = self.repo_root self.httpd = server.make_server(port=0) self.port = self.httpd.server_address[1] self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.thread.start()
def tearDown(self): self.httpd.shutdown() shutil.rmtree(self.tmp)
def test_add_comment_only_candidate(self): body = ( b'--X\r\nContent-Disposition: form-data; name="comment"\r\n\r\n' b'brighter crackle\r\n--X--\r\n' ) req = urllib.request.Request( f'http://localhost:{self.port}/api/candidates/weapon.pulse.bolt', data=body, method='POST', headers={'Content-Type': 'multipart/form-data; boundary=X'}, ) with urllib.request.urlopen(req) as resp: self.assertEqual(resp.status, 200) with open(os.path.join(self.repo_root, 'audio', 'sound_map.json')) as f: manifest = json.load(f) candidates = manifest['events'][0]['candidates'] self.assertEqual(len(candidates), 1) self.assertEqual(candidates[0]['comment'], 'brighter crackle') self.assertIsNone(candidates[0]['file'])
if __name__ == '__main__': unittest.main()- Step 2: Run test to verify it fails
Run: cd tools/sound_map && python3 -m unittest tests.server_test -v (adjust module path if
needed — Python test discovery requires tests/__init__.py; create an empty one)
Expected: FAIL — server.py doesn’t exist.
- Step 3: Write
server.py
#!/usr/bin/env python3"""Sound-map local dev server: static files + candidate upload + write-back.
Dependency-free (stdlib only), matching tools/design-bible's convention. Run from repo root: python3 tools/sound_map/server.pyServes the whole repo at http://localhost:8080/ so /audio/sound_map.json and/tools/sound_map/thumbnails/*.png resolve as plain static paths.
Write scope (see docs/superpowers/specs/2026-07-19-sound-map-tool-design.md's Safety section):ONLY audio/*.wav, audio/candidates/, audio/sound_map.json, and (wire-new mode) the two nameddict literals + the _ready() keys array in audio/audio_manager.gd. Never commits."""import cgiimport jsonimport osfrom http.server import HTTPServer, SimpleHTTPRequestHandlerfrom socketserver import ThreadingMixInfrom urllib.parse import urlparse
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
def manifest_path(): return os.path.join(REPO_ROOT, 'audio', 'sound_map.json')
def load_manifest(): with open(manifest_path()) as f: return json.load(f)
def save_manifest(manifest): with open(manifest_path(), 'w') as f: json.dump(manifest, f, indent=2) f.write('\n')
def next_candidate_id(event): existing = {c['id'] for c in event['candidates']} n = 1 while f'c{n}' in existing: n += 1 return f'c{n}'
class Handler(SimpleHTTPRequestHandler): def translate_path(self, path): # Serve from REPO_ROOT instead of the process cwd. rel = urlparse(path).path.lstrip('/') return os.path.join(REPO_ROOT, rel)
def do_POST(self): parsed = urlparse(self.path) parts = parsed.path.strip('/').split('/') if len(parts) == 3 and parts[0] == 'api' and parts[1] == 'candidates': return self._handle_add_candidate(parts[2]) self.send_error(404)
def _handle_add_candidate(self, event_id): ctype, pdict = cgi.parse_header(self.headers.get('Content-Type', '')) if ctype != 'multipart/form-data': self.send_error(400, 'expected multipart/form-data') return pdict['boundary'] = pdict['boundary'].encode() pdict['CONTENT-LENGTH'] = self.headers.get('Content-Length') fields = cgi.parse_multipart(self.rfile, pdict) comment = fields.get('comment', [b''])[0] if isinstance(comment, bytes): comment = comment.decode('utf-8') file_bytes = fields.get('file', [None])[0]
manifest = load_manifest() event = next((e for e in manifest['events'] if e['id'] == event_id), None) if event is None: self.send_error(404, f'unknown event {event_id}') return
candidate_id = next_candidate_id(event) file_rel = None if file_bytes: candidates_dir = os.path.join(REPO_ROOT, 'audio', 'candidates', event_id) os.makedirs(candidates_dir, exist_ok=True) file_rel = f'candidates/{event_id}/{candidate_id}.wav' with open(os.path.join(REPO_ROOT, 'audio', file_rel), 'wb') as f: f.write(file_bytes)
event['candidates'].append({ 'id': candidate_id, 'file': file_rel, 'label': '', 'comment': comment, 'added': None, # stamped by the caller session, not this stdlib-only server }) save_manifest(manifest)
self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({'id': candidate_id}).encode())
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True
def make_server(port=8080): return ThreadingHTTPServer(('localhost', port), Handler)
if __name__ == '__main__': httpd = make_server(8080) print(f'Sound map server: http://localhost:8080/tools/sound_map/index.html') httpd.serve_forever()- Step 4: Run test to verify it passes
Run: cd tools/sound_map && touch tests/__init__.py && python3 -m unittest tests.server_test -v
Expected: PASS (1 test)
- Step 5: Wire
app.js’sonAddCandidateto the real endpoint
// tools/sound_map/src/app.js — replace the onAddCandidate stubonAddCandidate: async (comment, file) => { const form = new FormData(); form.append('comment', comment); if (file) form.append('file', file); const res = await fetch(`/api/candidates/${event.id}`, { method: 'POST', body: form }); if (!res.ok) { alert('Failed to add candidate'); return; } manifest = await loadManifest(); // re-fetch so the new candidate shows up openDetail(event.id);},- Step 6: Manually verify end-to-end
Run: python3 tools/sound_map/server.py, open http://localhost:8080/tools/sound_map/index.html,
open a card’s detail panel, type a comment (no file), click “+ Add candidate”. Expected: the
candidate list re-renders with the new comment-only row; audio/sound_map.json on disk now
contains it (git diff audio/sound_map.json).
- Step 7: Commit
git add tools/sound_map/server.py tools/sound_map/src/app.js tools/sound_map/tests/server.test.py tools/sound_map/tests/__init__.pygit commit -m "feat(sound-map): server.py static serving + candidate upload endpoint"Task 10: server.py — assign endpoint, replace mode
Section titled “Task 10: server.py — assign endpoint, replace mode”Files:
- Modify:
tools/sound_map/server.py - Modify:
tools/sound_map/src/app.js(wireonAssign) - Modify:
tools/sound_map/tests/server.test.py
Interfaces:
-
Produces:
POST /api/assign/<event-id>with JSON body{candidate_id: string}→ for events with statusdone/generic, overwritesaudio/<sound_key>.wavwith the candidate’s file content and setsassigned_candidatein the manifest. -
Step 1: Write the failing test
# tools/sound_map/tests/server.test.py — add to the existing ServerTest class
def test_assign_replace_mode_overwrites_existing_wav(self): audio_dir = os.path.join(self.repo_root, 'audio') with open(os.path.join(audio_dir, 'pulse_zap.wav'), 'wb') as f: f.write(b'OLD_SOUND_BYTES') candidates_dir = os.path.join(audio_dir, 'candidates', 'weapon.pulse.bolt') os.makedirs(candidates_dir) with open(os.path.join(candidates_dir, 'c1.wav'), 'wb') as f: f.write(b'NEW_SOUND_BYTES')
manifest = json.load(open(os.path.join(audio_dir, 'sound_map.json'))) manifest['events'][0]['candidates'] = [{ 'id': 'c1', 'file': 'candidates/weapon.pulse.bolt/c1.wav', 'label': '', 'comment': 'brighter', 'added': None, }] json.dump(manifest, open(os.path.join(audio_dir, 'sound_map.json'), 'w'))
body = json.dumps({'candidate_id': 'c1'}).encode() req = urllib.request.Request( f'http://localhost:{self.port}/api/assign/weapon.pulse.bolt', data=body, method='POST', headers={'Content-Type': 'application/json'}, ) with urllib.request.urlopen(req) as resp: self.assertEqual(resp.status, 200)
with open(os.path.join(audio_dir, 'pulse_zap.wav'), 'rb') as f: self.assertEqual(f.read(), b'NEW_SOUND_BYTES') manifest = json.load(open(os.path.join(audio_dir, 'sound_map.json'))) self.assertEqual(manifest['events'][0]['assigned_candidate'], 'c1')- Step 2: Run test to verify it fails
Run: cd tools/sound_map && python3 -m unittest tests.server_test -v
Expected: FAIL — /api/assign/... returns 404 (no handler yet).
- Step 3: Add the assign handler to
server.py
# tools/sound_map/server.py — add import at topimport shutil
# add inside class Handler, alongside do_POST's routing: def do_POST(self): parsed = urlparse(self.path) parts = parsed.path.strip('/').split('/') if len(parts) == 3 and parts[0] == 'api' and parts[1] == 'candidates': return self._handle_add_candidate(parts[2]) if len(parts) == 3 and parts[0] == 'api' and parts[1] == 'assign': return self._handle_assign(parts[2]) self.send_error(404)
def _handle_assign(self, event_id): length = int(self.headers.get('Content-Length', 0)) body = json.loads(self.rfile.read(length)) candidate_id = body['candidate_id']
manifest = load_manifest() event = next((e for e in manifest['events'] if e['id'] == event_id), None) if event is None: self.send_error(404, f'unknown event {event_id}') return if event['status'] == 'flagged_source_shared': self.send_error(409, 'cannot assign: shared at the source, needs a code split first') return candidate = next((c for c in event['candidates'] if c['id'] == candidate_id), None) if candidate is None or not candidate['file']: self.send_error(400, 'candidate not found or has no file') return
if event['status'] in ('done', 'generic'): self._assign_replace(event, candidate) elif event['status'] == 'silent': self._assign_wire_new(event, candidate) # implemented in Task 11 else: self.send_error(400, f'unknown status {event["status"]}') return
event['assigned_candidate'] = candidate_id save_manifest(manifest) self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({'ok': True}).encode())
def _assign_replace(self, event, candidate): src = os.path.join(REPO_ROOT, 'audio', candidate['file']) dst = os.path.join(REPO_ROOT, 'audio', f"{event['sound_key']}.wav") shutil.copyfile(src, dst)- Step 4: Run test to verify it passes
Run: cd tools/sound_map && python3 -m unittest tests.server_test -v
Expected: PASS (2 tests)
- Step 5: Wire
app.js’sonAssignfor replace-mode events
// tools/sound_map/src/app.js — replace the onAssign stubonAssign: async (candidateId) => { const res = await fetch(`/api/assign/${event.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ candidate_id: candidateId }), }); if (!res.ok) { alert(`Assign failed: ${await res.text()}`); return; } manifest = await loadManifest(); renderGrid(gridEl, groupByCategory(manifest)); openDetail(event.id);},- Step 6: Manually verify end-to-end
Using a real done event (e.g. weapon.pulse.bolt): add a candidate with an uploaded file, click
Assign, confirm audio/pulse_zap.wav’s content changed (git diff --stat audio/) and the detail
panel now shows that candidate as live.
- Step 7: Commit
git add tools/sound_map/server.py tools/sound_map/src/app.js tools/sound_map/tests/server.test.pygit commit -m "feat(sound-map): assign endpoint, replace mode"Task 11: server.py — assign endpoint, wire-new mode
Section titled “Task 11: server.py — assign endpoint, wire-new mode”Files:
- Modify:
tools/sound_map/server.py - Modify:
tools/sound_map/tests/server.test.py
Interfaces:
-
Produces:
_assign_wire_new(event, candidate)— forsilent-status events, inserts a new key intoaudio_manager.gd’sSOUND_FOR_FXorSOUND_FOR_NAMED_REACTIONdict (chosen by whetherevent['trigger']is lowercase or ALL-CAPS, per the design spec) and into thekeys := [...]array inside_ready(), then places the candidate’s file at the new key’s path. -
Step 1: Write the failing test
# tools/sound_map/tests/server.test.py — add to ServerTest
def _write_fake_audio_manager(self): gd_dir = os.path.join(self.repo_root, 'audio') with open(os.path.join(gd_dir, 'audio_manager.gd'), 'w') as f: f.write( 'class_name AudioManager\n' 'extends Node\n\n' 'const SOUND_FOR_FX: Dictionary = {\n' '\t"bolt": "pulse_zap",\n' '}\n\n' 'const SOUND_FOR_NAMED_REACTION: Dictionary = {\n' '\t"BOSS2": "boss2_summon",\n' '}\n\n' 'func _ready() -> void:\n' '\tvar keys := [\n' '\t\t"pulse_zap", "boss2_summon",\n' '\t]\n' )
def test_assign_wire_new_mode_inserts_fx_kind(self): self._write_fake_audio_manager() audio_dir = os.path.join(self.repo_root, 'audio') candidates_dir = os.path.join(audio_dir, 'candidates', 'weapon.chalice.cast') os.makedirs(candidates_dir) with open(os.path.join(candidates_dir, 'c1.wav'), 'wb') as f: f.write(b'CHALICE_SOUND')
manifest = json.load(open(os.path.join(audio_dir, 'sound_map.json'))) manifest['events'].append({ 'id': 'weapon.chalice.cast', 'category': 'weapons', 'label': 'Chalice', 'thumbnail': 't.png', 'trigger': 'chalice_cast', 'sound_key': None, 'status': 'silent', 'sound_type': 'whoosh-cast', 'shared_with': None, 'candidates': [{'id': 'c1', 'file': 'candidates/weapon.chalice.cast/c1.wav', 'label': '', 'comment': '', 'added': None}], 'assigned_candidate': None, }) json.dump(manifest, open(os.path.join(audio_dir, 'sound_map.json'), 'w'))
body = json.dumps({'candidate_id': 'c1'}).encode() req = urllib.request.Request( f'http://localhost:{self.port}/api/assign/weapon.chalice.cast', data=body, method='POST', headers={'Content-Type': 'application/json'}, ) with urllib.request.urlopen(req) as resp: self.assertEqual(resp.status, 200)
with open(os.path.join(audio_dir, 'audio_manager.gd')) as f: content = f.read() self.assertIn('"chalice_cast": "chalice_cast"', content) self.assertIn('"chalice_cast",', content.split('func _ready')[1]) self.assertTrue(os.path.exists(os.path.join(audio_dir, 'chalice_cast.wav')))- Step 2: Run test to verify it fails
Run: cd tools/sound_map && python3 -m unittest tests.server_test -v
Expected: FAIL — _assign_wire_new raises NotImplementedError or the silent branch is missing.
- Step 3: Implement
_assign_wire_newinserver.py
# tools/sound_map/server.py — add alongside _assign_replace
AUDIO_MANAGER_PATH = lambda: os.path.join(REPO_ROOT, 'audio', 'audio_manager.gd')
def _insert_before_closing_brace(content, anchor_const, new_line): """Insert new_line as the last entry of the dict literal named anchor_const, just before its closing `}`. Anchored on the const name, not a global brace count, so it can't misfire if the file has other dict literals elsewhere.""" start = content.index(f'const {anchor_const}') brace_open = content.index('{', start) depth = 1 i = brace_open + 1 while depth > 0: if content[i] == '{': depth += 1 elif content[i] == '}': depth -= 1 i += 1 close = i - 1 return content[:close] + new_line + content[close:]
class WireNewError(Exception): pass
def wire_new_sound(gd_path, trigger, sound_key): with open(gd_path) as f: content = f.read()
is_named_reaction = trigger.isupper() dict_name = 'SOUND_FOR_NAMED_REACTION' if is_named_reaction else 'SOUND_FOR_FX' content = _insert_before_closing_brace( content, dict_name, f'\t"{trigger}": "{sound_key}",\n')
# Also register the key in _ready()'s load list, or play() silently no-ops on it. ready_start = content.index('func _ready') keys_start = content.index('[', content.index('keys', ready_start)) depth = 1 i = keys_start + 1 while depth > 0: if content[i] == '[': depth += 1 elif content[i] == ']': depth -= 1 i += 1 keys_close = i - 1 content = content[:keys_close] + f'\t\t"{sound_key}",\n' + content[keys_close:]
# Re-parse sanity check: both edited regions must still balance braces/brackets. for open_c, close_c in (('{', '}'), ('[', ']')): if content.count(open_c) != content.count(close_c): raise WireNewError(f'unbalanced {open_c}{close_c} after edit — aborting write')
with open(gd_path, 'w') as f: f.write(content)
# in class Handler: def _assign_wire_new(self, event, candidate): # sound_key convention: reuse the trigger string itself (lowercased) — simplest rule # that keeps the key human-readable and traceable back to its fx_event. sound_key = event['trigger'].lower() dst = os.path.join(REPO_ROOT, 'audio', f'{sound_key}.wav') src = os.path.join(REPO_ROOT, 'audio', candidate['file']) shutil.copyfile(src, dst) wire_new_sound(AUDIO_MANAGER_PATH(), event['trigger'], sound_key) event['sound_key'] = sound_key event['status'] = 'done'- Step 4: Run test to verify it passes
Run: cd tools/sound_map && python3 -m unittest tests.server_test -v
Expected: PASS (3 tests)
- Step 5: Manually verify against the REAL
audio_manager.gd
This edit touches production GDScript — verify against the real file before trusting it broadly:
cp audio/audio_manager.gd /tmp/audio_manager.gd.bak- Run the server, pick a real
silentweapon event (e.g. Chalice), add a candidate with a throwaway local audio file, click Assign. git diff audio/audio_manager.gd— confirm the new dict entry and_ready()keys-array entry both landed cleanly, no other lines touched.- Run:
godot --headless --path . --importthengodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_audio_manager -gexit— expect PASS (the existing audio-manager test suite still holds). git checkout -- audio/audio_manager.gd audio/sound_map.jsonafterward to discard this verification run’s changes (it was a throwaway file, not a real sourced sound).
- Step 6: Commit
git add tools/sound_map/server.py tools/sound_map/tests/server.test.pygit commit -m "feat(sound-map): assign endpoint, wire-new mode for silent events"Task 12: Flag source-shared GENERIC rows
Section titled “Task 12: Flag source-shared GENERIC rows”Files:
- Modify:
audio/sound_map.json
Interfaces:
-
No new code — a data-only pass over Task 1’s manifest, using Task 10/11’s server (already respects
flagged_source_sharedby refusing to assign) and Task 8’s UI (already renders the flag note) without further changes. -
Step 1: Identify every GENERIC row that shares an fx_event kind at the source
Cross-reference audio/SOUND_MAP.md’s existing GENERIC rows against their trigger column. A row
is flagged_source_shared (not just generic) when the SAME trigger kind is emitted by more than
one distinct entity’s code (not merely reusing the same audio file by choice). From the doc as
read during planning, at minimum:
weapon.scythe.slash— shares"slash"withweapon.blade.slash(weapon_scythe.gd:49emits the same kind asweapon_melee.gd:58).shared_with: "weapon.blade.slash".enemy.lancer.beam_fire— shares"beam"withweapon.beam.boltandboss.boss2.charge_slam(three-way share).shared_with: "weapon.beam.bolt"(pick the primary/oldest owner as the canonical pointer; the note text in Task 8’s UI can still be found by grepping either).boss.boss2.charge_slam— same"beam"share.shared_with: "weapon.beam.bolt".
Any other row still marked generic in the manifest but NOT in this list stays generic — it
means “nobody’s picked a better sound yet,” which the tool CAN fix via replace-mode, not “the code
won’t let two entities sound different.”
- Step 2: Update those rows’
statusandshared_withfields inaudio/sound_map.json
Edit each identified row directly (status: "flagged_source_shared", shared_with: "<the other event's id>").
- Step 3: Re-run the schema validation from Task 1 Step 7
Expected: VALID (schema.js’s validator already checks flagged_source_shared rows have a
non-null shared_with, from Task 1).
- Step 4: Re-run the GUT manifest test from Task 2
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_sound_map_manifest -gexit
Expected: PASS (these rows still have real, existing sound_keys — only their status changed).
- Step 5: Manually verify in the browser
Open the Scythe card, confirm the detail panel shows the flag note and no enabled Assign buttons.
- Step 6: Commit
git add audio/sound_map.jsongit commit -m "feat(sound-map): flag GENERIC rows that share an fx_event kind at the source"Task 13: Filter/sort bar
Section titled “Task 13: Filter/sort bar”Files:
- Create:
tools/sound_map/src/views/filterbar.js - Modify:
tools/sound_map/src/app.js - Test:
tools/sound_map/tests/filterbar.test.js
Interfaces:
-
Produces:
applyFilters(events, {status, category, hasCandidates}) -> Event[], pure function consumed byapp.js’s render loop. -
Step 1: Write the failing test
import { test } from 'node:test';import assert from 'node:assert/strict';import { applyFilters } from '../src/views/filterbar.js';
const EVENTS = [ { id: 'a', category: 'weapons', status: 'done', candidates: [] }, { id: 'b', category: 'weapons', status: 'silent', candidates: [{ id: 'c1' }] }, { id: 'c', category: 'enemies', status: 'done', candidates: [] },];
test('filters by status', () => { const result = applyFilters(EVENTS, { status: 'silent' }); assert.deepEqual(result.map(e => e.id), ['b']);});
test('filters by category', () => { const result = applyFilters(EVENTS, { category: 'enemies' }); assert.deepEqual(result.map(e => e.id), ['c']);});
test('filters by hasCandidates', () => { const result = applyFilters(EVENTS, { hasCandidates: true }); assert.deepEqual(result.map(e => e.id), ['b']);});
test('no filters returns everything', () => { assert.equal(applyFilters(EVENTS, {}).length, 3);});- Step 2: Run test to verify it fails
Run: cd tools/sound_map && npm test
Expected: FAIL — applyFilters not defined.
- Step 3: Write
src/views/filterbar.js
import { CATEGORIES, STATUSES } from '../schema.js';
export function applyFilters(events, { status, category, hasCandidates } = {}) { return events.filter(e => { if (status && e.status !== status) return false; if (category && e.category !== category) return false; if (hasCandidates && e.candidates.length === 0) return false; return true; });}
export function renderFilterBar(container, onChange) { container.innerHTML = ''; const state = { status: '', category: '', hasCandidates: false };
const statusSel = document.createElement('select'); statusSel.innerHTML = '<option value="">All statuses</option>' + STATUSES.map(s => `<option value="${s}">${s}</option>`).join(''); statusSel.onchange = () => { state.status = statusSel.value; onChange(state); };
const catSel = document.createElement('select'); catSel.innerHTML = '<option value="">All categories</option>' + CATEGORIES.map(c => `<option value="${c}">${c}</option>`).join(''); catSel.onchange = () => { state.category = catSel.value; onChange(state); };
const candCheck = document.createElement('label'); const candInput = document.createElement('input'); candInput.type = 'checkbox'; candInput.onchange = () => { state.hasCandidates = candInput.checked; onChange(state); }; candCheck.append(candInput, document.createTextNode(' has unreviewed candidates'));
container.append(statusSel, catSel, candCheck);}- Step 4: Run test to verify it passes
Run: cd tools/sound_map && npm test
Expected: PASS (all prior tests + 4 new)
- Step 5: Wire into
app.js
// tools/sound_map/src/app.js — add near the top-level stateimport { renderFilterBar, applyFilters } from './views/filterbar.js';
const filterBarEl = document.createElement('div');filterBarEl.id = 'filterbar';document.querySelector('header').appendChild(filterBarEl);
let currentFilters = {};
function rerenderGrid() { const filtered = applyFilters(manifest.events, currentFilters); renderGrid(gridEl, groupByCategory({ ...manifest, events: filtered }));}
// inside loadManifest().then(...):renderFilterBar(filterBarEl, (filters) => { currentFilters = filters; rerenderGrid(); });rerenderGrid(); // replaces the old direct renderGrid(gridEl, groupByCategory(manifest)) callAdd a small CSS rule: #filterbar { display: flex; gap: 8px; margin-top: 8px; }.
- Step 6: Manually verify in a browser
Confirm selecting “silent” in the status dropdown narrows the grid to only SILENT rows; clearing it restores the full grid.
- Step 7: Commit
git add tools/sound_map/src/views/filterbar.js tools/sound_map/src/app.js tools/sound_map/css/style.css tools/sound_map/tests/filterbar.test.jsgit commit -m "feat(sound-map): filter/sort bar (status, category, has-candidates)"Task 14: Retire audio/SOUND_MAP.md
Section titled “Task 14: Retire audio/SOUND_MAP.md”Files:
- Modify:
audio/SOUND_MAP.md(replace content with a pointer) - Modify:
docs/architecture/art-asset-pipeline.md(addtools/sound_map_baketo the tool-stack table, if referenced there — check for anySOUND_MAP.mdcross-references first)
Interfaces: None — documentation-only task.
- Step 1: Search for existing references to
SOUND_MAP.md
Run: grep -rl "SOUND_MAP.md" --include="*.md" --include="*.gd" .
Expected: hits in CLAUDE.md (none currently — verify), docs/architecture/*.md, and possibly
dark-cosmos/audio-direction/ROLE.md (already written to reference the new manifest, not
SOUND_MAP.md — no change needed there per the design spec’s own wording).
- Step 2: Replace
audio/SOUND_MAP.md’s content
# Sound Map — Dark Cosmos
**Retired 2026-07-19.** This file's living-audit role is now `audio/sound_map.json` (the sourceof truth) + the `tools/sound_map/` browse/audition/candidate tool. See`docs/superpowers/specs/2026-07-19-sound-map-tool-design.md` for the full design, and`dark-cosmos/audio-direction/ROLE.md` for the role that owns using it.
To browse the current sound map: `python3 tools/sound_map/server.py`, then open`http://localhost:8080/tools/sound_map/index.html`.- Step 3: Update any other doc that pointed at
SOUND_MAP.mdas a living reference
For each file found in Step 1 (excluding this plan and the design spec, which correctly reference
it historically), replace the pointer with audio/sound_map.json / tools/sound_map/ as
appropriate. If none exist beyond audio/SOUND_MAP.md itself, skip this step.
- Step 4: Commit
git add audio/SOUND_MAP.mdgit commit -m "docs(sound-map): retire SOUND_MAP.md in favour of audio/sound_map.json + the tool"Task 15: Manual verification pass
Section titled “Task 15: Manual verification pass”Files: None — verification only, no code changes expected. If verification surfaces a bug, fix it in the task where it was introduced and re-run this checklist from the top.
- Step 1: Fresh bake
Run: godot --path . res://tools/sound_map_bake/bake.tscn --rendering-method forward_plus
Expected: no errors; tools/sound_map/thumbnails/ populated for all 6 categories.
Then: git diff project.godot — expect no output (corruption guard).
- Step 2: Full test suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: PASS (GUT’s Run Summary shows 0 failures — ignore the post-teardown SIGABRT, per this
project’s known Godot-shutdown gotcha).
Then: cd tools/sound_map && npm test — expect PASS.
Then: cd tools/sound_map && python3 -m unittest discover tests -v — expect PASS.
Then: scripts/check-test-count.sh from repo root — expect exit 0.
- Step 3: End-to-end browser click-through
Run: python3 tools/sound_map/server.py, open http://localhost:8080/tools/sound_map/index.html.
-
Browse the grid, confirm thumbnails render for at least one entity per category.
-
Open a
doneweapon event, play the live sound, add a candidate (upload a real short audio clip), assign it, confirm the card’s thumbnail/status stays consistent and the live play button now plays the new sound. -
Open a
silentweapon event (e.g. Chalice), add a candidate, assign it, confirmaudio/audio_manager.gdpicked up the new mapping (git diff audio/audio_manager.gd) and the event’s status flipped todonein the grid after a refresh. -
Open the Scythe card, confirm Assign is disabled with the flag note visible.
-
Exercise the filter bar (status/category/has-candidates) and confirm the grid narrows correctly.
-
Step 4: Clean up any throwaway verification data
git status — confirm only intentional changes remain staged/modified (real candidate additions
you want to keep vs. throwaway test uploads from Step 3). git checkout -- <file> or rm any
throwaway candidate files/manifest entries added purely for this verification pass.
- Step 5: Final commit (if Step 4 left anything real to keep)
git add -Agit commit -m "chore(sound-map): manual verification pass, tool ready for use"If Step 4 left nothing to commit (all verification data was cleaned up), skip this step — the tool is done as of Task 14’s commit.