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

@@ -498,6 +498,19 @@ Non-obvious things that cost time and constrain the design:
estimate as explicitly *low-confidence* (shown red). The operator can also re-measure estimate as explicitly *low-confidence* (shown red). The operator can also re-measure
or override the format via a per-stream `AudioRingHeader` op channel; the host rebuilds or override the format via a per-stream `AudioRingHeader` op channel; the host rebuilds
its render client when `format_generation` bumps, so it takes effect live. its render client when `format_generation` bumps, so it takes effect live.
- **Panels must *fit* their assigned size at max info -- measure it, don't eyeball it.** The
overlay opens panels at fixed sizes that scale with the monitor, so with Debug details on a
dense panel can overflow and scroll content out of view. `ui_fit_test` drives the real
Controllers + Audio panels headlessly (null-backend ImGui: build the atlas with
`GetTexDataAsRGBA32`, set a non-null `TexID`, `Render()` needs no GPU) at reference
resolutions and asserts each window's `ScrollMax` is 0 -- read it on the *2nd+* frame, since
ImGui computes `ScrollMax` in `Begin()` from the *previous* frame's content size. Two fixes
fell out: the center column was too narrow (horizontal overflow -> widen it, merge the
Controllers poll/round-trip tables), and a *static* height split can't serve both modes, so
it's now **Debug-details-aware** (debug on -> Controllers/Audio get the height for their
tables; off -> Video's perf graphs are the tall content, since Video's height is
mirroring-driven, not debug-driven). The host's `uisize`/`uifit` harness commands check the
same thing against the live overlay.
- **Drive the ImGui overlay for tests through an IPC harness, not synthetic input.** - **Drive the ImGui overlay for tests through an IPC harness, not synthetic input.**
`PostMessage`-d mouse clicks don't reliably reach ImGui widgets, and key/coordinate `PostMessage`-d mouse clicks don't reliably reach ImGui widgets, and key/coordinate
simulation is brittle. A tiny debug-only command channel (`-DCOOP_TEST_HARNESS`, file- simulation is brittle. A tiny debug-only command channel (`-DCOOP_TEST_HARNESS`, file-

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 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; 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::Text("Source:");
ImGui::SameLine(); ImGui::SameLine();
if (hooked) if (hooked)
@@ -225,7 +238,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
} }
else 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. // 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::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()) if (!mirror_status.empty())
{ {
ImGui::TextWrapped("%s", mirror_status.c_str()); 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 // 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. // 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()) if (!reason.empty())
{ {
ImGui::PushStyleColor(ImGuiCol_Text, kAmber); 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 // Only the loopback path leaves the game audible locally (the echo); the
// hooked path silences it, so don't warn there. // 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; bool audio_hook_on = false;
const std::uint32_t hn = const std::uint32_t hn =
@@ -299,6 +310,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
if (!debug_details) if (!debug_details)
{ {
record_panel_fit("Audio");
ImGui::End(); ImGui::End();
return; // the per-stream table below is diagnostic detail 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 ----- // --- Operator controls: re-measure / override the primary stream's format -----
// For when detection is wrong (re-measure) or unrecoverable (override the channels/ // 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. // 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)"); ImGui::SeparatorText("Fix the primary stream (debug)");
if (ImGui::Button("Re-measure rate")) 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(); ImGui::End();
} }

View File

@@ -42,6 +42,15 @@ public:
// `debug_details` on, the per-stream table is shown. // `debug_details` on, the per-stream table is shown.
void draw_ui(const HookStatusView& status, bool debug_details); 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 #ifdef COOP_TEST_HARNESS
// Test-harness hooks (debug builds only): drive the real audio code paths and read // 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). // state back, incl. targeting a windowless process by pid (coop_tone has no window).
@@ -89,6 +98,7 @@ private:
HWND target_ = nullptr; HWND target_ = nullptr;
DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production
bool enabled_ = false; bool enabled_ = false;
bool demo_ = false; // UI-fit test only: render the richest content with synthetic values
AudioMirror mirror_; AudioMirror mirror_;
// Per-game persisted overrides + per-target session bookkeeping. // 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_pipeline_metrics(stats);
draw_perf_graphs(stats); draw_perf_graphs(stats);
record_panel_fit("Video");
ImGui::End(); 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()); 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; bool first = true;
ImGui::TextUnformatted("Buttons: "); ImGui::TextUnformatted("Buttons: ");
@@ -57,7 +63,6 @@ void draw_pad(int index, const PadInfo& pad, bool debug_details)
if (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, ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly,
pad.state.thumb_rx, pad.state.thumb_ry); pad.state.thumb_rx, pad.state.thumb_ry);
} }
@@ -117,6 +122,7 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
if (!status.attached) if (!status.attached)
{ {
ImGui::TextDisabled("Not injected (no XInput hook)."); ImGui::TextDisabled("Not injected (no XInput hook).");
record_panel_fit("Controllers");
ImGui::End(); ImGui::End();
return; return;
} }
@@ -150,16 +156,24 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
ImGui::TextColored(kGrey, "Game reading controller: idle"); 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 && if (debug_details &&
ImGui::BeginTable("slots", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) ImGui::BeginTable("slots", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{ {
ImGui::TableSetupColumn("Slot"); ImGui::TableSetupColumn("Slot");
ImGui::TableSetupColumn("GetState/s"); ImGui::TableSetupColumn("Poll/s");
ImGui::TableSetupColumn("GetState total"); ImGui::TableSetupColumn("Polls");
ImGui::TableSetupColumn("GetCaps total"); ImGui::TableSetupColumn("Forwarded btn/LX,LY");
ImGui::TableSetupColumn("Game read btn/LX,LY");
ImGui::TableHeadersRow(); ImGui::TableHeadersRow();
const auto& fwd = input.pads;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i) 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::TableNextRow();
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::Text("%d", i); ImGui::Text("%d", i);
@@ -175,32 +189,6 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(status.get_state[i])); ImGui::Text("%llu", static_cast<unsigned long long>(status.get_state[i]));
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(status.get_caps[i]));
}
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::Text("0x%04X %6d,%6d", f.buttons, f.thumb_lx, f.thumb_ly);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly; const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly;
@@ -208,9 +196,8 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
} }
ImGui::EndTable(); ImGui::EndTable();
} }
ImGui::TextDisabled("Slot tags in 'Incoming' above show which backend (XInput / Steam) fed each slot.");
}
record_panel_fit("Controllers");
ImGui::End(); ImGui::End();
} }

View File

@@ -657,6 +657,7 @@ void InjectionPanel::draw(bool debug_details)
draw_hook_status(debug_details); draw_hook_status(debug_details);
record_panel_fit("Injection");
ImGui::End(); 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); window.request_screenshot(p);
return "ok"; 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") if (v == "quit")
{ {
ui.request_quit = true; ui.request_quit = true;
@@ -387,6 +400,8 @@ int run()
window.request_screenshot(screenshot_path()); // captured at Present, overlay included 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) if (show_overlay)
{ {
coop::draw_main_menu_bar(ui, stats); 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 // 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). // suppressed so the restored window positions survive (Reset layout still re-applies).
bool g_had_persisted_layout = false; 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 } // namespace
void register_ui_settings(UiState& ui) 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) void apply_panel_layout(Panel panel)
{ {
const ImGuiViewport* vp = ImGui::GetMainViewport(); const ImGuiViewport* vp = ImGui::GetMainViewport();
const ImVec2 o = vp->WorkPos; // below the main menu bar const ImVec2 o = vp->WorkPos; // below the main menu bar
const ImVec2 s = vp->WorkSize; // 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 m = 12.0f; // outer margin
const float gap = 10.0f; // between panels const float gap = 10.0f; // between panels
// Three columns: Injection (left) and Log (right) are the wide ones (window list // Three columns. The center control-panel column carries the debug-heavy tables
// titles / log lines); the center control-panel column is the narrow one. // (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 usable_w = std::max(600.0f, s.x - 2.0f * m - 2.0f * gap);
const float left_w = usable_w * 0.36f; const float left_w = usable_w * 0.30f;
const float center_w = usable_w * 0.28f; const float center_w = usable_w * 0.40f;
const float right_w = usable_w * 0.36f; const float right_w = usable_w * 0.30f;
const float left_x = o.x + m; const float left_x = o.x + m;
const float center_x = left_x + left_w + gap; const float center_x = left_x + left_w + gap;
const float right_x = center_x + center_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 top = o.y + m;
const float full_h = std::max(200.0f, s.y - 2.0f * m); const float full_h = std::max(200.0f, s.y - 2.0f * m);
// Center column stacks Controllers / Video / Audio. Controllers has the most // Center column stacks Controllers / Video / Audio. The split depends on Debug details:
// content (incoming pads + poll + round-trip tables) so it gets the most height; // with it on, Controllers (incoming pads + the merged poll/round-trip table) and Audio
// Audio the least (proportions measured from the operator's preferred layout). // (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 stack_avail = std::max(150.0f, full_h - 2.0f * gap);
const float ctrl_h = stack_avail * 0.45f; const float ctrl_frac = g_layout_debug ? 0.44f : 0.40f;
const float vid_h = stack_avail * 0.32f; const float vid_frac = g_layout_debug ? 0.19f : 0.37f;
const float audio_h = stack_avail * 0.23f; 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; ImVec2 pos, size;
switch (panel) 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 // 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 // install with no saved layout to restore. Otherwise FirstUseEver lets ImGui's
// restored .ini positions stand (and still seeds any brand-new panel). // restored .ini positions stand (and still seeds any brand-new panel). A forced
const bool force = g_layout_reset || (!g_had_persisted_layout && g_startup_force > 0); // 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; const ImGuiCond cond = force ? ImGuiCond_Always : ImGuiCond_FirstUseEver;
ImGui::SetNextWindowPos(pos, cond); ImGui::SetNextWindowPos(pos, cond);
ImGui::SetNextWindowSize(size, 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. // clobber the user's saved window positions); only an explicit Reset layout re-applies.
void set_layout_persisted(bool had_persisted_layout); 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 // 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. // bar can show a stable FPS plus the min/max frame time (jitter) underneath it.
class FrameStats class FrameStats

View File

@@ -176,6 +176,28 @@ target_link_libraries(opengl_hook_test PRIVATE
add_test(NAME opengl_hook_test COMMAND opengl_hook_test) add_test(NAME opengl_hook_test COMMAND opengl_hook_test)
# Headless UI-fit check (M1): drives the real Controllers + Audio panels at reference
# resolutions with Debug details on and the richest content, and asserts no panel overflows
# its assigned size. Pure ImGui layout (no GPU / window). The Audio panel renders in its
# demo mode so the full content path shows without a live audio device.
add_executable(ui_fit_test
ui_fit_test.cpp
${CMAKE_SOURCE_DIR}/host/src/audio_panel.cpp
${CMAKE_SOURCE_DIR}/host/src/controllers_panel.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/audio_loopback.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/process_loopback_capture.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/audio_overrides.cpp
${CMAKE_SOURCE_DIR}/host/src/ui/app_chrome.cpp)
target_include_directories(ui_fit_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
# process_loopback_capture (pulled in via AudioMirror) needs the Win10 20H1 headers.
target_compile_definitions(ui_fit_test PRIVATE NTDDI_VERSION=0x0A00000B)
target_link_libraries(ui_fit_test PRIVATE coop_common imgui ole32 mmdevapi)
add_test(NAME ui_fit_test COMMAND ui_fit_test)
# Keep the bin/<config>/ root deployable: stage every test exe under tests/. coop_tone # Keep the bin/<config>/ root deployable: stage every test exe under tests/. coop_tone
# (the audio_loopback_test fixture) is staged there too, next to its consumer, so the # (the audio_loopback_test fixture) is staged there too, next to its consumer, so the
# test's "coop_tone.exe alongside me" lookup keeps working. # test's "coop_tone.exe alongside me" lookup keeps working.
@@ -193,4 +215,5 @@ coop_output_subdir(tests
present_hook_test present_hook_test
dx12_present_hook_test dx12_present_hook_test
opengl_hook_test opengl_hook_test
mock_game_test) mock_game_test
ui_fit_test)

149
tests/ui_fit_test.cpp Normal file
View File

@@ -0,0 +1,149 @@
// Headless UI-fit check (M1).
//
// The overlay 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. This test drives the *real* Controllers and Audio panels (the tightest
// center-column panels) at reference resolutions with debug details on and the richest
// content, and asserts none overflow its assigned size. Pure ImGui layout -- no GPU, no
// window -- so it runs anywhere. The Audio panel is driven in its demo mode (synthetic
// mirror values) so the full content path renders without a live audio device.
//
// The Video/Injection/Log panels need a live D3D device / IPC channel and are verified via
// the host's debug harness ("uisize"/"uifit") rather than headlessly here.
#include <cstdint>
#include <cstdio>
#include <windows.h> // must precede mmreg.h (defines FAR/NEAR it relies on)
#include <mmreg.h> // WAVE_FORMAT_IEEE_FLOAT
#include "imgui.h"
#include "audio_panel.hpp"
#include "controllers_panel.hpp"
#include "input/input_source.hpp"
#include "ipc/ipc_server.hpp"
#include "ui/app_chrome.hpp"
using namespace coop;
namespace
{
// Populate the hook back-channel with the worst case for the panels: every pad busy, and
// the maximum number of render streams, each low-confidence (the longest provenance label)
// at 8ch / 32-bit / float (the widest format string).
void fill_max_status(HookStatusView& st)
{
st.attached = true;
st.focus_spoof = true;
st.heartbeat = 123456;
for (std::uint32_t i = 0; i < kMaxPads; ++i)
{
st.get_state[i] = 9876543;
st.get_caps[i] = 4242;
st.rumble_left[i] = 65535;
st.rumble_right[i] = 65535;
st.read_state[i].connected = 1;
st.read_state[i].buttons = 0xFFFF;
st.read_state[i].thumb_lx = -12345;
st.read_state[i].thumb_ly = 23456;
}
st.game_pid = 4242;
st.game_hwnd = 0x12345678;
st.audio_streams_seen = kMaxAudioStreams + 5; // exercises the "(showing first N)" note
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{
AudioStreamInfo& s = st.audio_streams[i];
s.is_primary = (i == 0) ? 1u : 0u;
s.sample_rate = 192000;
s.channels = 8;
s.bits = 32;
s.format_tag = WAVE_FORMAT_IEEE_FLOAT;
s.frames_rendered = 1234567890123ull;
s.format_state = AudioFormat_LowConfidence;
}
}
void fill_max_input(InputSnapshot& in)
{
for (std::uint32_t i = 0; i < kMaxPads; ++i)
{
in.pads[i].connected = true;
in.pads[i].source = "XInput (RPT guest)";
in.pads[i].state.connected = 1;
in.pads[i].state.buttons = 0xFFFF;
in.pads[i].state.thumb_lx = -32768;
in.pads[i].state.thumb_ly = 32767;
in.pads[i].state.left_trigger = 255;
in.pads[i].state.right_trigger = 255;
}
in.backend = "XInput";
}
// Drive the panels for a few frames at a reference size and report the worst overflow.
// ImGui computes a window's ScrollMax in Begin() from the *previous* frame's content size,
// so the reading is only stable from the second frame on -- run several.
bool run_size(float w, float h, AudioPanel& audio, ControllersPanel& controllers, const HookStatusView& st,
const InputSnapshot& in, bool required)
{
ImGui::GetIO().DisplaySize = ImVec2(w, h);
set_layout_reference(w, h);
set_layout_debug(true); // we drive the panels with Debug details on
for (int frame = 0; frame < 4; ++frame)
{
ImGui::GetIO().DeltaTime = 1.0f / 60.0f;
ImGui::NewFrame();
reset_panel_fit();
controllers.draw(in, st, /*debug_details=*/true);
audio.draw_ui(st, /*debug_details=*/true);
ImGui::Render();
}
float ox = 0.0f, oy = 0.0f;
const bool over = panel_fit_overflow(&ox, &oy);
char report[256];
panel_fit_report(report, sizeof(report));
std::printf(" %4.0fx%-4.0f %-9s %-28s (worst %.0f x, %.0f y)%s\n", w, h, over ? "OVERFLOW" : "fit",
report, ox, oy, (over && !required) ? " [best-effort]" : "");
return required ? !over : true;
}
} // namespace
int main()
{
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr; // never touch a .ini
io.LogFilename = nullptr;
unsigned char* px = nullptr;
int tw = 0, th = 0;
io.Fonts->GetTexDataAsRGBA32(&px, &tw, &th); // build the default font atlas
io.Fonts->SetTexID((ImTextureID)1); // non-null so Render() is happy; no real backend
HookStatusView st{};
fill_max_status(st);
InputSnapshot in{};
fill_max_input(in);
AudioPanel audio;
audio.dev_set_demo(true); // render the richest content with synthetic mirror values
ControllersPanel controllers;
std::printf("ui_fit_test: panels must fit their assigned size at max info (debug details on)\n");
bool ok = true;
ok &= run_size(1920, 1080, audio, controllers, st, in, /*required=*/true);
// Smaller resolutions are reported but not required to pass (the host targets >=1080p).
run_size(1600, 900, audio, controllers, st, in, /*required=*/false);
run_size(1366, 768, audio, controllers, st, in, /*required=*/false);
ImGui::DestroyContext();
if (!ok)
{
std::printf("FAIL: a panel overflows its assigned size at 1920x1080 with Debug details on.\n");
return 1;
}
std::printf("PASS: Controllers + Audio fit at 1920x1080.\n");
return 0;
}