Debug Settings Panel Implementation Plan
Debug Settings Panel Implementation Plan
Section titled “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).
Global Constraints
Section titled “Global Constraints”- 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 modifyrender/quality_manager.gd’s own_apply()/tier logic,audio/audio_manager.gd, oraudio/bus_layout.tres. - tvOS-safe nav: any new UI overlay must use this project’s explicit debounced dpad +
JOY_BUTTON_A-confirm pattern (seeui/pause_menu.gd’s_move/_input) — do not rely on plain default GodotButton/CheckBoxfocus behavior alone. - Determinism baseline must stay unchanged — this work is entirely render/audio-side and
never touches
/sim. Re-verifytests/test_determinism_checksum.gdafter every task as a sanity check (expected: no change, since nothing here binds toSim). - Bump
Sim_Const.BUILDonly 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_requestedonPauseMenu, emitted when the new “Debug Settings” button is pressed. Task 4 connects to this signal inmain.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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pause_menu.gd -gexitExpected: 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_requestedsignal arm_remote_requestedsignal debug_settings_requestedsignal menu_requestedsignal shop_requestedsignal bestiary_requestedsignal bay_requestedsignal ship_config_requestedThen 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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_pause_menu.gd -gexitExpected: PASS, 2/2 tests green.
- Step 5: Full suite + boot check
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --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
git add ui/pause_menu.gd tests/test_pause_menu.gdgit 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_requestedto it). - Produces:
class_name DebugSettingsPanel extends CanvasLayersignal closedfunc 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 frommain.gd.var _rows: Array— internal list of row dictionaries (see Step 3), each with keyslabel: 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 (matchesPauseMenu’s own_buttonsconvention), 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: intvar _sfx_idx: intvar _music_was_muted: boolvar _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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexitExpected: 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 DebugSettingsPanelextends 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 = 0var _last_nav_ms: int = 0var _box: VBoxContainervar _qm: QualityManager = nullvar _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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexitExpected: PASS, 4/4 tests green.
- Step 5: Full suite + boot check
godot --headless --path . --import # new class_name in an existing dir — cheap safety netgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --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
git add ui/debug_settings_panel.gd tests/test_debug_settings_panel.gdgit 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(),_rowsarray,_manual_mode/_qmfields 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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexitExpected: 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 enabledThen 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 sessionInitialize 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] = trueThen 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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexitExpected: PASS, all tests green (Task 2’s 4 + Task 3’s 15 = 19 total in this file).
- Step 5: Full suite + determinism + boot check
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --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
git add ui/debug_settings_panel.gd tests/test_debug_settings_panel.gdgit 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)andDebugSettingsPanel.closed(Tasks 2-3), the existing persistentquality_managerfield (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: LabelIn _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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_debug_settings_panel.gd -gexitExpected: PASS, one more test than before (20 total in this file).
- Step 4: Full suite + determinism + boot check
bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_crystals.gd -gexitgodot --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
git add main.gd ui/debug_settings_panel.gdgit 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/_buttonsfield 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_detailproperty onhalo_targets/arena_bg). In production,halo_targets/arena_bgonly expose WRITE-only setter methods (set_halo_visible/set_low— confirmed viarender/quality_manager.gd’s own_apply(), which only ever callshas_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 callingset_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_statedictionary, 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_onandtest_reopening_panel_preserves_graphics_state.