// 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 harness runs two phases inside one fn::run_app session: // // 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. // // 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 #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 #include namespace { // ----- 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; 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; // 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() { // 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; } } if (!g_window) return; if (g_frame < kWarmupFrames) { ++g_frame; return; } // ----------------------------------------------------------------- // Phase 1 — sync test // ----------------------------------------------------------------- int rel = g_frame - kWarmupFrames; if (rel < kSyncFrames) { int tx = kBaseX + rel * kStepPx; int ty = kBaseY; glfwSetWindowPos(g_window, tx, ty); int ax = 0, ay = 0; glfwGetWindowPos(g_window, &ax, &ay); ImVec2 vp = ImGui::GetMainViewport()->Pos; 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_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); if (rel == 0 || rel == kSyncFrames - 1 || (rel % 10 == 0)) { std::fprintf(stdout, "[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_p1_done) { std::fprintf(stdout, "[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_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); } } // namespace int main(int argc, char** argv) { (void)argc; (void)argv; fn::AppConfig cfg; cfg.title = "altsnap_jitter_test"; cfg.width = 800; cfg.height = 600; cfg.vsync = false; cfg.viewports = true; cfg.init_gl_loader = false; cfg.about = {"altsnap_jitter_test", "0.1.0", "Smoke test anti-jitter (AltSnap, snap-assist) para fn::run_app."}; cfg.log = {"altsnap_jitter_test.log", 1}; cfg.auto_dockspace = false; // test no necesita dockspace int rc = fn::run_app(cfg, render); if (rc != 0) { std::fprintf(stderr, "[altsnap_jitter] run_app returned %d\n", rc); return rc; } // 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; }