The Desktop Shim — design (Sprint 15, T5/T6)

Status: design, agreed 2026-09-22. Implementation: S15-T5 (the shim), S15-T6 (the profile). Related: sprints/Sprint-15.md, playos-tools/docs/sdk.md.

The problem

A PlayOS game is a musl binary that talks to three things that only exist on the device:

  1. libplayos — the platform API (lifecycle, storage, power, system identity, audio, display policy, and a controller-state API).
  2. The PlayOS Raylib backend — raylib built with PLATFORM_PLAYOS, a Wayland client that renders through the PlayOS compositor and gets its input from evdev.
  3. playos-init's services — the trusted control socket, /data, and the lifecycle events that tell a game when it is foreground, backgrounded or about to be suspended.

None of that exists on a developer's laptop. The goal of these tasks is that the same game source builds and runs there, so a developer can iterate without flashing hardware — and can see a window rather than reading logs.

The two-ABI constraint

The device ABI is musl (x86_64-buildroot-linux-musl); a glibc binary cannot run on the device, and the musl libraries cannot link into a glibc desktop process. So the profiles are separate builds of the same source, not one binary run twice:

ProfileToolchainRayliblibplayos
devicemusl (from the SDK)PLATFORM_PLAYOS (from the SDK)musl, PLAYOS_BACKEND=evdev
desktopthe host's gccupstream raylib, default backendhost build, PLAYOS_BACKEND=stub
emulatormusl (from the SDK)PLATFORM_PLAYOSmusl, PLAYOS_BACKEND=evdev, run under QEMU

The desktop profile therefore uses raylib's own desktop backend (X11/Wayland on Linux, Win32/GLFW on Windows) rather than the PlayOS one. That is what makes a window possible on a laptop, and it is why the shim does not have to pretend to be a compositor.

What the shim is

libplayos built natively with PLAYOS_BACKEND=stub: the same public headers, the same API, but every PlayOS-only dependency replaced by a host-appropriate implementation. It is not a new API and not a mocking framework — the game must not need to know which profile it is in.

Per-module contract

ModuleOn the deviceIn the shim
lifecycletalks to playos-init; foreground/background/suspend are real transitionsSafe no-ops. playos_lifecycle_poll() returns "unchanged", wait* returns immediately, and a game that never checks keeps running.
inputevdev, via backend_evdev.c + the gamepad databaseBest effort from the host's evdev (see below); when no device is readable, it reports "no controller" and the game falls back to raylib input.
storage/data/... on the device$XDG_DATA_HOME/playos (or ~/.local/share/playos), created on first use, so saves and settings persist across runs.
powerbrightness/profile through playos-initNo-ops that report the last value they were given.
systemreal device identity, firmware versionA stable synthetic identity so code that keys on it behaves consistently between runs.
loggingto the device's log filesstderr, honouring the same level filter.
audioALSA mixer controlNo master-volume control (the host owns it); the calls succeed and report the current value.
displaythe compositor owns display policyReports the host window's size/refresh; set_vsync is a no-op.

Input design (the interesting one)

On the desktop, raylib is the primary input path — it already reads gamepads and keyboards through its own backend and the game is already written against it. The shim's controller API exists for code that uses libplayos directly, so it is best-effort:

  • Linux: read the host's evdev devices. backend_evdev.c already does exactly this for the device, including the gamepad database, so the shim reuses it rather than reimplementing it. A developer in the input group gets their real gamepad.
  • Keyboard as a controller: WASD → left stick (what games actually read), arrows → d-pad (menus), Z/X/C/V → South/East/West/North (A/B/X/Y), Q/E → L1/R1, Enter/Backspace → Start/Select. A development affordance, documented as such — not a device feature.
  • Not Linux (Windows via the emulator or a native build): report "no controller". The game uses raylib input, which works everywhere. No fake input is synthesised — a shim that invents button presses is worse than one that reports nothing.

backend_stub.c today is a 20-line seed that reports "no controller" and returns -1; T5 grows it into the above.

What the shim deliberately does not do

  • No trusted IPC. The control socket does not exist off-device; lifecycle calls become no-ops rather than failures, so games do not need #ifdefs.
  • No compositor, no overlay, no A/B slots, no real boot.json. Anything that depends on those is device-only behaviour.
  • No device identity spoofing beyond a stable synthetic value. Code that gates on "am I on a PlayOS device" should see something consistent, not a lie that changes between runs.
  • No performance or thermal fidelity. A laptop does not have the Ally's power/thermal profile, and pretending otherwise would mislead.

Verification plan

What each check can actually prove, and where:

CheckNeeds a display?What it proves
cmake -DPLAYOS_BACKEND=stub builds nativelynothe shim compiles with the host toolchain (done: builds on the workstation)
a sample links against the shim and startsnothe API is complete enough to link and reach main
lifecycle calls return promptly and do not blocknothe no-op contract — this is what would otherwise hang a desktop game
a window opens, keyboard and gamepad control the gameyesthe desktop profile end to end (T6)

A headless CI host can therefore prove the first three; the window check is the one that needs a machine with a display — and it is the check a developer performs on their own laptop anyway.

Decisions

  • raylib for the desktop profile: the SDK builds it from the vendored source. Decided 2026-09-22, and measured rather than assumed: the raylib already in the tree (playos-shell/external/raylib, the same source the device builds with PLATFORM_PLAYOS) configures and builds against the host's default desktop backend in 31 seconds (-DBUILD_EXAMPLES=OFF -DBUILD_SHARED_LIBS=ON, no system raylib present or needed). So the SDK ships both raylib builds - the device one and the desktop one - from one source of truth, and a developer needs no raylib package at all. A system raylib would have been faster to start but unreproducible, and it would have quietly diverged from the version games run on.

Open questions

  • Where the shim's storage root lives on Windows when the emulator profile is used from WSL - inside WSL, or on the Windows side so saves move between the two?
  • Audio: do we drive the host's mixer, or leave it entirely to the host? The current design leaves it to the host.
  • Where the shim's storage root lives on Windows when the emulator profile is used from WSL — inside WSL, or on the Windows side so saves move between the two?
  • Audio: do we drive the host's mixer, or leave it entirely to the host? The current design leaves it to the host.