From 4c73200f58838fc25a281743c4f3c058876f3d9a Mon Sep 17 00:00:00 2001 From: drmext <71258889+drmext@users.noreply.github.com> Date: Sat, 30 May 2026 19:52:35 +0000 Subject: [PATCH] cfg: d3d9 standalone configurator (#720) ## Description of change **Configurator (spicecfg.exe)** - Attempts to create a D3D9 device on startup and uses the hardware ImGui DX9 path when successful; falls back to the existing software rasterizer automatically if D3D9 init fails. - Rewrites the configurator window loop: refresh-rate-aware render timer, minimize/resize/`WM_DISPLAYCHANGE` handling, direct Win32 -> ImGui input (keyboard, mouse, wheel), and optimized software painting via `SetDIBitsToDevice` instead of per-frame GDI `HBITMAP` allocation. **Overlay / input (scoped to standalone configurator)** - Skips expensive per-frame rawinput polling in `ImGui_ImplSpice_NewFrame` when `CONFIGURATOR_STANDALONE` is set (Win32 messages drive input instead). - Adds software-renderer idle-frame detection (`sw_pixels_dirty` + draw-data hash) so spicecfg only repaints when pixels actually change. - Adds `ImGuiWindowFlags_NoScrollWithMouse` on the configurator root window to prevent scroll jolt on non-scrollable child widgets. --- src/spice2x/CMakeLists.txt | 4 +- src/spice2x/cfg/configurator.cpp | 72 +++- src/spice2x/cfg/configurator_wnd.cpp | 470 +++++++++++++++++++++-- src/spice2x/cfg/configurator_wnd.h | 32 +- src/spice2x/overlay/imgui/impl_spice.cpp | 101 +++-- src/spice2x/overlay/overlay.cpp | 111 +++++- src/spice2x/overlay/overlay.h | 20 + src/spice2x/overlay/windows/config.cpp | 4 + 8 files changed, 721 insertions(+), 93 deletions(-) diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index ac59ad5..fb8119a 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -765,7 +765,7 @@ set(SOURCE_FILES ${SOURCE_FILES} launcher/options.h launcher/options.cpp) set(RESOURCE_FILES cfg/manifest.manifest cfg/manifest.rc cfg/icon.rc cfg/Win32D.rc) add_executable(spicetools_cfg WIN32 ${SOURCE_FILES} ${RESOURCE_FILES}) target_link_libraries(spicetools_cfg - PUBLIC ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp strmiids + PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp strmiids PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features) target_link_libraries(spicetools_cfg PUBLIC winscard) set_target_properties(spicetools_cfg PROPERTIES PREFIX "") @@ -783,7 +783,7 @@ set(SOURCE_FILES ${SOURCE_FILES} launcher/options.h launcher/options.cpp) set(RESOURCE_FILES cfg/manifest.manifest cfg/manifest.rc cfg/icon.rc cfg/Win32D.rc) add_executable(spicetools_cfg_linux WIN32 ${SOURCE_FILES} ${RESOURCE_FILES}) target_link_libraries(spicetools_cfg_linux - PUBLIC ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp strmiids + PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp strmiids PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features) set_target_properties(spicetools_cfg_linux PROPERTIES PREFIX "") set_target_properties(spicetools_cfg_linux PROPERTIES OUTPUT_NAME "spicecfg_linux") diff --git a/src/spice2x/cfg/configurator.cpp b/src/spice2x/cfg/configurator.cpp index ea6570e..d8b9792 100644 --- a/src/spice2x/cfg/configurator.cpp +++ b/src/spice2x/cfg/configurator.cpp @@ -1,6 +1,9 @@ -#include "configurator.h" +#include "configurator.h" + +#include #include "overlay/overlay.h" +#include "util/logging.h" namespace cfg { @@ -16,11 +19,74 @@ namespace cfg { CONFIGURATOR_STANDALONE = false; } + // Attempt to bring up a D3D9 device backing the configurator's window so the + // overlay can use the hardware-accelerated imgui_impl_dx9 path instead of + // the CPU rasterizer. Returns true if both Direct3DCreate9 and CreateDevice + // succeed; the caller falls back to overlay::create_software() otherwise so + // that environments without D3D9 (Wine without dxvk, headless test boxes, + // GPUs whose drivers reject HAL) still get a working configurator. + static bool try_init_d3d9(ConfiguratorWindow &wnd) { + if (wnd.hWnd == nullptr) { + return false; + } + + IDirect3D9 *d3d = Direct3DCreate9(D3D_SDK_VERSION); + if (d3d == nullptr) { + log_warning("configurator", "Direct3DCreate9 returned NULL, falling back to software renderer"); + return false; + } + + D3DPRESENT_PARAMETERS pp {}; + pp.Windowed = TRUE; + pp.SwapEffect = D3DSWAPEFFECT_DISCARD; + // D3DFMT_UNKNOWN -> driver picks the current desktop format + pp.BackBufferFormat = D3DFMT_UNKNOWN; + pp.BackBufferWidth = static_cast(wnd.client_width); + pp.BackBufferHeight = static_cast(wnd.client_height); + pp.hDeviceWindow = wnd.hWnd; + pp.EnableAutoDepthStencil = FALSE; + pp.PresentationInterval = D3DPRESENT_INTERVAL_ONE; + + IDirect3DDevice9 *device = nullptr; + // SOFTWARE_VERTEXPROCESSING is the most compatible behavior flag; the UI + // is tiny so we don't need hardware T&L. FPU_PRESERVE keeps our floating + // point environment intact in case other spice code relies on it. + const DWORD behavior_flags = D3DCREATE_SOFTWARE_VERTEXPROCESSING | D3DCREATE_FPU_PRESERVE; + HRESULT hr = d3d->CreateDevice( + D3DADAPTER_DEFAULT, + D3DDEVTYPE_HAL, + wnd.hWnd, + behavior_flags, + &pp, + &device); + if (FAILED(hr) || device == nullptr) { + log_warning("configurator", + "D3D9 CreateDevice failed (hr={:#x}), falling back to software renderer", + static_cast(hr)); + d3d->Release(); + return false; + } + + wnd.d3d = d3d; + wnd.device = device; + wnd.pp = pp; + wnd.use_d3d9 = true; + return true; + } + void Configurator::run() { - // create instance + // bring up the overlay against either a real D3D9 device or the software + // rasterizer. The choice is one-shot - the in-game overlay always uses + // the same renderer the configurator picked here. overlay::ENABLED = true; - overlay::create_software(this->wnd.hWnd); + if (try_init_d3d9(this->wnd)) { + log_info("configurator", "using D3D9 hardware-accelerated renderer"); + overlay::create_d3d9(this->wnd.hWnd, this->wnd.d3d, this->wnd.device); + } else { + log_info("configurator", "using software renderer"); + overlay::create_software(this->wnd.hWnd); + } overlay::OVERLAY->set_active(true); overlay::OVERLAY->hotkeys_enable = false; ImGui::GetIO().MouseDrawCursor = false; diff --git a/src/spice2x/cfg/configurator_wnd.cpp b/src/spice2x/cfg/configurator_wnd.cpp index 13d22a6..3776268 100644 --- a/src/spice2x/cfg/configurator_wnd.cpp +++ b/src/spice2x/cfg/configurator_wnd.cpp @@ -1,11 +1,17 @@ -#include "configurator_wnd.h" +#include "configurator_wnd.h" + +#include +#include #include +#include #include "build/defs.h" +#include "external/imgui/imgui.h" #include "launcher/shutdown.h" #include "overlay/overlay.h" #include "util/logging.h" +#include "util/precise_timer.h" #include "cfg/configurator.h" #include "icon.h" @@ -16,6 +22,156 @@ static int WINDOW_SIZE_X = 800; static int WINDOW_SIZE_Y = 600; static HICON WINDOW_ICON = LoadIcon(GetModuleHandle(nullptr), MAKEINTRESOURCE(MAINICON)); +static const UINT_PTR RENDER_TIMER_ID = 1; + +// Map a virtual key code to the matching ImGuiKey. Mirrors the table used by +// the in-game ImGui spice backend so navigation keys behave identically when +// the configurator runs standalone and drives ImGui from Win32 messages. +static ImGuiKey vk_to_imgui_key(WPARAM vkey) { + switch (vkey) { + case VK_TAB: return ImGuiKey_Tab; + case VK_LEFT: return ImGuiKey_LeftArrow; + case VK_RIGHT: return ImGuiKey_RightArrow; + case VK_UP: return ImGuiKey_UpArrow; + case VK_DOWN: return ImGuiKey_DownArrow; + case VK_PRIOR: return ImGuiKey_PageUp; + case VK_NEXT: return ImGuiKey_PageDown; + case VK_HOME: return ImGuiKey_Home; + case VK_END: return ImGuiKey_End; + case VK_INSERT: return ImGuiKey_Insert; + case VK_DELETE: return ImGuiKey_Delete; + case VK_BACK: return ImGuiKey_Backspace; + case VK_SPACE: return ImGuiKey_Space; + case VK_RETURN: return ImGuiKey_Enter; + case VK_ESCAPE: return ImGuiKey_Escape; + case VK_LSHIFT: return ImGuiKey_LeftShift; + case VK_RSHIFT: return ImGuiKey_RightShift; + case VK_SHIFT: return ImGuiKey_LeftShift; + case VK_LCONTROL: return ImGuiKey_LeftCtrl; + case VK_RCONTROL: return ImGuiKey_RightCtrl; + case VK_CONTROL: return ImGuiKey_LeftCtrl; + case 'A': return ImGuiKey_A; + case 'C': return ImGuiKey_C; + case 'V': return ImGuiKey_V; + case 'X': return ImGuiKey_X; + case 'Y': return ImGuiKey_Y; + case 'Z': return ImGuiKey_Z; + default: return ImGuiKey_None; + } +} + +static ImGuiKey vk_to_imgui_mod_key(WPARAM vkey) { + switch (vkey) { + case VK_SHIFT: + case VK_LSHIFT: + case VK_RSHIFT: + return ImGuiMod_Shift; + case VK_CONTROL: + case VK_LCONTROL: + case VK_RCONTROL: + return ImGuiMod_Ctrl; + case VK_MENU: + case VK_LMENU: + case VK_RMENU: + return ImGuiMod_Alt; + default: + return ImGuiMod_None; + } +} + +static cfg::ConfiguratorWindow *get_state(HWND hWnd) { + return reinterpret_cast( + GetWindowLongPtrW(hWnd, GWLP_USERDATA)); +} + +// Returns the refresh rate (Hz) of the monitor the window currently lives on, +// clamped to a sane range. Falls back to 60 if detection fails or the value +// looks invalid (DEVMODE may report 0 or 1 to mean "use hardware default"). +static UINT detect_monitor_refresh_hz(HWND hWnd) { + UINT hz = 0; + HMONITOR mon = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST); + if (mon) { + MONITORINFOEXW mi {}; + mi.cbSize = sizeof(mi); + if (GetMonitorInfoW(mon, reinterpret_cast(&mi))) { + DEVMODEW dm {}; + dm.dmSize = sizeof(dm); + if (EnumDisplaySettingsW(mi.szDevice, ENUM_CURRENT_SETTINGS, &dm)) { + hz = dm.dmDisplayFrequency; + } + } + } + if (hz <= 1) { + HDC hdc = GetDC(hWnd); + if (hdc) { + int v = GetDeviceCaps(hdc, VREFRESH); + if (v > 1) { + hz = static_cast(v); + } + ReleaseDC(hWnd, hdc); + } + } + if (hz < 30 || hz > 240) { + hz = 60; + } + return hz; +} + +// Software-path WM_PAINT: blit the overlay's pixel buffer to the window using +// SetDIBitsToDevice. Avoids creating/destroying a GDI HBITMAP every frame. +static void paint_software(HWND hWnd) { + if (!overlay::OVERLAY) { + PAINTSTRUCT ps {}; + BeginPaint(hWnd, &ps); + EndPaint(hWnd, &ps); + return; + } + + int width = 0; + int height = 0; + uint32_t *pixel_data = overlay::OVERLAY->sw_get_pixel_data(&width, &height); + + PAINTSTRUCT paint {}; + HDC hdc = BeginPaint(hWnd, &paint); + + if (pixel_data && width > 0 && height > 0) { + BITMAPINFO bmi {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = width; + // negative height -> top-down DIB, matching how ImGui packs pixels + bmi.bmiHeader.biHeight = -height; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + SetDIBitsToDevice( + hdc, + 0, 0, + width, height, + 0, 0, + 0, height, + pixel_data, + &bmi, + DIB_RGB_COLORS); + } + + EndPaint(hWnd, &paint); +} + +void cfg::ConfiguratorWindow::start_timer() { + if (!this->timer_running && this->hWnd) { + SetTimer(this->hWnd, RENDER_TIMER_ID, this->timer_interval_ms, nullptr); + this->timer_running = true; + } +} + +void cfg::ConfiguratorWindow::stop_timer() { + if (this->timer_running && this->hWnd) { + KillTimer(this->hWnd, RENDER_TIMER_ID); + this->timer_running = false; + } +} + cfg::ConfiguratorWindow::ConfiguratorWindow() { // register the window class @@ -27,12 +183,22 @@ cfg::ConfiguratorWindow::ConfiguratorWindow() { wc.hIcon = WINDOW_ICON; RegisterClass(&wc); + // raise SetTimer resolution so high refresh rates (120/144Hz) are actually + // achievable. Without this, USER timers quantize to ~15.6ms and cap us + // near ~64 FPS. Uses the shared helper so it respects -notimerhacks + // (Use Legacy Timers) and the Win11 timer-throttling opt-out. The helper + // intentionally never calls timeEndPeriod; the kernel releases the request + // when spicecfg exits. + timeutils::set_timer_resolution(); + // determine window title if (cfg::CONFIGURATOR_TYPE == cfg::ConfigType::Config) { WINDOW_TITLE = "spice2x config (" + to_string(VERSION_STRING_CFG) + ")"; WINDOW_SIZE_X = 800; WINDOW_SIZE_Y = 600; } + this->client_width = WINDOW_SIZE_X; + this->client_height = WINDOW_SIZE_Y; // open window this->hWnd = CreateWindowEx( @@ -53,11 +219,23 @@ cfg::ConfiguratorWindow::ConfiguratorWindow() { cfg::ConfiguratorWindow::~ConfiguratorWindow() { + this->stop_timer(); + // close window DestroyWindow(this->hWnd); // unregister class UnregisterClass(CLASS_NAME, GetModuleHandle(NULL)); + + // release D3D9 resources if any + if (this->device) { + this->device->Release(); + this->device = nullptr; + } + if (this->d3d) { + this->d3d->Release(); + this->d3d = nullptr; + } } void cfg::ConfiguratorWindow::run() { @@ -67,8 +245,23 @@ void cfg::ConfiguratorWindow::run() { ShowWindow(this->hWnd, SW_SHOWNORMAL); UpdateWindow(this->hWnd); - // draw overlay in 60 FPS - SetTimer(this->hWnd, 1, 1000 / 60, nullptr); + // SW_SHOWNORMAL usually activates a top-level window, but not always (e.g., + // when the launching process doesn't have foreground rights to transfer). + // Force foreground+focus explicitly so WM_MOUSEWHEEL is routed to us from + // the very first frame; the Win32 default routes wheel events to the + // keyboard-focused window. + SetForegroundWindow(this->hWnd); + SetFocus(this->hWnd); + + // match the render timer to the monitor refresh rate so scrolling stays smooth + // on 60/120/144 Hz panels. Idle CPU is bounded by overlay::sw_pixels_dirty + // (software path) and overlay::d3d9_frame_dirty (D3D9 path); the timer is also + // paused entirely when the window is minimized (see WM_SIZE handler). + const UINT hz = detect_monitor_refresh_hz(this->hWnd); + this->timer_interval_ms = std::max(1, 1000 / hz); + log_info("configurator", "render timer {} ms ({} Hz)", + this->timer_interval_ms, hz); + this->start_timer(); // window loop BOOL ret; @@ -102,6 +295,72 @@ LRESULT CALLBACK cfg::ConfiguratorWindow::window_proc(HWND hWnd, UINT uMsg, WPAR break; } + case WM_SIZE: { + + // pause/resume the render timer based on visibility + auto *state = get_state(hWnd); + if (state) { + if (wParam == SIZE_MINIMIZED) { + state->window_minimized = true; + state->stop_timer(); + } else { + if (state->window_minimized) { + state->window_minimized = false; + // force a full repaint on the next frame + state->has_valid_draw_hash = false; + state->start_timer(); + } + const int new_w = LOWORD(lParam); + const int new_h = HIWORD(lParam); + if (new_w > 0 && new_h > 0 + && (new_w != state->client_width || new_h != state->client_height)) { + state->client_width = new_w; + state->client_height = new_h; + + // reset the D3D9 device with the new back-buffer size so the + // hardware-accelerated path matches the window dimensions. + if (state->use_d3d9 && state->device) { + if (overlay::OVERLAY) { + overlay::OVERLAY->reset_invalidate(); + } + state->pp.BackBufferWidth = static_cast(new_w); + state->pp.BackBufferHeight = static_cast(new_h); + HRESULT hr = state->device->Reset(&state->pp); + if (FAILED(hr)) { + log_warning("configurator", "D3D9 device Reset failed, hr={:#x}", + static_cast(hr)); + } + if (overlay::OVERLAY) { + overlay::OVERLAY->reset_recreate(); + } + } + + // force a full repaint after resize + state->has_valid_draw_hash = false; + } + } + } + break; + } + case WM_DISPLAYCHANGE: { + + // monitor refresh rate may have changed (or the window moved to a + // different monitor); re-detect and re-arm the render timer. + auto *state = get_state(hWnd); + if (state && state->timer_running) { + const UINT hz = detect_monitor_refresh_hz(hWnd); + const UINT new_interval = std::max(1, 1000 / hz); + if (new_interval != state->timer_interval_ms) { + state->stop_timer(); + state->timer_interval_ms = new_interval; + state->start_timer(); + log_info("configurator", + "WM_DISPLAYCHANGE: render timer {} ms ({} Hz)", + new_interval, hz); + } + } + break; + } case WM_CLOSE: case WM_DESTROY: { @@ -110,62 +369,189 @@ LRESULT CALLBACK cfg::ConfiguratorWindow::window_proc(HWND hWnd, UINT uMsg, WPAR break; } case WM_TIMER: { + if (wParam != RENDER_TIMER_ID) { + break; + } - // update overlay + auto *state = get_state(hWnd); + + // skip rendering when the window is minimized or hidden - the timer is + // already paused on minimize, but defensively skip here as well. + if (state && (state->window_minimized || !IsWindowVisible(hWnd))) { + break; + } + + const bool use_d3d9 = state && state->use_d3d9 && state->device; + + // build the imgui frame (input is already buffered via WM_* messages) if (overlay::OVERLAY) { overlay::OVERLAY->update(); overlay::OVERLAY->set_active(true); overlay::OVERLAY->new_frame(); - overlay::OVERLAY->render(); } - // repaint window - InvalidateRect(hWnd, nullptr, TRUE); + if (use_d3d9) { + const HRESULT cl = state->device->TestCooperativeLevel(); + if (cl == D3DERR_DEVICELOST) { + break; + } + if (cl == D3DERR_DEVICENOTRESET) { + if (overlay::OVERLAY) { + overlay::OVERLAY->reset_invalidate(); + } + const HRESULT reset_hr = state->device->Reset(&state->pp); + if (SUCCEEDED(reset_hr) && overlay::OVERLAY) { + overlay::OVERLAY->reset_recreate(); + } + break; + } + if (FAILED(cl)) { + break; + } + + if (overlay::OVERLAY) { + overlay::OVERLAY->render(); + } + + // skip Clear/Present when ImGui draw data is unchanged; the last + // presented frame stays visible (same fast path as sw_pixels_dirty). + const bool dirty = overlay::OVERLAY && overlay::OVERLAY->d3d9_frame_dirty; + const bool force_present = state && !state->has_valid_draw_hash; + if (dirty || force_present) { + if (state) { + state->has_valid_draw_hash = true; + } + state->device->Clear(0, nullptr, D3DCLEAR_TARGET, + D3DCOLOR_RGBA(20, 18, 18, 255), 1.0f, 0); + if (SUCCEEDED(state->device->BeginScene())) { + if (overlay::OVERLAY) { + overlay::OVERLAY->d3d9_render_draw(force_present); + } + state->device->EndScene(); + } + const HRESULT hr = state->device->Present(nullptr, nullptr, nullptr, nullptr); + if (hr == D3DERR_DEVICELOST) { + break; + } + } + } else { + if (overlay::OVERLAY) { + overlay::OVERLAY->render(); + } + // software path: the overlay's renderer already skipped the costly + // paint_imgui call when the draw data was unchanged. Only invalidate + // the window when those pixels actually changed, or on the first + // frame / after a resize when we lost the previously cached state. + const bool dirty = overlay::OVERLAY && overlay::OVERLAY->sw_pixels_dirty; + const bool force_repaint = state && !state->has_valid_draw_hash; + if (dirty || force_repaint) { + if (state) { + state->has_valid_draw_hash = true; + } + // pass FALSE for bErase - WM_ERASEBKGND already short-circuits, so + // skipping the erase region avoids one extra Win32 round trip. + InvalidateRect(hWnd, nullptr, FALSE); + } + } + break; } case WM_ERASEBKGND: { return 1; } case WM_PAINT: { - - // render overlay - if (overlay::OVERLAY) { - - // get pixel data - int width, height; - uint32_t *pixel_data = overlay::OVERLAY->sw_get_pixel_data(&width, &height); - if (width > 0 && height > 0) { - - // create bitmap - HBITMAP bitmap = CreateBitmap(width, height, 1, 8 * sizeof(uint32_t), pixel_data); - - // prepare paint - PAINTSTRUCT paint{}; - HDC hdc = BeginPaint(hWnd, &paint); - HDC hdcMem = CreateCompatibleDC(hdc); - SetBkMode(hdc, TRANSPARENT); - - // draw bitmap - SelectObject(hdcMem, bitmap); - BitBlt(hdc, paint.rcPaint.left, paint.rcPaint.top, - paint.rcPaint.right - paint.rcPaint.left, - paint.rcPaint.bottom - paint.rcPaint.top, - hdcMem, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY); - - // delete bitmap - DeleteObject(bitmap); - - // clean up - DeleteDC(hdcMem); - EndPaint(hWnd, &paint); - } else { - return DefWindowProc(hWnd, uMsg, wParam, lParam); - } + paint_software(hWnd); + break; + } + case WM_ACTIVATE: { + const WORD activation = LOWORD(wParam); + if (activation == WA_INACTIVE) { + // dropping any held mouse buttons so we don't get stuck in a + // "button still pressed" state when we come back. + auto &io = ImGui::GetIO(); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); + io.AddMouseButtonEvent(ImGuiMouseButton_Right, false); + io.AddMouseButtonEvent(ImGuiMouseButton_Middle, false); } else { - return DefWindowProc(hWnd, uMsg, wParam, lParam); + // WA_ACTIVE or WA_CLICKACTIVE: make sure the keyboard focus is + // actually on this window so WM_MOUSEWHEEL gets routed to us + // again. Without this, scrolling silently stops working after + // the user alt-tabs back to spicecfg. + SetFocus(hWnd); } break; } + case WM_KILLFOCUS: { + // mirror the WA_INACTIVE mouse-button drop for the keyboard - + // without this, a key held during alt-tab stays "down" in ImGui + // state until the user presses and releases it again. + ImGui::GetIO().ClearInputKeys(); + break; + } + // input messages routed straight into ImGui IO. This replaces the previous + // per-frame 256-VK rawinput scan in ImGui_ImplSpice_NewFrame for the + // standalone configurator (see CONFIGURATOR_STANDALONE branches there). + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + case WM_KEYUP: + case WM_SYSKEYUP: { + const bool down = (uMsg == WM_KEYDOWN) || (uMsg == WM_SYSKEYDOWN); + auto &io = ImGui::GetIO(); + const ImGuiKey key = vk_to_imgui_key(wParam); + if (key != ImGuiKey_None) { + io.AddKeyEvent(key, down); + } + const ImGuiKey mod = vk_to_imgui_mod_key(wParam); + if (mod != ImGuiMod_None) { + io.AddKeyEvent(mod, down); + } + break; + } + case WM_MOUSEMOVE: { + auto &io = ImGui::GetIO(); + io.AddMousePosEvent(static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + break; + } + case WM_LBUTTONDOWN: + case WM_LBUTTONUP: + case WM_RBUTTONDOWN: + case WM_RBUTTONUP: + case WM_MBUTTONDOWN: + case WM_MBUTTONUP: { + int button = 0; + bool down = false; + switch (uMsg) { + case WM_LBUTTONDOWN: button = ImGuiMouseButton_Left; down = true; break; + case WM_LBUTTONUP: button = ImGuiMouseButton_Left; down = false; break; + case WM_RBUTTONDOWN: button = ImGuiMouseButton_Right; down = true; break; + case WM_RBUTTONUP: button = ImGuiMouseButton_Right; down = false; break; + case WM_MBUTTONDOWN: button = ImGuiMouseButton_Middle; down = true; break; + case WM_MBUTTONUP: button = ImGuiMouseButton_Middle; down = false; break; + } + auto &io = ImGui::GetIO(); + io.AddMouseButtonEvent(button, down); + if (down) { + SetCapture(hWnd); + } else { + ReleaseCapture(); + } + break; + } + case WM_MOUSEWHEEL: { + const float delta = static_cast(GET_WHEEL_DELTA_WPARAM(wParam)) + / static_cast(WHEEL_DELTA); + ImGui::GetIO().AddMouseWheelEvent(0.0f, delta); + break; + } +#if !SPICE_XP + case WM_MOUSEHWHEEL: { + const float delta = static_cast(GET_WHEEL_DELTA_WPARAM(wParam)) + / static_cast(WHEEL_DELTA); + ImGui::GetIO().AddMouseWheelEvent(delta, 0.0f); + break; + } +#endif default: return DefWindowProc(hWnd, uMsg, wParam, lParam); } diff --git a/src/spice2x/cfg/configurator_wnd.h b/src/spice2x/cfg/configurator_wnd.h index 561b80e..b5ed425 100644 --- a/src/spice2x/cfg/configurator_wnd.h +++ b/src/spice2x/cfg/configurator_wnd.h @@ -1,6 +1,9 @@ -#pragma once +#pragma once + +#include #include +#include namespace cfg { @@ -9,11 +12,38 @@ namespace cfg { HWND hWnd; + // optional D3D9 device backing the configurator window; nullptr when running + // the software-rendered path. Owned by ConfiguratorWindow when set. + IDirect3D9 *d3d = nullptr; + IDirect3DDevice9 *device = nullptr; + D3DPRESENT_PARAMETERS pp {}; + bool use_d3d9 = false; + + // throttling / pause state. timer_interval_ms is the default until run() + // refines it to the actual monitor refresh rate (see detect_monitor_refresh_hz). + UINT timer_interval_ms = 1000 / 60; + bool timer_running = false; + bool window_minimized = false; + + // tracks whether we've issued at least one InvalidateRect since window + // creation/resize. The overlay's per-frame "pixels changed" flag suppresses + // idle blits; this flag forces the very first blit on startup or after + // a resize so the window doesn't show garbage until the user moves the mouse. + bool has_valid_draw_hash = false; + + // dimensions of the configurator client area (kept in sync with WM_SIZE) + int client_width = 0; + int client_height = 0; + ConfiguratorWindow(); ~ConfiguratorWindow(); void run(); + // start/stop the render timer based on visibility state + void start_timer(); + void stop_timer(); + static LRESULT CALLBACK window_proc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); }; } diff --git a/src/spice2x/overlay/imgui/impl_spice.cpp b/src/spice2x/overlay/imgui/impl_spice.cpp index 0c61e3e..9a6c9a2 100644 --- a/src/spice2x/overlay/imgui/impl_spice.cpp +++ b/src/spice2x/overlay/imgui/impl_spice.cpp @@ -369,6 +369,14 @@ void ImGui_ImplSpice_NewFrame() { const auto overlay_visible = overlay::OVERLAY && overlay::OVERLAY->get_active(); const auto accept_new_input = superexit::has_focus() && !rawinput::OS_WINDOW_ACTIVE && overlay_visible; + // when running as standalone spicecfg.exe the configurator window proc feeds + // ImGui directly via WM_KEY*/WM_MOUSE*/WM_MOUSEWHEEL messages. The per-frame + // rawinput device walk and the 256-VK scan below are pure CPU waste in that + // case (and the dominant per-frame cost on low-end PCs), so short-circuit + // them entirely. Rebind dialogs that explicitly need fresh device state call + // RI_MGR->devices_get_updated() themselves; see overlay/windows/config.cpp. + const bool drive_input_from_rawinput = !cfg::CONFIGURATOR_STANDALONE; + // remember old state std::array KeysDownOld; for (size_t i = 0; i < VKEY_MAX; i++) { @@ -378,11 +386,13 @@ void ImGui_ImplSpice_NewFrame() { const auto MouseDownOld = g_MouseDown; // reset keys state - g_MouseDown.fill(false); - g_KeysDown.fill(false); + if (drive_input_from_rawinput) { + g_MouseDown.fill(false); + g_KeysDown.fill(false); + } // apply windows mouse buttons - if (accept_new_input) { + if (accept_new_input && drive_input_from_rawinput) { g_MouseDown[ImGuiMouseButton_Left] |= get_async_primary_mouse(); g_MouseDown[ImGuiMouseButton_Right] |= get_async_secondary_mouse(); g_MouseDown[ImGuiMouseButton_Middle] |= (GetAsyncKeyState(VK_MBUTTON) & 0x8000) != 0; @@ -391,7 +401,7 @@ void ImGui_ImplSpice_NewFrame() { // read new keys state static long mouse_wheel_last = 0; long mouse_wheel = 0; - if (RI_MGR != nullptr) { + if (drive_input_from_rawinput && RI_MGR != nullptr) { auto devices = RI_MGR->devices_get(); for (auto &device : devices) { switch (device.type) { @@ -447,41 +457,45 @@ void ImGui_ImplSpice_NewFrame() { } } - // process keyboard input from all keyboard collapsed into one state (g_KeysDown) - for (size_t vKey = 0; vKey < VKEY_MAX; vKey++) { - const bool state = g_KeysDown[vKey]; - const auto imgui_key = get_imgui_key(vKey); - const auto changed = - (state && !KeysDownOld[vKey]) || - (!state && KeysDownOld[vKey]); + // process keyboard input from all keyboards collapsed into one state (g_KeysDown). + // Skipped entirely in standalone configurator mode where Win32 messages already + // drive io.AddKeyEvent / io.AddInputCharacter directly. + if (drive_input_from_rawinput) { + for (size_t vKey = 0; vKey < VKEY_MAX; vKey++) { + const bool state = g_KeysDown[vKey]; + const auto imgui_key = get_imgui_key(vKey); + const auto changed = + (state && !KeysDownOld[vKey]) || + (!state && KeysDownOld[vKey]); - if (imgui_key != ImGuiKey_None && changed) { - io.AddKeyEvent(imgui_key, state); - log_debug("imgui_impl_spice", "vkey {:#x} added as navigation event, state: {}", static_cast(vKey), state); + if (imgui_key != ImGuiKey_None && changed) { + io.AddKeyEvent(imgui_key, state); + log_debug("imgui_impl_spice", "vkey {:#x} added as navigation event, state: {}", static_cast(vKey), state); - // mod key must also be processed separately - const auto imgui_mod_key = get_imgui_mod_key(vKey); - if (imgui_mod_key != ImGuiMod_None) { - io.AddKeyEvent(imgui_mod_key, state); + // mod key must also be processed separately + const auto imgui_mod_key = get_imgui_mod_key(vKey); + if (imgui_mod_key != ImGuiMod_None) { + io.AddKeyEvent(imgui_mod_key, state); + } } - } - // generate character input, but only if WM_CHAR didn't take over the functionality - // only detecting rising edges here - this means holding a key won't work - // (it's better than repeating a character input every frame - cost we pay for providing input - // on top of rawinput instead of WM_CHAR) - if (!overlay::USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT && !KeysDownOld[vKey] && state) { - UCHAR buf[2]; - auto ret = ToAscii( - static_cast(vKey), - 0, - static_cast(KeysDownOld.data()), - reinterpret_cast(buf), - 0); - if (ret > 0) { - for (int i = 0; i < ret; i++) { - overlay::OVERLAY->input_char(buf[i]); - log_debug("imgui_impl_spice", "vkey {:#x} inputted as character", vKey); + // generate character input, but only if WM_CHAR didn't take over the functionality + // only detecting rising edges here - this means holding a key won't work + // (it's better than repeating a character input every frame - cost we pay for providing input + // on top of rawinput instead of WM_CHAR) + if (!overlay::USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT && !KeysDownOld[vKey] && state) { + UCHAR buf[2]; + auto ret = ToAscii( + static_cast(vKey), + 0, + static_cast(KeysDownOld.data()), + reinterpret_cast(buf), + 0); + if (ret > 0) { + for (int i = 0; i < ret; i++) { + overlay::OVERLAY->input_char(buf[i]); + log_debug("imgui_impl_spice", "vkey {:#x} inputted as character", vKey); + } } } } @@ -490,23 +504,26 @@ void ImGui_ImplSpice_NewFrame() { // set mouse wheel long wheel_diff = mouse_wheel - mouse_wheel_last; mouse_wheel_last = mouse_wheel; - if (wheel_diff != 0 && accept_new_input) { + if (wheel_diff != 0 && accept_new_input && drive_input_from_rawinput) { io.AddMouseWheelEvent(0, wheel_diff); } - // update OS mouse position + // update OS mouse position. The standalone configurator gets mouse position + // straight from WM_MOUSEMOVE, so skip the per-frame cursor poll there. const auto old_mouse_pos = io.MousePos; - if (accept_new_input) { + if (accept_new_input && drive_input_from_rawinput) { ImGui_ImplSpice_UpdateMousePos(); } const auto new_mouse_pos = io.MousePos; // update mouse buttons // doing this after ImGui_ImplSpice_UpdateMousePos since it can set g_MouseDown for touch input - for (size_t i = 0; i < g_MouseDown.size(); i++) { - if (MouseDownOld[i] != g_MouseDown[i]) { - io.AddMouseButtonEvent(i, g_MouseDown[i]); - log_debug("imgui_impl_spice", "mouse button {} event", g_MouseDown[i]); + if (drive_input_from_rawinput) { + for (size_t i = 0; i < g_MouseDown.size(); i++) { + if (MouseDownOld[i] != g_MouseDown[i]) { + io.AddMouseButtonEvent(i, g_MouseDown[i]); + log_debug("imgui_impl_spice", "mouse button {} event", g_MouseDown[i]); + } } } diff --git a/src/spice2x/overlay/overlay.cpp b/src/spice2x/overlay/overlay.cpp index c761868..05366a7 100644 --- a/src/spice2x/overlay/overlay.cpp +++ b/src/spice2x/overlay/overlay.cpp @@ -327,8 +327,9 @@ void overlay::SpiceOverlay::init() { // disable config io.IniFilename = nullptr; - // allow CTRL+WHEEL scaling - io.FontAllowUserScaling = true; + // allow CTRL+WHEEL scaling in the in-game overlay; disable for the standalone + // configurator so the mouse wheel always scrolls the configuration window. + io.FontAllowUserScaling = !cfg::CONFIGURATOR_STANDALONE; // add default font io.Fonts->AddFontDefaultBitmap(); @@ -590,6 +591,57 @@ void overlay::SpiceOverlay::new_frame() { ImGui::EndFrame(); } +// FNV-1a 64-bit hash helper used by the software renderer to detect idle frames. +static inline uint64_t overlay_fnv1a64(uint64_t h, const void *data, size_t len) { + const auto *p = static_cast(data); + for (size_t i = 0; i < len; i++) { + h ^= p[i]; + h *= 0x100000001B3ULL; + } + return h; +} + +// Compute a hash over the visual content of ImDrawData. Only the geometry the +// software rasterizer actually consumes (vertex/index buffers + display size) +// feeds into the hash, so frames where ImGui produces identical draw data +// (i.e. nothing animated this tick) can skip the full pixel rasterization. +static uint64_t overlay_hash_draw_data(const ImDrawData *draw_data) { + uint64_t h = 0xcbf29ce484222325ULL; + if (draw_data == nullptr) { + return h; + } + const float display[4] = { + draw_data->DisplayPos.x, + draw_data->DisplayPos.y, + draw_data->DisplaySize.x, + draw_data->DisplaySize.y, + }; + h = overlay_fnv1a64(h, display, sizeof(display)); + + const int cmd_lists = draw_data->CmdListsCount; + h = overlay_fnv1a64(h, &cmd_lists, sizeof(cmd_lists)); + + for (int i = 0; i < draw_data->CmdListsCount; i++) { + const ImDrawList *cmd_list = draw_data->CmdLists[i]; + if (cmd_list == nullptr) { + continue; + } + const int vtx_count = cmd_list->VtxBuffer.Size; + const int idx_count = cmd_list->IdxBuffer.Size; + h = overlay_fnv1a64(h, &vtx_count, sizeof(vtx_count)); + h = overlay_fnv1a64(h, &idx_count, sizeof(idx_count)); + if (vtx_count > 0) { + h = overlay_fnv1a64(h, cmd_list->VtxBuffer.Data, + static_cast(vtx_count) * sizeof(ImDrawVert)); + } + if (idx_count > 0) { + h = overlay_fnv1a64(h, cmd_list->IdxBuffer.Data, + static_cast(idx_count) * sizeof(ImDrawIdx)); + } + } + return h; +} + void overlay::SpiceOverlay::render() { // skip if new_frame() didn't begin a frame this tick @@ -603,7 +655,28 @@ void overlay::SpiceOverlay::render() { // implementation render switch (this->renderer) { case OverlayRenderer::D3D9: - ImGui_ImplDX9_RenderDrawData(ImGui::GetDrawData()); + if (cfg::CONFIGURATOR_STANDALONE) { + const auto *draw_data = ImGui::GetDrawData(); + const uint64_t draw_hash = overlay_hash_draw_data(draw_data); + const auto &io = ImGui::GetIO(); + const int display_w = static_cast(std::ceil(io.DisplaySize.x)); + const int display_h = static_cast(std::ceil(io.DisplaySize.y)); + const bool size_matches = (this->d3d9_last_display_w == display_w + && this->d3d9_last_display_h == display_h); + if (this->d3d9_has_last_draw_hash + && draw_hash == this->d3d9_last_draw_hash + && size_matches) { + this->d3d9_frame_dirty = false; + break; + } + this->d3d9_last_draw_hash = draw_hash; + this->d3d9_has_last_draw_hash = true; + this->d3d9_last_display_w = display_w; + this->d3d9_last_display_h = display_h; + this->d3d9_frame_dirty = true; + } else { + ImGui_ImplDX9_RenderDrawData(ImGui::GetDrawData()); + } break; #ifdef SPICE_D3D11 case OverlayRenderer::D3D11: @@ -619,6 +692,23 @@ void overlay::SpiceOverlay::render() { auto height = static_cast(std::ceil(io.DisplaySize.y)); auto pixels = width * height; + // skip the (expensive) full software rasterization when the draw data + // is byte-identical to the previous frame and the existing pixel + // buffer still matches the current display size. + const auto *draw_data = ImGui::GetDrawData(); + const uint64_t draw_hash = overlay_hash_draw_data(draw_data); + const bool size_matches = (this->pixel_data_width == width + && this->pixel_data_height == height + && this->pixel_data.size() >= pixels); + if (this->sw_has_last_draw_hash + && draw_hash == this->sw_last_draw_hash + && size_matches) { + this->sw_pixels_dirty = false; + break; + } + this->sw_last_draw_hash = draw_hash; + this->sw_has_last_draw_hash = true; + // make sure buffer is big enough if (this->pixel_data.size() < pixels) { this->pixel_data.resize(pixels, 0); @@ -635,6 +725,7 @@ void overlay::SpiceOverlay::render() { imgui_sw::paint_imgui(&this->pixel_data[0], width, height, options); pixel_data_width = width; pixel_data_height = height; + this->sw_pixels_dirty = true; break; } @@ -647,6 +738,16 @@ void overlay::SpiceOverlay::render() { this->has_pending_frame = false; } +void overlay::SpiceOverlay::d3d9_render_draw(const bool force_submit) { + if (this->renderer != OverlayRenderer::D3D9) { + return; + } + if (!force_submit && !this->d3d9_frame_dirty) { + return; + } + ImGui_ImplDX9_RenderDrawData(ImGui::GetDrawData()); +} + void overlay::SpiceOverlay::update() { // check overlay toggle @@ -795,6 +896,10 @@ void overlay::SpiceOverlay::reset_invalidate() { } switch (overlay::OVERLAY->renderer) { case OverlayRenderer::D3D9: + if (cfg::CONFIGURATOR_STANDALONE) { + overlay::OVERLAY->d3d9_has_last_draw_hash = false; + overlay::OVERLAY->d3d9_frame_dirty = true; + } ImGui_ImplDX9_InvalidateDeviceObjects(); break; #ifdef SPICE_D3D11 diff --git a/src/spice2x/overlay/overlay.h b/src/spice2x/overlay/overlay.h index d7d595c..d6e5f39 100644 --- a/src/spice2x/overlay/overlay.h +++ b/src/spice2x/overlay/overlay.h @@ -83,6 +83,9 @@ namespace overlay { void window_add(Window *wnd); void new_frame(); void render(); + // configurator-only (spicecfg D3D9): submit ImGui draw data inside BeginScene + // after render() when the frame is being presented. In-game overlay draws in render(). + void d3d9_render_draw(bool force_submit = false); void update(); void toggle_active(bool overlay_key = false); void show_main_menu(); @@ -139,6 +142,16 @@ namespace overlay { OverlayRenderer renderer; float total_elapsed = 0.f; + // set to true by the SOFTWARE renderer when the pixel buffer was updated + // in the most recent render() call. Allows the configurator's WM_PAINT + // driver to skip its full-window InvalidateRect on idle frames where the + // ImGui draw data is bitwise-identical to the previous frame. + bool sw_pixels_dirty = false; + + // configurator-only (spicecfg D3D9): set by render() when ImGui draw data + // changed since the last submitted frame; configurator skips Clear/Present when false. + bool d3d9_frame_dirty = false; + private: HWND hWnd = nullptr; @@ -159,6 +172,13 @@ namespace overlay { std::vector pixel_data; size_t pixel_data_width = 0; size_t pixel_data_height = 0; + uint64_t sw_last_draw_hash = 0; + bool sw_has_last_draw_hash = false; + + uint64_t d3d9_last_draw_hash = 0; + bool d3d9_has_last_draw_hash = false; + int d3d9_last_display_w = 0; + int d3d9_last_display_h = 0; std::vector> windows; diff --git a/src/spice2x/overlay/windows/config.cpp b/src/spice2x/overlay/windows/config.cpp index cca69bc..c30301a 100644 --- a/src/spice2x/overlay/windows/config.cpp +++ b/src/spice2x/overlay/windows/config.cpp @@ -78,6 +78,10 @@ namespace overlay::windows { this->flags |= ImGuiWindowFlags_NoTitleBar; this->flags |= ImGuiWindowFlags_NoCollapse; this->flags |= ImGuiWindowFlags_NoDecoration; + // prevent the parent window from absorbing wheel events when a hovered + // child has no scrollable content (otherwise the parent's tiny overflow + // causes the whole UI to jolt by a few pixels each tick) + this->flags |= ImGuiWindowFlags_NoScrollWithMouse; } this->flags |= ImGuiWindowFlags_MenuBar;