ADR-0025: SDL2 → SDL3 migration
Context
Section titled “Context”The KN-86 emulator and the Pi-side nOSh runtime both depend on SDL for windowing, input, audio output, and accelerated rendering. The current implementation in kn86-emulator/src/ targets SDL2 — 176 SDL call-sites across 13 source files, 87 unique SDL symbols, covering window/renderer/texture creation, the polled event loop, scancode-keyed input dispatch, the audio device + callback model, and the F11/F12 debug overlay rendering.
Two facts converged in April 2026:
- SDL2 is in maintenance mode. Upstream development moved to SDL3 in late 2024; SDL2 receives security and stability fixes but no new features. New contributor attention, new platform backends (Wayland improvements, modern GPU backends), and modern API ergonomics (callback-based main loop, opaque structs, audio streams) all live in SDL3. Building a Q4 2027 ship on a maintenance-mode major version is a slowly-accumulating tax.
- ADR-0026 just landed. Pi OS is now pinned to Debian 13 trixie, which ships
libsdl3-dev 3.2.10+ds-1inmain. This was the gating concern — Phase-1 planning held on whether SDL3 would be apt-available on the device path or whether we would have to vendor it from source. ADR-0026 resolves that. Both compile targets can now use a single SDL major version with no vendored-from-source bridge:- macOS emulator dev path:
brew install sdl3— already available and installed on the dev machine. - Pi Zero 2 W device path:
apt install libsdl3-devon the trixie-based system image.
- macOS emulator dev path:
Holding the codebase on SDL2 forces three persistent costs: (a) maintenance-mode dependency for a load-bearing host library, (b) divergence from the SDL3 examples and community recipes that are now upstream-current, and (c) eventual forced migration on someone else’s timeline (when SDL2 reaches EOL or when a future trixie successor drops SDL2 from apt). Migrating now — while the call-site count is still ~176, the codebase is C-only, and the FFI surface is firmly C-side (no cartridge ever sees SDL) — is the cheap window.
This ADR closes the SDL major-version decision for both compile targets.
Forcing functions
Section titled “Forcing functions”- SDL2 sunset. SDL2 is in maintenance mode. SDL3 is the upstream-current major version and the target for all new platform work, including the modern Wayland + Pipewire path that Debian 13 trixie now ships by default.
- Q4 2027 ship target. Migrating SDL major versions is a one-time mechanical cost. Doing it 18 months out is cheap; doing it three months before ship while debugging hardware bring-up is not.
- ADR-0026 timing. With trixie pinned,
libsdl3-devis apt-supplied on the device path. The single biggest objection to SDL3 (vendoring from source on the Pi) just disappeared. This ADR consumes that unblock immediately rather than letting it bit-rot. - Toolchain alignment between compile targets. Desktop emulator (macOS / Linux) and device (Pi Zero 2 W on trixie) both on a single SDL major version. No vendored bridge, no per-target API shimming, no
#ifdef SDL_MAJOR_VERSIONdivergence in the source tree.
Constraints
Section titled “Constraints”- C11. No C++. SDL3 is a C API; no surface change.
- No behavior changes. The migration is mechanical. Logical framebuffer, glyph rendering, scancode → 31-key dispatch, PSG audio output, F12 debug overlay — all observably identical pre- and post-migration. A diff against pre-migration screenshots and an A/B PCM capture of a known boot+cart sequence are the regression gates.
- No FFI surface changes. Cartridges never see SDL. The NoshAPI FFI surface (ADR-0005) is unchanged. SDL is a private implementation detail of the nOSh runtime; the migration is invisible to cart authors.
- 80×25 grid + 960×600 logical framebuffer untouched. ADR-0014 values are load-bearing. The migration changes the texture-upload and render-present API calls; it does not change the framebuffer dimensions, the 12×24 cell size, or the integer-scale composition rules.
- HIDPI must stay 1:1 logical-to-pixel. SDL3 changed the high-DPI defaults —
SDL_WINDOW_HIGH_PIXEL_DENSITYis now opt-in per-window and the renderer’s logical presentation defaults differ from SDL2. The migrated code must explicitly assert 1:1 logical-to-pixel mapping on Retina hosts; no Retina-side super-sampling, no fractional scale, no blurred glyphs. This is the single most likely place a “behavior change” sneaks in unnoticed and gets the migration code-reviewed back.
Decision
Section titled “Decision”The KN-86 emulator and the Pi-side nOSh runtime migrate from SDL2 to SDL3, single major version, across both compile targets. Concrete commitments:
1. SDL3 as the sole host abstraction across both targets
Section titled “1. SDL3 as the sole host abstraction across both targets”- Desktop emulator (macOS + Linux dev):
brew install sdl3on macOS;apt install libsdl3-devon Debian/Ubuntu Linux dev hosts. - Pi Zero 2 W device:
apt install libsdl3-devon the trixie-based system image (consumed viastage-kn86-runtimeper ADR-0026 §4). - CMake:
find_package(SDL3 REQUIRED)inkn86-emulator/CMakeLists.txt, replacing the currentfind_package(SDL2 REQUIRED). SDL2 is removed from the dependency list.
2. No behavior changes — migration is mechanical
Section titled “2. No behavior changes — migration is mechanical”The 176 SDL call-sites in kn86-emulator/src/ are translated 1:1 to their SDL3 equivalents. The migration changes API surface, not semantics. Specifically:
- The same window is created at the same logical size with the same integer scale.
- The same renderer is created with the same vsync behavior and the same pixel format (ARGB8888).
- The same texture is uploaded each frame with the same 960×600 dimensions.
- The same scancode → 31-key dispatch table from
input.ccovers the same physical keys, with the same SDL3 scancode constants (the SDL2 → SDL3 scancode rename is a near-1:1 mechanical sweep —SDL_SCANCODE_*constants are stable across the major version). - The same 44.1 kHz mono PCM stream feeds the audio device with the same sample format and the same callback semantics (re-expressed against SDL3’s
SDL_AudioStream-based model — see §4 below).
A pixel-diff harness against pre-migration screenshots and a PCM-capture diff against a known boot+cart trace gate the migration PR. A behaviorally identical migration is the bar.
3. No FFI surface changes — cartridges never see SDL
Section titled “3. No FFI surface changes — cartridges never see SDL”The NoshAPI FFI (ADR-0005) is unchanged by this ADR. Cartridges never link against SDL, never see SDL types, never observe a major-version transition. The migration is internal to the nOSh runtime. Cart-format compatibility (ADR-0006) is preserved bit-for-bit.
4. The audio path migrates from SDL_OpenAudioDevice to SDL_OpenAudioDeviceStream
Section titled “4. The audio path migrates from SDL_OpenAudioDevice to SDL_OpenAudioDeviceStream”The single largest API-shape change in this migration is the audio device model. SDL2’s SDL_OpenAudioDevice accepts a callback-style SDL_AudioSpec and pulls samples from the callback at the device’s pace. SDL3 retires that model in favor of SDL_OpenAudioDeviceStream, which exposes a push-style SDL_AudioStream the application feeds at its own pace; SDL3 internally schedules device pulls.
The KN-86 audio path in sound.c + sfx.c is callback-based today (see PSG mixing in the SDL2 audio callback). The migration:
- Keeps the 44.1 kHz mono PCM format, the YM2149 PSG mixer (ADR-0017 §coprocessor stub on the desktop emulator), and the per-frame mix budget unchanged.
- Re-expresses the SDL2 callback as an SDL3 audio-stream feed. The mixer runs in a small periodic call from the main loop (the simplest path) or in a dedicated SDL_AudioStream callback registered via
SDL_SetAudioStreamGetCallback(the ergonomic path closer to the SDL2 model). The companion code-migration task picks one based on what survives the PCM-diff gate. - Asserts identical observable PCM output on a known boot+cart trace. A bit-exact diff is the bar; if the SDL3 path introduces a 1-sample latency shift that isn’t audible, that is acceptable, but the call must be made and recorded in the migration PR.
5. The “easy bits” are mechanical sweeps
Section titled “5. The “easy bits” are mechanical sweeps”The bulk of the 176 call-sites is straightforward:
- Init / shutdown:
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)is unchanged in spelling but returns aboolin SDL3 (true = success) instead of anint(0 = success). Every error-check site flips polarity. - Window / renderer / texture creation:
SDL_CreateWindowloses itsx, yposition params (useSDL_WINDOWPOS_CENTEREDsemantics via the new property API or callSDL_SetWindowPositionafter creation).SDL_CreateRendererloses the index/flags params (driver name + properties API replaces them);SDL_RENDERER_ACCELERATEDandSDL_RENDERER_PRESENTVSYNCare gone — vsync is set viaSDL_SetRenderVSync. - Render loop:
SDL_RenderCopy→SDL_RenderTexture;SDL_RenderDrawRect→SDL_RenderRect;SDL_RenderClearandSDL_RenderPresentunchanged;SDL_SetRenderDrawColorunchanged.SDL_Rect(integer) is supplemented bySDL_FRect(float) for the new render API — the mechanical sweep uses the integer-rect equivalents where they exist. - Event loop:
SDL_PollEvent,SDL_Event,SDL_QUITare unchanged in spelling; the event-type constant naming flips to namespacedSDL_EVENT_*(e.g.,SDL_KEYDOWN→SDL_EVENT_KEY_DOWN,SDL_QUIT→SDL_EVENT_QUIT,SDL_MOUSEBUTTONDOWN→SDL_EVENT_MOUSE_BUTTON_DOWN). Mechanical find-and-replace. - Scancodes:
SDL_SCANCODE_*constants are stable across the major version. The 31-key dispatch table ininput.cports unchanged. - Timer / log:
SDL_GetTicksreturnsUint64in SDL3 (wasUint32in SDL2) — call sites that store the result must widen.SDL_Delay,SDL_Log,SDL_GetErrorunchanged.
6. CI hookup
Section titled “6. CI hookup”GitHub Actions runners gain SDL3 install steps in the emulator-build job:
- macOS runners:
brew install sdl3. - Linux runners (ubuntu-latest):
apt install libsdl3-devon Ubuntu 24.04+ runners; on older runners, install from the upstream PPA or build from source as a tagged fallback. The intent is “apt-supplied”; the fallback exists only if a runner image lags. - Build matrix: Both runners build with SDL3 only; no SDL2/SDL3 dual-build matrix. SDL2 is gone from the dependency list at the same commit that lands SDL3.
7. No CLAUDE.md canonical-spec value shifts
Section titled “7. No CLAUDE.md canonical-spec value shifts”The Canonical Hardware Specification in CLAUDE.md does not change. The host library (SDL2 vs. SDL3) is build-pipeline metadata, not a canonical hardware spec value. The 80×25 grid, 960×600 logical framebuffer, 12×24 cell, amber #E6A020 / black, 44.1 kHz mono PCM, and all other canonical-spec values are unchanged. Per Spec Hygiene Rule 3, the absence of a CLAUDE.md edit is intentional and is called out in the Documentation Updates section below.
Options Considered
Section titled “Options Considered”Option A: Migrate to SDL3 (ACCEPTED)
Section titled “Option A: Migrate to SDL3 (ACCEPTED)”Described above. SDL3 across both compile targets, single major version, apt-supplied on device per ADR-0026, Homebrew-supplied on macOS dev, mechanical migration of 176 call-sites with no behavior changes.
Option B: Stay on SDL2
Section titled “Option B: Stay on SDL2”Keep the SDL2 dependency through ship and migrate to SDL3 (or its successor) at some later date — possibly post-ship.
Rejected because:
- SDL2 is in maintenance mode. Building a Q4 2027 ship on a maintenance-mode major version means accumulating 18 months of “we should migrate eventually” tax, paid as small frictions every time the build host or the device base release advances.
- ADR-0026 just made SDL3 apt-supplied on device. The largest objection to SDL3 (vendoring on the Pi) is no longer in play. The cost of migration is at its lowest right now.
- The migration cost grows with the call-site count. Migrating at 176 call-sites is mechanical; migrating at the 400+ call-sites this codebase will have by ship is an order of magnitude harder. Migrating now is the cheap window.
- Toolchain alignment with the device is better on SDL3. trixie’s apt archive carries SDL3, not SDL2 (SDL2 stays via
libsdl2-devfor legacy software but the trixie-default DE and modern apps target SDL3). Aligning early avoids the worst-case “we ship on a base release whose default media stack we don’t use.”
Option C: Replace SDL with sokol_app + sokol_audio + sokol_gfx
Section titled “Option C: Replace SDL with sokol_app + sokol_audio + sokol_gfx”Drop SDL entirely; adopt the sokol header-only suite (sokol_app.h for windowing/input, sokol_audio.h for audio, sokol_gfx.h for rendering).
Rejected because:
- More integration to own. sokol is three header-only libraries that the KN-86 codebase would have to stitch together itself (event loop, audio callback wiring, render-target allocation). SDL3 hands us all three in one library with battle-tested integration.
- Smaller community / fewer recipes. sokol is a well-engineered solo project (Andre Weissflog) with a small but devoted community. SDL3 is a Khronos-tier project with packaging on every Debian-derivative distribution, every BSD, macOS via Homebrew, and Windows binaries — the apt path on Pi OS that ADR-0026 unlocks does not exist for sokol.
- Pi packaging story is worse. SDL3 is
apt install libsdl3-devon trixie. sokol would have to vendor into the source tree (header-only is its strength here, but it inverts the maintenance burden onto KN-86 instead of Debian’s package maintainers). - No KN-86-specific advantage. sokol’s cross-platform reach (web via WebAssembly, mobile) is irrelevant to the KN-86 mission profile. We don’t need what sokol gives us beyond SDL3.
Option D: GLFW + miniaudio (three-library stitch)
Section titled “Option D: GLFW + miniaudio (three-library stitch)”GLFW for windowing/input + miniaudio for audio + a hand-rolled software renderer for the 960×600 amber framebuffer composition.
Rejected because:
- Three libraries to stitch for what SDL3 does in one. Every additional integration boundary is a place for behavior drift between the emulator and the device path. SDL3 is the integrated story.
- GLFW does not provide a renderer. SDL3’s 2D renderer (and its Vulkan/Metal/D3D12 backends) is the load-bearing path for the 960×600 framebuffer composite onto the 1024×600 Elecrow with a 32 px letterbox per side. Replacing it with hand-rolled software composition is a meaningful new ownership burden for zero benefit.
- Audio path is no easier. miniaudio’s API is well-regarded but the migration cost from SDL2’s callback model to miniaudio’s stream model is the same shape as the cost to SDL3’s stream model — except SDL3 is what trixie ships in apt and miniaudio is not.
- Community / packaging story is the same as sokol’s. GLFW is in apt, miniaudio is header-only and would vendor. We pick up two of sokol’s downsides without sokol’s integrated story.
Option E: Native DRM/KMS + ALSA on device, SDL on emulator
Section titled “Option E: Native DRM/KMS + ALSA on device, SDL on emulator”Use SDL on the desktop emulator only; on the Pi Zero 2 W device, drive the framebuffer via DRM/KMS directly and feed audio via ALSA without going through SDL.
Rejected because:
- Two real backends to maintain. The KN-86 codebase has one set of platform-glue code today; this option doubles it. Every feature, every bugfix, every behavior-correctness check has to be done twice. The maintenance multiplier compounds with every sprint.
- Behavior-parity is much harder. SDL3 is the same library on emulator and device, so observable behavior is identical by construction. With native DRM/KMS on device, behavior is parity-tested rather than guaranteed — every release cycle has to re-check that the device path still matches the emulator.
- The Pi Zero 2 W can run SDL3 fine. The 1024×600 framebuffer is small; SDL3’s 2D renderer with the OpenGL ES 2 backend is well within the Pi Zero 2’s GPU envelope. There is no performance forcing function pushing toward DRM/KMS.
- No ADR-0017 alignment. ADR-0017 already moved the realtime-critical audio synthesis off the Pi onto the Pico 2 coprocessor. The Pi-side audio path is non-realtime envelope and mixdown only — SDL3’s audio stream is fully sufficient and ALSA buys nothing.
Option F: raylib
Section titled “Option F: raylib”Adopt raylib — a friendly C game-development library — as the SDL replacement.
Rejected because:
- Wrong abstraction level. raylib is a game framework — it bundles a renderer, a math library, an audio mixer, font loading, model loading, and a higher-level game loop. KN-86 needs a host-platform abstraction, not a framework. SDL3 sits at the right level; raylib is one level up and ships a lot of code we do not want.
- Conflicts with KN-86’s own framebuffer composition. The 960×600 logical framebuffer composited into a 1024×600 Elecrow with a 32 px letterbox is a custom path. raylib’s renderer assumes you draw with raylib’s primitives (sprites, shapes, text). Wedging a custom 960×600 ARGB8888 software composite into raylib is fighting the framework.
- Pi packaging is worse than SDL3’s. raylib is in some Debian repos but not as a first-class apt package on trixie at the SDL3 level. We would be back in the vendoring conversation, on a less-popular library, for less benefit.
Trade-off Analysis
Section titled “Trade-off Analysis”Against Option B (stay on SDL2), Option A wins on:
- Upstream-current major version. SDL3 receives feature work; SDL2 receives only stability fixes.
- Migration cost at minimum. 176 call-sites, all C, no FFI surface impact, mechanical sweep. The cost only grows from here.
- Toolchain alignment with trixie. apt-supplied SDL3 on device matches Homebrew-supplied SDL3 on macOS — single major version end-to-end.
The cost of Option A vs. Option B:
- One-time mechanical migration. ~176 call-sites translated; audio path re-expressed against
SDL_AudioStream; HIDPI defaults explicitly asserted. Companion code task is the cost; this ADR is the decision. - Pixel-diff and PCM-diff regression gates. The migration PR ships with pre/post screenshots of the boot sequence and a PCM capture of a known cart trace. These gates are new work, but they are also the regression suite we want long-term.
Against Options C / D / F (sokol / GLFW+miniaudio / raylib), Option A wins on packaging — apt on trixie, Homebrew on macOS. SDL3 is the only candidate where both compile targets have a first-class system-package path; the alternatives all push integration burden into the KN-86 source tree.
Against Option E (native DRM/KMS + ALSA on device), Option A wins on maintenance — one platform-glue codebase, not two; behavior parity by construction, not by parity-testing every release.
Migration Impact
Section titled “Migration Impact”Surface area
Section titled “Surface area”- 176 SDL_ call-sites across the emulator source tree (
kn86-emulator/src/). - 87 unique SDL symbols in use.
- 13 source files touched:
main.c,input.c,sound.c,sfx.c,sfx.h,cipher.h,keyboard_overlay.c,keyboard_overlay.h,keymap.h,legacy_terminal/launcher.h,panic.h,tutorial_wiring.h. - CMakeLists.txt:
find_package(SDL2 …)→find_package(SDL3 REQUIRED). Link-target switch fromSDL2::SDL2→SDL3::SDL3(and the matchingSDL3::Headersinclude if the codebase uses the modular target form).
The gnarly bits
Section titled “The gnarly bits”- Audio device + callback model. SDL2’s
SDL_OpenAudioDevice+SDL_AudioSpec.callbackis replaced by SDL3’sSDL_OpenAudioDeviceStream+SDL_AudioStream. The PSG mixer’s per-callback work is re-expressed against a push-style stream feed (or anSDL_SetAudioStreamGetCallbackregistration if the companion code task elects the closer-to-SDL2 path). PCM-diff gate is the regression bar. - HIDPI defaults. SDL3 changed high-DPI semantics. The migrated window-creation path must explicitly opt in to the right behavior for KN-86 — 1:1 logical-to-pixel on Retina hosts, no fractional scale, no super-sampling. Easy to miss; pixel-diff gate is the regression bar.
- Renderer creation API.
SDL_CreateRenderer(window, index, flags)→SDL_CreateRenderer(window, name). The vsync flag (SDL_RENDERER_PRESENTVSYNC) is gone — vsync is nowSDL_SetRenderVSync(renderer, 1)after creation. Mechanical but easy to leave behind. - Init return polarity.
SDL_Initreturnsbool(true = success) in SDL3 vs.int(0 = success) in SDL2. Every error-check call site flips. Simple find-and-replace, but a missed flip silently inverts the success path. - Event constant rename.
SDL_KEYDOWN→SDL_EVENT_KEY_DOWNand similar across the event types. Mechanical, but the rename is per-constant rather than per-prefix — sed-with-care. SDL_GetTickswidened toUint64. Any local that stores the result and any arithmetic against it has to handle the wider type. Most KN-86 timer arithmetic is short-window deltas where this is benign; the migration sweep verifies.
The easy bits
Section titled “The easy bits”- All
SDL_SCANCODE_*constants stable across the major version. The 31-key dispatch table ininput.cports unchanged. SDL_PollEvent,SDL_Event,SDL_Delay,SDL_Log,SDL_GetError,SDL_Rect,SDL_SetRenderDrawColor,SDL_RenderClear,SDL_RenderPresent— unchanged spelling and semantics.SDL_PIXELFORMAT_ARGB8888,SDL_TEXTUREACCESS_STREAMING,SDL_INIT_VIDEO,SDL_INIT_AUDIO,SDL_BUTTON_LEFT— unchanged.
Out of scope for this ADR
Section titled “Out of scope for this ADR”- The companion code-migration task lands the actual mechanical sweep, the audio re-expression, the HIDPI assertion, and the regression-gate harnesses. It is being executed by a parallel agent in a separate PR. This ADR is the docs-only deliverable.
- GitHub Actions CI updates to install SDL3 on macOS and Linux runners are part of the companion code task’s PR, not this one.
- Pi-side runtime integration. The nOSh runtime on the Pi consumes the same SDL3 surface as the emulator; the device-side build path is the
stage-kn86-runtimeapt step in the pi-gen pipeline (see ADR-0026 §4) and is exercised at Stage 0 bring-up.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Upstream-current major version. SDL3 across both targets; no maintenance-mode tax accumulating against ship.
- Toolchain alignment. Single SDL major version on emulator and device; apt on trixie, Homebrew on macOS, no vendored bridge.
- Migration cost at minimum. 176 call-sites, mechanical sweep, no FFI impact, no cart-format impact, no canonical-spec impact.
- Behavior parity by construction. Same library on both targets; pixel-diff and PCM-diff harnesses gate the migration PR.
- No CLAUDE.md canonical-spec change. Host library is build-pipeline metadata; the canonical hardware spec is unchanged.
Negative / Accepted costs
Section titled “Negative / Accepted costs”- Build-host requirement bumps. Every dev host now needs SDL3, not SDL2. macOS dev:
brew install sdl3(already on the dev machine). Linux dev:apt install libsdl3-dev(Debian 13+ / Ubuntu 24.04+ have it; older systems install from upstream or from the provided fallback). CI: add an install step. - One-time migration toil. 176 call-sites, the audio path, and the HIDPI assertion. Mechanical, gated by pixel-diff and PCM-diff, but not zero-cost. Owned by the companion code-migration task.
- README + build-doc updates.
kn86-emulator/README.mdanddocs/device/os/system-image-build.mdreference SDL2 in install steps and link-flag examples; the migration PR sweeps these.
Follow-on work this ADR creates
Section titled “Follow-on work this ADR creates”- F1 — Companion code-migration PR. Parallel agent’s deliverable. Mechanical sweep, audio re-expression, HIDPI assertion, pixel-diff and PCM-diff harnesses, CI updates. CMakeLists.txt + 13 source files.
- F2 — Stage 0 device bring-up exercises SDL3 on the Pi. The
stage-kn86-runtimeapt step from ADR-0026 §4 installslibsdl3-dev; the bring-up matrix verifies the nOSh runtime renders + plays audio identically on the device. - F3 — Pixel-diff and PCM-diff regression suite becomes part of the standard build. The harnesses written for the migration PR stay in CI as the long-term regression gate against any future SDL3 minor-version drift or rendering refactor.
- HIDPI silently mis-scales on Retina hosts. SDL3’s high-DPI defaults differ from SDL2’s, and the regression is visually subtle (slight glyph blur from fractional scale rather than crisp 1:1). Mitigated by the explicit opt-in / opt-out in window creation (per §1 Decision constraint) and by the pixel-diff gate against pre-migration screenshots — a fractional-scale regression shows up immediately in the diff.
- Audio behavior drift in the SDL2-callback → SDL3-stream re-expression. PSG mixing semantics are stable, but the buffer-feed cadence and the “first sample” alignment can shift under the stream model. Mitigated by the PCM-diff gate against a known boot+cart trace; an inaudible 1-sample shift is acceptable and documented in the migration PR, anything larger blocks the PR.
- Trixie’s
libsdl3-devminor version drifts before Stage 0 bring-up. ADR-0026 §Risks #1 covers the broader “trixie point-release churn” risk; the SDL3 specifics are downstream of that. Mitigated by the samesnapshot.debian.orgtimestamp pin once Stage 0 commits a triplet. - A subset of the 176 call-sites rely on undocumented SDL2 behavior. Mechanical sweeps catch API renames; they do not catch semantic differences in subtle corner cases (pixel-format byte order on big-endian, audio underrun behavior, window-show timing on first frame). Mitigated by the regression gates — any corner case that escapes the migration shows up in the pixel-diff or PCM-diff or a manual smoke run on the Pi at Stage 0.
- macOS Homebrew SDL3 bottle drifts ahead of trixie’s apt SDL3. The two compile targets could end up on different SDL3 minor versions with subtle behavior differences. Mitigated by SDL3’s stability guarantees within a major version; if a real regression appears, the device path’s snapshot timestamp pin (per ADR-0026) gives us an artifact to compare against and a place to pin.
- SDL3 itself yanks a feature we depend on in a future minor release. Same shape as risk #5 — stability is upstream’s job, our job is the regression suite. Mitigated by F3 (regression suite stays in CI).
Migration
Section titled “Migration”The migration is mechanical, gated by behavior-parity harnesses, and split across this ADR (the decision) and a companion code-migration PR (the implementation). Concrete steps:
- Land this PR (docs-only). ADR-0025 written, ADR README index updated. No source code touched. CLAUDE.md untouched (per §7 Decision).
- Companion code-migration PR lands the actual sweep. 176 SDL_ call-sites translated to SDL3. CMakeLists.txt switched. Audio path re-expressed. HIDPI assertion added. Pixel-diff and PCM-diff harnesses added. CI install steps added for SDL3 on both runner OSes. SDL2 removed.
- Stage 0 device bring-up exercises SDL3 on the Pi. Per ADR-0026 §4 —
stage-kn86-runtimeapt step installslibsdl3-dev; bring-up matrix verifies the nOSh runtime renders + plays audio identically on device vs. emulator. - Regression suite stays in CI long-term. The pixel-diff and PCM-diff harnesses written for the migration PR become permanent CI gates.
There is no in-field migration component — no devices have shipped on SDL2. This is a pre-bring-up host-library decision, not a field-update story.
Documentation Updates (REQUIRED — part of the decision, not aspirational)
Section titled “Documentation Updates (REQUIRED — part of the decision, not aspirational)”-
docs/adr/ADR-0025-sdl3-migration.md— this ADR. -
docs/adr/README.md— append entry for ADR-0025. -
kn86-emulator/CMakeLists.txt—find_package(SDL2 …)→find_package(SDL3 REQUIRED). Owned by the companion code-migration PR, not this PR. -
kn86-emulator/README.md— install steps referencebrew install sdl3(macOS) andapt install libsdl3-dev(Linux); SDL2 references removed. Owned by the companion code-migration PR. -
docs/device/os/system-image-build.md—stage-kn86-runtimeapt step installslibsdl3-dev(already aligned with ADR-0026 §4’s expectation; verify after the companion PR lands). Verification owned by the companion code-migration PR. - Project-wide grep sweep.
git grep -i sdl2andgit grep -i 'libsdl2'. After the sweep, the only remaining hits are:- This ADR’s own narrative (Context, Options Considered, Migration Impact).
- ADR-0026’s narrative reference to “SDL3 is not in the bookworm apt archive” — that’s a historical fact about the bookworm/trixie comparison, not a KN-86 commitment; keeps as-is.
- Any vendored upstream documentation that itself references SDL2 — third-party content is not rewritten.
- Owned by the companion code-migration PR, not this PR.
-
CLAUDE.md— no change. The host library (SDL2 vs. SDL3) is build-pipeline metadata, not a canonical hardware spec value. The Canonical Hardware Specification table is untouched. Per Spec Hygiene Rule 3, the absence of a CLAUDE.md edit is intentional.
This ADR PR is docs-only and ships only the two [x] items above (this ADR + the README index entry). The remaining open boxes are the contract for the companion code-migration PR. A code-migration PR that lands without ticking those boxes fails review.