Generalize UI: general status by default, diagnostics behind Debug details

Rework the spike-era debug readouts into general-purpose status, with the
verbose diagnostics gated behind the menu bar's "Debug details" switch:

- Controllers (was "Phase 0 spike"): always shows slot/source + live
  buttons; the raw stick/trigger numbers are debug-only.
- Injection hook status: general view is attached + focus spoof + a single
  "Game reading controller: N polls/s" summary; the per-slot poll table,
  focus-API counts, and input-path detection are debug-only.
- Audio: general view adds a "Buffered: N ms" health/latency proxy
  (AudioMirror now tracks buffered audio in both render paths) and keeps the
  render-stream count; the per-stream table is debug-only.

Default overlay is now clean general status; flip Debug details for the full
diagnostics. Completes the "generalize the UI" roadmap task. All tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:07:42 +02:00
parent b48cdd6515
commit d19974b17c
9 changed files with 79 additions and 31 deletions

View File

@@ -154,6 +154,7 @@ void AudioMirror::stop()
audio_ring_shm_.reset(); audio_ring_shm_.reset();
running_.store(false, std::memory_order_release); running_.store(false, std::memory_order_release);
source_.store(Source::None, std::memory_order_relaxed); source_.store(Source::None, std::memory_order_relaxed);
buffered_ms_.store(0, std::memory_order_relaxed);
pid_ = 0; pid_ = 0;
} }
@@ -380,6 +381,8 @@ bool AudioMirror::run_hooked(AudioRingHeader* ring)
} }
const UINT32 avail = render_frames - padding; const UINT32 avail = render_frames - padding;
const std::uint32_t ring_bytes = audio_ring_available(*ring); const std::uint32_t ring_bytes = audio_ring_available(*ring);
buffered_ms_.store(static_cast<unsigned>(ring_bytes / frame_bytes * 1000 / rate),
std::memory_order_relaxed);
if (!primed && ring_bytes >= prime_bytes) if (!primed && ring_bytes >= prime_bytes)
{ {
primed = true; primed = true;
@@ -571,6 +574,9 @@ void AudioMirror::run_loopback(DWORD pid)
continue; continue;
} }
const UINT32 avail = render_frames - padding; const UINT32 avail = render_frames - padding;
buffered_ms_.store(
static_cast<unsigned>(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec),
std::memory_order_relaxed);
if (!primed && ring.available() >= prime_bytes) if (!primed && ring.available() >= prime_bytes)
{ {
primed = true; primed = true;

View File

@@ -61,6 +61,13 @@ public:
return channels_.load(std::memory_order_relaxed); return channels_.load(std::memory_order_relaxed);
} }
// Audio currently buffered between capture and the output device, in ms — a
// health/latency proxy (rises if the consumer can't keep up). 0 when stopped.
[[nodiscard]] unsigned buffered_ms() const
{
return buffered_ms_.load(std::memory_order_relaxed);
}
// Which capture path is active, for the UI's source indicator. // Which capture path is active, for the UI's source indicator.
enum class Source enum class Source
{ {
@@ -107,6 +114,7 @@ private:
std::atomic<Source> source_{Source::None}; std::atomic<Source> source_{Source::None};
std::atomic<unsigned> sample_rate_{0}; std::atomic<unsigned> sample_rate_{0};
std::atomic<unsigned> channels_{0}; std::atomic<unsigned> channels_{0};
std::atomic<unsigned> buffered_ms_{0};
mutable std::mutex status_mutex_; mutable std::mutex status_mutex_;
std::string status_; std::string status_;

View File

@@ -29,7 +29,7 @@ const char* format_tag_name(std::uint32_t tag)
} // namespace } // namespace
void AudioPanel::draw_ui(const HookStatusView& status) void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{ {
const bool have_target = target_ != nullptr && IsWindow(target_); const bool have_target = target_ != nullptr && IsWindow(target_);
DWORD pid = 0; DWORD pid = 0;
@@ -76,6 +76,7 @@ void AudioPanel::draw_ui(const HookStatusView& status)
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s",
mirror_.source_name()); mirror_.source_name());
ImGui::Text("Buffered: %u ms", mirror_.buffered_ms());
} }
const std::string mirror_status = mirror_.status(); const std::string mirror_status = mirror_.status();
if (!mirror_status.empty()) if (!mirror_status.empty())
@@ -90,10 +91,10 @@ void AudioPanel::draw_ui(const HookStatusView& status)
ImGui::TextDisabled("Game audio also plays locally (echo). Inject the hook to remove it."); ImGui::TextDisabled("Game audio also plays locally (echo). Inject the hook to remove it.");
} }
// --- Render-stream debug view ----------------------------------------- // --- Render-stream view -----------------------------------------------
// The hook counts every render stream the game creates, even with mirroring // The hook counts every render stream the game creates, even with mirroring
// off. v1 captures only the first ("primary"); this makes multi-stream games // off. v1 captures only the first ("primary"); the count makes a multi-stream
// obvious so we know if/when that needs revisiting. // game obvious, and the per-stream table (debug details) shows why.
ImGui::Separator(); ImGui::Separator();
ImGui::Text("Render streams: %u", status.audio_streams_seen); ImGui::Text("Render streams: %u", status.audio_streams_seen);
if (status.audio_streams_seen > kMaxAudioStreams) if (status.audio_streams_seen > kMaxAudioStreams)
@@ -102,6 +103,12 @@ void AudioPanel::draw_ui(const HookStatusView& status)
ImGui::TextDisabled("(showing first %u)", kMaxAudioStreams); ImGui::TextDisabled("(showing first %u)", kMaxAudioStreams);
} }
if (!debug_details)
{
ImGui::End();
return; // the per-stream table below is diagnostic detail
}
const std::uint32_t rows = std::min<std::uint32_t>(status.audio_streams_seen, kMaxAudioStreams); const std::uint32_t rows = std::min<std::uint32_t>(status.audio_streams_seen, kMaxAudioStreams);
if (rows > 0 && if (rows > 0 &&
ImGui::BeginTable("audio_streams", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit)) ImGui::BeginTable("audio_streams", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit))

View File

@@ -23,8 +23,9 @@ public:
target_ = target; target_ = target;
} }
// `status` is the hook's back-channel, for the render-stream debug view. // `status` is the hook's back-channel, for the render-stream view. With
void draw_ui(const HookStatusView& status); // `debug_details` on, the per-stream table is shown.
void draw_ui(const HookStatusView& status, bool debug_details);
private: private:
HWND target_ = nullptr; HWND target_ = nullptr;

View File

@@ -21,7 +21,7 @@ constexpr ButtonBit kButtons[] = {
{0x1000, "A"}, {0x2000, "B"}, {0x4000, "X"}, {0x8000, "Y"}, {0x1000, "A"}, {0x2000, "B"}, {0x4000, "X"}, {0x8000, "Y"},
}; };
void draw_pad(int index, const PadInfo& pad) void draw_pad(int index, const PadInfo& pad, bool debug_details)
{ {
ImGui::PushID(index); ImGui::PushID(index);
if (!pad.connected) if (!pad.connected)
@@ -31,11 +31,10 @@ void draw_pad(int index, const PadInfo& pad)
return; return;
} }
ImGui::Text("Slot %d [%s]", index, pad.source.c_str()); ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Slot %d [%s]", index, pad.source.c_str());
ImGui::Text("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger);
ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly, pad.state.thumb_rx,
pad.state.thumb_ry);
// Live button state is useful general feedback (is the guest pressing
// anything?); the raw axis/trigger numbers are debug detail.
bool first = true; bool first = true;
ImGui::TextUnformatted("Buttons: "); ImGui::TextUnformatted("Buttons: ");
for (const ButtonBit& b : kButtons) for (const ButtonBit& b : kButtons)
@@ -52,17 +51,24 @@ void draw_pad(int index, const PadInfo& pad)
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextDisabled("(none)"); ImGui::TextDisabled("(none)");
} }
if (debug_details)
{
ImGui::Text("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger);
ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly,
pad.state.thumb_rx, pad.state.thumb_ry);
}
ImGui::Separator(); ImGui::Separator();
ImGui::PopID(); ImGui::PopID();
} }
} // namespace } // namespace
void draw_debug_overlay(const InputSource& input) void draw_controllers_panel(const InputSource& input, bool debug_details)
{ {
ImGui::SetNextWindowPos(ImVec2(24, 24), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(24, 40), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("CoopAllTheThings - Phase 0 spike"); ImGui::Begin("Controllers");
ImGui::Text("Input backend: %s", input.name()); ImGui::Text("Input backend: %s", input.name());
ImGui::TextDisabled("This window is what Remote Play Together captures."); ImGui::TextDisabled("This window is what Remote Play Together captures.");
@@ -72,7 +78,7 @@ void draw_debug_overlay(const InputSource& input)
const auto& pads = input.pads(); const auto& pads = input.pads();
for (int i = 0; i < static_cast<int>(pads.size()); ++i) for (int i = 0; i < static_cast<int>(pads.size()); ++i)
{ {
draw_pad(i, pads[i]); draw_pad(i, pads[i], debug_details);
} }
ImGui::End(); ImGui::End();

View File

@@ -5,9 +5,9 @@
namespace coop namespace coop
{ {
// Phase 0 overlay: confirms the host is alive and, crucially, shows which // Controllers panel: shows which controllers are visible to the host -- how we
// controllers are visible -- this is how we verify Remote Play Together is // verify Remote Play Together is routing a guest's gamepad into our window. With
// routing a guest's gamepad into our window. // `debug_details` on it also shows the full per-axis / per-button breakdown.
void draw_debug_overlay(const InputSource& input); void draw_controllers_panel(const InputSource& input, bool debug_details);
} // namespace coop } // namespace coop

View File

@@ -139,7 +139,7 @@ void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
server_.publish(synthetic); server_.publish(synthetic);
} }
void InjectionPanel::draw_hook_status() void InjectionPanel::draw_hook_status(bool debug_details)
{ {
if (!server_.running()) if (!server_.running())
{ {
@@ -170,13 +170,31 @@ void InjectionPanel::draw_hook_status()
return; return;
} }
ImGui::TextColored(kGreen, "Attached (game pid %u, hwnd 0x%llX)", status.game_pid, ImGui::TextColored(kGreen, "Attached (game pid %u)", status.game_pid);
static_cast<unsigned long long>(status.game_hwnd));
ImGui::TextColored(status.focus_spoof ? kGreen : kGrey, "Focus spoof: %s", ImGui::TextColored(status.focus_spoof ? kGreen : kGrey, "Focus spoof: %s",
status.focus_spoof ? "active" : "inactive"); status.focus_spoof ? "active" : "inactive");
// Per-slot XInput polling: shows exactly which slots the game reads and how // General summary: is the game actually polling our pad, and how fast.
// fast -- the requested visualization. double total_rate = 0.0;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
total_rate += state_rate_[i];
}
if (total_rate > 0.0)
{
ImGui::TextColored(kGreen, "Game reading controller: %.0f polls/s", total_rate);
}
else
{
ImGui::TextColored(kGrey, "Game reading controller: idle");
}
if (!debug_details)
{
return; // everything below is diagnostic detail
}
// Per-slot XInput polling: shows exactly which slots the game reads and how fast.
if (ImGui::BeginTable("slots", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) if (ImGui::BeginTable("slots", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{ {
ImGui::TableSetupColumn("Slot"); ImGui::TableSetupColumn("Slot");
@@ -233,7 +251,7 @@ void InjectionPanel::draw_hook_status()
status.dinput_loaded ? "yes (could be foreground-gated)" : "no"); status.dinput_loaded ? "yes (could be foreground-gated)" : "no");
} }
void InjectionPanel::draw() void InjectionPanel::draw(bool debug_details)
{ {
ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(420, 380), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(420, 380), ImGuiCond_FirstUseEver);
@@ -300,7 +318,7 @@ void InjectionPanel::draw()
ImGui::TextDisabled("(ignores your controller)"); ImGui::TextDisabled("(ignores your controller)");
} }
draw_hook_status(); draw_hook_status(debug_details);
ImGui::End(); ImGui::End();
} }

View File

@@ -21,7 +21,9 @@ class InjectionPanel
public: public:
InjectionPanel(); InjectionPanel();
void draw(); // `debug_details` shows the verbose hook diagnostics (per-slot poll table,
// focus-API counts, input-path detection); off shows a general summary.
void draw(bool debug_details);
// Forward the latest pad snapshot to the injected hook (if connected). When // Forward the latest pad snapshot to the injected hook (if connected). When
// test-input mode is on, a synthetic pattern is sent instead of `pads`. // test-input mode is on, a synthetic pattern is sent instead of `pads`.
@@ -43,7 +45,7 @@ public:
private: private:
void refresh_processes(); void refresh_processes();
void inject_selected(); void inject_selected();
void draw_hook_status(); void draw_hook_status(bool debug_details);
std::vector<ProcessEntry> processes_; std::vector<ProcessEntry> processes_;
char filter_[128] = {}; char filter_[128] = {};

View File

@@ -105,15 +105,15 @@ int run()
coop::draw_main_menu_bar(ui, stats); coop::draw_main_menu_bar(ui, stats);
if (ui.show_controllers) if (ui.show_controllers)
{ {
coop::draw_debug_overlay(*input); coop::draw_controllers_panel(*input, ui.debug_details);
} }
if (ui.show_injection) if (ui.show_injection)
{ {
injection.draw(); injection.draw(ui.debug_details);
} }
if (ui.show_audio) if (ui.show_audio)
{ {
audio.draw_ui(injection.hook_status()); audio.draw_ui(injection.hook_status(), ui.debug_details);
} }
if (ui.show_video) if (ui.show_video)
{ {