M1: UI-fit instrumentation + headless ui_fit_test + first overflow fixes

Add panel overflow instrumentation (record_panel_fit reading ImGui ScrollMax),
a forced layout-reference + debug-aware center split, and host harness commands
(uisize/uifit). New headless ui_fit_test drives the real Controllers + Audio
panels at reference resolutions with Debug details on and asserts no panel
overflows its assigned size; the Audio panel gains a demo mode so its richest
content renders without a live mirror.

Fixes from the measured overflow: widen the center column (was too narrow ->
horizontal overflow), merge the Controllers poll/round-trip tables and fold the
trigger line into the slot line, and make the center height split
Debug-details-aware (Video's height is mirroring-driven, not debug-driven, so a
static split can't serve both modes). Controllers + Audio now fit at 1920x1080
with max info. Video/Injection/Log fit is finalized at the end via the live
uifit harness (M1 stays open until then).

15/15 ctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 11:14:31 +02:00
parent fc43355db2
commit cf7e0c783a
11 changed files with 409 additions and 62 deletions

View File

@@ -206,11 +206,24 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
manage_overrides(status, pid); // auto-apply a saved override / auto-save a caught format
if (mirror_.running())
// Live mirror state -- or synthetic worst-case values in the UI-fit demo (no live
// AudioMirror), so the headless fit test renders the richest content path.
const bool running = demo_ ? true : mirror_.running();
const AudioMirror::Source src = demo_ ? AudioMirror::Source::Loopback : mirror_.source();
const unsigned m_rate = demo_ ? 44100u : mirror_.sample_rate();
const unsigned m_ch = demo_ ? 6u : mirror_.channels();
const unsigned m_buffered = demo_ ? 9999u : mirror_.buffered_ms();
const std::string mirror_status =
demo_ ? std::string("Loopback (echo): re-rendering the game's audio on the default endpoint.")
: mirror_.status();
const std::string reason =
demo_ ? std::string("render-hook did not publish a format in time; using WASAPI process loopback.")
: mirror_.fallback_reason();
if (running)
{
const AudioMirror::Source src = mirror_.source();
const bool hooked = src == AudioMirror::Source::Hooked;
ImGui::TextColored(kGreen, "Mirroring %u Hz, %u ch", mirror_.sample_rate(), mirror_.channels());
ImGui::TextColored(kGreen, "Mirroring %u Hz, %u ch", m_rate, m_ch);
ImGui::Text("Source:");
ImGui::SameLine();
if (hooked)
@@ -225,7 +238,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
}
else
{
ImGui::TextColored(kAmber, "%s", mirror_.source_name());
ImGui::TextColored(kAmber, "%s", demo_ ? "Loopback (echo)" : mirror_.source_name());
}
// Where the rendered format came from -- so it's clear the playback pitch is right.
@@ -242,9 +255,8 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{
ImGui::TextColored(kGreen, "device endpoint (known, post-mix)");
}
ImGui::Text("Buffered: %4u ms", mirror_.buffered_ms());
ImGui::Text("Buffered: %4u ms", m_buffered);
}
const std::string mirror_status = mirror_.status();
if (!mirror_status.empty())
{
ImGui::TextWrapped("%s", mirror_status.c_str());
@@ -252,7 +264,6 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
// Why we're on loopback instead of the no-echo hooked path (empty when hooked). Amber
// because it's a degraded-but-working state that auto-resolves when the hook catches up.
const std::string reason = mirror_.fallback_reason();
if (!reason.empty())
{
ImGui::PushStyleColor(ImGuiCol_Text, kAmber);
@@ -262,7 +273,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
// Only the loopback path leaves the game audible locally (the echo); the
// hooked path silences it, so don't warn there.
if (mirror_.source() == AudioMirror::Source::Loopback)
if (src == AudioMirror::Source::Loopback)
{
bool audio_hook_on = false;
const std::uint32_t hn =
@@ -299,6 +310,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
if (!debug_details)
{
record_panel_fit("Audio");
ImGui::End();
return; // the per-stream table below is diagnostic detail
}
@@ -372,7 +384,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
// --- Operator controls: re-measure / override the primary stream's format -----
// For when detection is wrong (re-measure) or unrecoverable (override the channels/
// bit-depth the hook had to assume). Only meaningful while mirroring is active.
if (mirror_.running())
if (running)
{
ImGui::SeparatorText("Fix the primary stream (debug)");
if (ImGui::Button("Re-measure rate"))
@@ -414,6 +426,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
}
}
record_panel_fit("Audio");
ImGui::End();
}

View File

@@ -42,6 +42,15 @@ public:
// `debug_details` on, the per-stream table is shown.
void draw_ui(const HookStatusView& status, bool debug_details);
// UI-fit check (headless test) only: render the richest content path -- as if a
// loopback mirror were running with long status/reason strings -- without a live
// AudioMirror, so the fit test can measure the panel's worst-case size. Never set in
// the shipping host (the render path is identical, just fed synthetic values).
void dev_set_demo(bool on)
{
demo_ = on;
}
#ifdef COOP_TEST_HARNESS
// Test-harness hooks (debug builds only): drive the real audio code paths and read
// state back, incl. targeting a windowless process by pid (coop_tone has no window).
@@ -89,6 +98,7 @@ private:
HWND target_ = nullptr;
DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production
bool enabled_ = false;
bool demo_ = false; // UI-fit test only: render the richest content with synthetic values
AudioMirror mirror_;
// Per-game persisted overrides + per-target session bookkeeping.

View File

@@ -186,6 +186,7 @@ void CapturePanel::draw_ui(const FrameStats& stats)
draw_pipeline_metrics(stats);
draw_perf_graphs(stats);
record_panel_fit("Video");
ImGui::End();
}

View File

@@ -37,6 +37,12 @@ void draw_pad(int index, const PadInfo& pad, bool debug_details)
}
ImGui::TextColored(kGreen, "Slot %d [%s]", index, pad.source.c_str());
if (debug_details)
{
// Triggers on the slot line (saves a row); thumbsticks below.
ImGui::SameLine();
ImGui::TextDisabled("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger);
}
bool first = true;
ImGui::TextUnformatted("Buttons: ");
@@ -57,7 +63,6 @@ void draw_pad(int index, const PadInfo& pad, bool debug_details)
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);
}
@@ -117,6 +122,7 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
if (!status.attached)
{
ImGui::TextDisabled("Not injected (no XInput hook).");
record_panel_fit("Controllers");
ImGui::End();
return;
}
@@ -150,16 +156,24 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
ImGui::TextColored(kGrey, "Game reading controller: idle");
}
// One per-slot table covers both the hook's poll counters and the input round-trip
// (what we forwarded vs what the game read back through the hook). A round-trip mismatch
// isolates a tool->game forwarding problem from an input->tool one. Merged into a single
// table so the (debug) controller view stays inside its panel even with every slot busy.
if (debug_details &&
ImGui::BeginTable("slots", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
ImGui::BeginTable("slots", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
ImGui::TableSetupColumn("Slot");
ImGui::TableSetupColumn("GetState/s");
ImGui::TableSetupColumn("GetState total");
ImGui::TableSetupColumn("GetCaps total");
ImGui::TableSetupColumn("Poll/s");
ImGui::TableSetupColumn("Polls");
ImGui::TableSetupColumn("Forwarded btn/LX,LY");
ImGui::TableSetupColumn("Game read btn/LX,LY");
ImGui::TableHeadersRow();
const auto& fwd = input.pads;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
const CoopPadState& f = fwd[i].state;
const CoopPadState& r = status.read_state[i];
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("%d", i);
@@ -175,42 +189,15 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(status.get_state[i]));
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(status.get_caps[i]));
ImGui::Text("0x%04X %6d,%6d", f.buttons, f.thumb_lx, f.thumb_ly);
ImGui::TableNextColumn();
const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly;
ImGui::TextColored(match ? kGreen : kGrey, "0x%04X %6d,%6d", r.buttons, r.thumb_lx, r.thumb_ly);
}
ImGui::EndTable();
}
// Round-trip view: what we forwarded (the active backend's pad, tagged per slot in
// the Incoming section above) vs what the game actually read back through the hook.
// A mismatch isolates a tool->game forwarding problem from an input->tool one.
if (debug_details)
{
ImGui::SeparatorText("Round-trip (forwarded vs game read)");
if (ImGui::BeginTable("roundtrip", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
ImGui::TableSetupColumn("Slot");
ImGui::TableSetupColumn("Forwarded btn / LX,LY");
ImGui::TableSetupColumn("Game read btn / LX,LY");
ImGui::TableHeadersRow();
const auto& fwd = input.pads;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
const CoopPadState& f = fwd[i].state;
const CoopPadState& r = status.read_state[i];
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("%d", i);
ImGui::TableNextColumn();
ImGui::Text("0x%04X %6d,%6d", f.buttons, f.thumb_lx, f.thumb_ly);
ImGui::TableNextColumn();
const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly;
ImGui::TextColored(match ? kGreen : kGrey, "0x%04X %6d,%6d", r.buttons, r.thumb_lx, r.thumb_ly);
}
ImGui::EndTable();
}
ImGui::TextDisabled("Slot tags in 'Incoming' above show which backend (XInput / Steam) fed each slot.");
}
record_panel_fit("Controllers");
ImGui::End();
}

View File

@@ -657,6 +657,7 @@ void InjectionPanel::draw(bool debug_details)
draw_hook_status(debug_details);
record_panel_fit("Injection");
ImGui::End();
}

View File

@@ -167,6 +167,19 @@ std::string apply_test_command(const std::string& cmd, coop::UiState& ui, coop::
window.request_screenshot(p);
return "ok";
}
if (v == "uisize")
{
// Force a reference layout size so the UI-fit check is monitor-independent.
coop::set_layout_reference(static_cast<float>(num(1)), static_cast<float>(num(2)));
return "ok";
}
if (v == "uifit")
{
// Report any panel whose content overflowed its assigned size last frame.
char buf[256];
coop::panel_fit_report(buf, sizeof(buf));
return buf;
}
if (v == "quit")
{
ui.request_quit = true;
@@ -387,6 +400,8 @@ int run()
window.request_screenshot(screenshot_path()); // captured at Present, overlay included
}
coop::reset_panel_fit(); // panels record their overflow as they draw (UI-fit check)
coop::set_layout_debug(ui.debug_details); // center split adapts to the debug verbosity
if (show_overlay)
{
coop::draw_main_menu_bar(ui, stats);

View File

@@ -57,6 +57,23 @@ int g_startup_force = 4;
// True when ImGui loaded a saved layout (.ini) at startup; then the startup force is
// suppressed so the restored window positions survive (Reset layout still re-applies).
bool g_had_persisted_layout = false;
// Forced reference WorkSize for the UI-fit check (0 = use the live viewport).
float g_ref_w = 0.0f;
float g_ref_h = 0.0f;
// Whether Debug details is on, so the center column splits its height accordingly.
bool g_layout_debug = false;
// Per-frame panel-overflow registry (UI-fit instrumentation).
struct PanelFit
{
char name[24];
float over_x;
float over_y;
};
PanelFit g_fits[8];
int g_fit_count = 0;
} // namespace
void register_ui_settings(UiState& ui)
@@ -95,20 +112,100 @@ void apply_layout_end_frame()
}
}
void set_layout_reference(float w, float h)
{
g_ref_w = w > 0.0f ? w : 0.0f;
g_ref_h = h > 0.0f ? h : 0.0f;
}
void set_layout_debug(bool on)
{
g_layout_debug = on;
}
void reset_panel_fit()
{
g_fit_count = 0;
}
void record_panel_fit(const char* name)
{
// Called while the panel's window is current (before End()). ScrollMax is the content
// overflow beyond the visible region (set every frame whether or not a scrollbar shows),
// so > 0 on either axis means content is cut off at the assigned size.
const float ox = ImGui::GetScrollMaxX();
const float oy = ImGui::GetScrollMaxY();
if (g_fit_count >= static_cast<int>(sizeof(g_fits) / sizeof(g_fits[0])))
{
return;
}
PanelFit& f = g_fits[g_fit_count++];
std::snprintf(f.name, sizeof(f.name), "%s", name);
f.over_x = ox;
f.over_y = oy;
}
bool panel_fit_overflow(float* worst_x, float* worst_y)
{
float mx = 0.0f, my = 0.0f;
for (int i = 0; i < g_fit_count; ++i)
{
mx = std::max(mx, g_fits[i].over_x);
my = std::max(my, g_fits[i].over_y);
}
if (worst_x != nullptr)
{
*worst_x = mx;
}
if (worst_y != nullptr)
{
*worst_y = my;
}
return mx > 0.5f || my > 0.5f;
}
void panel_fit_report(char* buf, int cap)
{
if (buf == nullptr || cap <= 0)
{
return;
}
int n = 0;
bool any = false;
for (int i = 0; i < g_fit_count && n < cap - 1; ++i)
{
if (g_fits[i].over_x <= 0.5f && g_fits[i].over_y <= 0.5f)
{
continue;
}
any = true;
n += std::snprintf(buf + n, static_cast<size_t>(cap - n), "%s%s:%.0f,%.0f", n > 0 ? " " : "",
g_fits[i].name, g_fits[i].over_x, g_fits[i].over_y);
}
if (!any)
{
std::snprintf(buf, static_cast<size_t>(cap), "fit");
}
}
void apply_panel_layout(Panel panel)
{
const ImGuiViewport* vp = ImGui::GetMainViewport();
const ImVec2 o = vp->WorkPos; // below the main menu bar
const ImVec2 s = vp->WorkSize;
const ImVec2 o = vp->WorkPos; // below the main menu bar
// Use the forced reference size when the UI-fit check set one, so panel sizes are
// deterministic regardless of the live monitor; otherwise the real work area.
const ImVec2 s = (g_ref_w > 0.0f) ? ImVec2(g_ref_w, g_ref_h) : vp->WorkSize;
const float m = 12.0f; // outer margin
const float gap = 10.0f; // between panels
// Three columns: Injection (left) and Log (right) are the wide ones (window list
// titles / log lines); the center control-panel column is the narrow one.
// Three columns. The center control-panel column carries the debug-heavy tables
// (controller poll / round-trip, audio streams, override editor), so it gets the most
// width so that content fits without horizontal scrolling at max info; Injection (left)
// and Log (right) still have ample room for window-list titles / log lines.
const float usable_w = std::max(600.0f, s.x - 2.0f * m - 2.0f * gap);
const float left_w = usable_w * 0.36f;
const float center_w = usable_w * 0.28f;
const float right_w = usable_w * 0.36f;
const float left_w = usable_w * 0.30f;
const float center_w = usable_w * 0.40f;
const float right_w = usable_w * 0.30f;
const float left_x = o.x + m;
const float center_x = left_x + left_w + gap;
const float right_x = center_x + center_w + gap;
@@ -116,13 +213,18 @@ void apply_panel_layout(Panel panel)
const float top = o.y + m;
const float full_h = std::max(200.0f, s.y - 2.0f * m);
// Center column stacks Controllers / Video / Audio. Controllers has the most
// content (incoming pads + poll + round-trip tables) so it gets the most height;
// Audio the least (proportions measured from the operator's preferred layout).
// Center column stacks Controllers / Video / Audio. The split depends on Debug details:
// with it on, Controllers (incoming pads + the merged poll/round-trip table) and Audio
// (status + stream table + override editor) carry tall content and get the larger shares;
// with it off, those collapse to a few lines and Video's perf graphs are the tall content.
// Tuned so each panel fits its content at max info at >=1080p.
const float stack_avail = std::max(150.0f, full_h - 2.0f * gap);
const float ctrl_h = stack_avail * 0.45f;
const float vid_h = stack_avail * 0.32f;
const float audio_h = stack_avail * 0.23f;
const float ctrl_frac = g_layout_debug ? 0.44f : 0.40f;
const float vid_frac = g_layout_debug ? 0.19f : 0.37f;
const float audio_frac = g_layout_debug ? 0.37f : 0.23f;
const float ctrl_h = stack_avail * ctrl_frac;
const float vid_h = stack_avail * vid_frac;
const float audio_h = stack_avail * audio_frac;
ImVec2 pos, size;
switch (panel)
@@ -151,8 +253,10 @@ void apply_panel_layout(Panel panel)
// Force the computed layout only when the user asks (Reset layout) or on a fresh
// install with no saved layout to restore. Otherwise FirstUseEver lets ImGui's
// restored .ini positions stand (and still seeds any brand-new panel).
const bool force = g_layout_reset || (!g_had_persisted_layout && g_startup_force > 0);
// restored .ini positions stand (and still seeds any brand-new panel). A forced
// reference size (UI-fit check) also forces, so the assigned sizes are exact.
const bool force =
g_layout_reset || g_ref_w > 0.0f || (!g_had_persisted_layout && g_startup_force > 0);
const ImGuiCond cond = force ? ImGuiCond_Always : ImGuiCond_FirstUseEver;
ImGui::SetNextWindowPos(pos, cond);
ImGui::SetNextWindowSize(size, cond);

View File

@@ -56,6 +56,37 @@ void apply_layout_end_frame();
// clobber the user's saved window positions); only an explicit Reset layout re-applies.
void set_layout_persisted(bool had_persisted_layout);
// --- UI-fit instrumentation (M1) -------------------------------------------
// The panels open at fixed sizes that scale with the monitor; with Debug details on
// (maximum information) a panel's content can exceed its assigned size and get scrolled
// out of view. These hooks let a test (and the debug harness) drive the overlay to its
// fullest and assert nothing overflows.
//
// Force a reference WorkSize for apply_panel_layout so the per-panel sizes are
// deterministic regardless of the real monitor (w<=0 restores the live viewport). When
// set, the computed layout is force-applied every frame.
void set_layout_reference(float w, float h);
// Tell the layout whether Debug details is on, so the center column can split its height
// to match: with debug on, Controllers and Audio carry tall tables/editors and get more;
// with it off, Video's perf graphs are the tall content and get more. Call once per frame
// before the panels draw (a layout reset / startup-force re-applies it).
void set_layout_debug(bool on);
// Per-frame overflow tracking. reset_panel_fit() at the top of the frame; each panel
// calls record_panel_fit(name) while its window is current (just before End()) to capture
// how far its content overflows the assigned size (ScrollMax x/y, 0 = fits).
void reset_panel_fit();
void record_panel_fit(const char* name);
// Worst overflow (px) seen since the last reset across all recorded panels. Returns true
// if anything overflowed; out params (optional) receive the worst horizontal/vertical.
bool panel_fit_overflow(float* worst_x, float* worst_y);
// One-line "Panel:x,y ..." report of every panel that overflowed since the last reset
// (or "fit" if all fit). Written into buf (NUL-terminated, clamped to cap).
void panel_fit_report(char* buf, int cap);
// Rolling frame-timing over a ~1 s window, recomputed each window so the status
// bar can show a stable FPS plus the min/max frame time (jitter) underneath it.
class FrameStats