DocsGuides

cinder/platform guide

cinder/platform is the thin Mojo layer over SDL3 and the native shim's surface helpers. It loads libcinder_native exactly once, initialises SDL, creates windows, drains SDL's event queue into engine-level Events, opens and closes gamepads as they appear, creates the wgpu surface for a window and, when the debug UI asks for it, forwards every raw SDL event to the ImGui backend. It sits directly above cinder/core and below cinder/gfx; it is the only engine module with per-operating-system branches (library file names, surface creation inside the shim). Everything above it is platform-neutral.

Source: cinder/platform/native.mojo, events.mojo, window.mojo. Reference: docs/api/platform.md. Tests: tests/platform/test_platform.mojo, tests/platform/test_swapchain.mojo (both need a display server; the driver is spikes/window/run_window_spike.sh).

Key types #

Type Role Ownership and lifetime
NativeLibrary Owns the one loaded copy of libcinder_native (the generated CinderNative struct: library handle plus every resolved symbol) at a stable heap address. Construct first, destroy last. __deinit__ destroys the CinderNative and frees the allocation, which closes the library.
NativePtr Pointer[CinderNative, MutUntrackedOrigin], from NativeLibrary.ptr(). How every subsystem refers to the library. Borrowed; valid while the NativeLibrary lives.
default_library_path() CINDER_NATIVE_LIB if set, otherwise native/build/release/lib/libcinder_native.{so,dylib} relative to the repository root. Free function.
Platform SDL initialised for video, events and gamepads; owns the SDL log queue, the open gamepads (gamepads: List[GamepadSlot]), SDL diagnostics and the forward_to_imgui flag. One per process, created on the main thread before any Window. __deinit__ closes gamepads, calls SDL_Quit, detaches and destroys the log queue.
Window An SDL window (handle, id). Creates and destroys wgpu surfaces for itself. Holds platform.lib, so the Platform must outlive it. __deinit__ calls SDL_DestroyWindow.
GamepadSlot SDL joystick id plus the open SDL_Gamepad handle. Owned by Platform.gamepads.
Event, EventKind One translated platform event; a tagged record whose fields are read per kind. Plain copyable values.
translate_event(ev: SDL_Event) -> Event The SDL union to Event translation. Unknown SDL events become EventKind.none(). Free function.

Loading the library #

mojo
from cinder.platform import NativeLibrary, Platform, Window

var lib = NativeLibrary()                  # default_library_path(), raises if missing or ABI mismatch
var platform = Platform(lib.ptr())
var window = Window(platform, String("cinder platform test"), 640, 480)
...
keep(window)
keep(platform)
keep(lib)

NativeLibrary(path) resolves every bound symbol and compares cn_native_abi_version() with the bindings' CN_NATIVE_ABI_VERSION; a mismatch raises libcinder_native ABI version N does not match the bindings' M. The library is loaded and checked before the heap cell that holds it is allocated, so a raise (missing file or symbol, ABI mismatch) unloads it and leaks nothing. The keep calls at the end of a function are not optional: OwnedDLHandle is destroyed right after its last syntactic use, and a subsystem that still holds a NativePtr then calls into unmapped memory (docs/MOJO_NOTES.md). Function pointers live only inside CinderNative; nothing copies them out.

Windows and events #

Platform.poll(mut events) clears events (capacity is retained, so a warmed-up list does not allocate), pumps SDL with SDL_PollEvent, translates each event, opens or closes gamepads on gamepad_added / gamepad_removed, appends every event whose kind is not none(), and then drains the shim's SDL log queue into diagnostics.

mojo
var events = List[Event]()
platform.poll(events)
for ev in events:
    if ev.kind == EventKind.window_resized() and ev.window_id == window.id:
        swapchain.resize(Int(ev.a), Int(ev.b))        # a, b: new pixel width and height
    elif ev.kind == EventKind.key():
        # ev.scancode, ev.keycode, ev.mods, ev.down, ev.repeat
        ...
    elif ev.kind == EventKind.quit() or ev.kind == EventKind.window_close():
        running = False
for record in platform.take_diagnostics():
    logger.log(record.level, record.category, record.message)   # category "sdl"

Field meanings per kind (from the EventKind docstrings):

Kind Fields
quit, window_close, window_minimized, window_restored, window_focus_gained, window_focus_lost window_id
window_resized window_id, a = width, b = height in pixels (SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
key scancode, keycode, mods, down, repeat
mouse_motion x, y absolute; dx, dy relative
mouse_button button (1 left, 2 middle, 3 right), down, x, y
mouse_wheel dx, dy scroll amounts; x, y mouse position
gamepad_added, gamepad_removed device (SDL joystick id)
gamepad_button device, button (an SDL_GamepadButton value), down
gamepad_axis device, axis (an SDL_GamepadAxis value), value in [-1, 1] (Sint16 / 32768)

SDL log priorities map to LogLevel: 1 and 2 to debug, 3 to info, 4 to warn, 5 and above to error. The records carry the category sdl and no fields.

Window methods: pixel_size() (raises when SDL fails), set_size(w, h) (raises on failure, then SDL_SyncWindow so the size change is applied before returning), set_title, show (for windows created with hidden=True), set_relative_mouse(enabled) -> Bool. Windows are created with SDL_WINDOW_HIGH_PIXEL_DENSITY and, by default, SDL_WINDOW_RESIZABLE; pixel_size therefore may differ from the requested logical size on a high-DPI display.

Injecting events #

Platform.push_event(ev: SDL_Event) -> Bool pushes a raw SDL event (tests, input replay). The test packs a variant into the 128-byte union and reads it back translated:

mojo
var key = SDL_KeyboardEvent(SDL_EVENT_KEY_DOWN, 0, 0, window.id, 1, SDL_SCANCODE_W, 119, 0, 0, True, False)
assert_true(platform.push_event(_blob(key)))      # _blob copies the variant into an SDL_Event
platform.poll(events)
for ev in events:
    if ev.kind == EventKind.key():
        assert_equal(Int(ev.scancode), Int(SDL_SCANCODE_W))
        assert_true(ev.down and not ev.repeat)

Creation events (focus, shown, resized) arrive on the first poll after Window construction; the test discards them with one poll before injecting.

Surfaces #

Window.create_surface(instance: WGPUInstance) raises -> WGPUSurface calls cn_platform_create_surface, which picks X11, Wayland or Metal inside the shim from SDL's window properties and fills a 256-byte error string on failure (raised as cn_platform_create_surface failed: ...). Window.destroy_surface(surface) releases it. There are two ways to pair a window with a device:

mojo
# 1. Gfx owns the surface: it is created with the device and destroyed in Gfx.__deinit__.
var gfx = Gfx(lib.ptr(), window)
var swapchain = Swapchain(_ptr(gfx), gfx.surface, size[0], size[1], True, True)

# 2. Headless device, surface managed by hand (tests of the platform layer).
var gfx = Gfx(lib.ptr(), WGPUSurface())
var surface = window.create_surface(gfx.instance)
...
window.destroy_surface(surface)

In form 1 Gfx stores a raw pointer to the Window (surface_window) and calls destroy_surface at teardown; the window must therefore outlive the Gfx and must not move while it exists. Requesting an adapter with a surface makes wgpu return one compatible with presentation to that surface.

ImGui event forwarding #

Platform.forward_to_imgui is False until the debug UI (cinder/tools) sets it. While it is set, poll hands every raw SDL_Event to cn_imgui_process_event before translating it, so ImGui sees the same events the engine does (there is no capture test: both receive everything). The ImGui context itself is owned by the tools layer; the platform only forwards.

Threading and error handling #

  • Everything in this module runs on the main thread: SDL requires its event pump and window calls there (docs/THREADING.md). Platform.poll is called once per frame.
  • SDL's log callback runs in C and writes into a CnEventQueue of capacity 256; poll drains it. No Mojo code runs inside the callback (ADR-0003).
  • Text from SDL (log lines, last_error, video_driver, error messages) is UTF-8 and is decoded with cinder.core.cstring.string_from_c; invalid sequences become U+FFFD.
  • Raises (Error with a message that includes SDL_GetError() where SDL has one): NativeLibrary construction, Platform construction (cn_event_queue_create failed, SDL_Init failed: ...), Window construction (SDL_CreateWindow failed: ...), pixel_size, set_size, create_surface. On a failed SDL_Init the log queue is detached and destroyed before raising.
  • Returns a status instead of raising: push_event (SDL refused the event) and set_relative_mouse. set_title and show ignore SDL's return value.
  • Nothing in this module aborts on its own. Memory-safety preconditions (_as[T] bitcasts of the SDL_Event union) rely on the layout test in cinder/ffi matching SDL's struct layouts.
  • translate_event is pure: unknown event types produce EventKind.none() and are dropped by poll.

Pitfalls #

  • Declare the NativeLibrary last in any struct that also holds objects whose destructors call into it; struct fields are destroyed in declaration order (docs/MOJO_NOTES.md). In a function, keep the library, the platform and every window after their last use, on every return path.
  • Window copies platform.lib at construction, not a reference to the Platform; destroying the Platform (which calls SDL_Quit) before the Window leaves SDL_DestroyWindow running after SDL has shut down.
  • poll clears the caller's list. Keep one List[Event] field for the frame loop rather than building a new list each frame (allocation-free after warm-up).
  • window_resized reports pixel size; a minimised window reports window_minimized, not a zero resize. The swapchain handles both (Swapchain.resize(0, 0) skips frames).
  • Gamepad axis values from SDL are Sint16; -32768 maps to exactly -1.0 and 32767 to just under 1.0.
  • SDL_Event is a 128-byte opaque union in the bindings (SDL_Event(Array[UInt8, 128](fill=0))); read variants only through translate_event, which checks the type field first.
  • Surface creation inside a container works only under a display server (Xvfb or Weston headless in CI, ADR-0007). Headless tests use Gfx(lib.ptr(), WGPUSurface()) and never touch Platform.
  • set_size on Wayland may be honoured asynchronously; SDL_SyncWindow is called so the following poll sees the window_resized event in the test.
View as MarkdownSource: docs/guides/platform.md