Dev voice feedback (R2) implementation plan
Dev voice feedback (R2) Implementation Plan
Section titled “Dev voice feedback (R2) 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: In Dev builds, R2 on a controller opens the feedback panel with the text box focused and the system keyboard up, so a tester can dictate a note (iOS now; tvOS once the fork gains a keyboard).
Architecture: InputRouter edge-detects the right trigger (dev-gated) → main.gd routes it into the existing touch “feedback direct” path with a voice flag → PauseMenu opens FeedbackPanel in voice mode (kind OTHER, text box focused) → the note goes out through the unchanged GameplayTelemetry.send_feedback with context.voice = true. On tvOS, a fork change makes virtual_keyboard_show present a native UIAlertController text field and advertises OS.has_feature("tvos_text_input"), which un-hides the panel’s text box.
Tech Stack: Godot 4.7 typed GDScript + GUT; the ~/Claude/godot-tvos engine fork (Objective-C++).
Spec: docs/superpowers/specs/2026-10-06-dev-voice-feedback-design.md
Global Constraints
Section titled “Global Constraints”- R2 is Dev-only: gated on
BuildConfig.dev_tools(). Store builds must be unaffected. - R2 =
JOY_AXIS_TRIGGER_RIGHT(an axis, not a button). R1 (warp) / L1 (decoy) unchanged. - Reuse the existing feedback path; no new route, no new D1 column. The marker rides in
context(stored asfeedback.context_jsonbytelemetry/src/worker.jsfeedbackRow, which keeps the wholecontextobject — verified 2026-10-06, so no Worker change). - Do not change the
PauseMenu.feedback_submitted(kind, note)orFeedbackPanel.submitted(kind, note)signal signatures (tests/test_diagnostics_toggle.gd:245emits the 2-arg form). /simuntouched; determinism baselines must not move.- Work in a worktree (
.claude/worktrees/voice-feedback, branch offorigin/main), per CLAUDE.md. - Test runs:
scripts/check-test-count.sh(never a raw GUT run into context); a single file:godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit 2>&1 | tail -20. Rungodot --headless --path . --importfirst in a fresh worktree. - Any new
docs/**/*.mdneeds Starlighttitle:frontmatter.
Task 1: InputRouter R2 trigger edge (dev-gated)
Section titled “Task 1: InputRouter R2 trigger edge (dev-gated)”Files:
- Modify:
input/input_router.gd(add after_all_joypad_button_down, ~line 169) - Test:
tests/test_input_router.gd(append)
Interfaces:
-
Produces:
static func trigger_edge(value: float, was_held: bool) -> Dictionaryreturning{"fire": bool, "held": bool};var feedback_trigger_enabled: bool(defaults toBuildConfig.dev_tools());func poll_feedback_trigger() -> bool(true once per R2 press on any playable pad). -
Step 1: Write the failing tests (append to
tests/test_input_router.gd)
# Dev voice feedback (2026-10-06): R2 is an analog trigger; fire once per squeeze, with hysteresis.func test_trigger_edge_fires_once_per_press() -> void: var e := InputRouter.trigger_edge(0.9, false) assert_true(bool(e["fire"]), "squeeze past 0.5 fires") assert_true(bool(e["held"])) e = InputRouter.trigger_edge(0.9, true) assert_false(bool(e["fire"]), "still held: no repeat") e = InputRouter.trigger_edge(0.4, true) assert_false(bool(e["fire"])) assert_true(bool(e["held"]), "between 0.3 and 0.5 stays held (hysteresis)") e = InputRouter.trigger_edge(0.1, true) assert_false(bool(e["held"]), "below 0.3 re-arms") e = InputRouter.trigger_edge(0.6, false) assert_true(bool(e["fire"]), "re-armed: fires again")
func test_trigger_edge_ignores_a_light_touch() -> void: var e := InputRouter.trigger_edge(0.4, false) assert_false(bool(e["fire"])) assert_false(bool(e["held"]))
func test_feedback_trigger_off_when_not_dev() -> void: var r := InputRouter.new() r.feedback_trigger_enabled = false assert_false(r.poll_feedback_trigger(), "store build: R2 never opens feedback") r.free()-
Step 2: Run, verify FAIL —
godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_input_router.gd -gexit 2>&1 | tail -20. Expected: parse/“nonexistent function trigger_edge” failures. -
Step 3: Implement (in
input/input_router.gd, after_all_joypad_button_down)
# Dev voice feedback (2026-10-06, spec docs/superpowers/specs/2026-10-06-dev-voice-feedback-design.md):# R2 (the right TRIGGER, an axis) opens the feedback panel ready for dictation. Dev builds only.# Pure edge with hysteresis: fire on crossing 0.5, re-arm only below 0.3, so a trigger resting# half-pressed can't machine-gun the panel.const TRIGGER_PRESS := 0.5const TRIGGER_RELEASE := 0.3static func trigger_edge(value: float, was_held: bool) -> Dictionary: if was_held: return {"fire": false, "held": value >= TRIGGER_RELEASE} var pressed := value >= TRIGGER_PRESS return {"fire": pressed, "held": pressed}
var feedback_trigger_enabled: bool = BuildConfig.dev_tools()var _r2_held := false
# Call every physics frame (even while frozen) so the edge state stays current.func poll_feedback_trigger() -> bool: if not feedback_trigger_enabled: _r2_held = false return false var v := 0.0 for dev in playable_joypads(): v = maxf(v, Input.get_joy_axis(dev, JOY_AXIS_TRIGGER_RIGHT)) var e := trigger_edge(v, _r2_held) _r2_held = bool(e["held"]) return bool(e["fire"])-
Step 4: Run, verify PASS (same command).
-
Step 5: Commit
git add input/input_router.gd tests/test_input_router.gdgit commit -m "feat(input): dev-only R2 trigger edge for voice feedback"Task 2: FeedbackPanel voice mode + tvOS text-entry gate
Section titled “Task 2: FeedbackPanel voice mode + tvOS text-entry gate”Files:
- Modify:
ui/feedback_panel.gd(text_entry_available~line 40;_readytail ~line 152; header comment lines 7-8) - Test:
tests/test_feedback_panel.gd(append)
Interfaces:
-
Produces:
var voice := falseonFeedbackPanel(set BEFOREadd_child);static func _text_entry_for(os_name: String, tvos_text_input: bool) -> bool;text_entry_available()keeps its name/signature. -
Step 1: Write the failing tests (append)
# Dev voice feedback (2026-10-06): tvOS gets the text box only when the engine fork has a keyboard.func test_text_entry_gate_follows_tvos_keyboard_feature() -> void: assert_true(FeedbackPanel._text_entry_for("iOS", false)) assert_true(FeedbackPanel._text_entry_for("macOS", false)) assert_false(FeedbackPanel._text_entry_for("tvOS", false), "old template: quick notes only") assert_true(FeedbackPanel._text_entry_for("tvOS", true), "fork keyboard present")
func test_voice_opens_on_other_with_the_box_focused() -> void: var p := FeedbackPanel.new() p.voice = true add_child_autofree(p) if p._line == null: pass_test("no text entry on this platform") return assert_eq(p.current_kind(), "other") assert_eq(p._rows[p._sel], p._line, "nav selection is the text box") assert_true(p._line.has_focus(), "focused, so the system keyboard comes up")
func test_voice_off_keeps_the_old_opening() -> void: var p := FeedbackPanel.new() add_child_autofree(p) assert_eq(p.current_kind(), "bug") assert_eq(p._sel, 0)-
Step 2: Run, verify FAIL (
-gtest=res://tests/test_feedback_panel.gd). -
Step 3: Implement
Replace text_entry_available:
static func text_entry_available() -> bool: return _text_entry_for(OS.get_name(), OS.has_feature("tvos_text_input"))
# Pure core. tvOS had no keyboard at all until the godot-tvos fork's UIAlertController keyboard# (2026-10-06), which advertises the "tvos_text_input" feature; an older template keeps quick notes.static func _text_entry_for(os_name: String, tvos_text_input: bool) -> bool: return os_name != "tvOS" or tvos_text_inputAdd the var next to var audio: AudioManager:
var voice := false # Dev R2 (2026-10-06): open on OTHER with the text box focused, ready to dictateReplace the last three lines of _ready() (_set_kind(0) / _sel = 0 / _focus()) with:
if voice and _line != null: _set_kind(KINDS.find("other")) _sel = _rows.find(_line) _line.grab_focus() # _focus() deliberately skips the box; here we WANT the keyboard up _line.set_caret_line(_line.get_line_count() - 1) _line.set_caret_column(_line.get_line(_line.get_line_count() - 1).length()) else: _set_kind(0) _sel = 0 _focus()Update the header comment line “The typed line is hidden on tvOS, where a controller has no sensible text entry” to: “The typed line is hidden on tvOS unless the engine fork provides a keyboard (feature tvos_text_input, 2026-10-06)”.
-
Step 4: Run, verify PASS (same file).
-
Step 5: Commit
git add ui/feedback_panel.gd tests/test_feedback_panel.gdgit commit -m "feat(feedback): voice mode + tvOS keyboard feature gate"Task 3: Wire R2 → pause → voice panel → send with context.voice
Section titled “Task 3: Wire R2 → pause → voice panel → send with context.voice”Files:
- Modify:
net/gameplay_telemetry.gd:72-91(feedback_body,send_feedback) - Modify:
ui/pause_menu.gd:266-271(open_feedback_then_resume),:326-339(_open_feedback), plus a newvar feedback_voice - Modify:
main.gd:585-586(send closure),main.gd_physics_process(~1527-1538),main.gd:2678-2683(_open_feedback_direct) - Modify:
control/src/whats_new.js(unbuilt entry) - Test:
tests/test_telemetry_reporters_queue.gd,tests/test_feedback_panel.gd(append)
Interfaces:
-
Consumes:
InputRouter.poll_feedback_trigger() -> bool(Task 1);FeedbackPanel.voice(Task 2). -
Produces:
GameplayTelemetry.feedback_body(kind, note, session, device, sim, input_style, voice := false);send_feedback(kind, note, sim, input_style, voice := false);PauseMenu.open_feedback_then_resume(voice := false);PauseMenu.feedback_voice: bool;main._open_feedback_direct(voice := false). -
Step 1: Write the failing tests
Append to tests/test_feedback_panel.gd:
func test_feedback_body_marks_voice_in_context() -> void: var b := GameplayTelemetry.feedback_body("other", "boss two is brutal", "", "", null, "pad", true) assert_true(bool(b["context"]["voice"])) var plain := GameplayTelemetry.feedback_body("bug", "it crashed", "", "", null, "pad") assert_false(plain["context"].has("voice"), "typed notes carry no marker")
func test_pause_menu_voice_open_sets_panel_voice() -> void: var m := PauseMenu.new() add_child_autofree(m) m.share_diagnostics = true m.open_feedback_then_resume(true) assert_true(m.feedback_voice) assert_not_null(m._feedback) assert_true(m._feedback.voice)(If PauseMenu.new() needs setup the existing tests/test_diagnostics_toggle.gd does — read it first and mirror its construction exactly.)
-
Step 2: Run, verify FAIL.
-
Step 3: Implement
net/gameplay_telemetry.gd:
static func feedback_body(kind: String, note: String, session: String, device: String, sim: Sim, input_style: String, voice := false) -> Dictionary: var ctx := {"input": input_style} if voice: ctx["voice"] = true # Dev R2 dictation (2026-10-06), so bh-feedback-triage can tell them apart # ... rest unchangedfunc send_feedback(kind: String, note: String, sim: Sim, input_style: String, voice := false) -> void: if not _enabled or _queue == null: return var body := feedback_body(kind, note, "", "", sim, input_style, voice) # ... rest unchangedui/pause_menu.gd — add near var _feedback:
var feedback_voice := false # the open panel came from Dev R2 (main reads it when sending)func open_feedback_then_resume(voice := false) -> void: if not share_diagnostics: _sel = _buttons.find(_diag_btn) _focus_sel() return _open_feedback(true, voice)func _open_feedback(resume_after := false, voice := false) -> void: if _feedback != null and is_instance_valid(_feedback): return if not share_diagnostics: return # confirm / hover emit `pressed` by hand, past `disabled`: nothing to send while off feedback_voice = voice _feedback = FeedbackPanel.new() _feedback.audio = audio _feedback.voice = voice # ... rest unchangedmain.gd send closure (line 585):
pause_menu.feedback_submitted.connect(func(kind: String, note: String) -> void: gameplay_telemetry.send_feedback(kind, note, sim, ButtonPrompt.current_style(), pause_menu.feedback_voice))main.gd _physics_process: poll every frame (edge state stays current while frozen), act only when not frozen. Insert right after the var _frozen := ... statement:
var _r2_feedback := input_router.poll_feedback_trigger() # Dev-only R2 → dictated feedbackand right after if _frozen: return:
if _r2_feedback: _open_feedback_direct(true) returnmain.gd _open_feedback_direct:
func _open_feedback_direct(voice := false) -> void: if _paused_for_menu: return _toggle_pause() if _paused_for_menu and pause_menu != null and is_instance_valid(pause_menu): pause_menu.open_feedback_then_resume(voice)control/src/whats_new.js: add (or append to) the top build: null, date: null, entry, keeping that first line exact:
{ build: null, date: null, title: 'Dev builds: R2 opens feedback ready to dictate', items: [ { text: 'Dev builds only: squeeze R2 (right trigger) on a controller mid-run to pause and open Send feedback with the text box focused. On iPhone tap the keyboard mic to dictate; on Apple TV (once the keyboard-enabled engine ships) hold the Siri Remote mic. The note is tagged as voice in the feedback inbox.', where: 'In a run: R2' }, ], },(Match the item shape of existing entries — check whether where is used; control/test/whats-new.test.mjs pins it.)
-
Step 4: Run tests — the two files, then
node --test control/test/whats-new.test.mjs, thenscripts/check-test-count.sh. All PASS. -
Step 5: Manual check on Mac (editor = dev):
godot --path . --rendering-method mobile, start Survival, squeeze R2 on a connected pad → pause + feedback on OTHER, caret in the box; type, Send, game resumes. Thengit checkout -- project.godot audio/bus_layout.tres. -
Step 6: Gate + commit
scripts/gate.shgit add net/gameplay_telemetry.gd ui/pause_menu.gd main.gd control/src/whats_new.js tests/test_feedback_panel.gdgit commit -m "feat(feedback): Dev R2 opens voice feedback; context.voice marker"Then merge to main (reviewer first, bh-merge-reviewed-branch), push, and do an iPhone Dev install (bh-deploy / scripts/dev/install-dev.sh, BUILD bump per CLAUDE.md, checking live branches for higher BUILDs). On-device check: R2 → keyboard → mic → dictate → Send → row in D1 feedback with "voice":true in context_json (skill bh-telemetry).
Task 4: Engine fork — tvOS virtual keyboard (~/Claude/godot-tvos/godot, branch tvos-render-1080p)
Section titled “Task 4: Engine fork — tvOS virtual keyboard (~/Claude/godot-tvos/godot, branch tvos-render-1080p)”Not in the game repo; no GUT coverage. Local-only branch, never pushed.
Files:
- Modify:
platform/tvos/display_server_tvos.h(public overrides) - Modify:
platform/tvos/display_server_tvos.mm(replace the// TODO: tvOS virtual keyboard support.block, ~line 99) - Modify:
drivers/apple_embedded/os_apple_embedded.mm:750(_check_internal_feature_support)
Interfaces:
-
Produces:
OS.has_feature("tvos_text_input") == trueon tvOS;virtual_keyboard_showpresents a native text prompt; Done injects text throughDisplayServerAppleEmbedded::key. -
Step 1: Header — in
DisplayServerTVOSpublic:add:
virtual void virtual_keyboard_show(const String &p_existing_text, const Rect2 &p_screen_rect, DisplayServerEnums::VirtualKeyboardType p_type, int p_max_length, int p_cursor_start, int p_cursor_end) override; virtual void virtual_keyboard_hide() override; virtual bool is_keyboard_active() const override;(Check is_keyboard_active is virtual in display_server_apple_embedded.h; if not, make it virtual there.)
- Step 2: Implementation — replace the TODO block in
display_server_tvos.mm:
// Dark Cosmos (2026-10-06): tvOS virtual keyboard. UIKeyInput on a custom view never raises the tvOS// keyboard (rdar://27389949), so present a UIAlertController with a UITextField instead: focusing// it opens the full-screen tvOS keyboard, which has dictation (Siri Remote mic). On Done the// Godot control's text is replaced via key events (the iOS keyboard view's path): one BACKSPACE// per UTF-32 char of the existing text (the caller puts the caret at the end), then the new text.static UIAlertController *gdt_tvos_kb_alert = nil;
static void gdt_tvos_send_text(const String &p_old, NSString *p_new) { DisplayServerAppleEmbedded *ds = DisplayServerAppleEmbedded::get_singleton(); if (!ds) { return; } for (int i = 0; i < p_old.length(); i++) { ds->key(Key::BACKSPACE, 0, Key::BACKSPACE, Key::NONE, 0, true, KeyLocation::UNSPECIFIED); ds->key(Key::BACKSPACE, 0, Key::BACKSPACE, Key::NONE, 0, false, KeyLocation::UNSPECIFIED); } String s = String::utf8([p_new UTF8String]); for (int i = 0; i < s.length(); i++) { char32_t c = s[i]; Key k = (c == '\n') ? Key::ENTER : Key::NONE; ds->key(k, c == '\n' ? 0 : c, k, Key::NONE, 0, true, KeyLocation::UNSPECIFIED); ds->key(k, c == '\n' ? 0 : c, k, Key::NONE, 0, false, KeyLocation::UNSPECIFIED); }}
void DisplayServerTVOS::virtual_keyboard_show(const String &p_existing_text, const Rect2 &p_screen_rect, DisplayServerEnums::VirtualKeyboardType p_type, int p_max_length, int p_cursor_start, int p_cursor_end) { if (gdt_tvos_kb_alert) { return; } String old = p_existing_text; NSString *existing = [NSString stringWithUTF8String:p_existing_text.utf8().get_data()]; UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleAlert]; [alert addTextFieldWithConfigurationHandler:^(UITextField *field) { field.text = existing; }]; [alert addAction:[UIAlertAction actionWithTitle:@"Done" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { NSString *t = alert.textFields.firstObject.text ?: @""; gdt_tvos_kb_alert = nil; if (![t isEqualToString:existing]) { gdt_tvos_send_text(old, t); } }]]; [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *a) { gdt_tvos_kb_alert = nil; }]]; gdt_tvos_kb_alert = alert; [GDTAppDelegateService.viewController presentViewController:alert animated:YES completion:nil];}
void DisplayServerTVOS::virtual_keyboard_hide() { if (gdt_tvos_kb_alert) { [gdt_tvos_kb_alert dismissViewControllerAnimated:YES completion:nil]; gdt_tvos_kb_alert = nil; }}
bool DisplayServerTVOS::is_keyboard_active() const { return gdt_tvos_kb_alert != nil;}(Verify GDTAppDelegateService.viewController is the accessor used elsewhere in the fork — it is in display_server_apple_embedded.mm:720. Verify DisplayServerAppleEmbedded::key is public; it is called from godot_keyboard_input_view.mm.)
- Step 3: Feature string — in
OS_AppleEmbedded::_check_internal_feature_support, beforereturn false;:
#ifdef TVOS_ENABLED if (p_feature == "tvos_text_input") { return true; // Dark Cosmos: DisplayServerTVOS presents a UIAlertController keyboard (2026-10-06) }#endif-
Step 4: Build + repackage the tvOS export templates exactly per
bh-appstore-release§0.55 (read it; do not improvise the scons flags). Verify:strings <built libgodot.tvos binary> | grep tvos_text_inputprints a hit. -
Step 5: Commit on the fork branch (local only):
cd ~/Claude/godot-tvos/godotgit add platform/tvos/display_server_tvos.h platform/tvos/display_server_tvos.mm drivers/apple_embedded/os_apple_embedded.mmgit commit -m "tvos: UIAlertController virtual keyboard + tvos_text_input feature"-
Step 6: Apple TV Dev install (
bh-deploy/bh-worktree-device-install, BUILD bump). On device: R2 → feedback panel shows the text box on OTHER → alert opens → hold Siri Remote mic, dictate → Done → text appears in the box → Send → D1 row with"voice":true. Watch for the pad’s A/B also acting in Godot under the alert (spec risk); if it does, gateFeedbackPanel._inputonDisplayServer.virtual_keyboard_get_height()/anis_keyboard_activecheck and re-test. -
Step 7: Docs — note the fork keyboard in
platform/tvos/CLAUDE.md(one line under platform rules) and the R2 control indocs/architecture/dev-tools.md(Remote Playtest / dev controls section). Commit + push the game repo.
Execution handoff
Section titled “Execution handoff”Recorded 2026-10-06 at the end of the planning session.
Chris’s decisions (brainstorm):
- Build for BOTH iPhone/iPad and Apple TV; reuse the existing in-game feedback path (earlier session, “yes to both”).
- tvOS dictation via the Siri Remote mic is acceptable (answer “A”): Toby presses R2 on the controller, then picks up the remote. Controller-only voice on tvOS was ruled impossible.
- tvOS approach = option 1, fork change (implement
virtual_keyboard_showin godot-tvos). Rejected: option 2 (file-handshake bridge inplatform/tvos/patches/dummy.mm, hacky polling) and option 3 (tvOS GDExtension, unknown whether the fork/godot-cpp load GDExtensions on tvOS). - All three spec sections approved as written (“yes” x3), spec approved (“go”).
Correction made during design (not obvious from code): FEATURE_VIRTUAL_KEYBOARD already returns true on tvOS via shared drivers/apple_embedded/display_server_apple_embedded.mm:392, so it CANNOT gate the text box — hence the fork-specific tvos_text_input feature string.
Verified during planning: the telemetry Worker’s feedbackRow stores the whole context object as context_json, so context.voice needs no Worker/schema change.
Already done: spec committed + pushed (968def41). Nothing else touched. The main checkout has UNRELATED uncommitted economy-sim edits (tests/test_economy_career.gd, tools/economy_sim/*) and an untracked remote-config/notice.json from 2026-10-04 — not this task’s; leave them alone, work in a worktree.
Authorization: implementation, gate, worktree commits are fine. Still needs Chris’s same-day OK: merge/push to main (auto-mode has blocked Claude’s git push origin HEAD:main before — hand Chris the ! push line if blocked), each device Dev install (BUILD bumps), the fork template rebuild/repackage (Task 4 Step 4, it feeds store tvOS builds too), any Worker redeploy (console Worker after control/ change).
Fork state: ~/Claude/godot-tvos/godot on branch tvos-render-1080p, HEAD 007f6f1, with untracked misc/dist/apple_embedded_xcode/libgodot.tvos.{debug,release}.xcframework/ (existing build output, leave it).
Next action: Task 1 in a worktree .claude/worktrees/voice-feedback off origin/main. Read bh-sdd-concurrent before dispatching Task 1 (concurrent sessions are common on this repo). Pass model: "opus" to code subagents (they default to Haiku). Tasks 1-3 are game-repo; Task 4 is the fork and needs Chris’s devices.