181c4f3dd6
- main.cpp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
9.6 KiB
C++
265 lines
9.6 KiB
C++
// 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 <GLFW/glfw3.h>
|
|
#ifdef _WIN32
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#include <windows.h>
|
|
#define GLFW_EXPOSE_NATIVE_WIN32
|
|
#include <GLFW/glfw3native.h>
|
|
#include <thread>
|
|
#endif
|
|
|
|
#include <atomic>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
|
|
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<int> g_render_counter{0}; // bumped every render() call
|
|
std::atomic<bool> g_p2_started{false};
|
|
std::atomic<bool> 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;
|
|
|
|
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;
|
|
}
|