Skip to content

Win32 — F02: resize correctness ​

Findings from ./examples/f02-resize/app.d, the Win32 implementation of the F02 spec: a continuously redrawn, corner-anchored gradient (corner values verified in the backbuffer after every draw) surviving a 14-step programmatic SetWindowPos storm — pure grows, pure shrinks, mixed, move-only and a same-size no-op — with every WM_SIZING / WM_SIZE / WM_WINDOWPOSCHANGING / WM_WINDOWPOSCHANGED / WM_ERASEBKGND / WM_PAINT logged with its payload. Two variants are flag toggles: WSI_NO_INVALIDATE=1 reproduces the scaffold's stale-shrink artifact, and WSI_GROW_ONLY=1 switches the DIB allocation strategy from realloc-per-resize to grow-only reuse.

Last reviewed: June 10, 2026

IMPORTANT

Everything observed below is A[wine] — measured under Wine 10.0 (wine64, null display driver, headless) with the exe cross-compiled by LDC 1.41.0 (-mtriple=x86_64-pc-windows-msvc). Wine is a reimplementation, not Windows: message orderings and timings are Wine's, and statements about them are phrased as such. Findings flagged as possibly Wine-specific are queued for confirmation on the Windows CI runner.


The demo ​

Three bounded runs (WSI_AUTO_EXIT=1: ~0.5 s of 16 ms-timer animation, then the storm, then DestroyWindow), all of which exited 0:

RunFlagsFramesbuffer_allocbuffer_reusestale_contentpaint_check failures
default—4412000
stale probeWSI_NO_INVALIDATE=13712040
grow-only DIBWSI_GROW_ONLY=1445700

The storm's 14 SetWindowPos steps (outer sizes; Wine's default frame costs 8 px horizontally, 34 px vertically): grows 520×360 → 640×480 → 800×600 → 1024×768, shrinks 700×500 → 500×350 → 320×240, mixed 240×640 → 640×240, two move-only steps (SWP_NOSIZE), one same-size/same-position step, then grow 800×600 and shrink back to 480×320. After each step the demo invalidates (unless WSI_NO_INVALIDATE=1) and forces a synchronous paint with UpdateWindow, then logs a step_result painted= verdict by comparing the frame counter.

Artifact freedom is checked, not eyeballed: after every gradient draw the four corners of the current client-size region are compared against the expected channel extremes (paint_check); a stale or wrongly-strided buffer fails immediately. All three runs reported paint_checks failed=0, and with the fix active every size-changing step reported painted=1 — no step presented a frame anchored to a previous size.


Annotated message sequences — A[wine] ​

Create + show (timestamps µs since init_start) ​

text
12869  step name=CreateWindowExW
13215  msg name=WM_NCCREATE
13868  msg name=WM_NCCALCSIZE
14107  msg name=WM_CREATE
14317  window_created
14469  step name=ShowWindow
14646  msg name=WM_SHOWWINDOW shown=1
14864  msg name=WM_WINDOWPOSCHANGING pos=0,0 size=0x0 flags=0x0043
18185  msg name=WM_ERASEBKGND
18391  msg name=WM_WINDOWPOSCHANGED pos=0,0 size=480x320 flags=0x1847
18734  msg name=WM_SIZE wparam=0 size=472x286
18981  first_configure size=472x286
19416  buffer_alloc size=472x286 cap=472x286 bytes=539968
19716  msg name=WM_MOVE pos=4,30
19914  step name=UpdateWindow
20111  msg name=WM_PAINT
20752  first_pixel_presented size=472x286

Same shape as the scaffold (and as F01's default mode), now with the WINDOWPOS payloads visible: WM_WINDOWPOSCHANGING carries the proposed geometry (flags=0x0043 = SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW — a show, not a resize), and WM_WINDOWPOSCHANGED the final outer geometry (480x320), from which the client size 472x286 is announced via WM_SIZE (wparam=0 = SIZE_RESTORED). WM_ERASEBKGND still precedes the size announcement (A[wine], flagged for the Windows runner).

Pure grow (storm step i=0, default run) ​

text
504700 step name=SetWindowPos i=0 kind=grow pos=0,0 size=520x360
505032 msg name=WM_WINDOWPOSCHANGING pos=0,0 size=520x360 flags=0x0016
505432 msg name=WM_NCCALCSIZE
506338 msg name=WM_ERASEBKGND
506659 msg name=WM_WINDOWPOSCHANGED pos=0,0 size=520x360 flags=0x1016
507011 msg name=WM_SIZE wparam=0 size=512x326
507546 buffer_alloc size=512x326 cap=512x326 bytes=667648
507877 msg name=WM_PAINT
508597 frame_callback t=508596 frame=31 size=512x326
508925 step_result i=0 kind=grow painted=1 client=512x326

The full pattern — proposed (WM_WINDOWPOSCHANGING) → non-client recalculation (WM_NCCALCSIZE) → erase of the newly exposed region → final (WM_WINDOWPOSCHANGED) → client-size notification (WM_SIZE) → DIB realloc → paint — runs re-entrantly, before SetWindowPos returns. Every requested size was granted verbatim.

Pure shrink, fix active (step i=6, default run) ​

text
535735 step name=SetWindowPos i=6 kind=shrink pos=0,0 size=320x240
536075 msg name=WM_WINDOWPOSCHANGING pos=0,0 size=320x240 flags=0x0016
536458 msg name=WM_NCCALCSIZE
537102 msg name=WM_WINDOWPOSCHANGED pos=0,0 size=320x240 flags=0x1016
537467 msg name=WM_SIZE wparam=0 size=312x206
537992 buffer_alloc size=312x206 cap=312x206 bytes=257088
538358 msg name=WM_PAINT
538781 frame_callback t=538781 frame=37 size=312x206
540156 step_result i=6 kind=shrink painted=1 client=312x206

Note the missing WM_ERASEBKGND: a shrink exposes nothing, so the system invalidates nothing. The WM_PAINT at 538358 exists only because the demo's explicit InvalidateRect requested it.

Pure shrink, fix disabled (WSI_NO_INVALIDATE=1, step i=4) — the stale-content artifact ​

text
525326 step name=SetWindowPos i=4 kind=shrink pos=0,0 size=700x500
525672 msg name=WM_WINDOWPOSCHANGING pos=0,0 size=700x500 flags=0x0016
526049 msg name=WM_NCCALCSIZE
527246 msg name=WM_WINDOWPOSCHANGED pos=0,0 size=700x500 flags=0x1016
527608 msg name=WM_SIZE wparam=0 size=692x466
528231 buffer_alloc size=692x466 cap=692x466 bytes=1289888
528552 step_result i=4 kind=shrink painted=0 client=692x466
528867 stale_content i=4 kind=shrink was=1016x734 now=692x466
       note=window_still_shows_frame_anchored_to_old_size

No WM_ERASEBKGND, no WM_PAINT — UpdateWindow was a no-op, exactly as documented:

The UpdateWindow function updates the client area of the specified window by sending a WM_PAINT message to the window if the window's update region is not empty. … If the update region is empty, no message is sent.

— UpdateWindow, Microsoft Learn

The retained window surface still covers the smaller client area, so the window keeps showing the previous frame's gradient, anchored to 1016×734, in a 692×466 window — the F02 artifact class. All four pure shrinks in this run (i=4,5,6,13) logged painted=0 + stale_content; both mixed steps (i=7,8) still painted, because their one growing dimension makes the system invalidate the newly exposed strip (their logs show WM_ERASEBKGND + WM_PAINT even without the demo's InvalidateRect). For a continuously-animating app on Win32, self-invalidation on resize exists only for grows; shrink correctness is entirely the app's responsibility. (A[wine]; the mechanism matches the UpdateWindow/InvalidateRect contract, so it is expected to hold on Windows — CI confirms.)

Move-only (SWP_NOSIZE, step i=9, stale-probe run) ​

text
547332 step name=SetWindowPos i=9 kind=move pos=120,120 size=0x0
547692 msg name=WM_WINDOWPOSCHANGING pos=120,120 size=0x0 flags=0x0015
548158 msg name=WM_WINDOWPOSCHANGED pos=120,120 size=640x240 flags=0x0815
548548 msg name=WM_MOVE pos=124,150
548776 step_result i=9 kind=move painted=0 client=632x206

No WM_NCCALCSIZE, no WM_SIZE, no paint: DefWindowProcW synthesizes only WM_MOVE from this WM_WINDOWPOSCHANGED —

By default, the DefWindowProc function sends the WM_SIZE and WM_MOVE messages to the window. The WM_SIZE and WM_MOVE messages are not sent if an application handles the WM_WINDOWPOSCHANGED message without calling DefWindowProc.

— WM_WINDOWPOSCHANGED, Microsoft Learn

WM_MOVE reports the client area's screen position (124,150 = outer 120,120 plus Wine's 4,30 frame offset). The raw flags= values are the WINDOWPOS flags; the bits beyond the documented SWP_* set (0x0800, 0x1000) are user32-internal markers correlating with which of WM_SIZE/WM_MOVE gets synthesized — logged raw, no claim beyond that.

Same size, same position (step i=11, stale-probe run) ​

text
550799 step name=SetWindowPos i=11 kind=same pos=0,0 size=640x240
551137 msg name=WM_WINDOWPOSCHANGING pos=0,0 size=640x240 flags=0x0016
551609 step_result i=11 kind=same painted=0 client=632x206

A genuinely no-op SetWindowPos still delivers WM_WINDOWPOSCHANGING — "a window whose size, position, or place in the Z order is about to change" (WM_WINDOWPOSCHANGING) — but WM_WINDOWPOSCHANGED and everything after it are suppressed once the system determines nothing changed (A[wine]; worth confirming on Windows). The proposed-change hook fires on intent, the changed notification only on effect.


Who picks the final size ​

Notification, not negotiation (A[wine], consistent with the documented model): the app requests an outer rectangle; every requested size in the storm was granted verbatim and announced after the fact via WM_SIZE — "[s]ent to a window after its size has changed" (WM_SIZE). There is no ack step and no compositor counter-offer (contrast Wayland's configure/ack/commit in the F02 spec). The app's veto/clamp points are all hooks on the way in:

  • WM_GETMINMAXINFO — clamp min/max tracking sizes (not exercised);
  • WM_WINDOWPOSCHANGING — "[d]uring this message, modifying any of the values in WINDOWPOS affects the window's new size, position, or place in the Z order" (not exercised);
  • WM_NCCALCSIZE — decides how much of the outer rectangle becomes client area (left to DefWindowProcW: outer 480×320 → client 472×286 under Wine's theme).

Buffer strategy: realloc-per-resize vs grow-only reuse ​

Both strategies are implemented behind WSI_GROW_ONLY and produced identical visual results (paint_checks failed=0, all steps painted=1):

Metricrealloc (default)grow-only (WSI_GROW_ONLY=1)
CreateDIBSection calls12 (1 create + 11 size steps)5 (1 create + the 4 pure grows)
buffer_reuse (stride-drawn)07 (every shrink/mixed/return step)
Peak DIB2,982,976 B, freed on shrink2,982,976 B (cap=1016x734), held to exit
Storm wall time~70.7 ms~68.5 ms

At this storm rate the difference is noise (A[wine]): CreateDIBSection plus the realloc dance cost roughly what the stride bookkeeping saves. The real trade-off is peak memory held vs allocator churn — grow-only pins the high-water-mark buffer (2.9 MB here) for the window's remaining lifetime, while realloc returns memory on every shrink but creates/destroys a kernel GDI object per resize. A grow-only (or pooled) strategy becomes interesting for interactive resize, where WM_SIZING storms arrive at mouse-move rate — that path is F03's measurement. One correctness note: a reused larger-than-client DIB must be drawn through its allocation stride (cap width), not the client width — the demo's paint_check corners would fail instantly otherwise.

The modal-resize gap (WM_SIZING) ​

Across all three runs: wm_sizing_count=0, wm_entersizemove_count=0. A programmatic SetWindowPos storm cannot reach the interactive sizing path — WM_SIZING is "[s]ent to a window that the user is resizing", i.e. only from the modal loop a border drag enters (WM_ENTERSIZEMOVE → WM_SIZING… → WM_EXITSIZEMOVE). The demo logs and counts all three so their absence is itself recorded evidence. Interactive border-drag resize on Win32 is therefore Tier C for this feature (per the spec's verification note), and the modal-loop pathology — the pump being held hostage during the drag — is exercised by F03.


Build & run — A[wine] ​

The scaffold's verified pipeline, run in docs/research/window-system-integration/os-apis/win32/examples/f02-resize/:

bash
nix develop .#win32 -c win32-ldc2 app.d instrument.d -of=build/f02-resize.exe
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 \
    nix develop .#win32 -c wine64 ./build/f02-resize.exe                    # default
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 WSI_NO_INVALIDATE=1 \
    nix develop .#win32 -c wine64 ./build/f02-resize.exe                    # stale probe
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 WSI_GROW_ONLY=1 \
    nix develop .#win32 -c wine64 ./build/f02-resize.exe                    # grow-only DIB

All three exit 0. Without WSI_AUTO_EXIT=1 the demo animates until the user closes the window — an interactive border drag would then exercise (and log) the WM_SIZING path. The package's dub.sdl (platforms "windows") exists for the Windows CI runner; locally dub is not part of the pipeline, and the demo must not be run under wine explorer /desktop=… (it swallows stdout and the exit code).


Sources ​