Headless 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. The tests named here were located and their CTest registration read; none was executed. The CI cell is described as the workflow file configures it, not as a recorded result. Headless says nothing about a real compositor, GPU or input device.
modules/platform/src/Headless/ is a complete implementation of the IPlatform contract with an in-memory window, a caller-fed event queue, a steady-clock timer and real filesystem and system-information services, and with every one of the 32 capability flags false. It is compiled into every binary whatever CNA_PLATFORM says, which makes it two things at once: a display-free host for game logic, and the second implementation that the platform conformance suite needs. This page is for maintainers changing the platform contract, the Game lifecycle or a display-free CI harness; the user-level summary is the seven-implementation table in Platform Support.
What “headless” does and does not promise
HeadlessPlatform::GetCapabilities returns a value-initialised PlatformCapabilities{}, so every field is false (see PlatformCapabilities.hpp: every field defaults to false). False does not mean that every method throws. The rule the contract states is “unsupported means refused, never silently ignored”, and Headless implements it in three distinct ways:
- Subsystems succeed.
AcquireSubsystemincrements an in-memory count for any of the fivePlatformSubsystemvalues (Video, Audio, Gamepad, Haptic, Sensor) and never throws, because game logic must still be able to initialise and shut down.ReleaseSubsystemdecrements it; an unpaired release returns silently, which is the no-op the contract inIPlatform.hpprequires.IsSubsystemInitializedis simply count > 0. No operating-system subsystem exists behind any of this. - Optional services are null. The accessors for keyboard, mouse, gamepad, joystick, text input, sensors, haptics, input devices, clipboard, displays, dialogs, tray, camera, OpenGL context and Vulkan surface all return
nullptr;GetPrimarySelectionis not overridden and inherits the contract's null default. - Operations that cannot be honoured throw.
CreateSurfacePresenteralways throwsPlatformNotSupportedException(PlatformCapability::SurfacePresentation, "Headless")instead of accepting and dropping frames.AdoptWindowandAdoptWindowHandleare not overridden either: the defaults inIPlatform.cpprange-check the token and then throw aPlatformExceptionsaying that external window adoption is unsupported byHeadless.
The distinction between a null service and a thrown refusal is deliberate. A false capability gives the caller a refusal path it can branch on; a fake presenter would hide a missing image and a stub clipboard would let a caller believe it had working copy and paste. The two services that are never null are real: GetFileSystem returns a StandardFileSystem constructed with the preference-root name cna-headless, and GetSystemInfo returns a StandardSystemInfo. The distinct root name keeps two platforms that live in one process (the conformance suite does exactly that) from colliding on one directory. Note where it points: GetPreferencesPath builds std::filesystem::temp_directory_path() / "cna-headless" / organization / application, creating it on demand and throwing PlatformException if that fails, so save data written through preference paths on this platform lands under the system temporary directory.
HeadlessPlatform (always compiled; CNA_PLATFORM=HEADLESS makes it the factory default)
|- refCounts_[PlatformSubsystem] in-memory only, no OS subsystem
|- windowTaken_ : shared_ptr<bool> shared with the one live HeadlessWindow
| '- HeadlessWindow title, x/y, size, pending size, resizable,
| borderless, visible, minimized, fullscreen mode
|- queued_ : vector<PlatformEvent> InjectEvent -> PollEvents -> Game::PollEvents
|- steady_clock nanoseconds (1 GHz), ms since construction, sleep_for
'- StandardFileSystem("cna-headless") + StandardSystemInfo never null
No native drawable, OpenGL context, Vulkan surface, CPU presenter or input service.
Selection is ordinary: with CNA_PLATFORM=HEADLESS, PlatformFactory.cpp resolves the default name to "Headless", so PlatformFactory::Create() and therefore Game::Game() construct this class. In every other configuration it is still reachable by name through PlatformFactory::Create("Headless") and is always listed by GetAvailable(), because modules/platform/CMakeLists.txt globs src/Headless/*.cpp unconditionally. It links no third-party library, which is why carrying it everywhere costs nothing.
An in-memory window preserves important lifecycle semantics
HeadlessWindow lives in an anonymous namespace inside HeadlessPlatform.cpp. It copies from the WindowDescription the title, the size, the position (0,0 when centered is set), resizable, borderless, visible and fullscreenMode; the minimum/maximum sizes, highDpi, renderIntent and the OpenGL framebuffer request are ignored because nothing here could honour them. GetNativeHandle returns a handle whose system is NativeWindowSystem::Headless with every pointer null and windowId zero, so a renderer that needs a real window refuses deterministically instead of dereferencing anything. Window ids come from a per-platform counter starting at 1, which satisfies the contract's “zero means no window” rule.
Only one window may be alive. CreateWindow checks the shared windowTaken_ flag and throws PlatformNotSupportedException(PlatformCapability::MultipleWindows, "Headless") for a second one. The occupied slot is published only after std::make_unique<HeadlessWindow> has succeeded, so a failed allocation or title copy leaves the platform able to retry. The flag is a shared_ptr<bool> held by both sides rather than a raw back-pointer from window to platform: the window's destructor clears it through its own reference, so destroying the platform before its window does not make that destructor write into freed memory. (A window outliving its platform is still a caller error; the shared flag only limits the damage.)
The window models the asynchrony that real window systems impose. SetSize records pending dimensions; Sync applies the positive ones. A test that reads GetClientBounds immediately after SetSize sees the old size here too, which is what keeps game code from depending on behaviour no windowing system guarantees. GetPixelSize equals the logical size and GetDisplayScale is always 1.0f. HasFocus is true only while the window is visible and not minimised; Maximize and Restore both just clear the minimised flag, so there is no separate maximised state. SetFullscreenMode(WindowFullscreenMode::BorderlessFullscreen) throws PlatformNotSupportedException(PlatformCapability::BorderlessFullscreen, "Headless"); the other modes are stored and reported back.
Two behaviours to keep in mind when writing tests against this window. First, the creation path copies WindowDescription::fullscreenMode without the borderless check that SetFullscreenMode performs, so a description requesting BorderlessFullscreen is accepted at creation and reported back by GetFullscreenMode; the conformance case BorderlessFullscreenFollowsItsCapability exercises only the setter. Second, no window-state change generates an event: PollEvents returns only what was queued, so SetSize followed by Sync never produces the Resized or PixelSizeChanged event that drives Game's viewport refresh (GameWindow update, GraphicsDevice::UpdateViewportFromWindow, OnSurfaceInvalidated). A test that needs the runtime to react must supply the event itself.
These make Headless a useful contract probe for GameWindow and GraphicsDeviceManager without a display, but none of it validates a real compositor's DPI, mode switch, focus policy or presentation.
Injecting exact input and lifecycle sequences
HeadlessPlatform::InjectEvent appends a value PlatformEvent to queued_. The next PollEvents clears the caller's vector, appends every queued event in order and empties the queue; nothing is coalesced, filtered or reordered. In principle that lets a harness drive quit, resize, focus or keyboard order without desktop timing. In practice two facts shape how it is used at this snapshot:
InjectEventis a member of the concrete class, declared only in the private headerHeadlessPlatform.hpp; the factory hands outIPlatform. No test at this snapshot calls it. Tests that script events wrap a real platform inPlatformTestDecorator(PlatformTestDecorator.hpp, which forwards everyIPlatformmethod so a test overrides only what it needs); the cross-implementation event oracle'sTranscriptPlatform::QueueinGameEventSemanticsGoldenTests.cppis the reference example, and consumer modules use theCanned*services beside it.- Events are not snapshots.
Game::PollEventsinGame.cppfirst hands each event toPlatformInputBridge::ProcessEvent, which updatesInputManager's key and mouse accumulators and the text-input callbacks, then applies quit and window handling, and only after the batch advances the keyboard and mouse snapshot services throughGetKeyboard()->Update()andGetMouse()->Update(). Both accessors are null here.Keyboard::GetState(Keyboard.cpp) returns an emptyKeyboardStatewhen the platform has no keyboard service, andMouse::GetStatereturns a defaultMouseStatewhen it has no mouse service. AKeyEventqueued on Headless therefore reaches the bridge and text input but never makesKeyboard::GetStatereport a pressed key. This is the event-versus-snapshot split described in Input internals.
The same ordering holds for the gated controller pump: acquiring the Gamepad subsystem on Headless marks it initialised, so Game::PollEvents asks for GetGamepad() and GetJoystick() each frame, finds null and updates nothing.
Timing is real, not simulated. GetPerformanceCounter returns std::chrono::steady_clock nanoseconds, GetPerformanceFrequency returns 1,000,000,000, GetTicksMilliseconds measures from construction, and Delay calls std::this_thread::sleep_for on the calling thread. There is no clock injection in this class, so a fixed-timestep test running on Headless observes real elapsed time unless it controls time at another layer (for example by overriding the timing methods in a PlatformTestDecorator). How Game::Tick consumes these values is traced in One frame source trace.
Three different things called “headless”
| Name | What it is | What it needs from the platform |
|---|---|---|
CNA_PLATFORM=HEADLESS | This page: the platform implementation. | Nothing; it is the platform. |
CNA_GRAPHICS_RENDERER=HEADLESS | The renderer in modules/renderers/headless; see Headless renderer internals. | Nothing: its descriptor declares RendererWindowKind::None, needsWindow = false and needsVideoSubsystem = false, so it runs on any platform. |
PresentationParameters::HeadlessEXT | An opt-in off-screen GraphicsDevice mode for renderers that normally want a window. | No window; only renderers that can run without a swap chain accept it (the property's documentation names the Direct3D 12 and SDL GPU renderers, and says Direct3D 11 and EasyGL throw from their constructors). |
Renderer pairing on this platform follows from the capability set rather than from a CMake rule: RendererSelection.cmake gates only TERMINAL to CPU renderers, and nothing at configure time rejects HEADLESS with a GPU renderer. The refusal happens at run time. SOFTWARE, PORTABLEGL, STUB and HEADLESS run off-screen (GraphicsDevice builds a presenter for a CPU family only where the platform reports surfacePresentation without nativeWindowHandle, which Headless does not). A context-backed family is refused: EasyGL's factory throws PlatformNotSupportedException(PlatformCapability::OpenGlContext, "EasyGL renderer") when it is handed no GL context service. GamePlatformOwnershipTests.cpp uses exactly that pairing to reach a failed Game construction in AFailedConstructionLeavesNothingInstalled, and skips where the build's renderer accepts Headless. See Renderer selection internals for the descriptor fields.
When to choose it, and when not to
PlatformConformance and PlatformWindowConformance in PlatformConformanceTests.cpp are parameterised over PlatformFactory::GetAvailable() as EveryImplementation/…/Headless, so the always-built Headless instance is held to the same assertions as every other implementation in the binary; the suite does not compare implementations with each other or treat one as the reference. The cases pin the rules this page describes: every optional service is null exactly when its capability is false (keyboard and mouse are exempt because their flags describe quality, and dialogs follow messageBox || nativeFileDialog); filesystem and system info are never null; an unsupported presenter refuses naming SurfacePresentation; subsystems are refcounted and an unpaired release is a no-op; PollEvents clears stale content and reuses capacity; timing is monotonic with a non-zero frequency; a size change lands after Sync; a second window follows MultipleWindows; destroying a window frees its slot; and borderless fullscreen follows its capability.
| Registration (cmake/UnitTests.cmake) | What covers Headless |
|---|---|
CnaPlatformTests (shuffled, --gtest_repeat=3) | The *PlatformConformance* instances and CurrentPlatformTest.* (which decorates a Headless platform). The filter also lists a HeadlessPlatform* token, but no suite of that name exists at this snapshot, so it matches nothing. |
CnaPlatformWindowTests (SDL_VIDEODRIVER=dummy) | The *PlatformWindowConformance* instances, including Headless. The other entry's *PlatformConformance* token does not match that suite name. |
Discovered CnaTests cases | StandardFileSystemTests.HeadlessAndTerminalReadXdgUserDirectories; the GameWindowTest cases that borrow a Headless window (title, native handle, minimise/restore, wrapper destruction, supported orientations); GamePlatformOwnershipTest.AFailedConstructionLeavesNothingInstalled; EveryImplementation/GameEventSemanticsGoldenTest.* against the checked-in transcript platform-event-semantics.txt. |
In platform-ci.yml the matrix cell “Headless + Headless + Null audio” is configured as the independence cell: no display and no SDL audio implementation. It runs CnaPlatformTests and the event oracle with DISPLAY and WAYLAND_DISPLAY unset, runs cna_demo_2d --smoke 6 without a display, and is the one cell that runs the seven platform source gates (sdl_inventory.py, sdl_classify.py, renderer_sdl_audit.py, sdl_ratchet.py --strict, hot_path_lint.py, nonproduction_sdl_audit.py, check_contract.py). That is what the workflow file configures; its run results were not inspected for this page.
Headless is the fastest route for a platform-neutral runtime or Game lifecycle change, particularly on a CI host with no display. HEADLESS platform plus HEADLESS renderer is a suitable game-logic harness. It is the wrong evidence for anything native: a window, graphics or input change needs the owning backend's tests and a real-host smoke run, and a renderer test that needs a GPU must not report headless-platform success as image-conformance evidence. A HeadlessEXT device on a real GPU renderer is a different configuration with a genuine device behind it.
For a maintainer adding a platform interface method, give Headless a truthful refusal or value first, update the capability contract, then compare the SDL3, Win32, X11, Wayland and Terminal implementations and extend the conformance suite. Do not add a default method that silently returns success when nothing happened. The wider recipe is I need to modify a platform backend.
HeadlessPlatform.hpp: the contract as Headless answers it, including the privateInjectEvent.HeadlessPlatform.cpp: the in-memory window, the shared window slot, event draining and timing.modules/platform/CMakeLists.txtandPlatformFactory.cpp: why it is always compiled and how it is named.PlatformConformanceTests.cpp: the behaviour any backend, new or old, must preserve.PlatformTestDecorator.hppandGameEventSemanticsGoldenTests.cpp: how tests script a platform without native event injection.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-177: The Headless and Terminal window constructors accept BorderlessFullscreen although their own SetFullscreenMode refuses it — Both capability-minimal windows store WindowDescription::fullscreenMode directly at construction instead of routing it through SetFullscreenMode, so a window created in BorderlessFullscreen reports that mode though the p
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Platform architecture
- Internals
- Platform backends overview · Terminal (the other always-built backend) · SDL3 (the default backend) · Headless renderer internals · Input internals
- Maintainer workflow
- I need to modify a platform backend · I need to debug shutdown and lifetime behavior
- Tests and validation
- Test architecture and change recipes · What to test after changing X
- Reference
- Test target index