Headless platform internals

CNA snapshot 009d40f5  ·  Development › Platform internals  ·  source links pinned to 009d40f5

✓

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. AcquireSubsystem increments an in-memory count for any of the five PlatformSubsystem values (Video, Audio, Gamepad, Haptic, Sensor) and never throws, because game logic must still be able to initialise and shut down. ReleaseSubsystem decrements it; an unpaired release returns silently, which is the no-op the contract in IPlatform.hpp requires. IsSubsystemInitialized is 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; GetPrimarySelection is not overridden and inherits the contract's null default.
  • Operations that cannot be honoured throw. CreateSurfacePresenter always throws PlatformNotSupportedException(PlatformCapability::SurfacePresentation, "Headless") instead of accepting and dropping frames. AdoptWindow and AdoptWindowHandle are not overridden either: the defaults in IPlatform.cpp range-check the token and then throw a PlatformException saying that external window adoption is unsupported by Headless.

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:

  • InjectEvent is a member of the concrete class, declared only in the private header HeadlessPlatform.hpp; the factory hands out IPlatform. No test at this snapshot calls it. Tests that script events wrap a real platform in PlatformTestDecorator (PlatformTestDecorator.hpp, which forwards every IPlatform method so a test overrides only what it needs); the cross-implementation event oracle's TranscriptPlatform::Queue in GameEventSemanticsGoldenTests.cpp is the reference example, and consumer modules use the Canned* services beside it.
  • Events are not snapshots. Game::PollEvents in Game.cpp first hands each event to PlatformInputBridge::ProcessEvent, which updates InputManager'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 through GetKeyboard()->Update() and GetMouse()->Update(). Both accessors are null here. Keyboard::GetState (Keyboard.cpp) returns an empty KeyboardState when the platform has no keyboard service, and Mouse::GetState returns a default MouseState when it has no mouse service. A KeyEvent queued on Headless therefore reaches the bridge and text input but never makes Keyboard::GetState report 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”

NameWhat it isWhat it needs from the platform
CNA_PLATFORM=HEADLESSThis page: the platform implementation.Nothing; it is the platform.
CNA_GRAPHICS_RENDERER=HEADLESSThe 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::HeadlessEXTAn 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 casesStandardFileSystemTests.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.

  1. HeadlessPlatform.hpp: the contract as Headless answers it, including the private InjectEvent.
  2. HeadlessPlatform.cpp: the in-memory window, the shared window slot, event draining and timing.
  3. modules/platform/CMakeLists.txt and PlatformFactory.cpp: why it is always compiled and how it is named.
  4. PlatformConformanceTests.cpp: the behaviour any backend, new or old, must preserve.
  5. PlatformTestDecorator.hpp and GameEventSemanticsGoldenTests.cpp: how tests script a platform without native event injection.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.