Skip to content

Debug Settings Panel 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: Add a dev-only pause-menu screen with individual on/off toggles for 8 graphics effects and 2 audio buses (Music, SFX), so a specific effect/sound can be isolated during performance investigation without writing one-off code each time.

Architecture: A new DebugSettingsPanel CanvasLayer, reached via a dev-gated button on the existing PauseMenu, drives already-existing null-safe knobs on the persistent QualityManager instance (bypassing its own adaptive tier system once touched) and mutes the existing "Music"/"SFX" audio buses directly. No new render-side systems — every toggle is a direct call into plumbing QualityManager’s own tier system (_apply()) already proves safe.

Tech Stack: Godot 4.6 / GDScript, GUT 9.6.0 (headless test runner).

  • Dev-only: gated by BuildConfig.dev_tools() (OS.has_feature("editor")) — must never be reachable in an exported build (device dev-install or App Store).
  • No persistence — every toggle resets to default (everything on, adaptive quality enabled) on next process launch.
  • No new render-side systems — every toggle calls an existing, already-null-safe knob on QualityManager’s bound nodes, or mutes an existing audio bus. Do not modify render/quality_manager.gd’s own _apply()/tier logic, audio/audio_manager.gd, or audio/bus_layout.tres.
  • tvOS-safe nav: any new UI overlay must use this project’s explicit debounced dpad + JOY_BUTTON_A-confirm pattern (see ui/pause_menu.gd’s _move/_input) — do not rely on plain default Godot Button/CheckBox focus behavior alone.
  • Determinism baseline must stay unchanged — this work is entirely render/audio-side and never touches /sim. Re-verify tests/test_determinism_checksum.gd after every task as a sanity check (expected: no change, since nothing here binds to Sim).
  • Bump Sim_Const.BUILD only when actually deploying to a device — not required for this dev-only feature unless Chris asks for a device build at the end.

Task 1: Add “Debug Settings” button + signal to PauseMenu

Section titled “Task 1: Add “Debug Settings” button + signal to PauseMenu”

Files:

  • Modify: ui/pause_menu.gd
  • Modify: tests/test_pause_menu.gd

Interfaces:

  • Produces: signal debug_settings_requested on PauseMenu, emitted when the new “Debug Settings” button is pressed. Task 4 connects to this signal in main.gd.

  • Step 1: Write the failing test

Add this test to tests/test_pause_menu.gd (the file currently has one test, test_shop_button_emits_shop_requested — add this as a second test in the same file):

func test_debug_settings_button_emits_debug_settings_requested() -> void:
var p := PauseMenu.new(); add_child_autofree(p); await get_tree().process_frame
watch_signals(p)
var found := false
for b in p._buttons:
if b.text == "Debug Settings":
b.emit_signal("pressed"); found = true; break
assert_true(found, "a Debug Settings button exists (dev build)")
assert_signal_emitted(p, "debug_settings_requested")
  • Step 2: Run test to verify it fails

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pause_menu.gd -gexit

Expected: FAIL — “a Debug Settings button exists (dev build)” (no such button yet), or a parse error if debug_settings_requested isn’t declared yet (either failure is fine here).

  • Step 3: Add the signal + button

In ui/pause_menu.gd, add the new signal alongside the existing ones (near line 12):

signal resume_requested
signal arm_remote_requested
signal debug_settings_requested
signal menu_requested
signal shop_requested
signal bestiary_requested
signal bay_requested
signal ship_config_requested

Then add the button right after the existing “Arm Remote Control” button (which sits inside the if BuildConfig.dev_tools(): block around line 82-85). The new button goes in the SAME dev-gated block, right after _arm_btn:

# Arm Remote Control — DEV builds only. (Drone Bay removed: open it by long-holding the drone button.)
if BuildConfig.dev_tools():
_arm_btn = _make_btn("Arm Remote Control", func() -> void: arm_remote_requested.emit(), Color(0.65, 0.65, 0.72))
box.add_child(_arm_btn)
box.add_child(_make_btn("Debug Settings", func() -> void: debug_settings_requested.emit(), Color(0.65, 0.65, 0.72)))
  • Step 4: Run test to verify it passes

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pause_menu.gd -gexit

Expected: PASS, 2/2 tests green.

  • Step 5: Full suite + boot check
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: suite all-green (one more test than before), boot check empty output.

  • Step 6: Commit
Terminal window
git add ui/pause_menu.gd tests/test_pause_menu.gd
git commit -m "feat(ui): add dev-only Debug Settings button to pause menu"

Task 2: DebugSettingsPanel skeleton — audio toggles + tvOS-safe nav

Section titled “Task 2: DebugSettingsPanel skeleton — audio toggles + tvOS-safe nav”

Files:

  • Create: ui/debug_settings_panel.gd
  • Create: tests/test_debug_settings_panel.gd

Interfaces:

  • Consumes: nothing from Task 1 directly (this task builds the panel in isolation; Task 4 wires PauseMenu.debug_settings_requested to it).
  • Produces:
    • class_name DebugSettingsPanel extends CanvasLayer
    • signal closed
    • func open_panel(qm: QualityManager) -> void — shows the panel, builds its row list, grabs focus on the first row. Task 3 extends the row-building to add graphics rows; Task 4 calls this from main.gd.
    • var _rows: Array — internal list of row dictionaries (see Step 3), each with keys label: String, get_state: Callable, set_state: Callable, is_graphics: bool. Task 3 appends graphics rows to this same array.
    • var _buttons: Array[Button] — one Button per row (matches PauseMenu’s own _buttons convention), plus a final “Close” button.

This task builds the panel with ONLY the 2 audio rows (Music, SFX) + Close, to prove the row-model, nav, and toggle mechanism end-to-end against the real AudioServer before Task 3 adds the more involved graphics rows.

  • Step 1: Write the failing tests

Create tests/test_debug_settings_panel.gd:

extends GutTest
# DebugSettingsPanel — dev-only pause-menu screen with individual graphics/audio toggles.
# Audio rows are exercised against the REAL AudioServer (GUT runs inside a real, if silent,
# Godot engine instance, so bus mute state is genuine — no mocking needed). Restores bus
# mute state after each test so a test run never leaves Music/SFX muted for a later test
# or a human playing in the same editor session.
var _music_idx: int
var _sfx_idx: int
var _music_was_muted: bool
var _sfx_was_muted: bool
func before_each() -> void:
_music_idx = AudioServer.get_bus_index("Music")
_sfx_idx = AudioServer.get_bus_index("SFX")
_music_was_muted = AudioServer.is_bus_mute(_music_idx)
_sfx_was_muted = AudioServer.is_bus_mute(_sfx_idx)
func after_each() -> void:
AudioServer.set_bus_mute(_music_idx, _music_was_muted)
AudioServer.set_bus_mute(_sfx_idx, _sfx_was_muted)
func _panel() -> DebugSettingsPanel:
var p := DebugSettingsPanel.new()
add_child_autofree(p)
return p
func test_open_panel_builds_music_and_sfx_rows() -> void:
var p := _panel()
p.open_panel(null)
await get_tree().process_frame
var labels: Array[String] = []
for b in p._buttons:
labels.append(b.text)
assert_true(labels.any(func(t: String) -> bool: return t.begins_with("Music:")), "a Music row exists")
assert_true(labels.any(func(t: String) -> bool: return t.begins_with("SFX:")), "an SFX row exists")
assert_true(labels.has("Close"), "a Close row exists")
func test_toggling_music_row_mutes_the_music_bus() -> void:
AudioServer.set_bus_mute(_music_idx, false) # start unmuted
var p := _panel()
p.open_panel(null)
await get_tree().process_frame
for b in p._buttons:
if b.text.begins_with("Music:"):
b.emit_signal("pressed")
break
assert_true(AudioServer.is_bus_mute(_music_idx), "pressing the Music row mutes the Music bus")
func test_toggling_sfx_row_twice_returns_to_unmuted() -> void:
AudioServer.set_bus_mute(_sfx_idx, false) # start unmuted
var p := _panel()
p.open_panel(null)
await get_tree().process_frame
var sfx_btn: Button = null
for b in p._buttons:
if b.text.begins_with("SFX:"):
sfx_btn = b
break
assert_not_null(sfx_btn, "an SFX row exists")
sfx_btn.emit_signal("pressed")
assert_true(AudioServer.is_bus_mute(_sfx_idx), "first press mutes SFX")
sfx_btn.emit_signal("pressed")
assert_false(AudioServer.is_bus_mute(_sfx_idx), "second press unmutes SFX again")
func test_close_button_emits_closed() -> void:
var p := _panel()
p.open_panel(null)
await get_tree().process_frame
watch_signals(p)
for b in p._buttons:
if b.text == "Close":
b.emit_signal("pressed")
break
assert_signal_emitted(p, "closed")
  • Step 2: Run tests to verify they fail

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexit

Expected: FAIL — DebugSettingsPanel doesn’t exist yet (parse/class-not-found error).

  • Step 3: Implement the panel skeleton

Create ui/debug_settings_panel.gd:

class_name DebugSettingsPanel
extends CanvasLayer
# Dev-only pause-menu screen: individual on/off toggles for graphics effects and audio
# (Music/SFX), so ONE effect or sound category can be isolated during investigation
# (e.g. bisecting a performance hitch) without writing one-off code each time. Gated
# behind BuildConfig.dev_tools() at the call site (PauseMenu's button + main.gd's wiring),
# same as the existing Remote Control tooling. Render-side only (NOT /sim) — determinism
# is unaffected by construction.
#
# Graphics rows (added in a later task) directly call the SAME null-safe knobs
# QualityManager's own adaptive tier system already drives — no new render-side plumbing.
# Touching any graphics row flips QualityManager.auto to false (the same flag its own F4
# override uses to pin a fixed tier), so its own tick()-driven _apply() stops re-asserting
# a bundled tier and fighting these individual toggles. This is a ONE-WAY flip for the rest
# of the session, matching how the F4 override itself behaves.
#
# Audio rows mute the existing "Music"/"SFX" buses directly via AudioServer — no changes
# to audio/audio_manager.gd or the bus layout.
signal closed
const NAV_DEBOUNCE_MS: int = 200
var _buttons: Array[Button] = []
var _rows: Array = [] # each: {label: String, get_state: Callable, set_state: Callable, is_graphics: bool}
var _sel: int = 0
var _last_nav_ms: int = 0
var _box: VBoxContainer
var _qm: QualityManager = null
var _manual_mode: bool = false # true once any graphics row has been touched
func _ready() -> void:
layer = 71 # above PauseMenu (70) — opened from within it
var dim := ColorRect.new()
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.color = Color(0.0, 0.01, 0.03, 0.9)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.theme = NeonTheme.get_theme()
add_child(center)
_box = VBoxContainer.new()
_box.add_theme_constant_override("separation", 10)
_box.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_child(_box)
var title := Label.new()
title.text = "DEBUG SETTINGS"
title.add_theme_font_override("font", NeonTheme.title_font())
title.add_theme_font_size_override("font_size", 40)
title.add_theme_color_override("font_color", NeonTheme.CYAN)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_box.add_child(title)
visible = false
# Shows the panel, (re)builds its row list against the given QualityManager (may be null —
# graphics rows are added in a later task; this skeleton only builds audio rows), and grabs
# focus on the first row. Safe to call repeatedly (e.g. reopening from the pause menu).
func open_panel(qm: QualityManager) -> void:
_qm = qm
visible = true
_build_rows()
_rebuild_buttons()
_sel = 0
_focus_sel()
func _build_rows() -> void:
_rows.clear()
_rows.append(_audio_row("Music", "Music"))
_rows.append(_audio_row("SFX", "SFX"))
func _audio_row(label: String, bus_name: String) -> Dictionary:
var idx := AudioServer.get_bus_index(bus_name)
return {
"label": label,
"get_state": func() -> bool: return not AudioServer.is_bus_mute(idx),
"set_state": func(v: bool) -> void: AudioServer.set_bus_mute(idx, not v),
"is_graphics": false,
}
func _rebuild_buttons() -> void:
for b in _buttons:
if is_instance_valid(b):
b.queue_free()
_buttons.clear()
for i in range(_rows.size()):
var row: Dictionary = _rows[i]
var btn := _make_row_button(row)
_box.add_child(btn)
_buttons.append(btn)
var close_btn := _make_btn("Close", func() -> void: closed.emit())
_box.add_child(close_btn)
_buttons.append(close_btn)
func _make_row_button(row: Dictionary) -> Button:
var b := _make_btn(_row_text(row), func() -> void: _toggle_row(row))
return b
func _row_text(row: Dictionary) -> String:
var on: bool = row["get_state"].call()
return "%s: %s" % [row["label"], "ON" if on else "OFF"]
func _toggle_row(row: Dictionary) -> void:
var on: bool = row["get_state"].call()
row["set_state"].call(not on)
if row["is_graphics"] and not _manual_mode and _qm != null:
_manual_mode = true
_qm.auto = false
_refresh_row_label(row)
func _refresh_row_label(row: Dictionary) -> void:
for i in range(_rows.size()):
if _rows[i] == row:
_buttons[i].text = _row_text(row)
return
func _make_btn(txt: String, cb: Callable, accent: Color = NeonTheme.CYAN) -> Button:
var b := Button.new()
b.text = txt
b.focus_mode = Control.FOCUS_ALL
b.custom_minimum_size = Vector2(360, 48)
b.add_theme_font_override("font", NeonTheme.title_font())
b.add_theme_font_size_override("font_size", 18)
b.add_theme_color_override("font_color", Color(0.95, 0.98, 1.0))
b.pressed.connect(cb)
return b
func _focus_sel() -> void:
if _sel >= 0 and _sel < _buttons.size():
_buttons[_sel].grab_focus.call_deferred()
func _move(d: int) -> void:
if _buttons.is_empty():
return
_sel = (_sel + d + _buttons.size()) % _buttons.size()
_focus_sel()
func _input(event: InputEvent) -> void:
if not visible:
return
var dir := 0
if MenuNav.is_down(event):
dir = 1
elif MenuNav.is_up(event):
dir = -1
if dir != 0:
var now := Time.get_ticks_msec()
if now - _last_nav_ms >= NAV_DEBOUNCE_MS:
_last_nav_ms = now
_move(dir)
get_viewport().set_input_as_handled()
return
var confirm := false
if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_A:
confirm = true
elif event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER):
confirm = true
if confirm and _sel >= 0 and _sel < _buttons.size():
_buttons[_sel].emit_signal("pressed")
get_viewport().set_input_as_handled()
  • Step 4: Run tests to verify they pass

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexit

Expected: PASS, 4/4 tests green.

  • Step 5: Full suite + boot check
Terminal window
godot --headless --path . --import # new class_name in an existing dir — cheap safety net
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: suite all-green (4 more tests than Task 1’s count), boot check empty output.

  • Step 6: Commit
Terminal window
git add ui/debug_settings_panel.gd tests/test_debug_settings_panel.gd
git commit -m "feat(ui): DebugSettingsPanel skeleton with Music/SFX toggles"

Task 3: Add the 8 graphics toggles + manual-override-mode

Section titled “Task 3: Add the 8 graphics toggles + manual-override-mode”

Files:

  • Modify: ui/debug_settings_panel.gd
  • Modify: tests/test_debug_settings_panel.gd

Interfaces:

  • Consumes: _build_rows(), _audio_row(), _rows array, _manual_mode/_qm fields from Task 2 (all in the same file).

  • Produces: static func apply_graphics_toggle(effect: String, enabled: bool, qm: QualityManager) -> void — the pure dispatch table Task 4 does NOT call directly (rows call it internally), but the reviewer/tester exercises directly against stub nodes.

  • Step 1: Write the failing tests

Add these tests to tests/test_debug_settings_panel.gd (append — do not remove the Task 2 tests). These test the pure dispatch function directly against a real QualityManager bound to lightweight stand-ins, matching the stubbing style tests/test_quality_manager.gd already uses (no real render nodes needed — QualityManager’s bound fields are loosely Node-typed except world_env, which needs a real WorldEnvironment).

class _StubHalo extends Node:
var visible_halo := true
func set_halo_visible(v: bool) -> void: visible_halo = v
class _StubFxLayer extends Node:
var enabled := true
class _StubToggleNode extends Node:
var enabled := true
var low_detail := false
func _bound_qm() -> QualityManager:
var qm := QualityManager.new()
autofree(qm)
var world_env := WorldEnvironment.new()
autofree(world_env)
world_env.environment = Environment.new()
var halo := _StubHalo.new()
autofree(halo)
var fx := _StubFxLayer.new()
autofree(fx)
var dmg := _StubToggleNode.new()
autofree(dmg)
var zones := _StubToggleNode.new()
autofree(zones)
var screen := _StubToggleNode.new()
autofree(screen)
var arena := _StubToggleNode.new()
autofree(arena)
var player := _StubToggleNode.new()
autofree(player)
qm.bind(world_env, [halo], fx, dmg, zones, screen, arena)
qm.player_visual = player
return qm
func test_apply_graphics_toggle_bloom() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("bloom", false, qm)
assert_false(qm.world_env.environment.glow_enabled, "bloom off disables glow")
DebugSettingsPanel.apply_graphics_toggle("bloom", true, qm)
assert_true(qm.world_env.environment.glow_enabled, "bloom on re-enables glow")
func test_apply_graphics_toggle_halos() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("halos", false, qm)
assert_false((qm.halo_targets[0] as _StubHalo).visible_halo, "halos off calls set_halo_visible(false)")
func test_apply_graphics_toggle_grid_is_inverted() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("grid", false, qm)
assert_true((qm.arena_bg as _StubToggleNode).low_detail, "grid OFF means low_detail true")
DebugSettingsPanel.apply_graphics_toggle("grid", true, qm)
assert_false((qm.arena_bg as _StubToggleNode).low_detail, "grid ON means low_detail false")
func test_apply_graphics_toggle_damage_numbers() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("damage_numbers", false, qm)
assert_false((qm.damage_numbers as _StubToggleNode).enabled)
func test_apply_graphics_toggle_screen_fx() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("screen_fx", false, qm)
assert_false((qm.screen_fx as _StubToggleNode).enabled)
func test_apply_graphics_toggle_zones_is_inverted() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("zones", false, qm)
assert_true((qm.zone_renderer as _StubToggleNode).low_detail, "zones OFF means low_detail true")
func test_apply_graphics_toggle_juice_fx() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("juice_fx", false, qm)
assert_false((qm.fx_layer as _StubFxLayer).enabled)
func test_apply_graphics_toggle_player_detail_is_inverted() -> void:
var qm := _bound_qm()
DebugSettingsPanel.apply_graphics_toggle("player_detail", false, qm)
assert_true((qm.player_visual as _StubToggleNode).low_detail, "player detail OFF means low_detail true")
func test_apply_graphics_toggle_is_null_safe() -> void:
var qm := QualityManager.new()
autofree(qm)
# Nothing bound — every call must no-op, not crash.
for effect in ["bloom", "halos", "grid", "damage_numbers", "screen_fx", "zones", "juice_fx", "player_detail"]:
DebugSettingsPanel.apply_graphics_toggle(effect, true, qm)
DebugSettingsPanel.apply_graphics_toggle(effect, false, qm)
func test_toggling_halos_row_twice_returns_to_on() -> void:
# Regression test: halo_targets/arena_bg only expose WRITE-only setter methods
# (set_halo_visible/set_low) in production, no readable property. A row whose
# get_state tries to read those back would silently read wrong and get stuck
# only ever turning back ON. Rows must track their own state locally instead.
var qm := _bound_qm()
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
var halos_btn: Button = null
for b in p._buttons:
if b.text.begins_with("Halos:"):
halos_btn = b
break
assert_not_null(halos_btn)
assert_eq(halos_btn.text, "Halos: ON", "starts ON")
halos_btn.emit_signal("pressed")
assert_eq(halos_btn.text, "Halos: OFF", "first press turns off")
assert_false((qm.halo_targets[0] as _StubHalo).visible_halo)
halos_btn.emit_signal("pressed")
assert_eq(halos_btn.text, "Halos: ON", "second press turns back on — NOT stuck")
assert_true((qm.halo_targets[0] as _StubHalo).visible_halo)
func test_reopening_panel_preserves_graphics_state() -> void:
var qm := _bound_qm()
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
for b in p._buttons:
if b.text.begins_with("Bloom:"):
b.emit_signal("pressed") # turn Bloom off
break
# Simulate closing and reopening the panel later in the same session.
p.open_panel(qm)
await get_tree().process_frame
var found_off := false
for b in p._buttons:
if b.text == "Bloom: OFF":
found_off = true
break
assert_true(found_off, "reopening the panel mid-session keeps the last-set state, not a reset")
func test_open_panel_builds_all_eight_graphics_rows() -> void:
var qm := _bound_qm()
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
var labels: Array[String] = []
for b in p._buttons:
labels.append(b.text)
for expected in ["Bloom:", "Halos:", "Arena Grid Detail:", "Damage Numbers:", "Screen FX:", "Zone Detail:", "Juice FX:", "Player Visual Detail:"]:
assert_true(labels.any(func(t: String) -> bool: return t.begins_with(expected)), "a %s row exists" % expected)
func test_touching_a_graphics_row_flips_quality_manager_to_manual() -> void:
var qm := _bound_qm()
assert_true(qm.auto, "starts adaptive")
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
for b in p._buttons:
if b.text.begins_with("Bloom:"):
b.emit_signal("pressed")
break
assert_false(qm.auto, "touching a graphics row flips QualityManager to manual")
func test_manual_mode_is_not_reset_by_a_second_toggle() -> void:
var qm := _bound_qm()
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
for b in p._buttons:
if b.text.begins_with("Bloom:"):
b.emit_signal("pressed")
break
assert_false(qm.auto)
qm.auto = true # simulate something else flipping it back on between toggles
for b in p._buttons:
if b.text.begins_with("Halos:"):
b.emit_signal("pressed")
break
assert_false(qm.auto, "a second graphics toggle re-asserts manual mode")
func test_audio_rows_do_not_flip_quality_manager() -> void:
var qm := _bound_qm()
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
for b in p._buttons:
if b.text.begins_with("Music:"):
b.emit_signal("pressed")
break
assert_true(qm.auto, "toggling an audio row must not touch QualityManager.auto")
AudioServer.set_bus_mute(AudioServer.get_bus_index("Music"), false) # restore for other tests
  • Step 2: Run tests to verify they fail

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexit

Expected: FAIL — apply_graphics_toggle doesn’t exist yet, and only 2 graphics-related rows are missing from _build_rows().

  • Step 3: Implement the graphics toggles

In ui/debug_settings_panel.gd, add the static dispatch function (place it near the top of the class, after the signal/const/var declarations, before _ready()):

# Pure dispatch: toggle-name -> node mutation. Mirrors the SAME knobs QualityManager's own
# _apply() already drives (see render/quality_manager.gd) — no new render-side plumbing.
# Three fields (grid/zones/player_detail) are phrased as "low_detail"/"set_low" in their own
# code, so this function inverts them here — every row in this panel reads naturally as
# "ON = full/normal quality" regardless of how the underlying field is phrased.
static func apply_graphics_toggle(effect: String, enabled: bool, qm: QualityManager) -> void:
match effect:
"bloom":
if is_instance_valid(qm.world_env) and qm.world_env.environment != null:
qm.world_env.environment.glow_enabled = enabled
"halos":
for h in qm.halo_targets:
if is_instance_valid(h) and h.has_method("set_halo_visible"):
h.set_halo_visible(enabled)
"grid":
if is_instance_valid(qm.arena_bg) and qm.arena_bg.has_method("set_low"):
qm.arena_bg.set_low(not enabled)
"damage_numbers":
if is_instance_valid(qm.damage_numbers):
qm.damage_numbers.enabled = enabled
"screen_fx":
if is_instance_valid(qm.screen_fx):
qm.screen_fx.enabled = enabled
"zones":
if is_instance_valid(qm.zone_renderer):
qm.zone_renderer.low_detail = not enabled
"juice_fx":
if is_instance_valid(qm.fx_layer):
qm.fx_layer.enabled = enabled
"player_detail":
if is_instance_valid(qm.player_visual):
qm.player_visual.low_detail = not enabled

Then add a locally-tracked state dictionary — graphics rows do NOT read live state back off render nodes, since halo_targets/arena_bg only expose WRITE-only setter methods (set_halo_visible/set_low) in production, no matching readable property. Tracking state locally on the panel (defaulting to “on,” matching a fresh run’s real state) is robust regardless of what each render node happens to expose, and correctly persists across reopening the panel within the same session. Add this field declaration near _manual_mode:

var _graphics_state: Dictionary = {} # effect name -> bool; persists across reopens this session

Initialize it once, at the end of _ready() (added in Task 2):

for effect in ["bloom", "halos", "grid", "damage_numbers", "screen_fx", "zones", "juice_fx", "player_detail"]:
_graphics_state[effect] = true

Then replace _build_rows() (from Task 2) with this version that also adds the 8 graphics rows when _qm is bound:

func _build_rows() -> void:
_rows.clear()
_rows.append(_audio_row("Music", "Music"))
_rows.append(_audio_row("SFX", "SFX"))
if _qm != null:
_rows.append(_graphics_row("Bloom", "bloom"))
_rows.append(_graphics_row("Halos", "halos"))
_rows.append(_graphics_row("Arena Grid Detail", "grid"))
_rows.append(_graphics_row("Damage Numbers", "damage_numbers"))
_rows.append(_graphics_row("Screen FX", "screen_fx"))
_rows.append(_graphics_row("Zone Detail", "zones"))
_rows.append(_graphics_row("Juice FX", "juice_fx"))
_rows.append(_graphics_row("Player Visual Detail", "player_detail"))
func _graphics_row(label: String, effect: String) -> Dictionary:
return {
"label": label,
"get_state": func() -> bool: return _graphics_state[effect],
"set_state": func(v: bool) -> void:
_graphics_state[effect] = v
apply_graphics_toggle(effect, v, _qm),
"is_graphics": true,
}
  • Step 4: Run tests to verify they pass

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexit

Expected: PASS, all tests green (Task 2’s 4 + Task 3’s 15 = 19 total in this file).

  • Step 5: Full suite + determinism + boot check
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: full suite green, determinism baseline UNCHANGED (this task never touches /sim), boot check empty output.

  • Step 6: Commit
Terminal window
git add ui/debug_settings_panel.gd tests/test_debug_settings_panel.gd
git commit -m "feat(ui): add 8 graphics toggles + manual-override-mode to DebugSettingsPanel"

Task 4: Wire into main.gd + full verification

Section titled “Task 4: Wire into main.gd + full verification”

Files:

  • Modify: main.gd

Interfaces:

  • Consumes: PauseMenu.debug_settings_requested (Task 1), DebugSettingsPanel.open_panel(qm) and DebugSettingsPanel.closed (Tasks 2-3), the existing persistent quality_manager field (main.gd:92).

  • Step 1: Add the persistent panel instance

In main.gd, near the existing var ship_config: ShipConfigPanel declaration (line 88), add:

var debug_settings: DebugSettingsPanel # dev-only graphics/audio toggle screen (pause menu)

Then, near the existing ship_config = ShipConfigPanel.new() instantiation (line 192), add:

debug_settings = DebugSettingsPanel.new()
add_child(debug_settings)
  • Step 2: Wire the pause-menu signal

In main.gd, in the pause-menu setup block (where pause_menu.ship_config_requested.connect(...) and pause_menu.bay_requested.connect(...) are wired, around line 308-320), add a new connection following the exact same hide-pause/await-closed/reshow-pause pattern used for ship_config_requested and bestiary_requested:

pause_menu.debug_settings_requested.connect(func() -> void:
if pause_menu != null:
pause_menu.visible = false
debug_settings.open_panel(quality_manager)
await debug_settings.closed
if pause_menu != null:
pause_menu.visible = true
pause_menu.refocus())
  • Step 3: Hide the panel on close

DebugSettingsPanel.closed is emitted by its own “Close” button (Task 2), but the panel itself needs to hide when that fires. In ui/debug_settings_panel.gd, connect the panel’s own signal to itself in _ready() — add this line at the end of _ready():

closed.connect(func() -> void: visible = false)
  • Step 3b: Add the “Adaptive Quality” status label

The design doc promises a status label reflecting _manual_mode (“Adaptive Quality: ON” before any graphics toggle is touched, “Adaptive Quality: OFF (manual)” afterward), but no earlier task actually built it — add it now in ui/debug_settings_panel.gd.

Add a new field declaration near _box:

var _status_label: Label

In _ready(), right after the existing title Label is added to _box (after the line _box.add_child(title)), add:

_status_label = Label.new()
_status_label.add_theme_font_override("font", NeonTheme.mono_font())
_status_label.add_theme_font_size_override("font_size", 16)
_status_label.add_theme_color_override("font_color", Color(0.6, 0.9, 1.0))
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status_label.text = "Adaptive Quality: ON"
_box.add_child(_status_label)

Then update _toggle_row to refresh this label whenever it flips _manual_mode — replace the existing body with:

func _toggle_row(row: Dictionary) -> void:
var on: bool = row["get_state"].call()
row["set_state"].call(not on)
if row["is_graphics"] and _qm != null:
_manual_mode = true
_qm.auto = false
if _status_label != null:
_status_label.text = "Adaptive Quality: OFF (manual)"
_refresh_row_label(row)

Add this test to tests/test_debug_settings_panel.gd (append):

func test_status_label_reflects_manual_mode() -> void:
var qm := _bound_qm()
var p := DebugSettingsPanel.new()
add_child_autofree(p)
p.open_panel(qm)
await get_tree().process_frame
assert_eq(p._status_label.text, "Adaptive Quality: ON", "starts adaptive")
for b in p._buttons:
if b.text.begins_with("Bloom:"):
b.emit_signal("pressed")
break
assert_eq(p._status_label.text, "Adaptive Quality: OFF (manual)", "flips after a graphics touch")

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexit

Expected: PASS, one more test than before (20 total in this file).

  • Step 4: Full suite + determinism + boot check
Terminal window
bash scripts/check-test-count.sh
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexit
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"

Expected: count guard passes (script count matches test_*.gd file count), both determinism baselines UNCHANGED from before this plan started, boot check empty output.

  • Step 5: Manual editor playtest (not headless-verifiable)

Open the project in the Godot editor (godot --path . or press F5), start a Survival run, press Esc or the controller Menu/Start button to open the pause menu, and confirm:

  • A “Debug Settings” button appears below “Arm Remote Control” (editor build = dev build).
  • Pressing it opens the new panel with Music/SFX + all 8 graphics rows, each showing “ON” initially, and a status line reading “Adaptive Quality: ON”.
  • Toggling any graphics row flips the status line to “Adaptive Quality: OFF (manual)”.
  • Toggling “Music” or “SFX” actually silences that category during play.
  • Toggling “Bloom” or “Halos” visibly changes the neon look immediately.
  • Toggling any graphics row makes the F4 override note true — F4 will now fight it — no need to test that explicitly here, just confirm the panel’s own toggles visibly apply.
  • “Close” returns to the pause menu, and “Resume” returns to the game.

Report back what you saw before considering this task done — this is real render behavior that a headless test cannot confirm.

  • Step 6: Commit
Terminal window
git add main.gd ui/debug_settings_panel.gd
git commit -m "feat(ui): wire DebugSettingsPanel into the pause menu"

Self-Review Notes (for the plan author, already applied above)

Section titled “Self-Review Notes (for the plan author, already applied above)”
  • Spec coverage: every design-doc item has a task — reachability + gating (Task 1), audio toggles (Task 2), all 8 graphics toggles + manual-override-mode + the documented F4 interaction (Task 3, called out in Global Constraints rather than a task since it’s an accepted no-op), no persistence (implicit — no task writes to MetaState/MetaStore), testing approach (stub-node pattern used throughout Task 3).
  • Placeholder scan: none found — every step has complete, runnable code.
  • Type consistency: open_panel(qm: QualityManager), apply_graphics_toggle(effect: String, enabled: bool, qm: QualityManager), signal closed, and the _rows/_buttons field names are used identically across Tasks 2-4.
  • Real bug caught during self-review, fixed inline: the first draft of Task 3 had graphics rows read their displayed ON/OFF state back from the render nodes themselves (e.g. checking a visible_halo/low_detail property on halo_targets/arena_bg). In production, halo_targets/arena_bg only expose WRITE-only setter methods (set_halo_visible/set_low — confirmed via render/quality_manager.gd’s own _apply(), which only ever calls has_method(...) before invoking, never reads a property back), so that readback would have silently returned the wrong value — every toggle press would have recomputed the SAME stale “off” reading and kept calling set_state(true), so the Halos (and likely Arena Grid Detail) row would get stuck only ever turning back ON, never OFF. Fixed by tracking each graphics row’s state locally on the panel (_graphics_state dictionary, seeded to “on” and never reset by _build_rows()), so display and toggling never depend on reading anything back from the render node — only writing to it. Two regression tests added in Task 3 to prove this: test_toggling_halos_row_twice_returns_to_on and test_reopening_panel_preserves_graphics_state.