From d73b43ad0d799ea7200b8311e3dad306eccb6658 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Wed, 1 Jul 2026 23:23:41 +0200 Subject: [PATCH] Make the host per-monitor DPI aware and scale the ImGui overlay The window sized itself to GetSystemMetrics(SM_CXSCREEN/CYSCREEN) but the process was DPI-unaware, so on a scaled display (e.g. 4K @ 150%) Windows handed us a virtualized resolution and bitmap-stretched the whole window up to native -- softening the mirror, which is the tool's entire point. Declare per-monitor-v2 awareness at startup so GetSystemMetrics/GetDpiForWindow report true pixels. That alone would shrink the fixed-13px ImGui overlay to crisp-but-tiny, so pair it with UI scaling: rebuild the default-font atlas at a DPI-scaled SizePixels (crisp at the target size, unlike FontGlobalScale's bitmap stretch) and ScaleAllSizes() the style. Net: same physical size as before, now sharp. - common/include/coop/dpi.hpp: pure DPI->scale math (uses USER_DEFAULT_SCREEN_DPI and a named kBaseFontPx, not bare 96/13 literals), with a zero fallback and clamping. Unit-tested by tests/dpi_test.cpp. - imgui_layer: apply_dpi() at init from GetDpiForWindow; set_dpi() for runtime changes (rebuild atlas + reset/scale style + invalidate the DX11 font texture). - d3d11_window: latch WM_DPICHANGED (honor the suggested rect), expose take_dpi_change(); main loop polls it and calls imgui.set_dpi() between frames. The DPI math is unit-tested; the actual awareness + font rasterization + live WM_DPICHANGED rescale are verified by hand. Co-Authored-By: Claude Opus 4.8 --- common/include/coop/dpi.hpp | 44 +++++++++++++++++++++++++++++++ host/src/d3d11_window.cpp | 16 ++++++++++++ host/src/d3d11_window.hpp | 17 ++++++++++++ host/src/imgui_layer.cpp | 42 +++++++++++++++++++++++++++++- host/src/imgui_layer.hpp | 10 +++++++ host/src/main.cpp | 12 +++++++++ tests/CMakeLists.txt | 6 +++++ tests/dpi_test.cpp | 52 +++++++++++++++++++++++++++++++++++++ 8 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 common/include/coop/dpi.hpp create mode 100644 tests/dpi_test.cpp diff --git a/common/include/coop/dpi.hpp b/common/include/coop/dpi.hpp new file mode 100644 index 0000000..8fc6a23 --- /dev/null +++ b/common/include/coop/dpi.hpp @@ -0,0 +1,44 @@ +// DPI -> UI scale math for the host overlay, factored out of ImGui/window code so it's unit-testable +// without a live window. The host is per-monitor-DPI-aware (see wWinMain), so GetSystemMetrics and +// GetDpiForWindow report true pixels; we scale ImGui's font and style by this factor ourselves to keep +// the overlay the same physical size -- and crisp -- across display scalings instead of letting Windows +// bitmap-stretch a virtualized-resolution window. +#pragma once + +#include + +#include // USER_DEFAULT_SCREEN_DPI (== 96, the 100%-scale baseline) + +namespace coop +{ + +// ImGui's built-in default font (ProggyClean) rasterizes at this pixel size at 100% scale. Named once +// here so the DPI math scales from a single owned constant instead of a bare 13 sprinkled around. +inline constexpr float kBaseFontPx = 13.0f; + +// Bound the derived scale: never shrink the overlay below 100% (a readability floor), and cap absurd +// values so a bogus DPI report can't blow the font atlas up to an enormous texture. +inline constexpr float kMinUiScale = 1.0f; +inline constexpr float kMaxUiScale = 8.0f; + +// UI scale factor for a monitor DPI (dots per inch, as GetDpiForWindow / WM_DPICHANGED report it). +// 96 DPI == 100% == 1.0; 144 DPI (150%) == 1.5. A zero/unknown DPI falls back to the 96 baseline so +// callers never derive a zero-size font. +inline float dpi_scale_from(unsigned dpi) +{ + if (dpi == 0) + { + dpi = USER_DEFAULT_SCREEN_DPI; + } + const float scale = static_cast(dpi) / static_cast(USER_DEFAULT_SCREEN_DPI); + return std::clamp(scale, kMinUiScale, kMaxUiScale); +} + +// Default-font pixel size for a monitor DPI: the base size scaled so glyphs rasterize crisp at the +// target size. Pair with ImGui::GetStyle().ScaleAllSizes(dpi_scale_from(dpi)) for spacing/padding. +inline float scaled_font_px(unsigned dpi) +{ + return kBaseFontPx * dpi_scale_from(dpi); +} + +} // namespace coop diff --git a/host/src/d3d11_window.cpp b/host/src/d3d11_window.cpp index 07bc39b..f4587a8 100644 --- a/host/src/d3d11_window.cpp +++ b/host/src/d3d11_window.cpp @@ -334,6 +334,22 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA self->resize_height_ = HIWORD(lparam); } return 0; + case WM_DPICHANGED: + // Per-monitor-v2: the DPI of the display we're on changed. Resize to the rect Windows suggests + // in lparam (its recommended handling; the resulting WM_SIZE repaints the swap chain via the + // deferred-resize path above), then latch the new DPI for the overlay to rescale its font/style. + if (self != nullptr) + { + if (const auto* suggested = reinterpret_cast(lparam); suggested != nullptr) + { + SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, + suggested->right - suggested->left, suggested->bottom - suggested->top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + self->dpi_pending_ = true; + self->pending_dpi_ = HIWORD(wparam); // X and Y DPI are equal; HIWORD is the Y value + } + return 0; case WM_SYSKEYDOWN: // F10 is our screenshot key; ImGui already saw this message (handler runs above), // so swallow it here to stop DefWindowProc from flicking into Win32 menu mode. diff --git a/host/src/d3d11_window.hpp b/host/src/d3d11_window.hpp index d9752e3..c1a8759 100644 --- a/host/src/d3d11_window.hpp +++ b/host/src/d3d11_window.hpp @@ -60,6 +60,21 @@ public: return device_lost_reason_; } + // If a WM_DPICHANGED arrived since the last call (the window moved to a different-DPI monitor, or + // the display scale changed at runtime), returns true and writes that monitor's DPI to `dpi`, + // clearing the pending flag; otherwise returns false. The overlay layer polls this to rescale its + // fonts/style. One-shot, mirroring the deferred-resize handling in pump_messages(). + [[nodiscard]] bool take_dpi_change(unsigned& dpi) + { + if (!dpi_pending_) + { + return false; + } + dpi = pending_dpi_; + dpi_pending_ = false; + return true; + } + [[nodiscard]] HWND hwnd() const { return hwnd_; @@ -91,6 +106,8 @@ private: bool resize_pending_ = false; UINT resize_width_ = 0; UINT resize_height_ = 0; + bool dpi_pending_ = false; // set by WM_DPICHANGED, consumed by take_dpi_change() + unsigned pending_dpi_ = 0; // the monitor DPI reported alongside that WM_DPICHANGED bool device_lost_ = false; HRESULT device_lost_reason_ = S_OK; diff --git a/host/src/imgui_layer.cpp b/host/src/imgui_layer.cpp index d5d551c..b1452a8 100644 --- a/host/src/imgui_layer.cpp +++ b/host/src/imgui_layer.cpp @@ -6,6 +6,7 @@ #include #include +#include "coop/dpi.hpp" #include "coop/tool_paths.hpp" #include "ui/app_chrome.hpp" @@ -54,7 +55,6 @@ bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* cont ini_path_ = to_utf8(ini_w); io.IniFilename = ini_path_.c_str(); set_layout_persisted(had_layout); - ImGui::StyleColorsDark(); if (!ImGui_ImplWin32_Init(hwnd)) { @@ -65,9 +65,49 @@ bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* cont return false; } initialized_ = true; + + // Scale the overlay (font + style) to the monitor the window opened on. The process is + // per-monitor-DPI-aware (set in wWinMain), so GetDpiForWindow returns that monitor's real DPI. + apply_dpi(GetDpiForWindow(hwnd)); return true; } +void ImGuiLayer::apply_dpi(unsigned dpi) +{ + const float scale = dpi_scale_from(dpi); + ImGuiIO& io = ImGui::GetIO(); + + // Rebuild the font atlas at the DPI-scaled pixel size so ImGui's built-in default font is + // rasterized crisp at the target size, rather than bitmap-stretched the way io.FontGlobalScale + // would leave it. scaled_font_px() scales the one named base-size constant. + io.Fonts->Clear(); + ImFontConfig cfg; + cfg.SizePixels = scaled_font_px(dpi); + io.Fonts->AddFontDefault(&cfg); + + // Reset to the base dark theme, then scale spacing/padding/border sizes to match. Resetting + // first keeps repeated DPI changes from compounding (ScaleAllSizes multiplies the style in place). + ImGui::StyleColorsDark(); + ImGui::GetStyle().ScaleAllSizes(scale); + + // Drop the DX11 backend's cached font texture so it rebuilds from the new atlas next frame. Before + // the first frame nothing is built yet, so this is a harmless no-op during init(). + if (initialized_) + { + ImGui_ImplDX11_InvalidateDeviceObjects(); + } + dpi_scale_ = scale; +} + +void ImGuiLayer::set_dpi(unsigned dpi) +{ + if (!initialized_ || dpi_scale_from(dpi) == dpi_scale_) + { + return; // not up yet, or the scale didn't actually change -- skip a needless atlas rebuild + } + apply_dpi(dpi); +} + void ImGuiLayer::begin_frame() { ImGui_ImplDX11_NewFrame(); diff --git a/host/src/imgui_layer.hpp b/host/src/imgui_layer.hpp index 9634e09..eba8533 100644 --- a/host/src/imgui_layer.hpp +++ b/host/src/imgui_layer.hpp @@ -22,8 +22,18 @@ public: void begin_frame(); void end_frame(); + // Rescale the overlay's font + style to a monitor DPI (dots per inch). Called from the window's + // deferred WM_DPICHANGED handling when the window moves to a different-DPI monitor or the display + // scale changes at runtime. No-op before init() or when the resulting scale is unchanged. + void set_dpi(unsigned dpi); + private: + // Rebuild the font atlas at the DPI-scaled size and re-apply the scaled dark style. Used by both + // init() (first apply) and set_dpi() (runtime change). + void apply_dpi(unsigned dpi); + bool initialized_ = false; + float dpi_scale_ = 1.0f; // last-applied UI scale; guards set_dpi against redundant atlas rebuilds // Backing storage for io.IniFilename (ImGui keeps the pointer, not a copy), so the // layout .ini path must outlive the context. Empty until init() sets it. std::string ini_path_; diff --git a/host/src/main.cpp b/host/src/main.cpp index e4ae5d4..b0fa9da 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -387,6 +387,13 @@ int run() while (window.pump_messages()) { + // Rescale the overlay if the window changed DPI (moved monitors, or the display scale changed). + // pump_messages() latches the new DPI; apply it here, outside any in-progress ImGui frame. + if (unsigned new_dpi = 0; window.take_dpi_change(new_dpi)) + { + imgui.set_dpi(new_dpi); + } + // When the operator enabled "Sync flip to game frames" (Hooked source), pace the // whole iteration to the game: wait for the next published frame before rendering, // then present without vsync so the flip lands in lockstep with the game. @@ -539,6 +546,11 @@ int run() int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) { + // Per-monitor DPI awareness (v2): make GetSystemMetrics / GetDpiForWindow report true pixels so the + // mirror renders at the display's native resolution instead of a virtualized one that Windows would + // bitmap-stretch (softening the image). We scale the ImGui overlay ourselves to stay readable. Must + // run before any window is created. + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); // WGC requires an initialized apartment; multi-threaded suits the // free-threaded frame pool. winrt::init_apartment(winrt::apartment_type::multi_threaded); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b6ef62b..9931b92 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -105,6 +105,11 @@ add_executable(tool_paths_test tool_paths_test.cpp) target_link_libraries(tool_paths_test PRIVATE coop_common) add_test(NAME tool_paths_test COMMAND tool_paths_test) +# Unit test for the DPI -> UI scale math (baseline/known scalings, zero fallback, clamping, font px). +add_executable(dpi_test dpi_test.cpp) +target_link_libraries(dpi_test PRIVATE coop_common) +add_test(NAME dpi_test COMMAND dpi_test) + # Unit test for the audio mixer math (decode/sum/soft-clip/encode). Header-only. add_executable(audio_mix_test audio_mix_test.cpp) target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) @@ -388,6 +393,7 @@ coop_output_subdir(tests shared_memory_test wav_test tool_paths_test + dpi_test mkb_map_test audio_mix_test tone_analysis_test diff --git a/tests/dpi_test.cpp b/tests/dpi_test.cpp new file mode 100644 index 0000000..191cbe6 --- /dev/null +++ b/tests/dpi_test.cpp @@ -0,0 +1,52 @@ +// Unit test for the DPI -> UI scale math (common/include/coop/dpi.hpp): the 96-DPI baseline, common +// Windows scalings, the zero-DPI fallback, clamping at both ends, and the default-font pixel-size +// derivation. Pure math -- no window, no ImGui -- so it's the reasonably-testable slice of the +// per-monitor DPI feature (the actual awareness + font rasterization are verified by hand). +#include +#include + +#include "coop/dpi.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} +bool approx(float a, float b) +{ + return std::fabs(a - b) < 1e-4f; +} +} // namespace + +int main() +{ + // Baseline and the common Windows display scalings. + check(approx(dpi_scale_from(96), 1.0f), "96 DPI -> 1.0 (100%)"); + check(approx(dpi_scale_from(120), 1.25f), "120 DPI -> 1.25 (125%)"); + check(approx(dpi_scale_from(144), 1.5f), "144 DPI -> 1.5 (150%)"); + check(approx(dpi_scale_from(192), 2.0f), "192 DPI -> 2.0 (200%)"); + + // A zero / unknown DPI falls back to the 96 baseline (never 0 -> a zero-size font). + check(approx(dpi_scale_from(0), 1.0f), "0 DPI -> 1.0 fallback"); + + // Clamping: never below 100%, and capped at the max for an absurd report. + check(approx(dpi_scale_from(48), kMinUiScale), "48 DPI clamps up to the min UI scale"); + check(approx(dpi_scale_from(100000), kMaxUiScale), "absurd DPI clamps to the max UI scale"); + + // Font size scales from the single named base constant, matching ImGui's 13px default at 100%. + check(approx(kBaseFontPx, 13.0f), "base font px is ImGui's 13px default"); + check(approx(scaled_font_px(96), 13.0f), "font px at 100% == base"); + check(approx(scaled_font_px(144), 19.5f), "font px at 150% == 13 * 1.5"); + check(approx(scaled_font_px(0), kBaseFontPx), "font px at unknown DPI == base (fallback)"); + + std::printf(g_failures == 0 ? "PASS dpi_test\n" : "FAILED dpi_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}