diff --git a/main.cpp b/main.cpp index f04f2e8..0ed94c8 100644 --- a/main.cpp +++ b/main.cpp @@ -1,30 +1,47 @@ -// altsnap_jitter_test — automated regression test for the multi-viewport +// altsnap_jitter_test — automated regression tests for the multi-viewport // jitter that AltSnap (and other Win32 window movers) trigger on the C++ -// app shell. The test spawns the standard fn::run_app with viewports ON, -// then drives the main GLFW window with glfwSetWindowPos every frame to -// emulate an external mover (AltSnap, tiling WM, snap-assist, ...). For -// each frame it samples the actual OS window position and ImGui's main -// viewport->Pos, and asserts both stay in sync within a 1-pixel tolerance -// after a 1-frame settle window. Without the anti-jitter patch in -// app_base.cpp the two diverge and the test exits 1. +// app shell. The harness runs two phases inside one fn::run_app session: // -// Linux/WSL: runs the same loop. With xvfb the window manager honors -// glfwSetWindowPos, so the test is meaningful in CI. On a real WSL X -// display some compositors clamp positions; the test logs and accepts -// "OS clamped" frames as long as ImGui tracks the clamped value. +// Phase 1 — Sync test (cross-platform). +// Drives the OS window with glfwSetWindowPos every frame and asserts +// ImGui's main viewport->Pos tracks the actual OS pos within 1px. This +// catches the original "ImGui Pos lags 1 frame, UpdatePlatformWindows +// reapplies stale value" bug fixed by the GLFW pos callback in +// cpp/framework/app_base.cpp. // -// Windows (target): cross-compiled via build_cpp_windows, deployed to -// the Desktop apps dir, launched via cmd.exe; this is where the original -// AltSnap symptom lives, so any divergence here is the real regression. +// Phase 2 — AltSnap simulation (Windows-only, no-op on Linux). +// A worker thread reproduces AltSnap's exact wire format: +// 1. PostMessage(hwnd, WM_ENTERSIZEMOVE) — fake bracket open +// 2. Burst of SetWindowPos(SWP_ASYNCWINDOWPOS) — async drag steps +// 3. PostMessage(hwnd, WM_EXITSIZEMOVE) — fake bracket close +// During the bracket the framework's WndProc subclass must observe +// ENTERSIZEMOVE and the main loop must STOP rendering / swapping +// buffers (mirroring native title-bar drag, where DefWindowProc blocks +// the app thread). The test counts render() invocations during the +// bracket: zero = pass, anything >0 = the fix didn't engage and the +// visible "grab and release" jitter would still appear. +// +// Linux/WSL: only Phase 1 runs. With xvfb the WM honors glfwSetWindowPos, +// so the sync test is meaningful. Phase 2 returns immediately. +// +// Windows (target): cross-compiled via build_cpp_windows, deployed to the +// Desktop apps dir, launched via cmd.exe; this is where AltSnap lives, so +// any Phase 2 failure here is the real regression. #include "app_base.h" #include "imgui.h" #include -#define GLFW_EXPOSE_NATIVE_WIN32 #ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include + #define GLFW_EXPOSE_NATIVE_WIN32 #include + #include #endif +#include #include #include #include @@ -32,35 +49,92 @@ namespace { -struct Sample { - int frame = 0; - int target_x = 0; - int target_y = 0; - int actual_x = 0; - int actual_y = 0; - float vp_x = 0.0f; - float vp_y = 0.0f; -}; - -constexpr int kWarmupFrames = 8; // wait for the platform to settle -constexpr int kTestFrames = 60; // measured frames -constexpr int kStepPx = 4; // px / frame +// ----- Phase 1 (sync test) tunables ----- +constexpr int kWarmupFrames = 8; +constexpr int kSyncFrames = 60; +constexpr int kStepPx = 4; constexpr int kBaseX = 200; constexpr int kBaseY = 200; -constexpr float kSyncTolerance = 1.0f; // ImGui viewport vs OS pos -constexpr int kClampTolerance = 6; // OS-applied vs target (compositor clamp ok up to this) +constexpr float kSyncTolerance = 1.0f; +constexpr int kClampTolerance = 6; +// ----- Phase 2 (AltSnap sim) tunables ----- +constexpr int kAltSnapSteps = 30; // SetWindowPos calls in the burst +constexpr int kAltSnapStepMs = 16; // delay between calls (~60 Hz) +constexpr int kAltSnapStepPx = 6; +constexpr int kAltSnapBaseX = 500; +constexpr int kAltSnapBaseY = 200; + +// ----- shared state ----- GLFWwindow* g_window = nullptr; int g_frame = 0; -int g_max_sync = 0; -int g_max_clamp = 0; -int g_bad_sync = 0; // frames where vp diverges from OS pos > tolerance -int g_bad_clamp = 0; // frames where OS pos diverges from target > clamp tolerance -bool g_done = false; + +// Phase 1 metrics +int g_p1_max_sync = 0; +int g_p1_max_clamp = 0; +int g_p1_bad_sync = 0; +int g_p1_bad_clamp = 0; +bool g_p1_done = false; + +// Phase 2 state +std::atomic g_render_counter{0}; // bumped every render() call +std::atomic g_p2_started{false}; +std::atomic g_p2_done{false}; +int g_p2_renders_before = 0; +int g_p2_renders_during = 0; +int g_p2_renders_after = 0; +bool g_p2_skipped = false; + +#ifdef _WIN32 +void altsnap_worker(HWND hwnd) { + // Wait briefly so the render loop is firmly running and the test app's + // counters are stable before we start counting. + Sleep(50); + + g_p2_renders_before = g_render_counter.load(std::memory_order_acquire); + + // Open the fake sizemove bracket. AltSnap uses PostMessage so the + // target thread processes the message in its own pump. SendMessage + // would re-enter and is harder to reason about. + PostMessageW(hwnd, WM_ENTERSIZEMOVE, 0, 0); + + // Give the target thread one tick to observe the bracket open BEFORE + // we measure the "during" baseline. + Sleep(kAltSnapStepMs); + int during_baseline = g_render_counter.load(std::memory_order_acquire); + + // Burst SetWindowPos calls exactly like AltSnap's MoveResizeWindowNow_ + // path: SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_NOACTIVATE + // | SWP_ASYNCWINDOWPOS. The async flag is the key — calls return + // immediately and the moves arrive on the target's queue. + UINT flags = SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER + | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS; + for (int i = 0; i < kAltSnapSteps; ++i) { + int x = kAltSnapBaseX + i * kAltSnapStepPx; + int y = kAltSnapBaseY; + SetWindowPos(hwnd, nullptr, x, y, 0, 0, flags); + Sleep(kAltSnapStepMs); + } + + int during_after = g_render_counter.load(std::memory_order_acquire); + g_p2_renders_during = during_after - during_baseline; + + // Close the fake bracket. The framework should clear in_sizemove and + // resume rendering on the next iteration of its main loop. + PostMessageW(hwnd, WM_EXITSIZEMOVE, 0, 0); + + // Give the target thread time to render a few frames after the close. + Sleep(200); + g_p2_renders_after = g_render_counter.load(std::memory_order_acquire) - during_after; + + g_p2_done.store(true, std::memory_order_release); +} +#endif // _WIN32 void render() { - // Locate the main window once. ImGui_ImplGlfw stashes it on the main - // viewport's PlatformHandle right after init. + // Bump render counter first thing — Phase 2 measures this. + g_render_counter.fetch_add(1, std::memory_order_acq_rel); + if (!g_window) { if (ImGuiViewport* vp = ImGui::GetMainViewport()) { g_window = (GLFWwindow*)vp->PlatformHandle; @@ -73,54 +147,83 @@ void render() { return; } + // ----------------------------------------------------------------- + // Phase 1 — sync test + // ----------------------------------------------------------------- int rel = g_frame - kWarmupFrames; - if (rel < kTestFrames) { - // Drive the OS window from inside the render loop. Mimics what an - // external mover (AltSnap, tiling WM) would do: a fresh SetWindowPos - // every tick. With the fix in place ImGui's main viewport->Pos must - // follow within the same frame because of the GLFW pos callback. + if (rel < kSyncFrames) { int tx = kBaseX + rel * kStepPx; int ty = kBaseY; glfwSetWindowPos(g_window, tx, ty); - // Sample AFTER the SetWindowPos. The callback fires synchronously on - // most platforms; if not, the per-frame sync pass in app_base.cpp - // covers it before this render() call on the next frame. We measure - // both here for diagnostics. int ax = 0, ay = 0; glfwGetWindowPos(g_window, &ax, &ay); ImVec2 vp = ImGui::GetMainViewport()->Pos; - int sync_dx = (int)std::abs(vp.x - (float)ax); - int sync_dy = (int)std::abs(vp.y - (float)ay); - int clamp_dx = std::abs(ax - tx); - int clamp_dy = std::abs(ay - ty); - int sync_d = std::max(sync_dx, sync_dy); - int clamp_d = std::max(clamp_dx, clamp_dy); + int sync_d = std::max((int)std::abs(vp.x - (float)ax), + (int)std::abs(vp.y - (float)ay)); + int clamp_d = std::max(std::abs(ax - tx), std::abs(ay - ty)); - if ((float)sync_d > kSyncTolerance) ++g_bad_sync; - if (clamp_d > kClampTolerance) ++g_bad_clamp; - g_max_sync = std::max(g_max_sync, sync_d); - g_max_clamp = std::max(g_max_clamp, clamp_d); + if ((float)sync_d > kSyncTolerance) ++g_p1_bad_sync; + if (clamp_d > kClampTolerance) ++g_p1_bad_clamp; + g_p1_max_sync = std::max(g_p1_max_sync, sync_d); + g_p1_max_clamp = std::max(g_p1_max_clamp, clamp_d); - // Verbose first/last frame for log readability. - if (rel == 0 || rel == kTestFrames - 1 || (rel % 10 == 0)) { + if (rel == 0 || rel == kSyncFrames - 1 || (rel % 10 == 0)) { std::fprintf(stdout, - "[altsnap_jitter] f=%d target=(%d,%d) actual=(%d,%d) vp=(%.1f,%.1f) sync_d=%d clamp_d=%d\n", + "[p1.sync] f=%d target=(%d,%d) actual=(%d,%d) vp=(%.1f,%.1f) sync_d=%d clamp_d=%d\n", rel, tx, ty, ax, ay, vp.x, vp.y, sync_d, clamp_d); std::fflush(stdout); } ++g_frame; return; } - - if (!g_done) { + if (!g_p1_done) { std::fprintf(stdout, - "[altsnap_jitter] DONE frames=%d max_sync_divergence=%dpx max_clamp_divergence=%dpx bad_sync=%d bad_clamp=%d\n", - kTestFrames, g_max_sync, g_max_clamp, g_bad_sync, g_bad_clamp); + "[p1.sync] DONE frames=%d max_sync=%dpx max_clamp=%dpx bad_sync=%d bad_clamp=%d\n", + kSyncFrames, g_p1_max_sync, g_p1_max_clamp, g_p1_bad_sync, g_p1_bad_clamp); std::fflush(stdout); - g_done = true; + g_p1_done = true; } + + // ----------------------------------------------------------------- + // Phase 2 — AltSnap simulation (Windows only) + // ----------------------------------------------------------------- +#ifdef _WIN32 + if (!g_p2_started.exchange(true, std::memory_order_acq_rel)) { + HWND hwnd = glfwGetWin32Window(g_window); + if (!hwnd) { + g_p2_skipped = true; + g_p2_done.store(true, std::memory_order_release); + } else { + std::fprintf(stdout, + "[p2.altsnap] starting worker — bracket=ENTERSIZEMOVE -> %d SetWindowPos -> EXITSIZEMOVE\n", + kAltSnapSteps); + std::fflush(stdout); + std::thread(altsnap_worker, hwnd).detach(); + } + } +#else + if (!g_p2_started.exchange(true, std::memory_order_acq_rel)) { + g_p2_skipped = true; + g_p2_done.store(true, std::memory_order_release); + } +#endif + + if (!g_p2_done.load(std::memory_order_acquire)) { + ++g_frame; + return; + } + + // Phase 2 finished — log and exit. + if (g_p2_skipped) { + std::fprintf(stdout, "[p2.altsnap] SKIPPED (non-Windows)\n"); + } else { + std::fprintf(stdout, + "[p2.altsnap] DONE renders before=%d during=%d after=%d (during should be 0)\n", + g_p2_renders_before, g_p2_renders_during, g_p2_renders_after); + } + std::fflush(stdout); glfwSetWindowShouldClose(g_window, GLFW_TRUE); } @@ -133,8 +236,8 @@ int main(int argc, char** argv) { cfg.title = "altsnap_jitter_test"; cfg.width = 800; cfg.height = 600; - cfg.vsync = false; // run as fast as possible - cfg.viewports = true; // exact config that exhibits the bug + cfg.vsync = false; + cfg.viewports = true; cfg.init_gl_loader = false; int rc = fn::run_app(cfg, render); @@ -143,10 +246,19 @@ int main(int argc, char** argv) { return rc; } - // Pass criterion: every measured frame must have ImGui viewport tracking - // the OS-reported window pos within 1px. A frame where the OS clamped - // the position (compositor) is fine as long as ImGui follows the clamp. - bool pass = (g_bad_sync == 0); - std::fprintf(stdout, "[altsnap_jitter] %s\n", pass ? "PASS" : "FAIL"); + // Pass criteria: + // p1_bad_sync == 0 (existing GLFW callback fix still works) + // p2_renders_during == 0 (subclass + main-loop gate kicked in) + // p2_renders_after > 0 (rendering resumed after EXITSIZEMOVE) + bool p1_pass = (g_p1_bad_sync == 0); + bool p2_pass = g_p2_skipped + || (g_p2_renders_during == 0 && g_p2_renders_after > 0); + bool pass = p1_pass && p2_pass; + + std::fprintf(stdout, + "[altsnap_jitter] p1=%s p2=%s overall=%s\n", + p1_pass ? "PASS" : "FAIL", + g_p2_skipped ? "SKIP" : (p2_pass ? "PASS" : "FAIL"), + pass ? "PASS" : "FAIL"); return pass ? 0 : 1; }