Skip to content

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).

  • 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 as tools/ship_preview/tools/ui_sweep).
  • server.py only ever writes to: audio/*.wav, audio/candidates/, audio/sound_map.json, and (wire-new mode only) the SOUND_FOR_FX/SOUND_FOR_NAMED_REACTION dict literals and the keys array inside _ready() in audio/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_shared events (GENERIC because the emission site shares an fx_event kind, not because no one picked a better sound) never get an enabled “Assign” control — the UI explains why and points at gameplay-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 from schema.js), used by every later JS task and by the Task 2 GUT test’s counterpart check.

  • Produces: audio/sound_map.json with the shape defined in the design spec’s Data Model section (schemaVersion, events[] with id/category/label/thumbnail/trigger/sound_key/status/ sound_type/shared_with/candidates[]/assigned_candidate).

  • Step 1: Write the failing schema test

tools/sound_map/tests/schema.test.js
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
tools/sound_map/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.json v1

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_event kind string or named-reaction string from SOUND_MAP.md’s “Fire event”/“Trigger” column (e.g. "bolt", "BLINK"). For UI/Music rows with no fx_event (they hook in via direct audio.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/.mp3 stripped, or null for SILENT rows.
  • status: done → "done", GENERIC → "generic" (unless it’s a source-shared case — see Task 12, which reclassifies specific rows to flagged_source_shared after this initial import), SILENT → "silent", MISWIRED/DOUBLE → "generic" (none exist as of this writing per SOUND_MAP.md’s snapshot line — 0 MISWIRED, 0 DOUBLE).
  • sound_type: copy verbatim from SOUND_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: null for now — Task 12 fills this in for the specific rows that need it.
  • candidates: [], assigned_candidate: null for 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
Terminal window
git add audio/sound_map.json tools/sound_map/package.json tools/sound_map/src/schema.js tools/sound_map/tests/schema.test.js
git 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

tests/test_sound_map_manifest.gd
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
Terminal window
git add tests/test_sound_map_manifest.gd
git 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 _WeaponIcon class, use WeaponIcon instead)
  • Test: tests/test_weapon_icon.gd

Interfaces:

  • Produces: class_name WeaponIcon extends Control with a settable kind: String property and the same _draw() behavior as the old _WeaponIcon — consumed by Task 4’s bake script and by weapon_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
tests/test_weapon_icon.gd
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 WeaponIcon
extends 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
Terminal window
git add ui/weapon_icon.gd ui/weapon_panel.gd tests/test_weapon_icon.gd
git commit -m "refactor(weapon-panel): extract WeaponIcon to a public class for reuse by the sound-map bake"

Files:

  • Create: tools/sound_map_bake/bake.gd
  • Create: tools/sound_map_bake/bake.tscn (a Node2D root running bake.gd, matching tools/ship_preview/preview.tscn’s structure)

Interfaces:

  • Consumes: WeaponIcon (Task 3), UpgradeSystem.grant_weapon(sim, pilot, wid) and UpgradeSystem.active_weapon_views(sim, pilot) -> Array (existing, sim/upgrade_system.gd), ContentLoader.load_from_path(path) -> ContentDB (existing, used the same way in tools/funzo_lab/preview.gd).

  • Produces: tools/sound_map/thumbnails/weapon.<wid>.<trigger>.png for every weapon in data/bible.json’s weapons category.

  • Step 1: Write bake.gd’s weapon-category function

tools/sound_map_bake/bake.gd
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.godot corruption

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
Terminal window
git add tools/sound_map_bake/bake.gd tools/sound_map_bake/bake.tscn
git 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>.png for every non-boss TYPE_*, and tools/sound_map/thumbnails/boss.<type_name>.png for 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.godot corruption

Run: git diff project.godot — expect no output (same guard as Task 4 Step 4).

  • Step 5: Commit
Terminal window
git add tools/sound_map_bake/bake.gd
git 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: .gitignore entry for tools/sound_map/thumbnails/

Interfaces:

  • Consumes: tools/ship_preview/out/ship_current.png (existing bake output, if present — falls back to a placeholder if ship_preview hasn’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.godot corruption

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
Terminal window
git add tools/sound_map_bake/bake.gd .gitignore
git 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’s schema.js).

  • Produces: groupByCategory(manifest) -> Map<string, Event[]> (exported from manifest.js), consumed by Task 8’s detail view and Task 13’s filter bar.

  • Step 1: Write the failing test

tools/sound_map/tests/manifest.test.js
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
tools/sound_map/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
tools/sound_map/index.html
<!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>
tools/sound_map/css/style.css
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; }
tools/sound_map/src/views/grid.js
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);
}
}
}
tools/sound_map/src/app.js
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
Terminal window
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.js
git 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) where callbacks = {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)

tools/sound_map/tests/detail.test.js
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
tools/sound_map/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
Terminal window
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.js
git 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 (wire onAddCandidate to a real POST)
  • Modify: tools/sound_map/package.json (serve script already points at server.py from 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’s candidates[] in audio/sound_map.json and, if a file was sent, saves it to audio/candidates/<event-id>/<candidate-id>.<ext>.

  • Step 1: Write the failing test

tools/sound_map/tests/server.test.py
import json
import os
import shutil
import tempfile
import threading
import unittest
import urllib.request
import sys
sys.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.py
Serves 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 named
dict literals + the _ready() keys array in audio/audio_manager.gd. Never commits.
"""
import cgi
import json
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
from socketserver import ThreadingMixIn
from 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’s onAddCandidate to the real endpoint
// tools/sound_map/src/app.js — replace the onAddCandidate stub
onAddCandidate: 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
Terminal window
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__.py
git 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 (wire onAssign)
  • Modify: tools/sound_map/tests/server.test.py

Interfaces:

  • Produces: POST /api/assign/<event-id> with JSON body {candidate_id: string} → for events with status done/generic, overwrites audio/<sound_key>.wav with the candidate’s file content and sets assigned_candidate in 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 top
import 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’s onAssign for replace-mode events
// tools/sound_map/src/app.js — replace the onAssign stub
onAssign: 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
Terminal window
git add tools/sound_map/server.py tools/sound_map/src/app.js tools/sound_map/tests/server.test.py
git 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) — for silent-status events, inserts a new key into audio_manager.gd’s SOUND_FOR_FX or SOUND_FOR_NAMED_REACTION dict (chosen by whether event['trigger'] is lowercase or ALL-CAPS, per the design spec) and into the keys := [...] 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_new in server.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:

  1. cp audio/audio_manager.gd /tmp/audio_manager.gd.bak
  2. Run the server, pick a real silent weapon event (e.g. Chalice), add a candidate with a throwaway local audio file, click Assign.
  3. git diff audio/audio_manager.gd — confirm the new dict entry and _ready() keys-array entry both landed cleanly, no other lines touched.
  4. Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_audio_manager -gexit — expect PASS (the existing audio-manager test suite still holds).
  5. git checkout -- audio/audio_manager.gd audio/sound_map.json afterward to discard this verification run’s changes (it was a throwaway file, not a real sourced sound).
  • Step 6: Commit
Terminal window
git add tools/sound_map/server.py tools/sound_map/tests/server.test.py
git commit -m "feat(sound-map): assign endpoint, wire-new mode for silent events"

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_shared by 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" with weapon.blade.slash (weapon_scythe.gd:49 emits the same kind as weapon_melee.gd:58). shared_with: "weapon.blade.slash".
  • enemy.lancer.beam_fire — shares "beam" with weapon.beam.bolt and boss.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’ status and shared_with fields in audio/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
Terminal window
git add audio/sound_map.json
git commit -m "feat(sound-map): flag GENERIC rows that share an fx_event kind at the source"

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 by app.js’s render loop.

  • Step 1: Write the failing test

tools/sound_map/tests/filterbar.test.js
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
tools/sound_map/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 state
import { 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)) call

Add 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
Terminal window
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.js
git commit -m "feat(sound-map): filter/sort bar (status, category, has-candidates)"

Files:

  • Modify: audio/SOUND_MAP.md (replace content with a pointer)
  • Modify: docs/architecture/art-asset-pipeline.md (add tools/sound_map_bake to the tool-stack table, if referenced there — check for any SOUND_MAP.md cross-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 source
of 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.md as 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
Terminal window
git add audio/SOUND_MAP.md
git commit -m "docs(sound-map): retire SOUND_MAP.md in favour of audio/sound_map.json + the tool"

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 done weapon 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 silent weapon event (e.g. Chalice), add a candidate, assign it, confirm audio/audio_manager.gd picked up the new mapping (git diff audio/audio_manager.gd) and the event’s status flipped to done in 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)
Terminal window
git add -A
git 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.