> Cinder 0.1.0 documentation: cinder/platform guide
> Canonical page: https://cinder.ash10.dev/docs/guides/platform/ · Source: `docs/guides/platform.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# `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 `Event`s, 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.

## Related documents

- `docs/ARCHITECTURE.md` (module map; platform is the only per-OS module), `docs/THREADING.md`.
- ADR-0003 `docs/adr/0003-native-shim-pull-model.md`: callbacks (SDL log included) are pulled from
  shim queues.
- ADR-0007 `docs/adr/0007-dev-environment-container.md`: Linux container, Xvfb/Weston for windowed
  tests, no macOS host.
- ADR-0009 `docs/adr/0009-gfx-layer.md`: swapchain over the surface created here.
- `docs/MOJO_NOTES.md`: `OwnedDLHandle` lifetime, `keep`, early-return hazards.
- Tests: `tests/platform/test_platform.mojo` (window lifecycle, resize event, surface, injected events,
  non-ASCII SDL log lines and window titles),
  `tests/platform/test_swapchain.mojo` (window + `Gfx(lib, window)` + `Swapchain`).
