Win32 platform internals
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Real-host (native Windows) execution is not established here: Wine is not Windows, the native MSVC workflow runs only on manual dispatch, and nothing was executed for this page. CNA's own Wine results are quoted with attribution.
The native Win32 backend (CNA_PLATFORM=WIN32) implements CNA's platform contract directly on user32, gdi32, WGL and the Windows shell, with no SDL anywhere in it. Win32Platform owns the service objects and one event queue; Win32Window owns or borrows an HWND and runs the window procedure; Win32EventMapper turns window messages into backend-neutral PlatformEvent values; a tagged native handle plus narrow WGL, Vulkan and GDI-presenter services connect renderers to the window. This page is the source-level map for a maintainer changing that code. What the backend offers a game, and what it lacks, is in the Native platforms guide; the seven-platform capability matrix is on Platform Support.
Selection and construction
cmake/PlatformSelection.cmake appends WIN32 to the offered platforms only inside if(WIN32), that is, when the target is Windows: a native MSVC or MinGW configure, or a mingw-w64 cross-build from Linux through cmake/toolchains/mingw-w64.cmake. On every other target WIN32 sits in the reserved list, so -DCNA_PLATFORM=WIN32 fails configuration with the "reserved identifier that is NOT implemented" message, which names the mingw-w64 toolchain file. Nothing falls back to SDL3. The mirror image holds for TERMINAL, which is reserved on Windows because it is built on termios.
modules/platform/CMakeLists.txt globs src/Win32/*.cpp only for this selection, because every translation unit there includes <windows.h>. It links user32 gdi32 opengl32 ole32 shell32 uuid PRIVATE: these are operating-system import libraries, and none of them becomes a usage requirement of CNA's consumers. vulkan-1.dll is deliberately not linked. Every Win32 source reaches <windows.h> through one header, Win32Common.hpp. It defines UNICODE, NOMINMAX and WIN32_LEAN_AND_MEAN, sets _WIN32_WINNT/WINVER to 0x0603, and #undefs five macros that would silently rename contract members: CreateWindow, CreateDirectory, MessageBox, GetClassName and the internal FindWindow. The source comment explains that the 0x0603 level raises what the headers declare, not what the binary requires, because newer DPI entry points are resolved at run time. A host application that includes both <windows.h> and CNA platform headers needs the first four of those #undef lines. The test Win32SourceAudit.WindowsIsIncludedOnlyThroughTheModulesOneGuardedEntryPoint keeps the single-entry rule in place.
modules/platform/src/PlatformFactory.cpp resolves the compile definition CNA_PLATFORM_WIN32 to the default name "Win32", so PlatformFactory::Create() constructs Win32::Win32Platform. GetAvailable() lists "Win32" and the always-compiled "Headless". Terminal is not compiled on Windows. The conformance suites are instantiated over GetAvailable() (INSTANTIATE_TEST_SUITE_P(EveryImplementation, …) in PlatformConformanceTests.cpp), so that list is what enrols Win32 in them. "One selected platform" therefore does not mean one platform class per binary.
The constructor in Win32Platform.cpp acquires no subsystem. It builds the service members declared in Win32Platform.hpp: clipboard, displays, dialogs, file system, system information, keyboard, mouse, text input, input-device enumeration, the WGL context service and the Vulkan surface service. The services that must resolve windows (displays, dialogs, mouse, text input, GL, Vulkan) receive the platform through the narrow Win32PlatformAccess interface. The constructor also reads the QueryPerformanceFrequency value once, falling back to 1 so the contract's "never zero" holds, and records a creation counter. Two members have host-visible construction effects:
Win32Dialogsholds aWin32ComRuntime(Win32ComRuntime.hpp). It callsCoInitializeEx(COINIT_APARTMENTTHREADED)on the constructing thread and releases only a reference it actually took. WhenS_OKorS_FALSEis returned the reference is owned.RPC_E_CHANGED_MODEmeans the host already chose another apartment: COM is still reported usable and nothing is released.Win32VulkanSurfacecallsLoadLibraryW(L"vulkan-1.dll")and resolvesvkGetInstanceProcAddr. If either step fails the service is simply unavailable, and the library is freed again when the platform is destroyed.
| Capability result | Members of PlatformCapabilities | Accessor behaviour |
|---|---|---|
| Always true | multipleWindows, highDpi, multipleDisplays, borderlessFullscreen, nativeWindowHandle, surfacePresentation, openGlContext, clipboard, textInput, exactKeyboardState, pixelAccurateMouse, relativeMouse, cursorShapes, globalPointer, inputDeviceEnumeration, powerInfo, messageBox, nativeFileDialog | Keyboard, mouse, text input, input devices, clipboard, displays, dialogs, file system, system info and GL are never null |
| Host-dependent | vulkanSurface = the loader was found (Win32VulkanSurface::IsAvailable) | GetVulkanSurface() returns null exactly when this is false |
| Always false | ime, gamepad, joystick, gamepadRumble, gamepadSensors, haptics, sensors, tray, camera, managedEntrypoint, dragAndDrop, primarySelection, clipboardData | GetGamepad, GetJoystick, GetSensors, GetHaptics, GetTray, GetCamera return null; GetPrimarySelection inherits the null default |
Do not infer support from the generic interface or from a subsystem enumerator. ime stays false even though text input is true. Win32TextInput::SetInputArea records the rectangle for future candidate-window work but applies nothing. AcquireSubsystem increments a per-instance count for all five PlatformSubsystem values, including Gamepad, Haptic and Sensor. No native initialisation happens, and acquisition is not refused, because the contract distinguishes "no such subsystem" from "nothing attached". ReleaseSubsystem on a count that is already zero returns silently, because GraphicsDevice::Dispose releases video unconditionally and cleanup may run after partial initialisation. One consequence: once a game asks for controllers, IsSubsystemInitialized(Gamepad) becomes true and Game::PollEvents would pump controllers, but GetGamepad() returns null, so nothing is pumped. CreateWindow also has no video-subsystem precondition: a Win32 process can create a window as soon as it has a message queue.
Window creation, adoption and lifetime
PlatformFactory::Create() → Win32Platform (default name "Win32")
GraphicsDevice → IPlatform::CreateWindow(WindowDescription) → unique_ptr<IPlatformWindow>
→ Win32Window(description, id, host)
→ Win32WindowClass: refcounted RegisterClassExW "CnaPlatformWindow"
CS_OWNDC | CS_DBLCLKS | CS_HREDRAW | CS_VREDRAW, no background brush
→ client size → outer size at the SYSTEM DPI (AdjustWindowRectExForDpi)
→ CreateWindowExW(..., lpCreateParams = this)
WM_NCCREATE: hwnd_ set, GWLP_USERDATA = this (earlier messages → DefWindowProcW)
→ SetPropW("CnaPlatformWindow.Owned"); centre; optional fullscreen; ShowWindow
→ Win32Platform::windows_[id] = raw pointer (owning wrappers only)
GraphicsDevice with DeviceWindowHandle ≠ 0 → AdoptWindowHandle(HWND as integer)
→ Win32Window(hwnd, id, host, AdoptTag): ownsWindow_ = false, tracked in adopted_
PollEvents → PeekMessageW(nullptr) → DispatchMessageW → StaticWindowProc → HandleMessage
~Win32Window → LeaveFullscreen / SetRawPointerCapture(false)
→ owned only: RemovePropW, GWLP_USERDATA = 0, DestroyWindow
→ host->OnWindowDestroyed(*this)
~Win32Platform → DetachHost() on every registered and adopted wrapper
A WindowDescription's width and height are the client area; CreateWindowExW takes the outer frame. Before the HWND exists its own DPI cannot be asked for, so Win32Window.cpp converts at the system DPI. A window that lands on a monitor with a different scale later receives WM_DPICHANGED and adopts the rectangle Windows suggests. Window-class registration in Win32WindowClass.cpp is reference-counted under a mutex and keyed to the module that contains CNA, found through GetModuleHandleExW on a local address, so it stays correct when CNA lives in a DLL. CS_OWNDC gives each window a permanent device context for WGL. CS_DBLCLKS is what makes MouseButtonEvent::clicks == 2 possible. With no class background brush and WM_ERASEBKGND claimed, a resize does not flash.
After CreateWindowExW succeeds, centring, fullscreen and show can still throw. For example, SetFullscreenMode throws when the monitor rectangle cannot be resolved. A throwing constructor never runs its destructor, so the constructor's catch undoes everything by hand: it leaves fullscreen, removes the property, clears GWLP_USERDATA, calls DestroyWindow, releases the class reference and rethrows. The platform therefore stays able to retry. Win32Platform::CreateWindow registers the wrapper in windows_ only after construction completes. The caller, normally GraphicsDevice in GraphicsDevice.cpp, owns the returned unique_ptr.
There are two adoption routes. AdoptWindow(WindowId) accepts only an id already present in windows_. AdoptWindowHandle(token) interprets the token as an HWND: it refuses zero or a handle for which IsWindow fails, reuses the established id when the HWND belongs to one of this platform's owning wrappers, and issues a fresh id for any other live window. GraphicsDevice uses the handle route when PresentationParameters' device-window handle is non-zero. Mouse::setWindowHandleProperty uses it briefly to resolve an id. A borrowed wrapper never calls DestroyWindow, never subclasses the window procedure and holds no class reference. Borrowed wrappers go into adopted_, never into windows_. The registry maps an id to the one wrapper that owns the window, and a borrowed wrapper shares its owner's id. If both lived in the registry, destroying the borrowed one would unregister the owner and every service resolving that id would find nothing. For the same reason, OnWindowDestroyed matches wrappers by identity, not by id.
Maintainer notes from reading the source (not executed). (1) The CnaPlatformWindow.Owned window property is set and removed but never read. Its comment says AdoptWindowHandle refuses a window the platform already owns. The code instead returns a borrowed wrapper that shares the owner's id, and Win32WindowTest.LegacyTokenRoundTripsThroughAdoption asserts that behaviour. (2) A host-created HWND adopted by handle is not routed through StaticWindowProc, so CNA translates none of its messages. Win32Platform::FindWindow searches only windows_, so the WGL context service, the Vulkan surface service, relative-mouse targeting and Win32TextInput::Start refuse such a window id with "the window id does not name a live window". Handle-consuming renderers, which call TryGetWin32, and the CPU presenter, which takes the Win32Window& itself, are unaffected. The Win32 adoption tests cover windows this platform created, not foreign ones.
Destruction is a two-way detachment. ~Win32Window first undoes process-global state: it restores the display mode if exclusive fullscreen changed it, and it releases raw-input capture, cursor confinement and cursor hiding. For an owned window it then clears GWLP_USERDATA before DestroyWindow, because WM_DESTROY/WM_NCDESTROY are dispatched synchronously and must not reach a wrapper that is being destroyed. StaticWindowProc clears the pointer again at WM_NCDESTROY, the last message a window receives. Finally the wrapper calls OnWindowDestroyed. Conversely, ~Win32Platform calls DetachHost() on every owned and borrowed wrapper that might outlive it. That nulls the host pointer and detaches the mapper's event sink, so a late message cannot push into a destroyed queue. The window stays a working native window and simply stops producing events. This order is part of the ownership contract, not optional cleanup. Game constructs platform_ before every other member, so in a game the device and its window are destroyed before the platform; see Ownership and shutdown.
Closing works in two layers. At the platform layer, WM_CLOSE becomes WindowEventKind::CloseRequested, and the message is reported as handled so DefWindowProcW does not destroy the window. The window dies only when its wrapper is destroyed. WM_DESTROY deliberately does not call PostQuitMessage, so closing one of several windows does not end the process. A QuitEvent comes only from a thread WM_QUIT seen by PollEvents or from a WM_ENDSESSION that was not vetoed. At the framework layer, Game::PollEvents in modules/runtime/src/Game.cpp calls Exit() on CloseRequested. Its comment explains that the quit event some backends synthesise for the last window is conditional, and that XNA's close button always ends the game. "The application decides" is therefore true for a direct IPlatform consumer. In a Game, the close button exits.
One native message to public input
WM_KEYDOWN / WM_SYSKEYDOWN (queued to the window's thread)
→ Win32Platform::PollEvents: PeekMessageW(nullptr, PM_REMOVE) → TranslateMessage → DispatchMessageW
→ Win32Window::StaticWindowProc → HandleMessage → Win32EventMapper::Translate
→ TranslateKey: scan code + extended bit (lParam 16–24) → sided virtual key
→ KeyCode + Scancode; repeat = lParam bit 30 on a press
returns "not handled" → DefWindowProcW still runs (Alt+F4, menus)
→ PushKey → KeyEvent → Win32Platform::Push → pending_ (std::deque)
→ after the pump: pending_ moved into the caller's vector (its capacity reused)
Game::PollEvents
→ for each event: PlatformInputBridge::ProcessEvent, then Game handling (Quit → Exit())
→ after the batch: Win32Keyboard::Update (GetKeyboardState → KeyboardSnapshot)
Win32Mouse::Update (mapper state of the focused CNA window)
Keyboard::GetState → GetCurrentPlatform().GetKeyboard()->GetSnapshot()
DispatchMessageW calls the window procedure synchronously, so a window cannot return events to PollEvents. Each mapper pushes into the platform's queue instead, and PollEvents clears the caller's vector, drains the thread's whole queue, and moves the accumulated events across. A thread-level WM_QUIT becomes a QuitEvent. PeekMessageW with a null window also dispatches messages for any other window created on the same thread, such as host windows and dialogs; those go to their own procedures. Windows queues a window's messages on the thread that created it, so PollEvents belongs on the thread that created the CNA windows; Thread and callback map has the wider picture. Win32EventMapper.hpp keeps translation pure: it never touches an HWND. That is why Win32EventMapperTests can drive it with synthetic (message, wParam, lParam) triples. Win32Window performs the side effects: capture, TrackMouseEvent, and the suggested DPI rectangle.
| Message | What Win32EventMapper.cpp emits | Reaches DefWindowProcW? |
|---|---|---|
WM_CLOSE | CloseRequested | No (this is what prevents destruction) |
WM_SIZE | Transition event (Minimized, Maximized, Restored) when the show state changes, then Resized and PixelSizeChanged with the client size. Minimise emits no resize; SIZE_MAXHIDE/SIZE_MAXSHOW are ignored | No |
WM_MOVE · WM_DISPLAYCHANGE | Moved (x, y) · DisplayChanged | Yes |
WM_SETFOCUS · WM_KILLFOCUS | FocusGained · synthetic releases of every held key, then FocusLost | Yes |
WM_PAINT · WM_SHOWWINDOW (shown) | Exposed. The window validates with BeginPaint/EndPaint itself | No · Yes |
WM_DPICHANGED | DisplayScaleChanged (data1 = new DPI) and PixelSizeChanged, after the window applied the suggested rectangle | No |
WM_KEYDOWN/UP, WM_SYSKEYDOWN/UP | KeyEvent with sided key, physical scancode, modifiers, repeat | Yes |
WM_CHAR | TextInputEvent only while text input is active for that window; control characters except Tab, and DEL, are dropped; surrogate pairs are assembled | No (always consumed) |
WM_MOUSEMOVE · button messages incl. *DBLCLK and X buttons | MouseMotionEvent with delta from the last position · MouseButtonEvent (buttons 1–5, clicks 1 or 2) | No (X buttons answer TRUE) |
WM_MOUSEWHEEL · WM_MOUSEHWHEEL | MouseWheelEvent in notches (delta / WHEEL_DELTA); horizontal sign flipped to match the SDL3 backend | No |
WM_INPUT | No event. Relative mouse displacement goes to Win32Mouse's accumulator while raw capture is on; absolute samples (tablet, remote desktop) are dropped | Yes |
WM_ENDSESSION (not vetoed) · thread WM_QUIT | QuitEvent | Yes · not dispatched |
Keyboard. TranslateKey reads the scan code and extended-key bit from lParam. It resolves generic VK_SHIFT/VK_CONTROL/VK_MENU to the sided key: Shift through MapVirtualKeyW(…, MAPVK_VSC_TO_VK_EX), Control and Alt through the extended bit. It treats bit 30 of a press as auto-repeat. System-key messages are deliberately left unhandled, which keeps Alt+F4, Alt+Space and menu mnemonics working. The mapper keeps the keys it has reported as held and releases them all on WM_KILLFOCUS. Without that, Alt+Tab would leave Alt held forever because the release goes to the newly focused window. That flush is what justifies exactKeyboardState = true. Note the two sources: events come from the mapper, while the polled snapshot comes from Win32Keyboard::Update in Win32InputServices.cpp, which reads GetKeyboardState (high bit only, so Caps Lock's toggle bit does not count as held) and the current modifiers.
Text. WM_CHAR is produced by TranslateMessage from key-down messages, whether or not anything asked for text. The gate is per window: Win32TextInput::Start(windowId, type) turns on SetTextInputActive for that window's mapper, and the TextInputType hint is accepted without effect. Changing the mode or losing focus discards a half-assembled surrogate pair. A key press is never a character event. IsScreenKeyboardShown is always false, because the Windows touch keyboard is shell-driven.
Mouse. Pointer coordinates are read as signed 16-bit values, so positions left of or above the client area stay negative. While any button is held the window takes SetCapture, unless raw capture already owns the pointer, and the mapper's button bookkeeping drives it. TrackMouseEvent requests WM_MOUSELEAVE, after which the position is forgotten. Win32Mouse::Update reads the mapper of the foreground CNA window, or else any CNA window: buttons, cumulative wheel totals in raw WHEEL_DELTA units, and position. It falls back to GetCursorPos/ScreenToClient before the first motion. Relative mode, set with SetRelativeMode, registers Raw Input for the generic mouse (usage page 1, usage 2) on the target window. If registration fails the call throws rather than leaving a half-enabled mode. On success it confines and captures the pointer, hides it, and serves deltas consume-on-read through ConsumeRelativeDelta. Every event also passes through the shared bridge declared in PlatformInputBridge.hpp and implemented, under a historical file name, in SdlInputBridge.cpp. Game::PollEvents hands each event to the bridge before its own handling, so events after a QuitEvent in the same batch still reach input state. The public façade is traced in Input internals.
DPI, resize, focus and timing
GetClientBounds() returns the GetClientRect size with its origin converted to screen coordinates. GetPixelSize() returns the same GetClientRect width and height. In a per-monitor-aware process that is real device pixels; in a system-aware or DPI-unaware process it is Windows' virtualised rectangle, which is the surface being drawn into. Multiplying it by GetDisplayScale() would apply scaling twice. GetDisplayScale() is the window DPI divided by 96. Win32DpiSupport.cpp resolves that DPI at run time through a chain: GetDpiForWindow (from the already-loaded user32.dll), then GetDpiForMonitor (from shcore.dll, loaded once and never freed), then GetDpiForSystem, then the screen DC's LOGPIXELSX. Frame conversion prefers AdjustWindowRectExForDpi and falls back to AdjustWindowRectEx. The backend never calls SetProcessDpiAwarenessContext or SetProcessDPIAware: the application's manifest decides, and the reported values are coherent for whatever it chose.
A resize produces Resized and PixelSizeChanged, kept distinct because a swap chain is sized from the drawable. Minimising emits only Minimized, so no renderer rebuilds a 0×0 swap chain. Restoring emits Restored, then the two size events. On WM_DPICHANGED the window first applies the suggested outer rectangle with SetWindowPos, which produces its own WM_SIZE, and then the mapper emits DisplayScaleChanged and PixelSizeChanged. In Game::PollEvents, all three size-related kinds call GameWindow::updateFromPlatform, GraphicsDevice::UpdateViewportFromWindow and the renderer's OnSurfaceInvalidated. Minimized, Maximized, Restored, Exposed and DisplayChanged invalidate the surface only. Focus events drive Game::IsActive. The rest of that frame is traced in One frame source trace.
SetSize is a request. ResizeClientArea converts the client size to an outer size at the window's current DPI and calls SetWindowPos. Its result is deliberately not turned into an exception, because a window manager may clamp the size. Sync() then drains only this HWND's messages (PeekMessageW(&message, hwnd_, …)) so the new bounds become observable without consuming another window's input. The events this produces wait in the queue for the next PollEvents. PlatformWindowConformance.SizeChangeLandsAfterSync is the contract test. Minimum and maximum sizes are client sizes converted to outer sizes in WM_GETMINMAXINFO. HasFocus() is true when the window has keyboard focus or is the foreground window.
Borderless fullscreen captures the windowed style, extended style and placement in Win32FullscreenState, switches to a popup style and covers the monitor rectangle. Exclusive fullscreen first does the same, then calls ChangeDisplaySettingsExW(…, CDS_FULLSCREEN) on the window's monitor, requesting that monitor's current rectangle size. LeaveFullscreen restores the registry mode (a null DEVMODE) only if a change succeeded, then restores style and placement. A borderless-to-exclusive switch always passes through windowed, so the captured appearance is the genuine one. SetResizable and SetBorderless are ignored while fullscreen. One thing a maintainer should know: although the source comment says a refused mode change is reported, EnterExclusiveFullscreen returns silently when monitor information, EnumDisplaySettingsW or ChangeDisplaySettingsExW fails. GetFullscreenMode() then reports exclusive fullscreen for a window that is borderless on an unchanged mode. This was read from source and not executed.
Timing. GetPerformanceCounter() reads QueryPerformanceCounter directly, and GetPerformanceFrequency() returns the value cached at construction. GetTicksMilliseconds() is derived from the same counter, so the two never disagree. It is split into whole seconds plus a remainder before multiplying, because the direct form overflows 64 bits after about 21 days at a 10 MHz counter. Delay() is ::Sleep and never timeBeginPeriod, since timer resolution is process-global state the host owns. That matters for pacing: Game::Tick calls Delay(1) while the accumulated time plus worstCaseSleepPrecision_ is short of the target, and Game::UpdateEstimatedSleepPrecision learns how long such a sleep actually takes under the host's timer resolution. Changing this clock or Delay therefore changes all frame pacing, not a diagnostics counter.
How graphics APIs cross the platform boundary
Win32Window::GetNativeHandle() returns a NativeWindowHandle tagged NativeWindowSystem::Win32 whose window is the HWND. GetWindowHandle() returns the same HWND as an integer token. The handle is a borrowed description, not an ownership transfer. Renderers never receive a Win32Window*. They call TryGetWin32 from NativeWindowHandle.hpp; at this snapshot the DIRECTX9, DIRECTX11, DIRECTX12, DIRECT2D, GDI and WEBGPU renderer sources do. GraphicsDevice::createRenderer passes each renderer a surface value (window id, native handle, drawable size, display scale) plus only the services its descriptor asks for: needsGlContext gets GetGlContext() (EasyGL's desktop profiles and OPENGL4), needsVulkanSurface gets GetVulkanSurface() (VULKAN), and needsSurfacePresenter gets CreateSurfacePresenter. A renderer must not reach into Win32Platform just because it runs on Windows. All three services live in Win32GraphicsServices.cpp.
WGL. Win32GlContext::CreateContext(window, description) checks the capability, resolves the window through FindWindow, and takes the window's permanent CS_OWNDC device context, which is not released. It builds a PIXELFORMATDESCRIPTOR with 32-bit RGBA colour, 8-bit alpha, depth clamped to 0–32, stencil clamped to 0–8, and double buffering if requested, and calls ChoosePixelFormat. SetPixelFormat is called only when GetPixelFormat returns 0, because a DC accepts a format once. It then creates a throwaway legacy context with wglCreateContext, remembers the caller's current context and DC, makes the bootstrap current to resolve wglCreateContextAttribsARB, and creates the requested major/minor/profile context (core, compatibility or ES bit). On success the bootstrap is deleted. Finally it restores the caller's previous binding and records the context against the window id. MakeCurrent(…, nullptr) with nothing current is a no-op, because some drivers and Wine fail wglMakeCurrent(nullptr, nullptr) in that state. Proc lookup tries wglGetProcAddress, rejects the historical sentinel values 0, 1, 2, 3 and −1, then falls back to GetProcAddress on opengl32.dll for core 1.1 symbols. SetSwapInterval uses wglSwapIntervalEXT and returns false when it is absent. The service's destructor unbinds and deletes any contexts still recorded.
WGL gaps visible in source (not executed). The pixel-format descriptor ignores GlContextDescription's multisample fields and per-channel colour sizes. Win32Window reads neither WindowDescription::renderIntent nor openGlFramebuffer; the format is fixed by the first CreateContext on that DC, although the header comment says the render intent must be right at creation. If wglCreateContextAttribsARB is missing or refuses, the legacy bootstrap context is returned, while GetContextAttributes still reports the requested description.
Vulkan. Win32VulkanSurface advertises VK_KHR_surface and VK_KHR_win32_surface. It resolves vkCreateWin32SurfaceKHR through the vkGetInstanceProcAddr of the vulkan-1.dll it loaded itself, and restates VkWin32SurfaceCreateInfoKHR locally so the platform never includes vulkan.h and handles stay opaque in the contract. It refuses, with explicit exceptions, a false capability, a null instance, a window id that names no owned window, an instance that does not export the entry point ("was VK_KHR_win32_surface enabled?") and a non-zero VkResult. DestroySurface calls vkDestroySurfaceKHR and is a no-op when the loader is unavailable. The Vulkan renderer must destroy the surface before its instance, and both before the platform unloads the loader; Vulkan renderer internals covers the consumer side.
CPU presentation. CreateSurfacePresenter accepts only a Win32Window and throws for a foreign wrapper. Win32SurfacePresenter::Present validates the SurfaceFrame, including stride, through Common::ValidateSurfaceFrame before reading a pixel. It converts RGBA8 in memory order to the BGRA a 32-bit DIB expects, into a reused buffer, and blits a top-down DIB (negative height) with StretchDIBits. The five PresentScaleMode values are implemented purely by the destination rectangle, Linear filtering selects HALFTONE, and uncovered margins are cleared black. GDI_ERROR becomes a PlatformException, and SetVSync returns false because GDI blits are not synchronised. At this snapshot, GraphicsDevice creates a presenter only when the platform reports surfacePresentation and no nativeWindowHandle, which is TERMINAL on a TTY. Win32 reports a native handle, so SOFTWARE stays off-screen on Win32. The presenter is exercised by its own tests and by direct IPlatform callers, not by the default device path.
Failure modes and patch review
- No window. Confirm the configure log says
Using WIN32 platform implementation. Then look for a class-registration error, theCreateWindowExWerror text (ThrowErrorcarriesGetLastError), or a post-creation throw from fullscreen setup. Check whether a caller-suppliedHWNDwas adopted instead of a window being created. Remember that SOFTWARE, PORTABLEGL, HEADLESS and STUB are off-screen on windowing platforms. Do not start by editing a renderer. - Resized but black. Separate the four stages:
WM_SIZE/WM_DPICHANGEDtranslation,GetPixelSize,GraphicsDevice::UpdateViewportFromWindow, and the renderer's swap-chain recreation afterOnSurfaceInvalidated. A minimise deliberately produces no resize. - Input absent. Verify, in order: is
PollEventsrunning on the thread that created the window, did the mapper emit the event, is it in theGamebatch, did the bridge see it, and didUpdaterefresh the snapshot. For text, check thatStartwas called for this window id. A hostHWNDadopted by handle produces no CNA events by design. - Stuck modifier after Alt+Tab. Check that
WM_KILLFOCUSstill reachesReleaseHeldKeys. - Relative mouse throws.
RegisterRawInputDevicesrefused. The message carries the system error text. - Crash after shutdown. Inspect the owner/back-pointer detachment:
GWLP_USERDATAcleared beforeDestroyWindowand atWM_NCDESTROY,OnWindowDestroyed, andDetachHost. - Desktop left at another resolution, or the cursor still clipped. A wrapper destructor did not run.
~Win32Windowand~Win32Mouseare where display mode, confinement and cursor visibility are restored. - Missing Vulkan. Distinguish
GetCapabilities().vulkanSurface == false(no loader) from a missingVK_KHR_win32_surfaceon the instance, and both from a later device-selection failure in the renderer.
When reviewing a patch: change the neutral contract only if every platform needs the concept, then implement it or refuse it in each backend, with a false capability, a null accessor and PlatformNotSupportedException, and extend the conformance suites. Keep the host-policy rule: the backend does not set DPI awareness, timer resolution, the current directory, or the thread's COM apartment beyond a balanced reference. Keep Win32Common.hpp the only route to <windows.h>, and keep the mapper free of HWND calls so it stays testable from synthetic messages. The step-by-step recipe is I need to modify a platform backend.
Tests and host evidence
| Test source | GoogleTest suite(s) | What it pins down |
|---|---|---|
Win32PlatformTests.cpp | Win32PlatformFactory, Win32PlatformTest | Factory name and default; claimed/unclaimed capabilities; null versus non-null services; Vulkan service matches the loader; per-instance subsystem counts and tolerated over-release; counter and ticks; adoption refusals; presenter refuses a foreign window |
Win32WindowTests.cpp | Win32WindowTest | Client-versus-outer size; handle and token round trip through adoption; adopted wrappers neither destroy nor evict the owner; a window outliving its platform; retry after a failed creation; no dangling GWLP_USERDATA; close request without destruction; fullscreen cycles; caller capacity reuse |
Win32EventMapperTests.cpp | Win32EventMapping | Pure translation from synthetic messages: sided modifiers, repeat, surrogate pairs, maximize/restore ordering, wheel, close |
Win32GraphicsServicesTests.cpp | Win32GraphicsServices | WGL creation/unbind/lifetime, proc loader, Vulkan extensions and refusals, presenter target size, scale modes, strided and malformed frames, declined VSync |
Win32DpiTests, Win32FullscreenStateTests, Win32InputServicesTests, Win32KeyCodeTests, Win32ScancodeTests, Win32UtfTests, Win32SystemServicesTests | Win32Dpi, Win32DpiWindow, Win32FullscreenState, Win32InputServices, Win32KeyCode, Win32Scancode, Win32Utf, Win32SystemServices | DPI arithmetic and agreement between window, monitor and display service; fullscreen state capture; snapshot services; key tables; UTF-8/UTF-16; clipboard, displays, dialogs, file system |
Win32NoSdlTests.cpp | Win32SourceAudit | No SDL reference or include in the backend's sources, licence headers, no unrecorded TODO, single <windows.h> entry point |
Win32DirectXIntegrationTests.cpp | Win32RendererBridge | The handle satisfies exactly the TryGetWin32 call the Direct3D renderers make, without linking a renderer |
cmake/UnitTests.cmake excludes Win32*.cpp from CnaTests unless CNA_PLATFORM=WIN32. In a full WIN32 configure the suites reach CTest one test at a time through gtest_discover_tests on CnaTests. The named CnaPlatformTests and CnaPlatformWindowTests filters do not name the Win32* suites, but their *PlatformConformance* and *PlatformWindowConformance* tokens reach Win32's conformance parameterisation. tools/platform/standalone_tests builds the platform module and its whole suite as cna_platform_tests without sharp-runtime, SDL or a renderer, registered as that tree's own CnaPlatformTests. For WIN32 it adds cna_win32_directx_probe (win32_directx_probe.cpp: a Direct3D 11 and 12 device and swap chain on a platform HWND; exit 0 success, 1 defect, 2 "no Direct3D here, nothing claimed"), cna_win32_native_stress, cna_win32_d3d11_cycle and cna_win32_native_desktop.
.github/workflows/platform-ci.yml runs on pushes and pull requests to next, develop and main (Markdown and docs/ changes ignored), and on manual dispatch. As configured:
win32-cross(ubuntu-24.04) cross-builds the standalone harness with the mingw-w64 toolchain forCNA_PLATFORM=WIN32, runscna_platform_tests.exeunder Wine on an Xvfb display, and runs the Direct3D probe. Exit status 2 becomes a notice; only a defect (1) fails the step.win32-native(windows-latest, MSVC) hasif: github.event_name == 'workflow_dispatch', so it runs only when dispatched by hand. It runs the same harness and probe, also tolerating exit 2. The workflow's own comment says it adds a second compiler, a real per-monitor-DPI desktop, real Direct3D 11/12, and real shell dialogs and clipboard.
Neither job builds the full engine or a DirectX game; those lanes are separate manual workflows (see What CI actually covers). No run results were inspected for this page. CNA's own docs/testing-win32-native.md records that, under Wine (Debian 13, wine-10.0), cna_platform_tests.exe gave 385 passed and 1 skipped in Debug and Release. It then lists what only native Windows can establish: per-monitor DPI v2 and monitor moves, real keyboard layouts (the backend has no IME path to test: it reports ime false, CNA-GAP-058), clipboard interoperability, raw input on real hardware, focus and foreground rules, and XInput. tools/platform/validate_win32_native.ps1 is the unattended native script. Its outcomes are five-valued (PASS, FAIL, NOT-RUN, ENVIRONMENT, INFO), and only FAIL sets the exit code. Companion scripts beside it cover the DPI matrix, clipboard interop, Unicode paths, soak and sanitizer builds. After a Win32 change, run the platform suites (standalone harness or a WIN32 CnaTests), then the affected renderer and input tests on real Windows where possible. Wine is not Windows. What to test after changing X and Test architecture place this in the wider test map.
Curated source route
PlatformSelection.cmake,platform CMakeListsandPlatformFactory.cpp: selection, what else is compiled beside Win32, and conformance enrolment.IPlatform.hpp,IPlatformWindow.hppandNativeWindowHandle.hpp: the backend-neutral promises, before any native code.Win32Common.hpp,Win32Platform.hppandWin32Platform.cpp: include discipline, capabilities, subsystems, adoption, the queue and timing.Win32WindowClass.cppandWin32Window.cpp: class registration, creation and its manual undo, the window procedure, DPI, fullscreen and destruction.Win32EventMapper.cpp,Win32InputServices.cppandWin32DpiSupport.cpp: message translation, snapshot services, relative mode and the DPI chain.Game.cpp(Game::PollEvents,Game::Tick),SdlInputBridge.cppandKeyboard.cpp: one event, then one snapshot, to public state.Win32GraphicsServices.cppandGraphicsDevice.cpp(createRendererand the presenter gate): the graphics seams and who uses them.- The Win32 tests above,
the standalone harness,platform-ci.ymlanddocs/platform-win32.md: which host actually validates which seam.
Sibling backends for comparison: SDL3 (the default implementation) and X11 (the other native desktop backend); the overview is Platform backends, and the layer above is Platform architecture. Renderer-side consumers: EasyGL, Vulkan and Graphics backends.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Windows from Linux: MinGW cross-builds, runtime staging and Wine evidence — The MinGW-w64 route from Linux to a runnable Windows test: toolchain, target-built SDL, DLL staging, CTest emulators, which Wine runtime owns each renderer, prefix hygiene and evidence tiers.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-179: Win32 EnterExclusiveFullscreen fails silently yet GetFullscreenMode reports ExclusiveFullscreen — EnterExclusiveFullscreen returns silently when a monitor-info, EnumDisplaySettingsW or ChangeDisplaySettingsExW step fails, though its comment says a refusal is reported; SetFullscreenMode still records ExclusiveFullscre
- CNA-BUG-180: Win32GlContext ignores multisample and per-channel format requests and reports the requested description as if it were granted — DescribeFormat ignores GlContextDescription's per-channel and multisample fields, and GetContextAttributes returns the requested description verbatim, so a legacy fallback context is reported as the requested version.
- CNA-BUG-182: Win32Dialogs file dialogs run modally inside Show* and invoke the callback before returning, contrary to the IPlatformDialogs timing contract — On the native WIN32 platform the three file dialogs run modally on the calling thread and call the result callback before returning, while IPlatformDialogs and FileDialog promise an asynchronous callback after the call r
- CNA-BUG-233: CLAUDE.md's Platform Boundary lists WIN32 as a reserved identifier that fails configuration, but cmake/PlatformSelection.cmake implements CNA_PLATFORM=WIN32 on Windows — CNA's contributor instructions name SDL3, SDL2, X11, WAYLAND, HEADLESS and TERMINAL as the platforms and SDL12/WIN32/EMSCRIPTEN as reserved; the build offers WIN32 on Windows and reserves TERMINAL there.
- CNA-BUG-238: Win32Window's CnaPlatformWindow.Owned property is written and removed but never read, and its comment misdescribes AdoptWindowHandle — The kOwnedWindowProperty is set on every CNA-created HWND and removed on destroy but read nowhere; its comment says it lets AdoptWindowHandle refuse an owned window, whereas adoption returns a borrowed wrapper sharing th
- CNA-BUG-239: Two stale comments in the Win32 backend: a nonexistent spike path and a wrong Windows version — Win32DirectXIntegrationTests.cpp says real device and swapchain creation on the platform's handle is proved by spikes/win32-directx-spike/, a directory that has never existed (spikes/win32-spike covers window, message pu
- CNA-GAP-056: A foreign HWND adopted by handle is not subclassed and is absent from windows_, so the Win32 GL, Vulkan, relative-mouse and text-input services refuse it — AdoptWindowHandle wraps a foreign HWND without subclassing it or adding it to windows_, so it raises no CNA events and FindWindow cannot resolve its id; the WGL, Vulkan, relative-mouse and text-input services then reject
- CNA-GAP-058: The native Win32 platform backend has no gamepad support, no IME composition events and no file drop — Win32Platform reports gamepad, joystick, haptics and IME as deliberately unsupported, handles no WM_IME_* message or Imm* call, and never raises GameWindow's FileDropEXT; raw mouse input and per-monitor DPI are implement
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Platform architecture
- Maintainer workflow
- Modify a platform backend
- Tests and validation
- Test architecture · What to test after changing X
- Reference
- Test target index