From cf7e0c783a2765c03b1e699e43e67bdc35311ecc Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 22 Jun 2026 11:14:31 +0200 Subject: [PATCH] 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 --- README.md | 13 +++ host/src/audio_panel.cpp | 31 +++++-- host/src/audio_panel.hpp | 10 +++ host/src/capture_panel.cpp | 1 + host/src/controllers_panel.cpp | 61 ++++++-------- host/src/injection_panel.cpp | 1 + host/src/main.cpp | 15 ++++ host/src/ui/app_chrome.cpp | 134 +++++++++++++++++++++++++---- host/src/ui/app_chrome.hpp | 31 +++++++ tests/CMakeLists.txt | 25 +++++- tests/ui_fit_test.cpp | 149 +++++++++++++++++++++++++++++++++ 11 files changed, 409 insertions(+), 62 deletions(-) create mode 100644 tests/ui_fit_test.cpp diff --git a/README.md b/README.md index ac95022..49cab39 100644 --- a/README.md +++ b/README.md @@ -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 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. +- **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.** `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- diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index b735a70..9f5e232 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -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(); } diff --git a/host/src/audio_panel.hpp b/host/src/audio_panel.hpp index d9a9c16..fef52f6 100644 --- a/host/src/audio_panel.hpp +++ b/host/src/audio_panel.hpp @@ -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. diff --git a/host/src/capture_panel.cpp b/host/src/capture_panel.cpp index 62f73bb..d34bc89 100644 --- a/host/src/capture_panel.cpp +++ b/host/src/capture_panel.cpp @@ -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(); } diff --git a/host/src/controllers_panel.cpp b/host/src/controllers_panel.cpp index a080e2a..071e25f 100644 --- a/host/src/controllers_panel.cpp +++ b/host/src/controllers_panel.cpp @@ -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(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(status.get_state[i])); ImGui::TableNextColumn(); - ImGui::Text("%llu", static_cast(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(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(); } diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 3697e37..5ddf0bb 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -657,6 +657,7 @@ void InjectionPanel::draw(bool debug_details) draw_hook_status(debug_details); + record_panel_fit("Injection"); ImGui::End(); } diff --git a/host/src/main.cpp b/host/src/main.cpp index 6b2feb8..efe7cf8 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -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(num(1)), static_cast(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); diff --git a/host/src/ui/app_chrome.cpp b/host/src/ui/app_chrome.cpp index caf3775..5cb6f18 100644 --- a/host/src/ui/app_chrome.cpp +++ b/host/src/ui/app_chrome.cpp @@ -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(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(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(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); diff --git a/host/src/ui/app_chrome.hpp b/host/src/ui/app_chrome.hpp index 22b579f..c438b9d 100644 --- a/host/src/ui/app_chrome.hpp +++ b/host/src/ui/app_chrome.hpp @@ -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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8a60177..011dbbf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -176,6 +176,28 @@ target_link_libraries(opengl_hook_test PRIVATE 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// 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 # test's "coop_tone.exe alongside me" lookup keeps working. @@ -193,4 +215,5 @@ coop_output_subdir(tests present_hook_test dx12_present_hook_test opengl_hook_test - mock_game_test) + mock_game_test + ui_fit_test) diff --git a/tests/ui_fit_test.cpp b/tests/ui_fit_test.cpp new file mode 100644 index 0000000..07d2c57 --- /dev/null +++ b/tests/ui_fit_test.cpp @@ -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 +#include + +#include // must precede mmreg.h (defines FAR/NEAR it relies on) + +#include // 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; +}