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:
13
README.md
13
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
|
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-
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
149
tests/ui_fit_test.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user