From 71d1cb47a41af0a44ad9fb2ef7f88f74e29fe8de Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 13 Oct 2026 14:01:46 -0500 Subject: [PATCH] More target platforms. Licenses updated. --- LICENSE.md | 21 + LICENSES.md | 449 +++++-- Makefile | 55 +- README.md | 110 +- docs/ApiAudio.adoc | 27 +- docs/ApiCore.adoc | 13 +- docs/ApiInput.adoc | 24 +- docs/ApiSerial.adoc | 24 +- docs/Appendix.adoc | 4 +- docs/Assets.adoc | 5 +- docs/BuildIntegration.adoc | 64 +- docs/GettingStarted.adoc | 177 ++- docs/Manual.adoc | 2 +- docs/Overview.adoc | 70 +- docs/Portability.adoc | 387 +++++- docs/ProgramLayout.adoc | 6 +- examples/adventure/adventure.c | 64 +- examples/adventure2/adventure2.c | 4 + examples/audio/audio.c | 4 + examples/draw/draw.c | 4 + examples/hello/hello.c | 4 + examples/joy/joy.c | 4 + examples/keys/keys.c | 4 + examples/palprobe/palprobe.c | 123 ++ examples/pattern/pattern.c | 4 + examples/save/save.c | 4 + examples/scb/scb.c | 4 + examples/sccprobe/sccprobe.c | 4 + examples/serial/serial.c | 4 + examples/serloss/serloss.c | 4 + examples/sermouse/sermouse.c | 4 + examples/sertest/sertest.c | 4 + examples/sprite/sprite.c | 4 + examples/tilebank/tilebank.c | 4 + examples/uber/uber.c | 8 +- include/joey/audio.h | 4 + include/joey/core.h | 4 + include/joey/debug.h | 4 + include/joey/draw.h | 4 + include/joey/file.h | 4 + include/joey/input.h | 4 + include/joey/joey.h | 4 + include/joey/palette.h | 4 + include/joey/platform.h | 113 +- include/joey/present.h | 4 + include/joey/serial.h | 4 + include/joey/sprite.h | 4 + include/joey/surface.h | 4 + include/joey/tile.h | 4 + include/joey/types.h | 4 + install.sh | 10 +- installer/components/amigaGcc.sh | 4 + installer/components/amigaLowlevel.sh | 39 + installer/components/amigaSerial.sh | 4 + installer/components/beosR5.sh | 65 + installer/components/beosVm.sh | 39 + installer/components/cadius.sh | 4 + installer/components/cwsdpmi.sh | 4 + installer/components/djgpp.sh | 4 + installer/components/dosbox.sh | 4 + installer/components/dvx.sh | 4 + installer/components/elf2x68k.sh | 4 + installer/components/emsdk.sh | 51 + installer/components/emutos.sh | 4 + installer/components/fsuae.sh | 4 + installer/components/haikuGcc.sh | 78 ++ installer/components/haikuGcc2.sh | 85 ++ installer/components/haikuTools.sh | 87 ++ installer/components/haikuVm.sh | 42 + installer/components/haikuVm32.sh | 42 + installer/components/hatari.sh | 4 + installer/components/headless.sh | 4 + installer/components/host.sh | 4 + installer/components/iigsFirmware.sh | 4 + installer/components/kickstart.sh | 4 + installer/components/kickstartA1200.sh | 36 + installer/components/kickstartA4000.sh | 36 + installer/components/kpcomm.sh | 43 + installer/components/kpjoy.sh | 43 + installer/components/libxmp.sh | 13 +- installer/components/llvm816.sh | 4 + installer/components/llvmMingw.sh | 42 + installer/components/mame.sh | 4 + installer/components/merlin32.sh | 4 + installer/components/mintGcc.sh | 4 + installer/components/nasm.sh | 4 + installer/components/ntp.sh | 4 + installer/components/openWatcom.sh | 59 + installer/components/ppsspp.sh | 73 ++ installer/components/pspdev.sh | 54 + installer/components/ptplayer.sh | 4 + installer/components/qemu.sh | 87 ++ installer/components/sdl3.sh | 68 + installer/components/sdl3Psp.sh | 55 + installer/components/sdl3Web.sh | 63 + installer/components/win311.sh | 41 + installer/components/wine.sh | 42 + installer/components/x68kCgrom.sh | 4 + installer/components/x68kFirmware.sh | 4 + installer/distroPackages.txt | 23 + installer/files.sha1 | 8 + installer/lib/beos.sh | 137 +++ installer/lib/bundle.sh | 26 +- installer/lib/common.sh | 31 +- installer/lib/config.sh | 4 + installer/lib/distro.sh | 13 +- installer/lib/haiku.sh | 200 +++ installer/lib/win16.sh | 170 +++ installer/lib/winDriver.sh | 61 + installer/patches/mame/README.md | 7 + .../patches/mame/lua/x68k-boot-check.lua | 4 + .../patches/mame/lua/x68k-crtc-probe.lua | 4 + .../patches/mame/lua/x68k-wait-probe.lua | 4 + .../ppsspp/0001-headless-gate-options.patch | 236 ++++ installer/patches/ppsspp/README.md | 34 + .../0001-emscripten-initial-axes.patch | 17 + installer/patches/sdl3Web/README.md | 14 + joey-run | 536 +++++++- make/amiga.mk | 4 + make/atarist.mk | 52 +- make/be.mk | 28 + make/beos.mk | 24 + make/common.mk | 29 +- make/dos.mk | 4 + make/dvx.mk | 4 + make/examples.mk | 27 +- make/haiku.mk | 20 + make/haiku32.mk | 22 + make/hosted.mk | 140 +++ make/iigs.mk | 4 + make/programs.mk | 4 + make/project.mk | 6 +- make/psp.mk | 65 + make/sdl3.mk | 35 + make/ste.mk | 15 + make/support/atarist/include-shim/math.h | 4 + make/support/atarist/include-shim/math_shim.c | 4 + make/support/be/gcc2.sh | 181 +++ make/support/be/gcc2Attributes.h | 21 + make/support/be/r5/stdint.h | 32 + make/support/dos/embed-dpmi.sh | 4 + make/support/dvx/writeIfChanged.sh | 4 + make/support/iigs/clang-build.sh | 4 + make/support/iigs/clang-smoke.sh | 4 + make/support/iigs/ntpIrqWindow.py | 4 + make/support/psp/packEboot.sh | 40 + make/support/web/shell.html | 71 ++ make/support/win16/noAttributes.h | 11 + make/support/win16/owcc.sh | 128 ++ make/tools.mk | 27 +- make/web.mk | 56 + make/win16.mk | 40 + make/win32.mk | 34 + make/x68000.mk | 4 + scripts/bench-amiga.sh | 11 +- scripts/bench-atarist.sh | 51 +- scripts/bench-be.sh | 64 + scripts/bench-dos.sh | 4 + scripts/bench-dvx.sh | 4 + scripts/bench-iigs.sh | 4 + scripts/bench-psp.sh | 56 + scripts/bench-sdl3.sh | 43 + scripts/bench-web.sh | 38 + scripts/bench-win16.sh | 53 + scripts/bench-win32.sh | 49 + scripts/bench-x68000.sh | 4 + scripts/build-docs.sh | 4 + scripts/check-blank.sh | 4 + scripts/check-dirtymark.sh | 4 + scripts/check-fileroots.sh | 4 + scripts/check-input.sh | 4 + scripts/check-millis.sh | 4 + scripts/check-save.sh | 4 + scripts/check-serial.sh | 4 + scripts/check-sfxmix.sh | 4 + scripts/check-tilebank.sh | 4 + scripts/dosbox-win16.conf | 42 + scripts/lib/amigaGate.sh | 44 + scripts/lib/beGate.sh | 302 +++++ scripts/lib/dosboxJoy.sh | 127 ++ scripts/lib/dvxGate.sh | 4 + scripts/lib/joeyEnv.sh | 16 +- scripts/lib/joyGate.sh | 139 +++ scripts/lib/joyScreen.py | 29 +- scripts/lib/keysRow.py | 4 + scripts/lib/mameNullModemCfg.py | 4 + scripts/lib/padGate.sh | 120 ++ scripts/lib/pspAudioGrade.py | 124 ++ scripts/lib/pspGate.sh | 94 ++ scripts/lib/qmp.py | 144 +++ scripts/lib/serialLoopPeer.py | 4 + scripts/lib/serialStreamPeer.py | 4 + scripts/lib/tileBankGate.sh | 4 + scripts/lib/virtualPad.py | 170 +++ scripts/lib/webDriver.mjs | 430 +++++++ scripts/lib/webGate.sh | 61 + scripts/lib/win16Gate.sh | 141 +++ scripts/lib/wineGate.sh | 110 ++ scripts/lib/x68kDisk.sh | 4 + scripts/make-iigs-boot-disk.sh | 4 + scripts/make-iigs-disk.sh | 4 + scripts/make-iigs-game-disk.sh | 4 + scripts/profile-x68000.sh | 4 + scripts/pulseRecordLib.sh | 4 + scripts/verify-all.sh | 133 +- scripts/verify-amiga-audio.sh | 12 +- scripts/verify-amiga-input.sh | 12 +- scripts/verify-amiga-joy.sh | 223 ++++ scripts/verify-amiga-palette.sh | 123 ++ scripts/verify-amiga-serial.sh | 9 +- scripts/verify-amiga-tilebank.sh | 10 +- scripts/verify-atarist-audio.sh | 250 +++- scripts/verify-atarist-dirtymark.sh | 4 + scripts/verify-atarist-input.sh | 4 + scripts/verify-atarist-joy.sh | 223 ++++ scripts/verify-atarist-palette.sh | 132 ++ scripts/verify-atarist-serial.sh | 4 + scripts/verify-atarist-tilebank.sh | 4 + scripts/verify-be-input.sh | 96 ++ scripts/verify-dos-audio.sh | 4 + scripts/verify-dos-input.sh | 4 + scripts/verify-dos-joy.sh | 105 ++ scripts/verify-dos-serial.sh | 4 + scripts/verify-dos-tilebank.sh | 4 + scripts/verify-dvx-audio.sh | 4 + scripts/verify-dvx-display.sh | 4 + scripts/verify-dvx-fullscreen.sh | 4 + scripts/verify-dvx-input.sh | 4 + scripts/verify-dvx-joy.sh | 223 ++-- scripts/verify-dvx-serial.sh | 4 + scripts/verify-dvx-tilebank.sh | 4 + scripts/verify-iigs-all.sh | 4 + scripts/verify-iigs-audio.sh | 4 + scripts/verify-iigs-game-disk.sh | 4 + scripts/verify-iigs-input.sh | 4 + scripts/verify-iigs-joy.sh | 237 ++++ scripts/verify-iigs-save.sh | 4 + scripts/verify-iigs-serial-loss.sh | 4 + scripts/verify-iigs-serial.sh | 4 + scripts/verify-iigs-tilebank.sh | 4 + scripts/verify-iigs.sh | 4 + scripts/verify-psp-audio.sh | 61 + scripts/verify-psp-input.sh | 174 +++ scripts/verify-psp-save.sh | 51 + scripts/verify-psp-tilebank.sh | 56 + scripts/verify-sdl3-audio.sh | 127 ++ scripts/verify-sdl3-input.sh | 62 + scripts/verify-sdl3-joy.sh | 91 ++ scripts/verify-sdl3-save.sh | 42 + scripts/verify-sdl3-tilebank.sh | 58 + scripts/verify-ste-machine.sh | 85 ++ scripts/verify-web-audio.sh | 72 ++ scripts/verify-web-input.sh | 42 + scripts/verify-web-joy.sh | 105 ++ scripts/verify-web-save.sh | 43 + scripts/verify-web-tilebank.sh | 44 + scripts/verify-win16-audio.sh | 151 +++ scripts/verify-win16-input.sh | 61 + scripts/verify-win16-joy.sh | 115 ++ scripts/verify-win16-save.sh | 65 + scripts/verify-win16-serial.sh | 63 + scripts/verify-win16-tilebank.sh | 56 + scripts/verify-win32-audio.sh | 151 +++ scripts/verify-win32-input.sh | 51 + scripts/verify-win32-joy.sh | 84 ++ scripts/verify-win32-save.sh | 51 + scripts/verify-win32-serial.sh | 59 + scripts/verify-win32-tilebank.sh | 53 + scripts/verify-x68000-audio.sh | 4 + scripts/verify-x68000-golden.sh | 4 + scripts/verify-x68000-input.sh | 4 + scripts/verify-x68000-joy.sh | 4 + scripts/verify-x68000-mouse.sh | 4 + scripts/verify-x68000-scb.sh | 4 + scripts/verify-x68000-serial-mouse.sh | 4 + scripts/verify-x68000-serial-scb.sh | 4 + scripts/verify-x68000-serial.sh | 4 + scripts/verify-x68000-tilebank.sh | 4 + scripts/x68kSerialPeer.py | 4 + src/amiga/SDI_compiler.h | 4 + src/amiga/amigaPlanar.h | 4 + src/amiga/amigaTile.h | 4 + src/amiga/audio.c | 4 + src/amiga/hal.c | 202 ++- src/amiga/input.c | 67 +- src/amiga/libinit.c | 4 + src/amiga/presentScan.s | 4 + src/amiga/save.c | 4 + src/amiga/serial.c | 4 + src/atarist/audio.c | 293 ++++- src/atarist/audio_isr.s | 4 + src/atarist/circle.s | 4 + src/atarist/copyRect.s | 4 + src/atarist/fillCircle.s | 4 + src/atarist/hal.c | 198 ++- src/atarist/input.c | 10 + src/atarist/lineSpan.s | 4 + src/atarist/presentSlam.s | 4 + src/atarist/save.c | 4 + src/atarist/serial.c | 4 + src/atarist/spriteAsm.s | 4 + src/atarist/spriteWalk.s | 4 + src/atarist/stPlanar.h | 4 + src/atarist/stTile.h | 4 + src/atarist/ste/blitter.c | 118 ++ src/atarist/ste/machine.c | 104 ++ src/atarist/ste/steBlitter.h | 21 + src/atarist/ste/steMachine.h | 26 + src/atarist/superExit.s | 4 + src/atarist/tileMap.s | 4 + src/blank/blank.c | 4 + src/codegen/spriteCompile.c | 5 + src/codegen/spriteEmitChunky.h | 4 + src/codegen/spriteEmitter.h | 4 + src/codegen/spriteStage.c | 8 + src/codegen/spriteStage.h | 4 + src/core/assetLoad.c | 23 +- src/core/audio.c | 4 + src/core/audioSfxMix.c | 4 + src/core/audioSfxMixInternal.h | 4 + src/core/codegenArena.c | 6 + src/core/codegenArenaInternal.h | 4 + src/core/debug.c | 4 + src/core/draw.c | 4 + src/core/fileRoot.h | 4 + src/core/init.c | 4 + src/core/input.c | 4 + src/core/inputInternal.h | 4 + src/core/music.c | 4 + src/core/musicInternal.h | 4 + src/core/palette.c | 4 + src/core/port.h | 18 + src/core/present.c | 4 + src/core/random.c | 4 + src/core/save.c | 4 + src/core/scb.c | 4 + src/core/serial.c | 4 + src/core/sprite.c | 198 +-- src/core/spriteDispatch.h | 183 +-- src/core/spriteInternal.h | 35 +- src/core/spritePrecompiled.h | 9 +- src/core/surface.c | 4 + src/core/surfaceInternal.h | 4 + src/core/tile.c | 4 + src/dos/audio.c | 4 + src/dos/dosTimer.h | 4 + src/dos/hal.c | 4 + src/dos/input.c | 62 +- src/dos/save.c | 4 + src/dos/serial.c | 4 + src/dvx/audio.c | 4 + src/dvx/dvxApp.c | 4 + src/dvx/dvxPort.h | 4 + src/dvx/dvxSdk.h | 4 + src/dvx/hal.c | 4 + src/dvx/input.c | 4 + src/dvx/program.c | 4 + src/dvx/save.c | 4 + src/dvx/serial.c | 4 + src/generic/genericDraw.c | 4 + src/generic/genericPort.c | 4 + src/generic/genericSave.c | 4 + src/generic/genericSerial.c | 4 + src/generic/genericSprite.c | 4 + src/generic/genericSurface.c | 4 + src/generic/genericTile.c | 4 + src/generic/spriteEmitStub.c | 4 + src/hosted/audio.c | 471 +++++++ src/hosted/be/beBackend.cpp | 1090 +++++++++++++++++ src/hosted/hal.c | 271 ++++ src/hosted/hosted.h | 116 ++ src/hosted/input.c | 166 +++ src/hosted/sdl3/sdl3Backend.c | 896 ++++++++++++++ src/hosted/win16/win16Backend.c | 832 +++++++++++++ src/hosted/win16/win16Serial.c | 220 ++++ src/hosted/win32/win32Backend.c | 620 ++++++++++ src/hosted/win32/win32Serial.c | 185 +++ src/iigs/audio_full.c | 4 + src/iigs/hal.c | 4 + src/iigs/input.c | 4 + src/iigs/joeyDraw.s | 841 +++++++++---- src/iigs/peislam.s | 4 + src/iigs/save.c | 4 + src/iigs/serial.c | 4 + src/iigs/serialIrq.s | 4 + src/iigs/sfxCopy.s | 4 + src/iigs/spriteEmitIigs.c | 78 +- src/m68k/planarFill.s | 4 + src/m68k/planarPlot.s | 4 + src/m68k/planarTile.h | 4 + src/m68k/spriteEmitC2p.h | 4 + src/m68k/spriteEmitInterleaved68k.c | 4 + src/m68k/spriteEmitPlanar68k.c | 4 + src/m68k/stageBands.s | 4 + src/x68000/audio.c | 4 + src/x68000/audioPcm.c | 4 + src/x68000/hal.c | 4 + src/x68000/halPlanar.c | 4 + src/x68000/save.c | 4 + src/x68000/serial.c | 4 + src/x68000/x68kPlanar.h | 4 + src/x68000/x68kPresent.s | 4 + src/x68000/x68kRasterIsr.s | 4 + src/x68000/x68kSccRxIsr.s | 4 + src/x68000/x68kTickIsr.s | 4 + src/x68000/x68kTile.h | 4 + src/x68000/x68kVdispIsr.s | 4 + src/x86/scanSet1.h | 9 +- src/x86/spriteEmitX86.c | 4 + src/x86/x86Expand.h | 4 + src/x86/x86Tile.h | 4 + tests/host/dirtyMarkHost.c | 4 + tests/host/fileRootHost.c | 4 + tests/host/inputHost.c | 4 + tests/host/millisHost.c | 4 + tests/host/saveHost.c | 4 + tests/host/serialHost.c | 4 + tests/host/sfxMixHost.c | 4 + tests/host/tileBankHost.c | 4 + tests/host/tileBankStage.py | 4 + tools/adfTool.py | 4 + tools/assetbake/assetbake.py | 4 + tools/diff-uber-hashes | 4 + tools/diff-uber-perf | 4 + tools/gradeAudioPhases.py | 4 + tools/iigsStart/iigsStart.c | 4 + tools/iigsStart/iigsStartQuit.s | 4 + tools/joeymod/joeymod | 4 + tools/modloopend/modloopend.c | 4 + tools/rawCd.py | 88 ++ tools/scanPtrBug.py | 4 + tools/songbake/songbake.py | 4 + tools/spritebake/spritebake.c | 6 +- tools/traceLogo.py | 4 + tools/uber-perf-table | 4 + tools/winIni.py | 88 ++ tools/xdftool.py | 4 + 437 files changed, 19317 insertions(+), 1237 deletions(-) create mode 100644 LICENSE.md create mode 100644 examples/palprobe/palprobe.c create mode 100644 installer/components/amigaLowlevel.sh create mode 100644 installer/components/beosR5.sh create mode 100644 installer/components/beosVm.sh create mode 100644 installer/components/emsdk.sh create mode 100644 installer/components/haikuGcc.sh create mode 100644 installer/components/haikuGcc2.sh create mode 100644 installer/components/haikuTools.sh create mode 100644 installer/components/haikuVm.sh create mode 100644 installer/components/haikuVm32.sh create mode 100644 installer/components/kickstartA1200.sh create mode 100644 installer/components/kickstartA4000.sh create mode 100644 installer/components/kpcomm.sh create mode 100644 installer/components/kpjoy.sh create mode 100644 installer/components/llvmMingw.sh create mode 100644 installer/components/openWatcom.sh create mode 100644 installer/components/ppsspp.sh create mode 100644 installer/components/pspdev.sh create mode 100644 installer/components/qemu.sh create mode 100644 installer/components/sdl3.sh create mode 100644 installer/components/sdl3Psp.sh create mode 100644 installer/components/sdl3Web.sh create mode 100644 installer/components/win311.sh create mode 100644 installer/components/wine.sh create mode 100644 installer/lib/beos.sh create mode 100644 installer/lib/haiku.sh create mode 100644 installer/lib/win16.sh create mode 100644 installer/lib/winDriver.sh create mode 100644 installer/patches/ppsspp/0001-headless-gate-options.patch create mode 100644 installer/patches/ppsspp/README.md create mode 100644 installer/patches/sdl3Web/0001-emscripten-initial-axes.patch create mode 100644 installer/patches/sdl3Web/README.md create mode 100644 make/be.mk create mode 100644 make/beos.mk create mode 100644 make/haiku.mk create mode 100644 make/haiku32.mk create mode 100644 make/hosted.mk create mode 100644 make/psp.mk create mode 100644 make/sdl3.mk create mode 100644 make/ste.mk create mode 100755 make/support/be/gcc2.sh create mode 100644 make/support/be/gcc2Attributes.h create mode 100644 make/support/be/r5/stdint.h create mode 100755 make/support/psp/packEboot.sh create mode 100644 make/support/web/shell.html create mode 100644 make/support/win16/noAttributes.h create mode 100755 make/support/win16/owcc.sh create mode 100644 make/web.mk create mode 100644 make/win16.mk create mode 100644 make/win32.mk create mode 100755 scripts/bench-be.sh create mode 100755 scripts/bench-psp.sh create mode 100755 scripts/bench-sdl3.sh create mode 100755 scripts/bench-web.sh create mode 100755 scripts/bench-win16.sh create mode 100755 scripts/bench-win32.sh create mode 100644 scripts/dosbox-win16.conf create mode 100644 scripts/lib/amigaGate.sh create mode 100644 scripts/lib/beGate.sh create mode 100644 scripts/lib/dosboxJoy.sh create mode 100644 scripts/lib/joyGate.sh create mode 100644 scripts/lib/padGate.sh create mode 100644 scripts/lib/pspAudioGrade.py create mode 100644 scripts/lib/pspGate.sh create mode 100644 scripts/lib/qmp.py create mode 100644 scripts/lib/virtualPad.py create mode 100644 scripts/lib/webDriver.mjs create mode 100644 scripts/lib/webGate.sh create mode 100644 scripts/lib/win16Gate.sh create mode 100644 scripts/lib/wineGate.sh create mode 100755 scripts/verify-amiga-joy.sh create mode 100755 scripts/verify-amiga-palette.sh create mode 100755 scripts/verify-atarist-joy.sh create mode 100755 scripts/verify-atarist-palette.sh create mode 100755 scripts/verify-be-input.sh create mode 100755 scripts/verify-dos-joy.sh create mode 100755 scripts/verify-iigs-joy.sh create mode 100755 scripts/verify-psp-audio.sh create mode 100755 scripts/verify-psp-input.sh create mode 100755 scripts/verify-psp-save.sh create mode 100755 scripts/verify-psp-tilebank.sh create mode 100755 scripts/verify-sdl3-audio.sh create mode 100755 scripts/verify-sdl3-input.sh create mode 100755 scripts/verify-sdl3-joy.sh create mode 100755 scripts/verify-sdl3-save.sh create mode 100755 scripts/verify-sdl3-tilebank.sh create mode 100755 scripts/verify-ste-machine.sh create mode 100755 scripts/verify-web-audio.sh create mode 100755 scripts/verify-web-input.sh create mode 100755 scripts/verify-web-joy.sh create mode 100755 scripts/verify-web-save.sh create mode 100755 scripts/verify-web-tilebank.sh create mode 100755 scripts/verify-win16-audio.sh create mode 100755 scripts/verify-win16-input.sh create mode 100755 scripts/verify-win16-joy.sh create mode 100755 scripts/verify-win16-save.sh create mode 100755 scripts/verify-win16-serial.sh create mode 100755 scripts/verify-win16-tilebank.sh create mode 100755 scripts/verify-win32-audio.sh create mode 100755 scripts/verify-win32-input.sh create mode 100755 scripts/verify-win32-joy.sh create mode 100755 scripts/verify-win32-save.sh create mode 100755 scripts/verify-win32-serial.sh create mode 100755 scripts/verify-win32-tilebank.sh create mode 100644 src/atarist/ste/blitter.c create mode 100644 src/atarist/ste/machine.c create mode 100644 src/atarist/ste/steBlitter.h create mode 100644 src/atarist/ste/steMachine.h create mode 100644 src/hosted/audio.c create mode 100644 src/hosted/be/beBackend.cpp create mode 100644 src/hosted/hal.c create mode 100644 src/hosted/hosted.h create mode 100644 src/hosted/input.c create mode 100644 src/hosted/sdl3/sdl3Backend.c create mode 100644 src/hosted/win16/win16Backend.c create mode 100644 src/hosted/win16/win16Serial.c create mode 100644 src/hosted/win32/win32Backend.c create mode 100644 src/hosted/win32/win32Serial.c create mode 100644 tools/rawCd.py create mode 100644 tools/winIni.py diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..913c9b4 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Scott Duensing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES.md b/LICENSES.md index b193b47..ff4f274 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -1,78 +1,198 @@ # Third-Party Licenses -This file inventories every third-party component JoeyLib depends on, -either at runtime (linked into the produced binaries) or at build / -test time. Components are grouped by role; within each group entries -list the platforms that consume the component, the upstream URL, and -the license. +JoeyLib itself is distributed under the MIT License (`LICENSE.md`). This +file inventories every component JoeyLib depends on but does not contain: +what is linked into or shipped with the programs it builds, what builds +them, and what tests them. JoeyLib's own tree holds no third-party code or +assets, apart from the patches in `installer/patches/`, which are under +the licenses of the projects they patch (noted in those entries). +Components are grouped by role; each entry lists the platforms that use +it, where it comes from, its license and where the installer puts it. -When upstream license terms change, update the corresponding row here. +When upstream license terms change, update the corresponding entry here. +This file summarises; the license text that comes with each component is +what counts. --- -## Runtime libraries (linked into joeylib output binaries) +## Shipped in programs (linked in, or on the disk beside them) -These ship as part of, or are linked into, an executable built by -JoeyLib. License compatibility for these matters most. +These end up in what a JoeyLib program ships as, so their terms matter +most. -### libxmp-lite +### libxmp-lite 4.7.0 -- **Used on:** DOS, Atari ST +- **Used on:** DOS, Atari ST, X68000, SDL3, PSP, web, Win16, Win32, Haiku, + BeOS - **URL:** - **License:** MIT -- **Role:** ProTracker / Amiga MOD replayer + SMIX SFX overlay. Linked - into AUDIO binaries on the chunky-display platforms. +- **Role:** ProTracker / Amiga MOD replayer + SMIX SFX overlay. Each + port's makefile compiles it into AUDIO programs. - **Installed at:** `toolchains/opt/libxmp/` (installer component `libxmp`) +### SDL 3.4.18 + +- **Used on:** SDL3, PSP, web +- **URL:** +- **License:** zlib +- **Role:** window, input and audio for the hosted SDL3 code. Built as a + static library for the host, for the PSP with pspdev and for Emscripten + (with JoeyLib's patches, `installer/patches/sdl3Web/`, which are under + SDL's zlib license), and linked into each program. +- **Installed at:** `toolchains/opt/sdl3/`, `toolchains/opt/sdl3Psp/`, + `toolchains/opt/sdl3Web/` (installer components `sdl3`, `sdl3Psp`, + `sdl3Web`) + ### NinjaTrackerPlus (NTP) - **Used on:** Apple IIgs - **URL:** (downloads from ) -- **License:** No explicit license in the source distribution; written - by Jesse Blue (NinjaForce). Treated here as free for use; consult - upstream before commercial redistribution. +- **License:** Unknown -- no file in the source distribution carries a + license or notice. Written by Jesse Blue (Ninjaforce), who has given + JoeyLib permission to redistribute it; that permission is recorded here, + not in the archive. - **Role:** Apple IIgs Ensoniq DOC `.NTP` module replayer + SFX streamer. Merlin32-assembled into `ntpplayer`, which ships on the disk - next to each IIgs program and is loaded at run time. -- **Installed at:** `toolchains/opt/ntp/` (source; installer component `ntp`). + next to each IIgs program and is loaded at run time. Its + `ntpconverter.php` makes the `.NTP` files (tools/joeymod). +- **Installed at:** `toolchains/opt/ntp/` (source; installer component `ntp`) -### PTPlayer +### PTPlayer 6.4 - **Used on:** Amiga - **URL:** (author Frank Wille, ) -- **License:** Public Domain +- **License:** Public Domain (the archive's `license` file: the Unlicense) - **Role:** ProTracker MOD replayer + `mt_playfx` SFX channel for the Paula audio HAL. - **Installed at:** `toolchains/opt/ptplayer/` (installer component `ptplayer`) +### CWSDPMI r7 + +- **Used on:** DOS +- **URL:** +- **License:** Custom redistribution terms (Charles W. Sandmann, + (c) 1995-2010). Binary may be redistributed without source provided + the executables are unmodified and end users are notified of + source availability -- full terms in `cwsdpmi.doc`. Source is + GPL-licensed if distributed. +- **Role:** the DPMI host. `make/support/dos/embed-dpmi.sh` puts + CWSDSTUB.EXE in front of every DOS executable, so each `.EXE` carries it + and runs without a separate CWSDPMI.EXE. +- **Installed at:** `toolchains/opt/cwsdpmi/` (installer component `cwsdpmi`) + +### The toolchains' C libraries and start files + +Each toolchain below also links its own C library, start files and +compiler runtime into the programs it builds. What a shipped program owes +them, in brief (the entries below have the detail): + +- **DOS:** DJGPP's libc and crt0 (LGPL); a program may ship without their + source if its users are told how to get DJGPP (`copying.dj`). DVX + programs are modules DVX loads, built with DVX's SDK. +- **Atari ST:** MiNTLib, file by file under its own license (COPYMINT), + the LGPL 2 or later, BSD, Henry Spencer's and musl's MIT terms; a linked + program meets all of them (MiNTLib's `LICENSES`). +- **Amiga:** libnix (public domain). +- **X68000:** newlib (a collection of permissive licenses, + `COPYING.NEWLIB`), with elf2x68k's Human68k support (BSD). +- **PSP:** the PSPSDK (BSD) and newlib. +- **Win16:** Open Watcom's C runtime, and its Win386 supervisor, bound + into every program (Sybase Open Watcom Public License: a program that + carries them needs the notice of its section 2.2(d), in the program and + in its documentation). Every Win16 program carries the notice, never + shown, and docs/Portability.adoc ("Shipping") gives the line for its + documentation; see Open Watcom below. +- **Win32:** mingw-w64's start files and runtime (Zope Public License 2.1, + parts public domain); MSVCRT.DLL is Windows' own. +- **web:** Emscripten's system libraries (musl, MIT; libc++ and + compiler-rt, Apache 2.0 with LLVM Exceptions). +- **Apple IIgs:** llvm816's runtime (crt0, libc): Apache License 2.0 with + LLVM Exceptions, which let a program it is compiled into ship without + the license's notice conditions. +- **BeOS:** the start files from your BeOS 5 Professional CD (Be, Inc.; + terms Unknown; see the BeOS entry under the never-downloaded media + below). +- **Haiku:** the system's own shared libraries, at run time (MIT). +- **Every gcc port:** libgcc, under the GCC Runtime Library Exception, + which puts no conditions on the programs it is linked into. + +--- + +## Sibling projects (by JoeyLib's author) + +Separate repositories beside this one, which the installer takes from a +checkout there (or the path named). + +### DVX + +- **Used on:** DVX +- **Source:** `../dvxgui` (or `install.sh --dvx-dir PATH`) +- **License:** MIT +- **Role:** the DOS desktop the dvx port's programs run in, and the SDK + they are built with. +- **Installed at:** `toolchains/opt/dvx/` (installer component `dvx`) + +### KPJOY.DRV + +- **Used on:** Win16 +- **Source:** `../kpjoy` (or `KPJOY_DIR`); no public home yet +- **License:** MIT. The Open Watcom code it carries (DLL start-up and C + library helpers) is under the OWPL; the driver carries its notice (see + Open Watcom below). +- **Role:** the joystick driver. Windows 3.1 has the joystick API but + ships no driver for it, so without one no program sees a stick. Every + win16 run's Windows uses it, and a Win16 program can ship it. +- **Installed at:** `toolchains/opt/kpjoy/`, with its `LICENSE.md` beside + the driver so a copy taken from there carries its notice (installer + component `kpjoy`) + +### KPCOMM.DRV + +- **Used on:** Win16 +- **Source:** `../mscomm/drv` (or `KPCOMM_DIR`) +- **License:** MIT (the `drv` folder only). Built with Open Watcom, it + carries Open Watcom code (DLL start-up and C library helpers) under the + OWPL, and its notice (see Open Watcom below). +- **Role:** replaces Windows' COMM.DRV, for reliable 57600 and 115200 baud + on a 16550A. Every win16 run's Windows uses it, and a Win16 program can + ship it. +- **Installed at:** `toolchains/opt/kpcomm/`, with its `LICENSE.md` beside + the driver (installer component `kpcomm`) + --- ## Build-time toolchains (NOT linked, used to produce binaries) These run on the developer's machine to compile / assemble / link -JoeyLib for each target. They are not redistributed inside the -produced game binaries. +JoeyLib for each target. The compilers themselves are not redistributed +inside the produced binaries; the libraries they link in are summarised +above. -### llvm-mos (clang / llvm-mc / link816 w65816 toolchain) +### llvm816 (clang / llvm-mc / link816 w65816 toolchain) - **Used on:** Apple IIgs -- **URL:** -- **License:** Apache License 2.0 with LLVM Exceptions (the upstream - LLVM license). +- **URL:** + (a fork of llvm-mos, ) +- **License:** Apache License 2.0 with LLVM Exceptions, llvm-mos's (and + LLVM's) license, for all of it: llvm-mos's code and the W65816 backend, + link816, omfEmit and the runtime (its `LICENSE.TXT`). Its README lists + the third-party code it holds under other licenses (the C++ runtime's + Embedded Template Library, MIT; test and demo sources), none of which + reaches a JoeyLib program. - **Role:** clang C compiler targeting the 65816, plus llvm-mc for - assembling hand-written `.s` files and link816 for producing OMF - S16 executables. Driven by `make/support/iigs/clang-build.sh`; the - toolchain root is located via the `LLVM816_ROOT` environment - variable. + assembling hand-written `.s` files, link816 for producing OMF S16 + executables, and the runtime every IIgs program links. Driven by + `make/support/iigs/clang-build.sh`; the toolchain root is located via + the `LLVM816_ROOT` environment variable. - **Installed at:** `toolchains/opt/llvm816/` (installer component `llvm816`) -### Merlin32 +### Merlin32 1.1 - **Used on:** Apple IIgs - **URL:** -- **License:** Freely distributed by Brutal Deluxe Software +- **License:** Unknown -- freely distributed by Brutal Deluxe Software (Olivier Zardini, Antoine Vignau). Source published with no formal open-source license header -- Brutal Deluxe's "open source philosophy" but copyright is retained. Treat as "free to use, @@ -81,14 +201,34 @@ produced game binaries. - **Role:** 65816 cross-assembler. Builds NinjaTrackerPlus. - **Installed at:** `toolchains/opt/merlin32/` (installer component `merlin32`) +### cadius 1.4.6 + +- **Used on:** Apple IIgs +- **URL:** +- **License:** GNU GPL v3 +- **Role:** ProDOS disk-image tool every IIgs disk script drives. +- **Installed at:** `toolchains/opt/cadius/` (installer component `cadius`) + ### Bebbo m68k-amigaos GCC toolchain - **Used on:** Amiga - **URL:** -- **License:** GNU GPL v2 (gcc, binutils, libnix); per-component - copyrights listed in upstream `README.md`. Includes assets covered - by individual permissive notices (Roadshow, fd2pragma public - domain, ira freeware, etc.). +- **License:** by part (the sub-projects of the pinned commit's + `default-repos`, and the two archives its build downloads): + - the toolchain's build scripts: GNU GPL v2 + - gcc 6.5 and binutils: GNU GPL v3, with the GCC Runtime Library + Exception for libgcc + - libnix, the C library programs link (`-noixemul`): public domain + - clib2: BSD (3-clause, by its README) + - newlib and the ixemul SDK, installed but not linked (programs use + libnix): newlib's collection of permissive licenses; ixemul GNU GPL v2 + - sfdc, fd2sfd, libdebug: GNU GPL v2 + - ira: Unknown (its README calls it freeware) + - fd2pragma, amiga-netinclude (the networking headers), aros-stuff: + Unknown (none stated) + - the NDK 3.2 headers (Hyperion Entertainment's; a free download from + Hyperion and Aminet): Unknown (its readme states none) + - vasm: below - **Role:** AmigaOS HUNK-format gcc + binutils + libnix used to compile the Amiga port and link against PTPlayer. - **Installed at:** `toolchains/opt/amigaGcc/` (installer component `amigaGcc`) @@ -109,42 +249,34 @@ produced game binaries. - **Installed at:** built with the Amiga GCC toolchain (AmigaPorts `vasm` sub-project), `toolchains/opt/amigaGcc/bin/vasmm68k_mot` -### m68k-atari-mint GCC toolchain (Thorsten Otto builds) +### m68k-atari-mint GCC toolchain - **Used on:** Atari ST - **URL:** - (gcc upstream: GNU; mintlib: ) -- **License:** GNU GPL v2 (gcc, binutils); mintlib is a mix of GPL - and permissive (per-file headers). + (Vincent Riviere's builds; mintlib: ) +- **License:** gcc 15.2 and binutils 2.45: GNU GPL v3, with the GCC + Runtime Library Exception for libgcc. MiNTLib 0.60.1: per file, under + the MiNTLib license (COPYMINT), the LGPL 2 or later, BSD, Henry + Spencer's or musl's MIT terms; a program linked with it meets all of + them (MiNTLib's `LICENSES`). - **Role:** GEMDOS-native gcc + binutils + mintlib used to compile the Atari ST port. - **Installed at:** `toolchains/opt/mintGcc/` (installer component `mintGcc`) ### DJGPP (build-djgpp packaging) -- **Used on:** DOS +- **Used on:** DOS, DVX - **URL:** (DJGPP upstream: ) -- **License:** Build script GNU GPL v3; bundled gcc/binutils GNU - GPL v2; DJGPP runtime libraries (libc) are mostly public-domain or - permissive -- see DJGPP `readme.1st`. +- **License:** build-djgpp's scripts: GNU GPL v3. gcc 12.2.0 and + binutils: GNU GPL v3, with the GCC Runtime Library Exception for + libgcc. DJGPP's own code (`copying.dj`): GNU GPL, except the sources of + crt0 and libc, which are LGPL; those may be linked into a program shipped + without source as long as its users are told how to get DJGPP. - **Role:** 32-bit protected-mode gcc cross-compiler producing COFF binaries that run on 386+ DOS via DPMI. - **Installed at:** `toolchains/opt/djgpp/` (installer component `djgpp`) -### CWSDPMI - -- **Used on:** DOS -- **URL:** -- **License:** Custom redistribution terms (Charles W. Sandmann, - (c) 1995-2010). Binary may be redistributed without source provided - the executables are unmodified and end users are notified of - source availability -- full terms in `cwsdpmi.doc`. Source is - GPL-licensed if distributed. -- **Role:** Stubbed-into-binary DPMI server for DJGPP-compiled DOS - executables (via `embed-dpmi.sh`). -- **Installed at:** `toolchains/opt/cwsdpmi/` (installer component `cwsdpmi`) - ### NASM - **Used on:** DOS @@ -154,6 +286,100 @@ produced game binaries. port. - **Installed from:** the Linux distribution's `nasm` package +### elf2x68k 20260602 + +- **Used on:** X68000 +- **URL:** +- **License:** elf2x68k's own sources: BSD; its scripts: Apache License + 2.0. gcc 13.4.0 and binutils 2.44: GNU GPL v3, with the GCC Runtime + Library Exception for libgcc. newlib 4.5.0: its collection of + permissive licenses (`COPYING.NEWLIB`). +- **Role:** m68k-xelf gcc, whose link step converts the ELF output into a + Human68k `.X` executable. +- **Installed at:** `toolchains/opt/elf2x68k/` (installer component + `elf2x68k`) + +### Open Watcom v2 (2026-10-01 build) + +- **Used on:** Win16 +- **URL:** +- **License:** Sybase Open Watcom Public License 1.0 (`license.txt` in + the release). A program that carries Open Watcom code in object form + -- its C runtime, and the Win386 supervisor (WIN386.EXT) bound into + every Win16 program -- must, by section 2.2(d), include a prominent + notice, in the program and its documentation, that the source is + available under the license and where to get it. Every Win16 program + carries it (`hostWin16OwplNotice`, src/hosted/win16/win16Backend.c), as + do KPJOY.DRV and KPCOMM.DRV, as a string never shown; docs/Portability.adoc + ("Shipping") gives the same line for a program's documentation. +- **Role:** the Win386 target compiles the win16 port's 32-bit programs + and binds them to the supervisor; the 16-bit Windows target builds + KPJOY.DRV and KPCOMM.DRV. +- **Installed at:** `toolchains/opt/openWatcom/` (installer component + `openWatcom`) + +### llvm-mingw 20260922 (msvcrt) + +- **Used on:** Win32 +- **URL:** +- **License:** clang, lld and the LLVM runtimes: Apache License 2.0 with + LLVM Exceptions. mingw-w64's headers and runtime: Zope Public License + 2.1, with parts marked public domain, BSD or LGPL. +- **Role:** i686 cross compiler for the win32 port's programs, which link + Windows' MSVCRT.DLL. +- **Installed at:** `toolchains/opt/llvmMingw/` (installer component + `llvmMingw`) + +### pspdev v20261001 + +- **Used on:** PSP +- **URL:** +- **License:** psp-gcc and binutils: GNU GPL v3, with the GCC Runtime + Library Exception for libgcc. The PSPSDK: BSD. newlib: its collection + of permissive licenses. Every library pspdev carries has its license + under `psp/share/licenses/`. +- **Role:** psp-gcc, the PSPSDK and the tools that make an EBOOT.PBP. +- **Installed at:** `toolchains/opt/pspdev/` (installer component `pspdev`) + +### Emscripten 6.0.11 (emsdk) + +- **Used on:** web +- **URL:** + (emsdk: ) +- **License:** emsdk: MIT. Emscripten: MIT or the University of + Illinois/NCSA Open Source License. Its system libraries, compiled into + each program: musl (MIT); libc++, libc++abi and compiler-rt (Apache + License 2.0 with LLVM Exceptions). Its clang and binaryen: Apache 2.0 + (with LLVM Exceptions for clang). Node.js: MIT. +- **Role:** compiles the web port's programs to WebAssembly. +- **Installed at:** `toolchains/opt/emsdk/` (installer component `emsdk`) + +### Haiku cross compilers (R1/beta6) + +- **Used on:** Haiku (x86_64 gcc 13.3), Haiku x86 and BeOS (gcc 2.95.3) +- **URL:** (the toolchain layers of Haiku's + published cross-compiler images, `ghcr.io/haiku/cross-compiler`) +- **License:** gcc and binutils: GNU GPL (v3 for gcc 13.3, v2 for gcc + 2.95.3), with the GCC Runtime Library Exception for gcc 13's runtime. + The sysroots (Haiku's system libraries and `haiku_devel`): MIT. +- **Role:** build the haiku and haiku32 ports; gcc 2.95.3 also builds the + beos port against BeOS R5's own sysroot. +- **Installed at:** `toolchains/opt/haikuGcc/`, `toolchains/opt/haikuGcc2/` + (installer components `haikuGcc`, `haikuGcc2`) + +### Haiku host tools (R1/beta6) + +- **Used on:** Haiku, Haiku x86, BeOS +- **URL:** + (jam: ) +- **License:** Haiku: MIT. jam: Perforce's Jam license (free use and + distribution, keeping its notice). +- **Role:** `package` unpacks the `.hpkg` packages the sysroots are made + from; `bfs_shell` reads and writes the BFS disks the VMs take their jobs + on. +- **Installed at:** `toolchains/opt/haikuTools/` (installer component + `haikuTools`) + --- ## Test-time emulators (not shipped, not linked) @@ -162,16 +388,17 @@ Used by `joey-run` and the test gates to run the produced binaries on the developer's machine. Not part of any redistributed artifact. Installed by `./install.sh install emulators`. -### MAME +### MAME 0.264 - **Used on:** Apple IIgs (apple2gs driver) and Sharp X68000 (x68k driver) - **URL:** - **License:** GNU GPL v2+ (GPL-2.0-or-later) plus a 3-clause BSD alternative for some components. - **Installed at:** `toolchains/opt/mame/`, built from the 0.264 release with - the patches in `installer/patches/mame/` (installer component `mame`). + the patches in `installer/patches/mame/`, which are under MAME's license + (installer component `mame`). -### Hatari +### Hatari 2.4.1 - **Used on:** Atari ST - **URL:** @@ -180,15 +407,15 @@ developer's machine. Not part of any redistributed artifact. Installed by `toolchains/opt/hatari/`, built from the 2.4.1 source (installer component `hatari`) -### DOSBox Staging +### DOSBox Staging 0.83.0 -- **Used on:** DOS +- **Used on:** DOS, DVX, Win16 (Windows for Workgroups runs on it) - **URL:** -- **License:** GNU GPL v2+ +- **License:** GNU GPL v2+ (GPL-2.0-or-later) - **Installed at:** `toolchains/opt/dosbox/` (the project's Linux release; installer component `dosbox`) -### FS-UAE +### FS-UAE 3.1.66 - **Used on:** Amiga - **URL:** @@ -197,7 +424,7 @@ developer's machine. Not part of any redistributed artifact. Installed by `toolchains/opt/fsuae/`, built from the 3.1.66 release (installer component `fsuae`) -### EmuTOS +### EmuTOS 1.4 - **Used on:** Atari ST (TOS replacement for Hatari) - **URL:** @@ -205,13 +432,64 @@ developer's machine. Not part of any redistributed artifact. Installed by - **Installed at:** `toolchains/firmware/atarist/emutos-512k.img` (installer component `emutos`) +### QEMU 11.1.2 (with pixman 0.46.4) + +- **Used on:** Haiku, Haiku x86, BeOS +- **URL:** (pixman: ) +- **License:** QEMU: GNU GPL v2. pixman, linked into it: MIT. +- **Installed at:** `toolchains/opt/qemu/`, built from the release source + (installer component `qemu`) + +### PPSSPP 1.20.4 + +- **Used on:** PSP +- **URL:** +- **License:** GNU GPL v2+ (GPL-2.0-or-later) +- **Installed at:** `toolchains/opt/ppsspp/`, its headless runner built from + the release source with the patch in `installer/patches/ppsspp/`, which + is under PPSSPP's license (installer component `ppsspp`) + +### Wine 11.19 (Kron4ek's portable WoW64 build) + +- **Used on:** Win32 +- **URL:** + (build: ) +- **License:** GNU LGPL v2.1+ +- **Installed at:** `toolchains/opt/wine/` (installer component `wine`) + --- -## System ROMs and disk images (used for testing only) +## Distribution packages (build and test hosts only) + +Installed from the Linux distribution by the components named below +(`host`, `headless`, `ntp`); none of them ships in a program. + +- **host:** GNU make, the host C compiler (gcc), tar and diffutils (GNU GPL + v3+); git and socat (GNU GPL v2); git-lfs (MIT); curl (the curl + license); xz (0BSD, parts GPL); bzip2 (bzip2's BSD-style license); + unzip (the Info-ZIP license); lhasa (ISC); procps (GNU GPL v2+ and LGPL); + Python 3 (PSF License) with Pillow (MIT-CMU). +- **headless:** Xvfb (X.Org's MIT/X11 license); xdotool (3-clause BSD); + ImageMagick (the ImageMagick License); pactl and parec, from + pulseaudio-utils (GNU LGPL v2.1+). +- **ntp:** PHP's command line (the PHP License 3.01) runs NTP's converter. +- **tools/traceLogo.py**, which made `assets/joeylibLogo.svg` and is run + only if the logo art changes, takes potracer (GNU GPL v2+), Pillow and + numpy (BSD), installed by hand with pip. The script holds none of + potracer's code, and the logo it made is not covered by potracer's + license. +- Building MAME, QEMU, PPSSPP and SDL 3 also takes the distribution's + development packages (SDL2, X11, Wayland, PulseAudio, ALSA, GLib and the + rest), each under its own license. + +--- + +## System ROMs, operating systems and disk images (used for testing only) These are **not** part of JoeyLib distribution. `./install.sh` -places them in `toolchains/firmware/` for emulator-side testing only; see -the source of each before shipping anything that depends on them. +places them in `toolchains/firmware/` or `toolchains/opt/` for +emulator-side testing only; see the source of each before shipping +anything that depends on them. ### Downloaded by the installer @@ -221,11 +499,16 @@ the source of each before shipping anything that depends on them. - **Apple IIgs System 6.0.4** (`System.Disk`): Apple Inc. proprietary OS, from the Apple II archive (). Component `iigsFirmware`. -- **CFFA2 card firmware** (`cffa20eec02.bin`): freely redistributed by - dreher.net (). Component `iigsFirmware`. +- **CFFA2 card firmware** (`cffa20eec02.bin`): license Unknown; freely + redistributed by dreher.net (). Component + `iigsFirmware`. - **X68000 IPL-ROM and Human68k 3.02 system disk**: from Sharp's own free release (2000), published by the X68000 LIBRARY (). Component `x68kFirmware`. +- **Haiku R1/beta6 anyboot images** (x86_64, and x86 gcc2h): Haiku is MIT; + the third-party packages on the images carry their own licenses. Made to + boot to the desktop and run the gates' job runner. Components `haikuVm` + and `haikuVm32`. ### Never downloaded (supplied by the user or the project's own bundle) @@ -236,13 +519,33 @@ project hosts (`JOEY_BUNDLE_URL`); matched by the checksums in - **Amiga Kickstart 3.1 ROM**: Cloanto / Amiga Corp. proprietary, sold today (Amiga Forever). Required by the Amiga test gates; `joey-run` falls back to FS-UAE's AROS without it. Component `kickstart`. +- **Amiga Kickstart 3.1 ROMs for the A1200 (r40.68) and A4000T (r40.70)**: + Cloanto / Amiga Corp. proprietary (Amiga Forever). Optional: they run the + Amiga gates on those machines as well as the A500. Components + `kickstartA1200` and `kickstartA4000`. - **Amiga serial.device 37.4** (Workbench 3.1's `Devs/serial.device`): Cloanto / Amiga Corp. proprietary, part of Workbench 3.1 (Amiga Forever). Kickstart 3.1 has no serial.device in ROM, so `joey-run` and the Amiga serial gate put this copy on the boot drive; `import` also finds it inside a Workbench `.adf`. Component `amigaSerial`. +- **Amiga lowlevel.library 40.35** (Workbench 3.1's `Libs/lowlevel.library`): + Cloanto / Amiga Corp. proprietary (Amiga Forever). The Amiga port reads + the joysticks through it and Kickstart 3.1 has none in ROM, so `joey-run` + and the Amiga joystick gate put this copy on the boot drive; `import` + also finds it inside a Workbench `.adf`. Component `amigaLowlevel`. - **X68000 CG (font) ROM**: not part of Sharp's free release. MAME's x68000 driver needs it. Component `x68kCgrom`. +- **Windows for Workgroups 3.11** (your install disc's ISO) **and the S3 + Trio64V+ display driver** (its `.zip`): Microsoft and S3 proprietary. + Installed unattended into the DOSBox PC every win16 run uses. A Win16 + program's users bring their own Windows. Component `win311`. +- **BeOS 5 Professional CD** (its image): Be, Inc. proprietary; the terms + for its start files are Unknown. Its boot floppy and x86 system become + the BeOS R5 VM (component `beosVm`), and its Development package's + headers, start files and system libraries the beos port's sysroot + (component `beosR5`). Every beos program links the start files + (`start_dyn.o`, `init_term_dyn.o`, `crti.o`, `crtn.o`), as every program + built with Be's own tools did; check their terms before shipping one. --- @@ -250,10 +553,14 @@ project hosts (`JOEY_BUNDLE_URL`); matched by the checksums in When adding a new third-party dependency: -1. Append a row in the appropriate section above. +1. Append an entry in the appropriate section above. 2. Record platform(s), upstream URL, license name, and a one-line role description. 3. List where the installer puts it (`toolchains/opt//` or `toolchains/firmware/`). 4. If license terms restrict redistribution, call that out explicitly (as we do for Apple ROMs). +5. If any of it ends up inside programs (a library, start files, a + runtime, a file on the program's disk), put it under "Shipped in + programs", or in that section's toolchain summary, with what a shipped + program owes it. diff --git a/Makefile b/Makefile index 8d21c26..79b32e2 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,26 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # JoeyLib top-level Makefile. # # Usage: # make builds every target whose toolchain is installed # make iigs builds Apple IIgs only # make amiga builds Commodore Amiga only -# make atarist builds Atari ST only +# make atarist builds Atari ST only (runs on every ST) +# make ste builds the Atari STe variant (STe and Mega STe only) # make dos builds MS-DOS only # make x68000 builds Sharp X68000 only # make dvx builds DVX (JoeyLib programs as DVX desktop programs) +# make sdl3 builds SDL3 (JoeyLib programs as native windowed programs) +# make web builds the web port (JoeyLib programs as web pages) +# make win32 builds Win32 (Windows 95 through today, as .EXE programs) +# make win16 builds Win16 (Windows 3.1 on a 386, as .EXE programs) +# make psp builds the PSP port (JoeyLib programs as PSP homebrew EBOOT.PBPs) +# make haiku builds Haiku x86_64 (the Be API backend, gcc 13) +# make haiku32 builds Haiku 32-bit x86 (the Be API backend, gcc 2.95, BeOS ABI) +# make beos builds BeOS R5 x86 (the same backend and compiler, R5's own headers and libraries) # make docs renders the manual to build/docs (HTML + PDF) # make verify builds every port, then runs every gate (needs emulators) # make verify-audio runs every port's audio gate (needs emulators) @@ -25,7 +38,7 @@ include $(dir $(firstword $(MAKEFILE_LIST)))make/common.mk ALL_TARGETS := $(JOEY_PORTS_INSTALLED) -.PHONY: all iigs iigs-disk iigs-verify amiga atarist dos x68000 dvx blank-check tools clean help status verify verify-audio verify-serial docs +.PHONY: all iigs iigs-disk iigs-verify amiga atarist ste dos x68000 dvx sdl3 web win32 win16 psp haiku haiku32 beos blank-check tools clean help status verify verify-audio verify-serial docs all: $(ALL_TARGETS) tools ifeq ($(ALL_TARGETS),) @@ -51,6 +64,9 @@ amiga: atarist: @$(MAKE) -f $(REPO_DIR)/make/atarist.mk +ste: + @$(MAKE) -f $(REPO_DIR)/make/ste.mk + dos: @$(MAKE) -f $(REPO_DIR)/make/dos.mk @@ -60,6 +76,30 @@ x68000: dvx: @$(MAKE) -f $(REPO_DIR)/make/dvx.mk +sdl3: + @$(MAKE) -f $(REPO_DIR)/make/sdl3.mk + +web: + @$(MAKE) -f $(REPO_DIR)/make/web.mk + +win32: + @$(MAKE) -f $(REPO_DIR)/make/win32.mk + +win16: + @$(MAKE) -f $(REPO_DIR)/make/win16.mk + +psp: + @$(MAKE) -f $(REPO_DIR)/make/psp.mk + +haiku: + @$(MAKE) -f $(REPO_DIR)/make/haiku.mk + +haiku32: + @$(MAKE) -f $(REPO_DIR)/make/haiku32.mk + +beos: + @$(MAKE) -f $(REPO_DIR)/make/beos.mk + # Compile + link-check the new-port template (src/blank/blank.c) with the host # cc. No toolchain needed; keeps the template from bit-rotting as the lib grows. blank-check: @@ -101,12 +141,21 @@ status: @echo " iigs: $(if $(HAVE_IIGS),yes,NO)" @echo " amiga: $(if $(HAVE_AMIGA),yes,NO)" @echo " atarist: $(if $(HAVE_ATARIST),yes,NO)" + @echo " ste: $(if $(HAVE_STE),yes,NO)" @echo " dos: $(if $(HAVE_DOS),yes,NO)" @echo " x68000: $(if $(HAVE_X68000),yes,NO)" @echo " dvx: $(if $(HAVE_DVX),yes,NO)" + @echo " sdl3: $(if $(HAVE_SDL3),yes,NO)" + @echo " web: $(if $(HAVE_WEB),yes,NO)" + @echo " win32: $(if $(HAVE_WIN32),yes,NO)" + @echo " win16: $(if $(HAVE_WIN16),yes,NO)" + @echo " psp: $(if $(HAVE_PSP),yes,NO)" + @echo " haiku: $(if $(HAVE_HAIKU),yes,NO)" + @echo " haiku32: $(if $(HAVE_HAIKU32),yes,NO)" + @echo " beos: $(if $(HAVE_BEOS),yes,NO)" help: - @echo "Targets: iigs amiga atarist dos x68000 dvx all clean status help" + @echo "Targets: iigs amiga atarist ste dos x68000 dvx sdl3 web win32 win16 psp haiku haiku32 beos all clean status help" @echo " iigs-disk iigs-verify (IIgs MAME visual check)" @echo " blank-check (host-cc smoke test of the new-port template + gnu89 lint)" @echo " verify (build every port, then run every gate; PORTS= subset)" diff --git a/README.md b/README.md index a473ffa..c8aa7bb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # JoeyLib A unified C game-development library targeting five early 16-bit -platforms, and one DOS desktop, from a single codebase: +platforms, one DOS desktop, today's desktops and browsers, Haiku and BeOS, +and the PSP, from a single codebase: - Apple IIgs (reference platform) - Commodore Amiga (A500 / 68000 baseline) @@ -11,6 +12,24 @@ platforms, and one DOS desktop, from a single codebase: - DVX (the DOS desktop GUI in `../dvxgui`: each program a windowed `.app` that can also take the whole screen, built with the DOS compiler against DVX's SDK) +- SDL3 (a native windowed program for the machine you work on, Linux today: + the hosted core in `src/hosted` with an SDL3 backend, SDL 3 linked in + statically; sprites are interpreted) +- Web (the SDL3 port compiled to WebAssembly with Emscripten: each program + a page, its DATA packed in, saves kept in the browser) +- Win32 (Windows 95 through today: the hosted core with a backend written to + the Win32 API, built with llvm-mingw against MSVCRT.DLL, tested under Wine) +- Win16 (Windows 3.1 on a 386: the hosted core with a backend written to the + Windows 3.1 API, built with Open Watcom's Win386 target, tested on Windows + for Workgroups 3.11 in DOSBox Staging) +- Haiku and BeOS (the hosted core with a backend written to the Be API, + which Haiku reimplements: Haiku x86_64 and x86, and BeOS R5 x86, built + with gcc 2.95 against R5's own headers and libraries; each tested in a VM + under QEMU) +- PSP (homebrew for custom firmware: the SDL3 port built with pspdev, each + program an `EBOOT.PBP` in a folder for `PSP/GAME/`, the stage at 1:1 in + the 480x272 screen; tested under PPSSPP with every memory access checked + for alignment as a PSP checks it) The Apple IIgs defines the capability ceiling. Stronger platforms coast. Hot paths are hand-written assembly per port; the public API is C. @@ -53,7 +72,26 @@ packages them onto a disk image. DVX needs the `dvx` installer component, which unpacks DVX and its SDK from a DVX tree's distribution, `dist/dvx.zip` and `dist/sdk.zip` (`./install.sh --dvx-dir PATH install dvx`; the default is `../dvxgui`); its examples land in `build/dvx/bin/apps/joeylib//`, -laid out as they install in DVX. +laid out as they install in DVX. SDL3 needs the `sdl3` component +(`./install.sh install toolchain:sdl3`), which builds SDL 3 from its release +source; its examples are native programs in `build/sdl3/bin/`, and running +one takes seconds rather than an emulator's minutes. The web port needs the +`web` components (`./install.sh install toolchain:web`: Emscripten through +emsdk, and SDL 3 built with it); its examples are pages in +`build/web/bin//`. The Win32 port needs `toolchain:win32` (llvm-mingw) +and, to run its programs, `emulator:win32` (a portable Wine). The Win16 port +needs `toolchain:win16` (Open Watcom) and, to run its programs, +`emulator:win16` (Windows for Workgroups 3.11 in DOSBox Staging, set up from +your own install disc and S3 display driver). The PSP port +needs `toolchain:psp` (pspdev's prebuilt release, and SDL 3 built with it) +and, to run its programs, `emulator:psp` (PPSSPP's headless build, patched +for the gates); its examples are folders `build/psp/bin//` holding +`EBOOT.PBP` and `DATA/`, ready for a memory stick's `PSP/GAME/`. The Haiku +ports need `toolchain:haiku` or `toolchain:haiku32` (cross compilers built +from Haiku R1/beta6) and, to run their programs, `emulator:haiku` or +`emulator:haiku32` (a Haiku VM for QEMU); the BeOS port needs +`toolchain:beos` and `emulator:beos`, both made from your own BeOS 5 +Professional CD. Your own program does not go into this tree: it lives in a directory of its own whose Makefile describes it and includes `make/project.mk` and @@ -77,13 +115,24 @@ the emulators and their firmware: ./joey-run --port amiga draw ./joey-run --headless --seconds 30 --snapshot draw.png --port atarist draw ./joey-run --port dvx draw # DVX in DOSBox, booting into the program +./joey-run --port sdl3 draw # natively, in a window (no emulator) +./joey-run --port web draw # served here, opened in your browser +./joey-run --port win32 draw # under Wine +./joey-run --port win16 draw # on Windows 3.11 in DOSBox +./joey-run --port psp --headless --snapshot draw.png draw # under headless PPSSPP +./joey-run --port haiku draw # in a Haiku VM under QEMU +./joey-run --port beos draw # in a BeOS R5 VM under QEMU ``` -Three files are never downloaded -- the Amiga Kickstart, the Amiga -`serial.device` from Workbench 3.1 and the X68000 font ROM -- so supply your -copies once with `./install.sh import PATH` -(files, folders, `.zip` archives or Amiga `.adf` disk images). The manual's Getting Started chapter -covers `joey-run`'s options and the private-file bundle. +A few files are never downloaded -- the Amiga Kickstart, the Amiga +`serial.device` and `lowlevel.library` from Workbench 3.1, the X68000 font +ROM, the Windows for Workgroups 3.11 install disc and S3 Trio64V+ driver +the Win16 port runs on, and the BeOS 5 Professional CD -- so supply your +copies once with `./install.sh import PATH` (files, folders, `.zip` or +`.7z` archives or Amiga `.adf` disk images; the Windows disc as its `.iso`, +the BeOS CD as its CloneCD `.img`). +The manual's Getting Started chapter covers `joey-run`'s options and the +private-file bundle. ## Building for a single target @@ -92,11 +141,16 @@ covers `joey-run`'s options and the private-file bundle. make iigs make amiga make atarist +make ste make dos make x68000 make dvx ``` +`make atarist` builds programs for every ST; `make ste` builds the STe +variant, which uses the STe's BLiTTER, DMA sound and 4-bit palette and runs +only on an STe or Mega STe. + Every fragment also answers to a bare `clean`, and every fragment that builds through `make/common.mk` -- all of them except `make/iigs.mk`, which assembles its own clang flags -- honors `EXTRA_CFLAGS`: @@ -114,9 +168,23 @@ logs a surface hash per op. `tools/diff-uber-hashes ` compares two `joeylog.txt` captures and exits non-zero on any mismatch. The frozen references live in `tests/goldens/uber/`. All five ports currently agree on all 51 hashes; the X68000 has no golden of its own and is diffed against -`tests/goldens/uber/iigs.txt`, which it matches exactly, and DVX, which shows -the DOS stage, is diffed against `tests/goldens/uber/dos.txt` -(`scripts/bench-dvx.sh`, then `tools/diff-uber-hashes`): +`tests/goldens/uber/iigs.txt`, which it matches exactly, and DVX, SDL3, the +web and the PSP, which share the DOS stage, are diffed against +`tests/goldens/uber/dos.txt` (`scripts/bench-dvx.sh`, `scripts/bench-sdl3.sh`, +`scripts/bench-web.sh` or `scripts/bench-psp.sh`, then +`tools/diff-uber-hashes`). SDL3's run needs no emulator and no X server +(SDL's dummy drivers) and takes about fifteen seconds; the web's runs UBER's +page in headless Chromium; the PSP's runs UBER under headless PPSSPP in a few +seconds, with every memory access checked as a PSP checks it, so an unaligned +16- or 32-bit access anywhere fails it: + +``` +scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt +scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt +scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt +``` + +The X68000 has gates of its own: ``` make -f make/x68000.mk x68000-verify-golden # ~1 min under the patched MAME @@ -144,6 +212,11 @@ src/generic/ portable-C default for every overridable op src/codegen/ shared sprite-codegen staging / compile machinery src// per-machine HAL + per-CPU sprite emitter (iigs, amiga, atarist, dos, x68000, dvx) +src/hosted/ the hosted core (desktop OSes and browsers: RGB + present, virtual frame clock, input events, software + mixer) and its backends (src/hosted/sdl3, which the + sdl3, web and psp ports all build; src/hosted/win32; + src/hosted/win16) src/m68k/ 68k code shared across machines (planar and word- interleaved sprite emitters; surface68k.s, which is linked by Amiga and Atari ST only) @@ -217,7 +290,7 @@ void jlRandomSeed (uint32_t seed); All surfaces are 320x200 16-color images with a 200-entry SCB table and 16 palettes of 16 `$0RGB` colors. In-memory storage is -target-native: chunky 4bpp packed on IIgs, DOS and DVX, native planar +target-native: chunky 4bpp packed on IIgs, DOS, DVX, SDL3, the web and the PSP, native planar (separate bitplanes on Amiga and Sharp X68000, word-interleaved planes on Atari ST) on the 68k ports. The public API speaks in color indices (0..15) and hides the storage format. @@ -678,7 +751,10 @@ from compiler-predefined macros is a fallback. Game code can conditionally compile on these: ``` -JOEYLIB_PLATFORM_IIGS / _AMIGA / _ATARIST / _DOS / _X68000 / _DVX // exactly one defined +JOEYLIB_PLATFORM_IIGS / _AMIGA / _ATARIST / _DOS / _X68000 / _DVX / _HOSTED // exactly one defined +JOEYLIB_HOSTED_SDL3 / _WIN32 / _WIN16 // the hosted backend +JOEYLIB_ATARI_STE // with _ATARIST: the STe build (make ste) +JOEYLIB_SPRITE_INTERPRETED // no sprite code emitter (hosted, blank) JOEYLIB_CPU_65816 / _68000 / _X86 JOEYLIB_ENDIAN_LITTLE / _BIG JOEYLIB_NATIVE_CHUNKY / _NATIVE_PLANAR @@ -690,4 +766,12 @@ JOEYLIB_VERSION_MAJOR / _MINOR / _PATCH / _STRING ## License -TBD. +JoeyLib is distributed under the MIT License ([LICENSE.md](LICENSE.md)). +A program built with it has JoeyLib compiled in, so it carries JoeyLib's +copyright and permission notice, in its documentation for instance. Each +of JoeyLib's source files opens with a short header that says so +(`SPDX-License-Identifier: MIT`); a new file gets the same. + +The components JoeyLib depends on, their licenses and what a shipped +program owes them are in [LICENSES.md](LICENSES.md). The patches in +`installer/patches/` are under the licenses of the projects they patch. diff --git a/docs/ApiAudio.adoc b/docs/ApiAudio.adoc index c29e561..016302a 100644 --- a/docs/ApiAudio.adoc +++ b/docs/ApiAudio.adoc @@ -175,8 +175,8 @@ deactivates. [WARNING] ==== -This is called from the audio engine's refill context -- the main thread on DOS -and ST (on DVX, DVX's own sound pump, while the program is yielded), but the +This is called from the audio engine's refill context -- the main thread on DOS, +ST and SDL3 (on DVX, DVX's own sound pump, while the program is yielded), but the *audio interrupt* on Amiga and IIgs. It must not block and must not allocate. ==== @@ -275,6 +275,7 @@ What each port can actually do: all audio at once. |DOS, ST |Ignored. Output is mono. |DVX |All three, through DVX's stereo mixer (on a stereo card). +|SDL3 |All three: the port mixes in stereo. |=== === jlAudioStopSfx @@ -304,7 +305,8 @@ call. Designed for AGI-era music where the engine programs a divisor and lets the chip play, so CPU cost during playback is zero. Single voice; uses voice 0 on platforms with several oscillators. On DOS this programs PIT counter 2 and the speaker gate; on DVX it is a square tone of DVX's sound library, on the FM -chip when there is one. +chip when there is one; on SDL3 it is voice 0 of the port's mixer at full +volume. [cols="1,1,4", options="header"] |=== @@ -339,7 +341,8 @@ Mapping to hardware: DOS uses AdLib OPL2 melodic channels 0..2; the ST uses YM2149 channels A, B and C; the Amiga uses Paula channels 0..2 looping a two-byte square sample; the IIgs uses Ensoniq DOC oscillators playing a resident square; DVX plays square tones of its sound library, which puts them -on the FM chip when there is a free voice and mixes them otherwise. +on the FM chip when there is a free voice and mixes them otherwise; SDL3 mixes +square waves in software. Voice state is sticky -- a voice keeps its last frequency until you change it -- and CPU cost during sustained playback is zero, because the hardware @@ -351,7 +354,8 @@ The IIgs is the exception to stickiness: each call is an approximately 80 ms retriggered one-shot, so a note held longer must be re-asserted about once a frame. It also requires `jlAudioInit`, because the DOC path rides the tracker engine. On DOS, ST and Amiga this is direct hardware programming and works -without `jlAudioInit`; on DVX it opens the program's sound on first use. +without `jlAudioInit`; on DVX it opens the program's sound on first use. On +SDL3 the voices are mixed in software and sound only after `jlAudioInit`. ==== === jlAudioNoise @@ -378,7 +382,9 @@ Mapping: the ST uses the YM2149 noise generator mixed onto channel C; DOS uses the OPL2 rhythm-mode snare, leaving the three tone channels untouched; the Amiga uses Paula channel 3 with an LFSR noise sample; the IIgs uses a resident noise sample on slot 3 with the same retriggered one-shot model as its tone -voices; DVX uses a noise tone of its sound library. +voices; DVX uses a noise tone of its sound library; SDL3 mixes the YM2149's +own noise in software (its 17-bit generator, clocked at 125 kHz divided by +the pitch), so it sounds as on the ST. [WARNING] ==== @@ -526,6 +532,11 @@ sound library, which `jlAudioVoice` and the rest go through there, may not be called from an interrupt. `fn` may therefore do anything, but what it does lands on the frame, not between frames. +On SDL3 the sound is mixed in program time, a few frames ahead, and `fn` is +called by the mixer between exactly the two samples where its tick falls: in +program time, so it may do anything, yet a note it starts begins at the right +place in the sound. `hz` may be anything up to the mixer's 44100. + === jlAudioCriticalEnter / jlAudioCriticalExit [source,c] @@ -537,7 +548,7 @@ void jlAudioCriticalExit(void); Critical section against the tick interrupt. If you mutate state that the registered tick callback reads, wrap the mutation in these so the interrupt cannot observe a half-written value. On DOS this is `cli` and `sti`; on ports -with no audio interrupt, and on DVX, where the callback runs in program time, -both are no-ops. +with no audio interrupt, and on DVX and SDL3, where the callback runs in +program time, both are no-ops. *Parameters:* none. *Returns:* nothing. diff --git a/docs/ApiCore.adoc b/docs/ApiCore.adoc index 1249e39..5d391ab 100644 --- a/docs/ApiCore.adoc +++ b/docs/ApiCore.adoc @@ -18,7 +18,8 @@ typedef struct { |`codegenBytes` |`uint32_t` |Size of the runtime compiled-sprite cache. `0` selects a per-platform - default: 8 KB on IIgs, 32 KB on Amiga, ST, DOS and DVX. On the IIgs the cache is + default: 8 KB on IIgs, 32 KB on Amiga, ST, DOS and DVX (SDL3 interprets every + sprite and leaves the cache unused). On the IIgs the cache is one Memory Manager block that may not span a 64 KB bank boundary, so values above 65536 are clamped to 65536, and even a clamped request can fail if no free bank exists. Size it against `jlSpriteCompiledSize()`. @@ -79,7 +80,9 @@ const char *jlPlatformName(void); *Parameters:* none. *Returns:* a human-readable platform identifier, for example `"Apple IIgs"`, -`"MS-DOS"` or `"DVX"`. Library-owned. +`"MS-DOS"`, `"DVX"`, `"SDL3"` (the web port is the SDL3 port in a browser) or +`"Windows"`. +Library-owned. === jlVersionString @@ -196,7 +199,7 @@ uint16_t jlFrameHz(void); *Returns:* the nominal display frame rate in Hz -- 50 on Amiga PAL, 60 on IIgs and ST NTSC, 55 on the X68000, 70 on VGA mode 13h and on DVX, whose window has a virtual 70 Hz (and whose full screen is mode 13h's, or a VBE mode's, with the -same frame count). The X68000 times its display at `jlInit` and rounds to the +same frame count), and 60 under SDL3, whose window has a virtual 60 Hz. The X68000 times its display at `jlInit` and rounds to the nearest whole number: 55.46 Hz on the machine, 56.07 Hz under MAME, which therefore reports 56. The real cadence may drift slightly; this is the figure to divide by when converting frames to seconds. @@ -208,8 +211,8 @@ to divide by when converting frames to seconds. uint32_t jlMillisElapsed(void); ---- -Monotonic millisecond counter since `jlInit`. DOS, ST, X68000 and DVX read a -real hardware timer; IIgs and Amiga derive it from the frame counter, so the +Monotonic millisecond counter since `jlInit`. DOS, ST, X68000, DVX and SDL3 read a +real timer; IIgs and Amiga derive it from the frame counter, so the value steps at frame granularity there. Two calls separated by a long render report the true elapsed time, not the number of vertical blanks that fired. diff --git a/docs/ApiInput.adoc b/docs/ApiInput.adoc index ba182e7..9229a52 100644 --- a/docs/ApiInput.adoc +++ b/docs/ApiInput.adoc @@ -74,8 +74,9 @@ int jlInputGetChar(void); Pops the next typed character. Shift, caps lock and the keyboard layout have already been applied by the backend, so punctuation and shifted symbols arrive -correctly. The layout is the machine's own on IIgs, Amiga, ST and X68000, and -DVX's own on DVX; the DOS port translates with a fixed US layout. +correctly. The layout is the machine's own on IIgs, Amiga, ST and X68000, +DVX's own on DVX and the operating system's on SDL3; the DOS port translates +with a fixed US layout. Arrow keys are not characters and stay on the predicate API. @@ -138,11 +139,13 @@ bool jlMousePresent(void); *Parameters:* none. *Returns:* `true` if the platform confirmed a mouse at init. On DOS that means -the INT 33h driver reset successfully; on Amiga, ST, IIgs, X68000 and DVX the -OS mouse is always wired. On DVX the position is the pointer's over the -program's window, divided by the window's scale; it keeps its last value +the INT 33h driver reset successfully; on Amiga, ST, IIgs, X68000, DVX and +SDL3 the OS mouse is always wired. On DVX the position is the pointer's over +the program's window, divided by the window's scale; it keeps its last value while the pointer is elsewhere. On DVX's full screen it is the pointer's -place on the stage (DVX draws no pointer there). +place on the stage (DVX draws no pointer there). On SDL3 it is the pointer's +place on the stage however the window scales it, held to the stage's edge +while the pointer is beside it; the OS pointer is hidden over the window. === jlMouseX / jlMouseY @@ -155,6 +158,8 @@ int16_t jlMouseY(void); *Parameters:* none. *Returns:* pointer position in surface coordinates, 0..319 and 0..199. +Until the platform reports the pointer, it is the middle of the stage, +(160, 100), on every port. === jlMouseDown / jlMousePressed / jlMouseReleased @@ -188,7 +193,8 @@ bool jlJoystickConnected(jlJoystickE js); *Returns:* `true` if the platform reports a stick on that port. On the ST and X68000 both ports always report one: an empty port cannot be told from a stick -at rest. +at rest. The Amiga (`lowlevel.library`) has the same blind spot the other +way: it reports a stick only once it has moved, and never a mouse. === jlJoystickX / jlJoystickY @@ -258,7 +264,9 @@ the new centre, so the user must be holding the stick centred when you call it. On Amiga, ST and DOS the sticks are genuinely digital and already report -1, 0 or +1, so recalibration is a no-op and `deadZone` is ignored. DVX's sticks are analog but calibrated, dead zone included, in DVX's own Control Panel, so the -call is a no-op there too. The call still +call is a no-op there too. SDL3's sticks are gamepads the OS calibrates; there +the call only sets the dead zone, inside which the stick reads as centred. +The call still clears any auto-disconnect state, so it is safe and correct to make it unconditionally. ==== diff --git a/docs/ApiSerial.adoc b/docs/ApiSerial.adoc index 696c9d9..f2d1f38 100644 --- a/docs/ApiSerial.adoc +++ b/docs/ApiSerial.adoc @@ -22,11 +22,13 @@ DOS uses an interrupt-driven 8250/16550 UART; the Amiga uses `serial.device`; the ST goes through the BIOS Aux channel; the X68000 transmits through IOCS and receives on its own SCC interrupt; DVX goes through DVX's serial library, which owns the COM ports for every program on the desktop (a port another -program has open cannot be opened). +program has open cannot be opened); Win32 and Win16 go through Windows' own +communications calls, whose driver owns the ports the same way. SDL3, the web +and the PSP have no serial port: `jlSerialOpen` fails there. The model is *non-blocking and buffered*. Received bytes accumulate in a background ring -- a hardware interrupt on DOS, the X68000 and the IIgs, the -OS on Amiga, ST and DVX -- and reads never block. Call `jlSerialPoll` once per +OS on Amiga, ST, DVX and Windows -- and reads never block. Call `jlSerialPoll` once per frame: it is next to a no-op where the interrupt or OS already fills the ring, and the hardware drain where nothing does, so calling it every frame is always correct. On DVX it also gives the other programs their turn @@ -61,6 +63,7 @@ at 9600 baud, so: |DOS, 16550A UART |16 bytes, interrupt at 14 |~2 ms from the interrupt |DOS, 8250 or 16450 UART |1 byte |~1 ms |DVX |as DOS: DVX's library drives the same UARTs |as DOS +|Win32, Win16 |the UART's; Windows' driver empties it into a 4 KB queue |as DOS |Amiga (Paula) |1 byte |~1 ms |Atari ST (68901 MFP) |1 byte |~1 ms |X68000 (Zilog 8530) |3 bytes |~3 ms @@ -151,8 +154,13 @@ typedef struct { The IIgs is the machine with three device choices; elsewhere `JL_SERIAL_MODEM` and `JL_SERIAL_PRINTER` both mean the default port. `JL_SERIAL_DEFAULT` selects -the IIgs modem port, DOS or DVX COM1, the ST Modem port, Amiga unit 0 or the -X68000's RS-232C port. +the IIgs modem port, COM1 on DOS, DVX, Win32 and Win16, the ST Modem port, +Amiga unit 0 or the X68000's RS-232C port. + +On Win16 a `baud` above 57600 runs at 115200 through KPCOMM.DRV, the +communications driver JoeyLib's Windows runs with in place of Windows 3.1's +own (the `kpcomm` installer component, built from the mscomm project). On a +Windows with its own COMM.DRV, which has no 115200, it runs at 57600. On the X68000 the serial add-on *requires* Human68k's RS-232C driver, RSDRV.SYS. Every Human68k system disk has it and the stock CONFIG.SYS loads @@ -165,8 +173,8 @@ fills the receive ring. 38400 is really 39062 bps, 1.7% fast, within the usual tolerance of a UART at the far end. `unit` disambiguates `JL_SERIAL_SLOT` and is ignored otherwise: an IIgs Super -Serial Card slot 1..7, a DOS or DVX COM number 1..4, or an Amiga -`serial.device` unit. +Serial Card slot 1..7, a DOS or DVX COM number 1..4, a Win16 COM number +1..9, a Win32 COM number 1..255, or an Amiga `serial.device` unit. === jlSerialOpen @@ -209,6 +217,10 @@ door returning to its BBS, or a terminal program exiting while the call stays up. RTS is not part of that promise; on the Amiga, `serial.device` puts back whatever RTS was before the open. +Windows cannot make the promise. On Win32 and Win16 the close gives the port +back to Windows' driver, and neither has a call that keeps DTR up past it. A +Windows program that must not hang up the line keeps the port open. + To hang up, drop DTR first: call `jlSerialSetDtr(false)`, wait longer than the modem needs to notice (a Hayes modem's S25 setting, 50 ms by default), then close. The line stays down. diff --git a/docs/Appendix.adoc b/docs/Appendix.adoc index 8b2c535..a1cd009 100644 --- a/docs/Appendix.adoc +++ b/docs/Appendix.adoc @@ -171,8 +171,8 @@ |The character queue holds 31 entries and drops the rest. Pop it every frame. |Music plays but `jlAudioVoice` does nothing -|On ST and Amiga the module player owns those channels. Use one layer per - scene. +|On ST and Amiga the module player owns those channels (in the ST build; the + STe build plays modules through the DMA sound chip). Use one layer per scene. |A note sounds for only a fraction of a second on the IIgs |`jlAudioVoice` is a roughly 80 ms one-shot there. Re-assert it about once a diff --git a/docs/Assets.adoc b/docs/Assets.adoc index 45eb793..8ed9fc1 100644 --- a/docs/Assets.adoc +++ b/docs/Assets.adoc @@ -293,8 +293,9 @@ $(call programDataDir,MYGAME)/font.tbk: assets/font.png ---- Tile banks and `.spc` files differ per platform, while `.spr`, `.jym` and -`.sfx` files are shared: `ASSET_PLATFORM` names the target a bake is for (DVX -takes the DOS bakes) and `SPRITEBAKE` the matching host `spritebake`. +`.sfx` files are shared: `ASSET_PLATFORM` names the target a bake is for (DVX, +SDL3 and the web take the DOS bakes; the last two use only a `.spc`'s cels, not +their code) and `SPRITEBAKE` the matching host `spritebake`. `examples/tilebank` is a working reference: one rule in `make/examples.mk` bakes the same PNG for each target into its `DATA/tilebank` (`TILEBANK_DATA`), diff --git a/docs/BuildIntegration.adoc b/docs/BuildIntegration.adoc index f197f6f..58e42a3 100644 --- a/docs/BuildIntegration.adoc +++ b/docs/BuildIntegration.adoc @@ -45,7 +45,7 @@ directory -- the paths in the Makefile are relative to it -- or with `make -C`: [source,console] ---- $ make # every target whose toolchain is installed -$ make dos # one target: iigs amiga atarist dos x68000 dvx +$ make dos # one target: iigs amiga atarist ste dos x68000 dvx $ make clean # the project's build/ (clean-dos: one target's) $ make help ---- @@ -65,11 +65,21 @@ The project's outputs land in its own `build/`, laid out like JoeyLib's: `build/iigs/data/MYGAME/`, and with `MYGAME_IIGS_GAME_DISK` the disks `build/iigs/bin/MYGAME.2mg` and `MYGAME-data.2mg` |Amiga |`build/amiga/bin/Mygame` -|Atari ST |`build/atarist/bin/MYGAME.PRG` +|Atari ST |`build/atarist/bin/MYGAME.PRG`, which runs on every ST +|Atari STe |`build/ste/bin/MYGAME.PRG`, the STe build: STe and Mega STe only +(<<_the_atari_st_family>>); same toolchain and baked data as the ST |MS-DOS |`build/dos/bin/MYGAME.EXE`, with the DPMI server built in |Sharp X68000 |`build/x68000/bin/MYGAME.X` |DVX |`build/dvx/bin/apps/joeylib/mygame/`: `mygame.app`, `mygame.hlp` and its `DATA/` +|SDL3 |`build/sdl3/bin/MYGAME`, a native program with SDL 3 linked in +|Web |`build/web/bin/MYGAME/`: `MYGAME.html`, `.js`, `.wasm`, and `.data` +(its `DATA/`, packed in) +|Win32 |`build/win32/bin/MYGAME.EXE`, a 32-bit Windows program +|Win16 |`build/win16/bin/MYGAME.EXE`, a Windows 3.x program +|PSP |`build/psp/bin/MYGAME/`: `EBOOT.PBP` and its `DATA/`, a folder for the +memory stick's `PSP/GAME/`; the ELF it is packed from in +`build/psp/elf/MYGAME.elf` |=== A program with `MYGAME_FOLDER` set ships in a folder of its own with its @@ -122,10 +132,12 @@ probes for gates). |`ID_PORTS` |The targets it builds for. Every target when unset. |`ID_NAME` |Its file name without the extension (see <<_naming>>). |`ID_FOLDER` |Set: it ships in a folder of its own with its `DATA/` (Amiga, -ST, DOS, X68000; on the IIgs and DVX every program is packaged apart anyway). +ST, DOS, X68000, SDL3, Win32, Win16; on the IIgs, DVX, the web and the PSP +every program is packaged apart anyway). |`ID_BINDIR` |Build it there instead of `bin/`. |`ID_DATA` |Its data files (<<_data_files>>). -|`ID_TITLE` |DVX: its window title. +|`ID_TITLE` |DVX: its window title. PSP: its title in the PSP's menu (by +default its name). |`ID_HELP` |DVX: the line its help file says about it. No apostrophes. |`ID_IIGS_OVERFLOW_CAP` |IIgs: link large overflow segments (`0xF000`), which a big program needs to leave the Memory Manager room (see @@ -162,9 +174,11 @@ set up: |`$(call programDataDir,ID)` |Where its `DATA/` files go on this target. |`$(call programOptionalDir,ID)` |Where the files go that it can run without (<<_disk_images_and_packaging>>). -|`PLATFORM` |The target: `iigs`, `amiga`, `atarist`, `dos`, `x68000`, `dvx`. +|`PLATFORM` |The target: `iigs`, `amiga`, `atarist`, `ste`, `dos`, `x68000`, `dvx`, `sdl3`, `web`, `win32`, `win16`, `psp`. |`ASSET_PLATFORM` |The target its bakes are for. DVX shows the DOS stage and -runs x86 code, so it takes the DOS bakes. +runs x86 code, so it takes the DOS bakes; the hosted ports' stage (SDL3, the +web, Win32, Win16, the PSP) is the same chunky one, so they take them too +(the cels of a pre-compiled sprite bank, without their code). |`SPRITEBAKE` |The host `spritebake` for this target (its rule is JoeyLib's). |`PROJECT_DIR`, `PROJECT_BUILD` |The project and its `build/`. @@ -217,6 +231,11 @@ program's id: says otherwise |`Mygame` |Sharp X68000 |Uppercase 8.3, `.X` |`MYGAME.X` |DVX |Lowercase, 8 characters, a folder of its own |`apps/joeylib/mygame/mygame.app` +|SDL3 |The id, no extension |`MYGAME` +|Web |The id, a folder of its own |`MYGAME/MYGAME.html` +|Win32 |Uppercase 8.3, `.EXE` |`MYGAME.EXE` +|Win16 |Uppercase 8.3, `.EXE` |`MYGAME.EXE` +|PSP |The id, a folder of its own holding `EBOOT.PBP` |`MYGAME/EBOOT.PBP` |=== Keep the id to 8 characters and letters and digits only, so the same name works @@ -255,6 +274,29 @@ from `MYGAME_HELP`) and its `DATA/`. Copy that folder into a DVX installation's `APPS\JOEYLIB\` and start it from DVX, or let `joey-run` do it (<<_dvx_programs>>). +*SDL3.* A program is one native executable with SDL 3 linked in statically, +so the folder it is in, with its `DATA/`, is all it needs on a machine like +the one that built it. + +*Web.* A program is its folder, `build/web/bin/MYGAME/`, with its `DATA/` +packed into `MYGAME.data` at link time (so changing a DATA file relinks it). +Copy the folder to any web server; `MYGAME.html` is the page. + +*Win32.* A program is one `.EXE` linked statically against the system's own +DLLs, so its folder, with its `DATA/`, is all it needs; it ships the way a +DOS program does. + +*Win16.* A program is one `.EXE`, its 32-bit code bound to Open Watcom's +Win386 supervisor and calling only what Windows 3.1 has (USER, GDI, KERNEL +and MMSYSTEM), so its folder, with its `DATA/`, is all it needs; it ships the +way a DOS program does. + +*PSP.* A program is its folder, `build/psp/bin/MYGAME/`: `EBOOT.PBP` (the +program, SDL 3 linked in, packed as the PSPSDK packs it, with no icon or +pictures) and its `DATA/`. Copy the folder into `PSP/GAME/` on a memory +stick; the PSP shows it by its `ID_TITLE`. The unstripped ELF stays in +`build/psp/elf/`, for `psp-addr2line` on an address a crash reports. + === Verifying It Actually Runs Build success is weak evidence on retro targets. Run it with `joey-run` (see @@ -310,8 +352,14 @@ Human68k floppies, environment checks) takes paths. |`make verify-audio` |Every port's audio gate: the IIgs DOC and X68000 chips are read directly, - the ST's YM2149 register writes are traced, and the Amiga, DOS and DVX - output is recorded and graded. Exits 3, not 0, if a gate had to be skipped. + the ST's YM2149 register writes are traced (and for the STe build the + samples the DMA sound chip plays), the Amiga, DOS and DVX output + is recorded and graded, SDL3's mix is written to a file by SDL's disk + driver and graded, and the web page's sound is recorded in headless + Chromium and graded, as is the Win32 program's under Wine, the Win16 + program's on Windows 3.11 in DOSBox, and what reaches the PSP's audio output + under PPSSPP. Exits 3, not 0, +if a gate had to be skipped. |`make iigs-verify` |The IIgs actually reached the program and drew something. diff --git a/docs/GettingStarted.adoc b/docs/GettingStarted.adoc index aa7fab7..a836cda 100644 --- a/docs/GettingStarted.adoc +++ b/docs/GettingStarted.adoc @@ -3,7 +3,10 @@ === Setting Up the Toolchains JoeyLib cross-compiles for five machines and for DVX, a DOS desktop whose -programs run in windows (see <<_dvx_programs>>). It builds on Linux x86-64, +programs run in windows (see <<_dvx_programs>>), builds native programs for +the machine you work on through SDL3 (see <<_sdl3_programs>>), builds web +pages through Emscripten (see <<_web_programs>>), and builds PSP homebrew +through pspdev (see <<_psp_programs>>). It builds on Linux x86-64, and on Windows inside WSL2. One script installs every cross toolchain, pinned to a known-good version and checked against a published checksum, into `toolchains/` in the repository. The only things it installs anywhere else @@ -42,7 +45,7 @@ install: [source,console] ---- $ make amiga -make/amiga.mk:5: *** toolchain:amiga is not installed; run: ./install.sh install toolchain:amiga. Stop. +make/amiga.mk:13: *** toolchain:amiga is not installed; run: ./install.sh install toolchain:amiga. Stop. ---- Check what the build can see: @@ -106,7 +109,14 @@ A target is a component name from `list`, or a group: | `toolchain:PORT` | Everything needed to build for one port: `iigs`, `amiga`, `atarist`, `dos`, -`x68000` or `dvx`. +`x68000`, `dvx`, `sdl3` (SDL 3, built as a static library that every SDL3 +program links in, so it runs without SDL installed), `web` (Emscripten, +through emsdk, and SDL 3 built with it), `win32` (llvm-mingw; its +emulator is a portable Wine), `win16` (Open Watcom; its emulator is Windows +for Workgroups 3.11 in DOSBox Staging, set up from your own install disc, see +<>) or `psp` (pspdev's prebuilt release, +and SDL 3 built with it; its emulator is PPSSPP's headless build, patched for +the gates). | `emulator:PORT` | Everything needed to run that port's programs: the emulator and its @@ -202,6 +212,14 @@ $ make atarist # Atari ST only $ make dos # MS-DOS only $ make x68000 # Sharp X68000 only $ make dvx # DVX only +$ make sdl3 # native programs through SDL3 only +$ make web # web pages through Emscripten only +$ make win32 # Windows programs only +$ make win16 # Windows 3.x programs only +$ make psp # PSP homebrew (EBOOT.PBP) only +$ make haiku # Haiku x86_64 programs only +$ make haiku32 # Haiku x86 (gcc 2.95, the BeOS ABI) programs only +$ make beos # BeOS R5 x86 programs only $ make clean # remove all build outputs ---- @@ -312,7 +330,8 @@ back buffer called the *stage*; nothing reaches the display until you present. === Running What You Built `joey-run`, at the top of the repository, starts a built program in the right -emulator. The emulators, and the firmware they boot, come from the installer: +emulator (an SDL3 program needs none: it runs on your machine). The emulators, +and the firmware they boot, come from the installer: [source,console] ---- @@ -335,6 +354,13 @@ $ ./joey-run build/amiga/bin/Draw $ ./joey-run --port atarist draw $ ./joey-run build/iigs/bin/DRAW.2mg $ ./joey-run --port dvx draw +$ ./joey-run --port sdl3 draw +$ ./joey-run --port web draw +$ ./joey-run --port win32 draw +$ ./joey-run --port win16 draw +$ ./joey-run --port psp --headless --snapshot draw.png draw +$ ./joey-run --port haiku draw +$ ./joey-run --port beos draw ---- It recognises the machine from the file itself. Interactive runs use the @@ -365,6 +391,16 @@ example the game an interpreter plays. | `--machine rom01` | Apple IIgs: the ROM 01 machine instead of ROM 03. +| `--machine st` +| Atari: Hatari's machine, `st`, `megast`, `ste` or `megaste`. By default an +ST-build program (`make atarist`) gets a plain ST and an STe-build one +(`make ste`) an STe; the STe build runs only on `ste` and `megaste`. + +| `--machine a1200` +| Amiga: FS-UAE's machine, `a500` (the default), `a1200` (68EC020, AGA) or +`a4000` (68040, AGA), each on its own Kickstart 3.1. The AGA ones need their +Kickstarts (`./install.sh install emulator:aga`). + | `--ram SIZE` | Apple IIgs: MAME's memory size. The default is `4M`. @@ -383,8 +419,9 @@ example the game an interpreter plays. default is 9600. | `-- ARGS` -| Pass `ARGS` to the program. DOS only: the other machines' autostart cannot -pass arguments. +| Pass `ARGS` to the program. DOS, SDL3, Win32 and Win16 only: the other +machines' autostart cannot pass arguments, and a page and a PSP program have +none. |=== ==== DVX @@ -398,10 +435,86 @@ the program saves, in DVX's `CONFIG` folder, is there the next time; closing DOSBox ends the run. When the program ends, DVX stays up with an empty desktop. -A headless run with a snapshot is the quickest way to check a change: +==== SDL3 + +An SDL3 program is a native program, so `joey-run` simply starts it, in a +window; running the file yourself does the same. `--headless` runs it on a +private virtual display at one window pixel per stage pixel, so a +`--snapshot` is exactly the 320x200 stage. The window's size, shape and full +screen are set by environment variables (<<_sdl3_programs>>). The SDL3 port +has no serial port. + +==== Web + +A web program is a page: `joey-run` serves its folder over HTTP on this +machine (to this machine only) and opens it in your browser; Ctrl+C stops the +server. With `--headless` it runs in headless Chromium instead, at one canvas +pixel per stage pixel, so a `--snapshot` is the stage. Its `DATA/` is packed +in when it is built, so `--data` does not apply. + +==== Win32 + +A Windows program runs under Wine: in a window on your display, with its +Wine settings kept in the project's `build/win32/prefix` so they are set up +once (nothing goes in `~/.wine`; a run with `--serial` uses a private one, so +the kept COM1 is left alone). The window's size, shape and full screen are +set by the same environment variables as SDL3's, and Alt+Enter switches. With +`--headless` it runs on a private virtual display at one pixel per stage +pixel, so a `--snapshot` is the stage. + +==== Win16 + +A Windows 3.x program runs on Windows for Workgroups 3.11 in DOSBox Staging, +on a fresh copy, each run, of the Windows the installer set up from your own +disc (`./install.sh install emulator:win16`): 800x600 in 256 colours through +the S3 Trio64V+ driver, with Windows' Sound Blaster driver and two drivers +of JoeyLib's own: KPJOY.DRV, the joystick driver Windows 3.1 lacks (DOSBox's +game port, two sticks), and KPCOMM.DRV in place of Windows' COMM.DRV (COM1 +up to 115200 baud). Their components, `kpjoy` and `kpcomm`, build them from +the kpjoy and mscomm projects, checkouts beside JoeyLib's (`../kpjoy`, +`../mscomm/drv`; `KPJOY_DIR` and `KPCOMM_DIR` name others). The program is +Windows' shell, so Windows ends when the program does, and its folder is +drive D:, so what it saves stays there. The window's size, shape and full +screen are set by the same environment variables as SDL3's: `joey-run` puts +them in the DOS environment Windows starts from. Alt+Enter is DOSBox's own +full-screen key, so the program switches with Alt and the keypad's Enter. +With `--headless` it runs on a private virtual display at one window pixel +per stage pixel, and a `--snapshot` is DOSBox's picture of the whole 800x600 +screen. + +==== PSP + +A PSP program runs only with `--headless` here, under PPSSPP's headless +build (`./install.sh install emulator:psp`), from a copy of its folder on a +private memory stick, `PSP/GAME/NAME`, as on a PSP. PPSSPP checks every +memory access as a PSP does: an unaligned one stops the run, and `joey-run` +names the function it was in. A `--snapshot` is the PSP's whole 480x272 +screen. To play the program, copy `build/psp/bin/NAME` into `PSP/GAME/` on +a PSP's memory stick, or open its `EBOOT.PBP` in PPSSPP. The PSP has no +serial port. + +==== Haiku and BeOS + +A Haiku (`haiku`, `haiku32`) or BeOS R5 (`beos`) program runs in that port's +VM under QEMU (`./install.sh install emulator:haiku`, `emulator:haiku32` or +`emulator:beos`), in QEMU's window, from a copy of its folder on a second +disk. The machine boots from a disk it never writes to, runs the program, +and powers off when the program ends; what the program wrote in its folder +(saves, its log) is then copied back. The window's size, shape and full +screen are set by the same environment variables as SDL3's. Haiku's VM has +sound; BeOS's has none, and is taken through R5's boot loader menu to its +fail-safe video mode (1024x768, 16-bit colour), as R5 has no driver for any +of QEMU's video cards. With `--headless` it runs without a window at one +window pixel per stage pixel; `--seconds` counts from when the program +starts, after the boot, and a `--snapshot` is QEMU's picture of the whole +screen. Neither has a serial port here. + +A headless run with a snapshot is the quickest way to check a change, and on +SDL3 it takes seconds, not the minutes an emulated machine needs: [source,console] ---- +$ ./joey-run --headless --seconds 5 --snapshot draw.png --port sdl3 draw $ ./joey-run --headless --seconds 30 --snapshot draw.png --port dos draw ---- @@ -462,6 +575,19 @@ arrives. To connect out, `socat` joins a pseudo-terminal to the network. | DVX | COM1 | As on DOS; DVX's serial library drives the port. + +| Win16 +| COM1 +| As on DOS; Windows' own communications driver drives the port. + +| Win32 +| COM1 +| As on the ST: Wine's COM1 can only be a device, so `socat` joins a +pseudo-terminal to the network both ways. + +| SDL3, Web, PSP +| None +| These ports have no serial port; `jlSerialOpen` fails there. |=== `socat` comes from your distribution: `./install.sh deps host --install` @@ -478,29 +604,38 @@ rate the program sets. and X68000, where MAME prints `Unable to load image` and runs with the port unplugged. * While a port is listening, what the program sends waits for the connection, -except on DOS, which drops it just as a cable with nothing on the other end -would. `examples/sertest` sends its banner at once, so on DOS a client that -connects late sees only the echo. +except on DOS, DVX and Win16, where DOSBox drops it just as a cable with +nothing on the other end would. `examples/sertest` sends its banner at once, +so there a client that connects late sees only the echo. ==== Firmware that is not downloaded Almost everything the emulators need is downloaded: EmuTOS for the Atari ST, the Apple IIgs ROMs, GS/OS 6.0.4 and the CFFA2 card's firmware, and Sharp's own -free release of the X68000 IPL-ROM and Human68k 3.02. Three files are not, +free release of the X68000 IPL-ROM and Human68k 3.02. A few files are not, because they were never released for free: the Amiga Kickstart 3.1 ROM, the -Amiga `serial.device` from Workbench 3.1 (Kickstart 3.1 has none in ROM, and -a program cannot open the serial port without it) and the X68000 CG (font) -ROM. Supply your copies once: +Amiga `serial.device` and `lowlevel.library` from Workbench 3.1 (Kickstart +3.1 has neither in ROM, and a program cannot open the serial port without +the one or see a joystick without the other), the A1200's and A4000's +Kickstart 3.1 (optional: only for running the Amiga programs on those +machines), the X68000 CG (font) ROM, +for the Win16 port the Windows for Workgroups 3.11 install disc (as an +`.iso`) and the S3 Trio64V+ 1.70.04 Windows 3.1 display driver, from which +the installer sets Windows up unattended, and for the BeOS port the BeOS 5 +Professional CD (its CloneCD `.img`, or the `.7z` holding it), from which +the installer takes R5's headers and libraries and makes the VM. Supply your +copies once: [source,console] ---- $ ./install.sh import ~/my-roms ---- -`import` takes files, folders, `.zip` archives and Amiga `.adf` disk images -(point it at your Workbench 3.1 disk for `serial.device`), identifies each -file by its checksum (the list is `installer/files.sha1`) and puts it in -place. +`import` takes files, folders, `.zip` and `.7z` archives and Amiga `.adf` disk images +(point it at your Workbench 3.1 disk for `serial.device` and +`lowlevel.library`, and at the S3 +driver's `.zip` for its three files), identifies each file by its checksum +(the list is `installer/files.sha1`) and puts it in place. A project that hands its setup to testers can host these files instead: `./install.sh bundle joeylib-private.tar.gz` packs the ones present on @@ -537,4 +672,8 @@ from its own directory against a JoeyLib checkout, as any program of yours would (<<_building_your_program>>). On DVX every example is a folder of its own under `build/dvx/bin/apps/joeylib/` -with its help file (<<_dvx_programs>>). +with its help file (<<_dvx_programs>>). On SDL3 every example is a native +program in `build/sdl3/bin/`, beside the shared `DATA/` (<<_sdl3_programs>>), +on the web a page in a folder of its own, `build/web/bin/NAME/` +(<<_web_programs>>), and on the PSP a folder for `PSP/GAME/`, +`build/psp/bin/NAME/` with `EBOOT.PBP` and its `DATA/` (<<_psp_programs>>). diff --git a/docs/Manual.adoc b/docs/Manual.adoc index cd2255b..2820d29 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -1,5 +1,5 @@ = JoeyLib Manual -Scott Duensing +Scott Duensing :revnumber: 1.0 :revdate: 2026 :doctype: book diff --git a/docs/Overview.adoc b/docs/Overview.adoc index df0f8a6..8361850 100644 --- a/docs/Overview.adoc +++ b/docs/Overview.adoc @@ -3,9 +3,10 @@ === What JoeyLib Is JoeyLib is a single-codebase C game library for five 16-bit and 32-bit retro -machines, and for DVX, a DOS desktop whose programs run in windows. You write -one program against one API, and the same source compiles and runs on all of -them: +machines, for DVX, a DOS desktop whose programs run in windows, and for +today's desktops, browsers and the PSP through SDL3, and for Windows from +3.1 on. You write one program against one API, and the same source compiles +and runs on all of them: [cols="2,2,2,4", options="header"] |=== @@ -19,12 +20,16 @@ them: |Commodore Amiga |68000 (A500 class) |512 KB chip, 512 KB fast recommended -|OCS chipset. Native planar bitplanes. +|OCS chipset. Native planar bitplanes. Also tested on an A1200 (68020, + AGA) and an A4000 (68040, AGA). -|Atari ST -|68000 (STF or STE) +|Atari ST, STe, Mega STe +|68000 at 8 MHz (16 MHz on a Mega STe) |512 KB -|Word-interleaved planar. Shares 68000 assembly with the Amiga port. +|Word-interleaved planar. Shares 68000 assembly with the Amiga port. Two + builds: the ST build runs on every ST and never uses STe hardware; the STe + build (`make ste`) uses the STe's 4-bit palette, DMA sound and BLiTTER, and a + Mega STe's 16 MHz, and runs only there. See <<_the_atari_st_family>>. |MS-DOS |386 or later @@ -42,6 +47,42 @@ them: |A program in a window on the DVX desktop, at 1x to 3x, or on the whole screen at 320x200. The MS-DOS port's stage and code; a virtual 70 Hz. See <<_dvx_programs>>. + +|SDL3 +|whatever the host has +|whatever the host has +|A native program in a window (Linux today), scaled to fit at 4:3 or with + square pixels, or on the whole screen. Sprites are interpreted; a virtual + 60 Hz. The fast way to try a change without an emulator. See + <<_sdl3_programs>>. + +|Web +|the browser's +|the browser's +|The SDL3 port compiled to WebAssembly: a program is a web page anyone can + open. See <<_web_programs>>. + +|Win32 +|386 or later +|what Windows needs +|A Windows program for Windows 95 through today: the hosted core with a + backend written to the Win32 API. See <<_windows_programs>>. + +|Win16 +|386 or later +|what Windows 3.1 in 386 enhanced mode needs +|A Windows 3.x program: the hosted core with a backend written to the + Windows 3.1 API, in 32-bit code through Open Watcom's Win386 extender; 8-bit + indexed, so every palette is exact on a 256-colour display. See + <<_windows_programs>>. + +|PSP +|MIPS Allegrex at 222 MHz +|32 MB (PSP-1000) +|PSP homebrew, for custom firmware and PPSSPP: the SDL3 port built with + pspdev. The stage sits at 1:1 in the middle of the 480x272 screen; the + d-pad, nub and buttons are joystick 0, Start is Return and Select Escape. + See <<_psp_programs>>. |=== The Apple IIgs is deliberately the slowest target and the design reference. If @@ -75,7 +116,7 @@ that out of your program: |=== |Platform |In-memory pixel storage -|Apple IIgs, MS-DOS, DVX +|Apple IIgs, MS-DOS, DVX, SDL3, Web, PSP |Chunky 4bpp packed: two pixels per byte, high nibble is the left pixel. |Commodore Amiga @@ -109,6 +150,19 @@ documented and flagged in this manual -- see <<_writing_portable_code>>. * A deterministic, seedable random generator that produces *bit-identical* output on every port for a given seed +=== License + +JoeyLib is distributed under the MIT License (`LICENSE.md`). A program built +with it has JoeyLib compiled in, so it carries JoeyLib's copyright and +permission notice, in its documentation for instance. A program also carries +code from the libraries each port links (libxmp-lite for music on most +ports, the toolchains' C libraries and start files), whose terms +`LICENSES.md` lists with what a shipped program owes them. On the Atari ST +that includes MiNTLib's LGPL parts: a closed-source game lets its users +relink it against a changed MiNTLib (by shipping its object files, for +instance). For Windows 3.x, see the Win16 port's "Shipping" note in +<<_windows_programs>>. + === How to Read This Manual If you are new to JoeyLib, read <<_getting_started>>, then diff --git a/docs/Portability.adoc b/docs/Portability.adoc index d75f401..80be1f6 100644 --- a/docs/Portability.adoc +++ b/docs/Portability.adoc @@ -1,7 +1,7 @@ == Writing Portable Code A JoeyLib program that follows the rules in this chapter compiles and behaves -the same on all five machines and on DVX. Most of the rules exist because one +the same on all five machines, on DVX, under SDL3, in a browser and on Windows. Most of the rules exist because one particular target cannot do something the others can, and the Apple IIgs is usually that target. @@ -178,7 +178,10 @@ hardware: On the Atari ST and Amiga, the tone voices *are* the same hardware the module player drives. While a `.MOD` is playing, `jlAudioVoice` writes fight the player's register writes and lose. Choose the module layer or the voice and -tracker layer *per scene*, not both at once. +tracker layer *per scene*, not both at once. (The STe build plays modules and +sound effects through the STe's DMA sound chip instead, so there the voices +keep the YM2149 to themselves -- but a program that also ships the ST build +still has to follow the rule.) ==== The chip tracker is the portable choice for music. It costs almost nothing on @@ -199,9 +202,9 @@ error. The API is uniform; these are the places where what you get differs. -[cols="2,2,2,2,2,2,2", options="header"] +[cols="2,2,2,2,2,2,2,2,2", options="header"] |=== -|Feature |IIgs |Amiga |Atari ST |MS-DOS |X68000 |DVX +|Feature |IIgs |Amiga |Atari ST |MS-DOS |X68000 |DVX |SDL3, Web, Win32, PSP, Haiku, BeOS |Win16 |Frame rate |60 Hz @@ -210,6 +213,8 @@ The API is uniform; these are the places where what you get differs. |70 Hz |55 Hz (56 under MAME) |70 Hz (virtual; the real retrace on the whole screen) +|60 Hz (virtual) +|60 Hz (virtual) |Pixel storage |chunky 4bpp @@ -218,6 +223,18 @@ The API is uniform; these are the places where what you get differs. |chunky 4bpp |text-plane planar |chunky 4bpp +|chunky 4bpp +|chunky 4bpp + +|Sprites +|compiled +|compiled +|compiled +|compiled +|compiled +|compiled +|interpreted +|interpreted |Stereo pan |all three @@ -226,6 +243,8 @@ The API is uniform; these are the places where what you get differs. |ignored (mono) |ignored |all three +|all three +|ignored (mono) |`jlAudioTone` |DOC oscillator @@ -234,6 +253,8 @@ The API is uniform; these are the places where what you get differs. |PIT counter 2 |- |a DVX tone (FM chip or mixer) +|voice 0 of the mixer +|voice 0 of the mixer |Joystick |analog, auto-disables @@ -242,6 +263,8 @@ The API is uniform; these are the places where what you get differs. |digital |digital |analog (DVX calibrates) +|the OS's gamepads or joysticks (OS calibrates); the PSP's own controls +|Windows' joysticks (Control Panel calibrates) |Keyboard layout |machine's own @@ -250,6 +273,8 @@ The API is uniform; these are the places where what you get differs. |fixed US |machine's own |DVX's +|the OS's; the PSP has no keyboard +|Windows' |Serial ports |modem, printer, slot card @@ -258,6 +283,8 @@ The API is uniform; these are the places where what you get differs. |COM1..COM4 |- |COM1..COM4 (DVX's library) +|Win32: COM1 and up; the others - +|COM1..COM9 |=== The joystick row deserves a note: the IIgs port *auto-disables* joystick @@ -271,6 +298,27 @@ empty port cannot be told from a stick at rest. `JOY_BUTTON_0` is a stick's trigger A and `JOY_BUTTON_1` its trigger B. Holding both directions of an axis reads as the positive one (right, down), as on the Amiga and ST. +On the ST, `JOYSTICK_0` is port 1, the joystick port, and `JOYSTICK_1` port 0. +JoeyLib keeps TOS's keyboard-processor mode, in which port 0 is the mouse's: +the ST reports no stick movement there, so `JOYSTICK_1` stays at rest whatever +is plugged in. Port 1's fire button is the same line as the mouse's right +button, so a press reads as both `JOY_BUTTON_0` and `MOUSE_BUTTON_RIGHT`. + +On the Amiga the sticks are read through `lowlevel.library`, which Kickstart +3.1 does not have in ROM: AmigaOS loads it from `LIBS:`, so the boot drive must +carry Workbench's `Libs/lowlevel.library`, or no stick is seen. `joey-run` puts +the installer's copy there (component `amigaLowlevel`); a game shipped to boot +on its own must do the same. `lowlevel.library` reports a stick only once it +has moved, and never a mouse. It also checks each port again now and then, and +a two-button stick with both buttons held can read as nothing at that moment, +so once a port has shown a stick JoeyLib keeps it one; `jlJoystickReset` hands +the port back to `lowlevel.library`'s own detection. + +On DOS the game port's counts depend on the stick and on the CPU's speed, so +the first reading after `jlInit` or `jlJoystickReset` is taken as the stick's +rest, and a push past half that value either way reads as a full push. Call +`jlJoystickReset` with the stick centred if it was not at the start. + The X68000's display refreshes at 55.46 Hz. `jlFrameHz` times it when the program starts and rounds to the nearest whole number, so it reports 55 -- and 56 under MAME, whose X68000 display runs slightly fast (56.07 Hz). @@ -310,6 +358,65 @@ That last item is the one that matters. Every port has emulator run scripts under `scripts/`, and building successfully proves considerably less than running. +=== The Atari ST Family + +The ST port builds two ways, and a program ships the one that suits its +audience (or both): + +* *The ST build* (`make atarist`, programs in `build/atarist/bin`) is the + baseline. It is pure 68000 and *never uses STe hardware*: it runs on every + ST -- STF, STFM, Mega ST, STe and Mega STe alike -- exactly as on a plain + ST, at no cost for the STe's existence. +* *The STe build* (`make ste`, programs in `build/ste/bin`) is the same port + compiled with `JOEYLIB_ATARI_STE`. It uses the STe's hardware + unconditionally, so it *needs an STe or Mega STe*: on any other machine + `jlInit` fails and `jlLastError` says why (a Mega ST has a BLiTTER but no + DMA sound, so it is refused too). `jlPlatformName` reports `Atari STe`. + +Nothing in the API differs between them: + +[cols="2,3,3", options="header"] +|=== +|Feature |ST build (any ST) |STe build (STe, Mega STe) + +|Palette +|3 bits per channel: each channel's low bit is dropped, so `$0RGB` values one + apart can look the same +|4 bits per channel: every `$0RGB` value exactly + +|Digital sound (`.MOD`, SFX, streams) +|PWM through the YM2149's channel A volume, driven by a 12288 Hz timer + interrupt; tone voice 0 is silent while it plays +|The 8-bit DMA sound chip, mono at 12517 Hz, with no interrupt; all three tone + voices keep playing + +|Fills and copies +|The CPU +|The BLiTTER, for fills of 8 or more rows (24 for one 16-pixel column) and + copies of 4 or more rows; smaller work stays on the CPU, which is faster + there + +|CPU clock +|As the machine is (8 MHz; TOS's setting on a Mega STe) +|8 MHz on an STe; a Mega STe is switched to 16 MHz with its cache on while + the program runs, and back to what TOS had when it ends (checked once, at + `jlInit`) +|=== + +The pixels are identical: UBER's 51 surface hashes match the ST's golden for +the STe build on an STe and a Mega STe, and the gates check that under Hatari. +`./joey-run` gives an ST-build program a plain ST and an STe-build program an +STe; `--machine st`, `megast`, `ste` or `megaste` picks another. A project +builds the STe variant with `make ste`, like any other port; it takes the ST's +baked assets. + +What the STe build buys, measured with UBER under Hatari against the ST build +on a plain ST: a large `jlFillRect` runs up to 1.76 times as fast (80x80), a +vertical line 1.4 times and `jlSurfaceClear` 1.1 times; everything else runs +as on an ST. On a Mega STe at 16 MHz every operation runs about twice as fast. +Hatari models that 16 MHz without the RAM wait states a real Mega STe has, so +expect less on memory-bound operations there. + === DVX Programs DVX is a DOS desktop: a VESA graphics GUI with windows, cooperative @@ -384,3 +491,275 @@ hashes are DOS's -- and the rest behaves like this: `make dvx` builds every example as a DVX program (the `dvx` installer component supplies DVX and its SDK, <<_setting_up_the_toolchains>>), and `./joey-run --port dvx NAME` starts one in DVX under DOSBox Staging. + +=== SDL3 Programs + +The SDL3 port builds a JoeyLib program as a native program for the machine +you work on: Linux today, through SDL 3, which the installer builds into +JoeyLib and every program links in (the `sdl3` component). It is the hosted +core in `src/hosted` -- everything that is the same under any desktop +operating system -- with an SDL3 backend in `src/hosted/sdl3`. Its stage is +the generic chunky one, so UBER's hashes are DOS's, and the rest behaves like +this: + +* *A window, or the whole screen.* The stage is scaled to fill the window at + 4:3, the shape 320x200 has on the old machines' monitors (each pixel 1.2 + times as tall as it is wide), with black bars as needed; SDL's pixel-art + scaling keeps the pixels sharp at any size. Each scanline goes through its + own palette, so SCB palettes are exact. Three environment variables set it + up: `JOEYLIB_SCALE=N` opens the window N times 320 pixels wide (default 3), + `JOEYLIB_ASPECT=square` keeps the pixels square (16:10) instead, and + `JOEYLIB_FULLSCREEN=1` starts on the whole screen. Alt+Enter switches + between the window and the whole screen. JoeyLib's API has no full-screen + call; it is the user's choice. +* *A virtual 60 Hz.* A window has no vertical blank. `jlFrameHz()` is 60 and + `jlWaitVBL()` sleeps to the next sixtieth of a second of the machine's + clock. The window is redrawn at most once a frame, however often the + program presents. +* *Sprites are interpreted.* There is no code emitter for the host's CPU, so + `jlSpriteCompile` returns false and every sprite is drawn by the + interpreter -- far faster than any of the old machines all the same. + `joey/platform.h` defines `JOEYLIB_SPRITE_INTERPRETED`, for a program that + checks how many of its sprites compiled. A pre-compiled bank (`.spc`) + loads its cels' pixels and leaves their code: the DOS bake is used. +* *Input.* Keys by where they are on the keyboard, so another layout does not + move the arrow keys; typed characters through the OS's keyboard layout. The + mouse is the pointer over the stage, in stage pixels (the OS pointer is + hidden over the window: JoeyLib programs draw their own). The first two + gamepads are joysticks 0 and 1: the left stick or the d-pad, the south + button (A on an Xbox pad) as `JOY_BUTTON_0` and the east button (B) as + `JOY_BUTTON_1`; they come and go as they are plugged in. +* *Closing ends the program.* Closing the window ends the program at its next + `jlWaitVBL`, `jlInputPoll` or `jlStagePresent`, as `exit()` does anywhere: + its `atexit` handlers run and the window and the sound are closed. +* *Files.* The program's own folder is the current directory from `jlInit` + on, as on the machine ports: `DATA/`, `SAVES/` and `joeylog.txt` are beside + the program. +* *Sound* is mixed in software into 16-bit stereo at 44.1 kHz: `.MOD` music + through libxmp-lite (as on DOS), the SFX slots in stereo, the three tone + voices as square waves and the noise voice as the ST's YM2149 makes it. It + is mixed in program time by `jlAudioFrameTick`, `jlWaitVBL`, + `jlInputPoll` and `jlStagePresent`, a few frames ahead, so a + `jlAudioTickRegister` callback runs in program time too -- but between + exactly the samples it falls between, so its notes start in the right + place in the sound. `jlAudioInit` is needed for every sound, the tone + voices included. +* *No serial port.* + +`make sdl3` builds every example as a native program, each in +`build/sdl3/bin/` (a program that ships in a folder, in a folder of its own +with its `DATA/`), and `./joey-run --port sdl3 NAME` runs one; so does the +program itself, from anywhere. + +=== Web Programs + +The web port is the SDL3 port compiled to WebAssembly with Emscripten (the +`web` installer components: Emscripten through emsdk, and SDL 3 built with +it): the same hosted core, the same backend, the same stage, so UBER's hashes +are DOS's here too. Everything in <<_sdl3_programs>> holds, except: + +* *A page.* A program is a folder, `build/web/bin/NAME/`: `NAME.html` (the + page), `NAME.js`, `NAME.wasm`, and `NAME.data`, the program's `DATA/` + packed in when it is built. Put the folder on any web server and open + `NAME.html`; a browser will not run it from a `file:` address. The page's + address takes the window's settings: `NAME.html?scale=2&aspect=square`. +* *Your loop still works.* A browser does not let a page's program block, + and a JoeyLib program owns its loop and blocks in `jlWaitVBL`. Programs are + built with Emscripten's ASYNCIFY, and the port's waits hand the browser its + turn and carry on where they were, so an unmodified program runs. It gets + its turn back at least once a frame even when it never waits (a loop on + `jlInputPoll` alone, as `jlWaitForAnyKey` is). +* *Sound starts with the first click or key press*, as browsers require; the + page says so. +* *Saves persist.* `SAVES/` is kept in the browser's IndexedDB for the page's + address, read back when the program starts and written back every two + seconds, so a save outlives the page. +* *No full screen by default and no closing.* Alt+Enter asks the browser for + the whole screen; the program ends when the page is closed. + +`make web` builds every example as a page, and `./joey-run --port web NAME` +serves its folder on this machine and opens it in your browser. + +=== PSP Programs + +The PSP port is the SDL3 port built as PSP homebrew: the same hosted core +and backend, cross-compiled with pspdev's `psp-gcc` against SDL 3's PSP port +(the `psp` installer components: pspdev, and SDL 3 built with it). Its stage +is the same chunky one, so UBER's hashes are DOS's. Everything in +<<_sdl3_programs>> holds, except: + +* *A folder for `PSP/GAME`.* A program is a folder, `build/psp/bin/NAME/`: + `EBOOT.PBP`, titled with the program's name (or its description's `TITLE`) + in the PSP's menu, and its `DATA/`. Copy the folder into `PSP/GAME/` on the + memory stick. Its folder is its current directory, so `DATA/`, `SAVES/` + and `joeylog.txt` are there. It runs on a PSP with custom firmware (it is + not signed) and in PPSSPP. The ELF it is packed from stays in + `build/psp/elf/`, for `psp-addr2line`. +* *The screen.* The PSP's 480x272 screen shows the stage at 1:1 in its + middle, black around it. The scale and aspect settings do not apply (a PSP + has no environment), and there is no full screen to switch to. +* *Its own controls, no keyboard.* The PSP's controls are joystick 0, + through SDL's gamepad mapping: the d-pad or the analog nub, cross as + `JOY_BUTTON_0` and circle as `JOY_BUTTON_1`; joystick 1 is never there. + Having no keyboard, the PSP also gives Start as the Return key and Select + as the Escape key, held (`KEY_RETURN`, `KEY_ESCAPE`) and typed + (`JL_CHAR_RETURN`, `JL_CHAR_ESCAPE` from `jlInputGetChar`) as those keys + are, so `jlWaitForAnyKey`, Return to go on and Escape to quit work. No + other key is ever down, nothing else is typed (the PSP's on-screen + keyboard is never opened), and there is no pointer to move. +* *HOME quits.* The HOME menu's Exit ends the program as closing a window + does. +* *Free space* (`jlDiskFree`) is that of the memory stick (or a PSP Go's + internal storage) the program is on. +* *Memory.* A PSP-1000 has 32 MB, about 24 MB of it for programs; later + models have more. +* *Alignment.* The PSP's MIPS processor stops a program that reads or writes + a 16- or 32-bit value at an address that is not a multiple of its size, + where an x86 does not notice (a 68000 stops too, but only on odd + addresses). Read a multi-byte value from a file buffer a byte at a time. + The gates run every PSP program with PPSSPP checking every access the same + way. + +`make psp` builds every example, and `./joey-run --port psp --headless NAME` +runs one under PPSSPP's headless build (the `ppsspp` installer component, +patched for the gates); `--snapshot` saves the PSP's screen. There is no +window to play in here: to play, copy the folder to a PSP, or open its +`EBOOT.PBP` in PPSSPP. None of it has run on a real PSP yet. + +=== Windows Programs + +The Win32 port is the hosted core with a backend of its own, written to the +Win32 API (`src/hosted/win32`), for the Windows SDL3 does not reach -- +Windows 95, 98, ME, NT 4 and 2000 -- and every Windows since. Its stage is +the same chunky one, so UBER's hashes are DOS's. Everything in +<<_sdl3_programs>> holds, except: + +* *Old Windows on purpose.* A program is a 32-bit `NAME.EXE` built by + llvm-mingw against `MSVCRT.DLL` (the `win32` installer component), marked + for Windows 4.0 so 95 and NT 4 will load it, linked statically, and + calling only what USER, GDI, WINMM and KERNEL32 had in 1995. `MSVCRT.DLL` + came with Windows 95 OSR2 and Internet Explorer; a first-release Windows + 95 needs it installed. +* *One thread.* The window is made on the program's thread, so its messages + arrive there and the port dispatches them at `jlInputPoll`, `jlWaitVBL` + and `jlStagePresent`; the sound goes out through waveOut buffers handed + over at the same calls. There are no callbacks and no locks. +* *The window.* The stage is drawn with `StretchDIBits` at the stage's shape, + 4:3 or square (the same `JOEYLIB_SCALE`, `JOEYLIB_ASPECT` and + `JOEYLIB_FULLSCREEN` settings), black around it; full screen is a + borderless window over the whole monitor, which leaves the display mode + alone. On a 256-colour desktop Windows maps every colour to the nearest + one it has. +* *Keys* by the PC scan code every key message carries (the same table as + DOS), typed characters through the user's keyboard layout, and the first + two joysticks through `joyGetPosEx`, calibrated in the Control Panel. Keys + held when the window loses the keyboard are let go. +* *Serial* through the Win32 COM calls (COM1, or COM for a slot). + Closing the port lets Windows drop DTR; Win32 has no call to prevent it. + +`make win32` builds every example as a Windows program in `build/win32/bin/`, +and `./joey-run --port win32 NAME` runs one under Wine (the `wine` installer +component, a portable Wine kept in `toolchains/`); the gates run there too. +None of it has run on a real Windows 9x or NT 4 here. + +The Win16 port is the hosted core with a backend written to the Windows 3.1 +API (`src/hosted/win16`), for Windows 3.1 and Windows for Workgroups 3.11 on +a 386 or better. Its stage is the same chunky one, so UBER's hashes are DOS's +too. Everything said of Win32 above holds -- one thread, the window, keys by +scan code, DTR dropped at close -- except: + +* *32-bit code on Windows 3.1.* A program is a Windows 3.x `NAME.EXE` built + with Open Watcom's Win386 target (the `win16` installer component): flat + 32-bit code bound to Watcom's supervisor, which turns each call into the + 16-bit one Windows takes, so it needs Windows in 386 enhanced mode. It + calls only what Windows 3.1 has: USER, GDI, KERNEL and MMSYSTEM. +* *Exact colours.* The frame is 8-bit, a DIB whose 256-entry colour table + holds all 16 palettes and is realized as a logical palette whenever a frame + is shown, so on a 256-colour display every colour is exact, SCB palettes + included. `JOEYLIB_SCALE` defaults to 2, and the settings are read from the + DOS environment Windows started from (`joey-run` puts its own there). + Under DOSBox, Alt+Enter is DOSBox's own full-screen key; use Alt with the + keypad's Enter. +* *Sound* is 8-bit mono at 22.05 kHz through waveOut, what a Sound Blaster + and a 386 handle well; stereo pan is ignored. +* *Joysticks* are the first two MMSYSTEM sticks, through `joyGetPos`, with + their buttons 1 and 2. Windows 3.x reads sticks only through a joystick + driver, which Windows 3.1 itself does not have; without one no stick is + seen. JoeyLib's Windows runs KPJOY.DRV (the `kpjoy` installer component, + built from the kpjoy project): the PC game port's two sticks, each axis's + rest taken from its first readings, so leave the stick centred as the + program starts. A game shipped for Windows 3.x can ship KPJOY.DRV with it + (`[drivers] joystick=kpjoy.drv` in SYSTEM.INI). +* *Files.* The program's folder, drive included, is the current directory + from the moment the window opens. +* *Serial* through Windows 3.1's communications calls (`OpenComm` and the + rest): COM1, or COM for a slot. JoeyLib's Windows runs KPCOMM.DRV + in place of COMM.DRV (the `kpcomm` installer component, built from the + mscomm project): both 16550A FIFOs and 115200 baud. Windows 3.1's own + driver goes up to 57600, and a faster rate gets that there. +* *Shipping.* Every Win16 program carries Open Watcom's C library and its + Win386 supervisor, and KPJOY.DRV and KPCOMM.DRV carry Open Watcom code + too. Open Watcom's license (the Sybase Open Watcom Public License, + section 2.2(d)) asks for a notice, in the program and in its + documentation, that the source of that code is available and where. The + port builds the notice into every program and the drivers carry it, never + shown; the documentation's part is this line, in the game's readme or + manual: ++ +.... +This software contains code from Open Watcom. Its source code is available +under the terms of the Sybase Open Watcom Public License version 1.0 from +https://github.com/open-watcom/open-watcom-v2 +.... ++ +KPJOY.DRV and KPCOMM.DRV are MIT licensed: ship each with its `LICENSE.md`, +which the installer puts beside the driver in `toolchains/opt/`. + +`make win16` builds every example as a Windows 3.x program in +`build/win16/bin/`, and `./joey-run --port win16 NAME` runs one on Windows +for Workgroups 3.11 in DOSBox Staging (the `win311` installer component, set +up from your own install disc); the gates run there too. None of it has run +on a real 386 here. + +=== Haiku and BeOS Programs + +The Be API ports are the hosted core with a backend written to the Be API +(`src/hosted/be`, C++), which Haiku reimplements, kept to the calls BeOS R5 +already had. One source builds three ports: `haiku` (Haiku x86_64, gcc 13), +`haiku32` (Haiku x86, gcc 2.95, the BeOS C++ ABI) and `beos` (BeOS R5 x86, +built with the same gcc 2.95 against R5's own headers and libraries, which +the `beosR5` installer component copies off your BeOS 5 Professional CD). +Their stage is the same chunky one, so UBER's hashes are DOS's. Everything in +<<_sdl3_programs>> holds, except: + +* *Threads.* The program's `main` keeps its own thread; the application and + the window run on threads of their own, as every Be program's do. Key and + mouse messages reach the program as events taken at `jlInputPoll`, + `jlWaitVBL` and `jlStagePresent`, and the sound comes from a + `BSoundPlayer`. +* *The window* shows the stage from a bitmap the app_server scales without + filtering, at the stage's shape, 4:3 or square, black around it (the same + `JOEYLIB_SCALE`, `JOEYLIB_ASPECT` and `JOEYLIB_FULLSCREEN` settings; + `JOEYLIB_SCALE` defaults to 3). Alt+Enter switches between the window and + the whole screen. The pointer is hidden over the window. +* *Keys* by raw key code, the key's place on the keyboard whatever the + keymap; typed characters are the keymap's, 7-bit ASCII only. The first + two joysticks (`BJoystick`) are `JOYSTICK_0` and `JOYSTICK_1`, their first + two axes and buttons. +* *Files.* The program's folder is the current directory from the moment + the window opens. +* *No serial port.* +* *BeOS R5's loader* refuses any program with a dynamic tag it does not + know, so `make beos` links without combined relocations (`-z + nocombreloc`). Build for R5 with `make beos`, not `make haiku32`. + +`make haiku`, `make haiku32` and `make beos` build every example into +`build//bin/`, and `./joey-run --port NAME` runs one in that +port's VM under QEMU: Haiku R1/beta6 (the `haikuVm` and `haikuVm32` installer +components) or BeOS R5 (`beosVm`, made from your CD), booted from a disk it +never writes to, with the program's folder on a second disk. R5 drives none +of QEMU's video cards, so its VM is taken through the boot loader's menu to +the fail-safe (VESA) mode, 1024x768 in 16-bit colour; it has no sound card. +The gates run in the same VMs (`scripts/verify-be-input.sh`, +`scripts/bench-be.sh`). None of it has run on a real BeOS or Haiku machine +here. diff --git a/docs/ProgramLayout.adoc b/docs/ProgramLayout.adoc index 9c8a861..c5fcf63 100644 --- a/docs/ProgramLayout.adoc +++ b/docs/ProgramLayout.adoc @@ -25,7 +25,7 @@ native sprite blitter code into. Getting it wrong is the most common cause of sprites drawing slowly, so it is worth understanding. * `0` selects a per-platform default: 8 KB on the IIgs, 32 KB on - Amiga, ST, DOS and DVX. + Amiga, ST, DOS and DVX. SDL3 interprets every sprite and never uses it. * The 68000 and x86 emitters produce code roughly four to eight times less dense than the 65816 one, so the same sprite needs far more arena space on those ports. A 32x32 sprite can need 8 to 16 KB there. @@ -141,8 +141,8 @@ rows. That is the one case where you have to mark the region yourself. ==== Frame-Rate Independence The targets do not share a refresh rate: 50 Hz (Amiga PAL), 55 Hz -(X68000), 60 Hz (IIgs, ST NTSC) and 70 Hz (VGA mode 13h, and DVX's virtual -rate). A loop +(X68000), 60 Hz (IIgs, ST NTSC, and SDL3's virtual rate) and 70 Hz (VGA +mode 13h, and DVX's virtual rate). A loop that moves a sprite "two pixels per frame" runs 40 percent faster on DOS than on a PAL Amiga. diff --git a/examples/adventure/adventure.c b/examples/adventure/adventure.c index f83b92d..d7421e9 100644 --- a/examples/adventure/adventure.c +++ b/examples/adventure/adventure.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Sierra-style mini-adventure built on JoeyLib. // // What this demonstrates: @@ -206,7 +210,6 @@ static uint8_t gEgoTiles[DIR_COUNT][EGO_FRAMES][EGO_TILE_BYTES]; static uint8_t gEgoBackup[EGO_BACKUP_BYTES] __attribute__((aligned(2))); static jlSpriteBackupT gEgoBak; -static bool gEgoBakValid; /* Cursor sprite per verb (+ item-icon for "using" mode) */ static jlSpriteT *gCursor[VERB_COUNT]; @@ -215,7 +218,6 @@ static jlSpriteT *gLampLitSprite; /* lit-state inventory icon */ static uint8_t gLampLitTiles[2 * 2 * TILE_BYTES]; static uint8_t gCursorBackup[CURSOR_BACKUP_BYTES] __attribute__((aligned(2))); static jlSpriteBackupT gCursorBak; -static bool gCursorBakValid; /* Ego state */ static int16_t gEgoX, gEgoY; /* top-left pixel of sprite */ @@ -1324,6 +1326,34 @@ static void buildPropSprites(void) { * save-under for props. */ +// A hotspot added to a room (no compound literals: plain C89, so every +// port's compiler takes it). +static void roomHotspotAdd(RoomT *r, HotspotIdE id, int16_t x, int16_t y, int16_t w, int16_t h, const char *name) { + HotspotT *hs; + + hs = &r->hotspots[r->hotspotCount++]; + hs->id = id; + hs->x = x; + hs->y = y; + hs->w = w; + hs->h = h; + hs->name = name; +} + + +// A prop added to a room; resolvePropSprites gives it its sprite. +static void roomPropAdd(RoomT *r, int16_t x, int16_t y, int16_t w, int16_t h) { + PropT *prop; + + prop = &r->props[r->propCount++]; + prop->x = x; + prop->y = y; + prop->w = w; + prop->h = h; + prop->sp = NULL; +} + + static void initRoomForest(void) { RoomT *r; int i; @@ -1334,15 +1364,15 @@ static void initRoomForest(void) { } r->hotspotCount = 0; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_TREE, 50, 92, TREE_W, TREE_H, "the tree" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_TREE, 140, 100, TREE_W, TREE_H, "the tree" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_TREE, 240, 95, TREE_W, TREE_H, "the tree" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_DOOR_TO_COTTAGE, 290, 110, 30, 50, "the path east" }; + roomHotspotAdd(r, HS_TREE, 50, 92, TREE_W, TREE_H, "the tree"); + roomHotspotAdd(r, HS_TREE, 140, 100, TREE_W, TREE_H, "the tree"); + roomHotspotAdd(r, HS_TREE, 240, 95, TREE_W, TREE_H, "the tree"); + roomHotspotAdd(r, HS_DOOR_TO_COTTAGE, 290, 110, 30, 50, "the path east"); r->propCount = 0; - r->props[r->propCount++] = (PropT){ 50, 92, TREE_W, TREE_H, NULL }; - r->props[r->propCount++] = (PropT){140, 100, TREE_W, TREE_H, NULL }; - r->props[r->propCount++] = (PropT){240, 95, TREE_W, TREE_H, NULL }; + roomPropAdd(r, 50, 92, TREE_W, TREE_H); + roomPropAdd(r, 140, 100, TREE_W, TREE_H); + roomPropAdd(r, 240, 95, TREE_W, TREE_H); r->egoEnterFromE_x = 290; r->egoEnterFromE_y = 130; @@ -1363,18 +1393,18 @@ static void initRoomCottage(void) { } r->hotspotCount = 0; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_WINDOW, 60, 18, 50, 40, "the window" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_FIRE, 220, 30, 70, 60, "the fireplace" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_TABLE, 140, 108, TABLE_W, TABLE_H, "the table" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_LAMP_ON_TABLE, 156, 100, LAMP_W, LAMP_H, "a small lamp" }; - r->hotspots[r->hotspotCount++] = (HotspotT){ HS_DOOR_TO_FOREST, 8, 60, 36, 80, "the door" }; + roomHotspotAdd(r, HS_WINDOW, 60, 18, 50, 40, "the window"); + roomHotspotAdd(r, HS_FIRE, 220, 30, 70, 60, "the fireplace"); + roomHotspotAdd(r, HS_TABLE, 140, 108, TABLE_W, TABLE_H, "the table"); + roomHotspotAdd(r, HS_LAMP_ON_TABLE, 156, 100, LAMP_W, LAMP_H, "a small lamp"); + roomHotspotAdd(r, HS_DOOR_TO_FOREST, 8, 60, 36, 80, "the door"); r->propCount = 0; - r->props[r->propCount++] = (PropT){ 140, 108, TABLE_W, TABLE_H, NULL }; + roomPropAdd(r, 140, 108, TABLE_W, TABLE_H); /* The lamp prop is drawn ONLY while the player hasn't picked it * up; the prop is added/removed dynamically. We pre-allocate the * slot here so the priority pass can find it. */ - r->props[r->propCount++] = (PropT){ 156, 100, LAMP_W, LAMP_H, NULL }; + roomPropAdd(r, 156, 100, LAMP_W, LAMP_H); r->egoEnterFromE_x = 280; r->egoEnterFromE_y = 150; @@ -2200,8 +2230,6 @@ static void enterRoom(RoomE room, bool fromEast, bool fromWest) { } gEgoMoving = false; gEgoFrameIdx = 0; - gEgoBakValid = false; - gCursorBakValid = false; gPathLen = 0; gPathStep = 0; diff --git a/examples/adventure2/adventure2.c b/examples/adventure2/adventure2.c index 2df1d0f..886d44f 100644 --- a/examples/adventure2/adventure2.c +++ b/examples/adventure2/adventure2.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // adventure2 -- a Sierra-faithful sample built on JoeyLib. // // The first adventure example (examples/adventure/adventure.c) used diff --git a/examples/audio/audio.c b/examples/audio/audio.c index 8817dea..97db167 100644 --- a/examples/audio/audio.c +++ b/examples/audio/audio.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Audio demo: starts the baked chip-tracker song (demoSong.song -> // demoSong.h via tools/songbake) on entry and exits on ESC. The keys: // SPACE a short digital SFX, test.sfx from the DATA folder diff --git a/examples/draw/draw.c b/examples/draw/draw.c index 22651d3..44c28af 100644 --- a/examples/draw/draw.c +++ b/examples/draw/draw.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Drawing primitive smoke test. Lays out a 2x2 grid of cells, each // exercising one family of primitives. On screen each cell should show // a clear visual signal that the underlying inner loops (C or ASM) diff --git a/examples/hello/hello.c b/examples/hello/hello.c index 3caf34a..2ba99d8 100644 --- a/examples/hello/hello.c +++ b/examples/hello/hello.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // JoeyLib hello-world example. // // Validates that the C API headers, library, and per-platform link diff --git a/examples/joy/joy.c b/examples/joy/joy.c index 3f5fa4a..c1ff0c5 100644 --- a/examples/joy/joy.c +++ b/examples/joy/joy.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Joystick demo: shows position and button state for both joystick // ports as live, redraw-on-change visualizations. Each stick gets a // frame with a dot indicating the current axis values, plus two diff --git a/examples/keys/keys.c b/examples/keys/keys.c index 3b54aac..53f14e5 100644 --- a/examples/keys/keys.c +++ b/examples/keys/keys.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Visual keyboard + mouse demo: one square per jlKeyE, lit when its // key is held, and a small pointer drawn at the live mouse position. // Holding the left mouse button while the pointer is over a cell lights diff --git a/examples/palprobe/palprobe.c b/examples/palprobe/palprobe.c new file mode 100644 index 0000000..5f2572f --- /dev/null +++ b/examples/palprobe/palprobe.c @@ -0,0 +1,123 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// Palette-change probe (PALPROBE, a gate's program, not an example): how +// fast palette and SCB changes reach the screen, and whether they reach it +// right. +// +// It times, over PROBE_FRAMES frames each, a palette change followed by a +// present, and an SCB change followed by a present, on a screen of four SCB +// bands, and logs the rates. Then it paints the bands (colour 1 of palettes +// 0..3 red, green, blue, white), makes one palette change (palette 2's +// colour 1 to yellow) and one SCB change that keeps every band where it was +// (band 3 to palette 0), logs "PALPROBE: ready" and holds the screen for +// HOLD_FRAMES frames, so a gate can look at it: top to bottom red, green, +// yellow, red. scripts/verify-amiga-palette.sh is that gate. + +#include + +#include + +#define PROBE_FRAMES 200u +#define HOLD_FRAMES 400u +#define BANDS 4u +#define BAND_LINES 50u +#define PAINT_COLOR 1u +#define MS_PER_SECOND 1000ul +#define COLOR_MASK 0x0FFFu +#define PALETTE_MASK 3u +#define SCB_SWAP_FIRST 0u +#define SCB_SWAP_LAST (BAND_LINES - 1u) +#define YELLOW 0xFF0u +#define CHANGED_PALETTE 2u +#define MOVED_BAND 3u +#define MOVED_TO 0u + +static void check(void); +static void timeChanges(const char *name, bool scb); + + +// Paint the bands, make the two changes, and hold the screen. +static void check(void) { + static const uint16_t firsts[BANDS] = { 0xF00, 0x0F0, 0x00F, 0xFFF }; + uint16_t colors[SURFACE_COLORS_PER_PALETTE]; + jlSurfaceT *stage; + uint16_t band; + uint16_t frame; + + stage = jlStageGet(); + for (band = 0; band < BANDS; band++) { + memset(colors, 0, sizeof(colors)); + colors[PAINT_COLOR] = firsts[band]; + jlPaletteSet(stage, (uint8_t)band, colors); + jlScbSetRange(stage, (uint16_t)(band * BAND_LINES), (uint16_t)(band * BAND_LINES + BAND_LINES - 1u), (uint8_t)band); + jlFillRect(stage, 0, (int16_t)(band * BAND_LINES), SURFACE_WIDTH, BAND_LINES, PAINT_COLOR); + } + jlStagePresent(); + jlWaitVBL(); + memset(colors, 0, sizeof(colors)); + colors[PAINT_COLOR] = YELLOW; + jlPaletteSet(stage, CHANGED_PALETTE, colors); + jlStagePresent(); + jlScbSetRange(stage, (uint16_t)(MOVED_BAND * BAND_LINES), (uint16_t)(MOVED_BAND * BAND_LINES + BAND_LINES - 1u), MOVED_TO); + jlStagePresent(); + jlLogF("PALPROBE: ready"); + for (frame = 0; frame < HOLD_FRAMES; frame++) { + jlWaitVBL(); + } +} + + +// PROBE_FRAMES frames of palette changes (or SCB changes), each presented. +static void timeChanges(const char *name, bool scb) { + uint16_t colors[SURFACE_COLORS_PER_PALETTE]; + jlSurfaceT *stage; + uint16_t start; + uint32_t ms0; + uint32_t elapsed; + unsigned long count; + + stage = jlStageGet(); + memset(colors, 0, sizeof(colors)); + count = 0; + jlWaitVBL(); + start = jlFrameCount(); + ms0 = jlMillisElapsed(); + while ((uint16_t)(jlFrameCount() - start) < PROBE_FRAMES) { + if (scb) { + jlScbSetRange(stage, SCB_SWAP_FIRST, SCB_SWAP_LAST, (uint8_t)(count & PALETTE_MASK)); + } else { + colors[PAINT_COLOR] = (uint16_t)(count & COLOR_MASK); + jlPaletteSet(stage, (uint8_t)(count & PALETTE_MASK), colors); + } + jlStagePresent(); + count++; + } + elapsed = jlMillisElapsed() - ms0; + jlLogF("PALPROBE: %s: %lu in %lu ms = %lu/s", name, count, (unsigned long)elapsed, (unsigned long)(count * MS_PER_SECOND / elapsed)); +} + + +int main(void) { + jlConfigT cfg; + jlSurfaceT *stage; + uint16_t band; + + cfg.codegenBytes = 0; + if (!jlInit(&cfg)) { + return 1; + } + stage = jlStageGet(); + for (band = 0; band < BANDS; band++) { + jlScbSetRange(stage, (uint16_t)(band * BAND_LINES), (uint16_t)(band * BAND_LINES + BAND_LINES - 1u), (uint8_t)band); + jlFillRect(stage, 0, (int16_t)(band * BAND_LINES), SURFACE_WIDTH, BAND_LINES, (uint8_t)(band + 1u)); + } + jlStagePresent(); + timeChanges("palette+present", false); + timeChanges("scb+present", true); + check(); + jlLogF("PALPROBE: done"); + jlShutdown(); + return 0; +} diff --git a/examples/pattern/pattern.c b/examples/pattern/pattern.c index ab3542e..7d27bfd 100644 --- a/examples/pattern/pattern.c +++ b/examples/pattern/pattern.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // M1 deliverable: visible test pattern exercising surfaces, palettes, // SCBs, jlFillRect, and present. // diff --git a/examples/save/save.c b/examples/save/save.c index 9c77d4f..814bcf5 100644 --- a/examples/save/save.c +++ b/examples/save/save.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Save-file demo: a launch counter that persists across runs, plus a free-disk // bar -- no text assets. On start it reads SAVES/runs.dat, increments the // count, and writes it back; the 16-bit count shows as lit/unlit cells (MSB diff --git a/examples/scb/scb.c b/examples/scb/scb.c index 5cb2068..6179d4e 100644 --- a/examples/scb/scb.c +++ b/examples/scb/scb.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // SCB stress screen: the per-scanline palette under the heaviest load a // program can reasonably put on it. Written for the ports that honour the SCB // by reloading ONE hardware palette as the beam scans (the X68000's text-plane diff --git a/examples/sccprobe/sccprobe.c b/examples/sccprobe/sccprobe.c index 4b511ad..330743f 100644 --- a/examples/sccprobe/sccprobe.c +++ b/examples/sccprobe/sccprobe.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Does opening one built-in SCC port survive the OTHER one? // // The Zilog 8530 in a IIgs carries both built-in ports: channel A is the diff --git a/examples/serial/serial.c b/examples/serial/serial.c index 312fce5..25cc659 100644 --- a/examples/serial/serial.c +++ b/examples/serial/serial.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Serial add-on demo: an echo terminal + loopback self-test with no text // assets. It opens the DEFAULT serial port at 9600 8N1 and each frame: // - drains received bytes (jlSerialPoll + jlSerialRead), echoes each one diff --git a/examples/serloss/serloss.c b/examples/serloss/serloss.c index 8817f4c..a3b9b82 100644 --- a/examples/serloss/serloss.c +++ b/examples/serloss/serloss.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Does a serial receiver survive the longest deaf window a JoeyLib program has? // // The host peer (scripts/verify-iigs-serial-loss.sh) waits for "READY" from diff --git a/examples/sermouse/sermouse.c b/examples/sermouse/sermouse.c index ce0929f..6a5a60b 100644 --- a/examples/sermouse/sermouse.c +++ b/examples/sermouse/sermouse.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Serial and mouse together: traffic both ways on the serial port while the // mouse is read and drawn, for a machine whose serial port and mouse share a // chip. The X68000 is one: its Z8530 SCC carries the RS-232C port on channel A diff --git a/examples/sertest/sertest.c b/examples/sertest/sertest.c index 541b172..c3500df 100644 --- a/examples/sertest/sertest.c +++ b/examples/sertest/sertest.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Minimal headless serial self-test -- NO jlInit (no video/graphics), so it // runs even where the graphics HAL can't (e.g. AROS on the Amiga). Opens the // DEFAULT port at 9600 8N1, sends a banner (TX heartbeat), then echoes every diff --git a/examples/sprite/sprite.c b/examples/sprite/sprite.c index ffcfc30..ed2ac50 100644 --- a/examples/sprite/sprite.c +++ b/examples/sprite/sprite.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Sprite demo: bounces a 16x16 ball using the jlSpriteT API. The ball // art is authored as a 16x16 4bpp packed image in this file (one row // per source line) and converted at startup into the 2x2 tile layout diff --git a/examples/tilebank/tilebank.c b/examples/tilebank/tilebank.c index 43ec7d1..3dbc13d 100644 --- a/examples/tilebank/tilebank.c +++ b/examples/tilebank/tilebank.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Tile-bank check on a real port: loads this port's bake of the host gate's // fixture both ways, checks every pixel, and makes sure every other target's // bake is refused. It is the on-machine half of scripts/check-tilebank.sh: diff --git a/examples/uber/uber.c b/examples/uber/uber.c index c9c0aca..1c2a3cb 100644 --- a/examples/uber/uber.c +++ b/examples/uber/uber.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Uber demo: exercise every JoeyLib public API and measure throughput // of the per-frame-hot ones. Results are written to joeylog.txt via // jlLogF. A green screen on exit means the run completed. @@ -123,7 +127,7 @@ extern uint16_t gJoeyLogRingHead; // compiles a FILE* logger everywhere else), so there is nothing to point at. #define uberMbLayout() ((void)0) #else -#define uberMbOp(_i) ((void)0) +#define uberMbOp(_i) ((void)(_i)) #define uberMbPhase(_p) ((void)0) #define uberMbBeat() ((void)0) #define uberMbTick(_t) ((void)0) @@ -133,7 +137,6 @@ extern uint16_t gJoeyLogRingHead; typedef void (*OpFn)(void); -static const char *gCurName = "(none)"; static jlSurfaceT *gStage = NULL; static jlSpriteT *gSprite = NULL; static jlSpriteBackupT gBackup; @@ -261,7 +264,6 @@ static void timeOp(const char *name, OpFn op) { uint32_t millisDelta; uint32_t hash; - gCurName = name; gOpIndex++; uberMbOp(gOpIndex); diff --git a/include/joey/audio.h b/include/joey/audio.h index f51033a..511a4bc 100644 --- a/include/joey/audio.h +++ b/include/joey/audio.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Audio: 4-channel Protracker .MOD music with digital one-shot SFX. // // Authors compose music as .MOD modules. The host-side asset pipeline diff --git a/include/joey/core.h b/include/joey/core.h index 3ad760c..b068237 100644 --- a/include/joey/core.h +++ b/include/joey/core.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // JoeyLib lifecycle: configuration, initialization, shutdown. #ifndef JOEYLIB_CORE_H diff --git a/include/joey/debug.h b/include/joey/debug.h index e2a6078..5bd4fbf 100644 --- a/include/joey/debug.h +++ b/include/joey/debug.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Crash-tracing logger. See src/core/debug.c. #ifndef JOEYLIB_DEBUG_H diff --git a/include/joey/draw.h b/include/joey/draw.h index 3cd59ec..4462b10 100644 --- a/include/joey/draw.h +++ b/include/joey/draw.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Drawing primitives. // // All primitives clip to the surface rectangle. Out-of-bounds draws are diff --git a/include/joey/file.h b/include/joey/file.h index 8048bf6..e809861 100644 --- a/include/joey/file.h +++ b/include/joey/file.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // JoeyLib file access: read-only game data, writable save files, and a disk- // space query. // diff --git a/include/joey/input.h b/include/joey/input.h index 872d055..094cc53 100644 --- a/include/joey/input.h +++ b/include/joey/input.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Keyboard, mouse, and joystick input polling. // // Call jlInputPoll() once per frame (typically right before diff --git a/include/joey/joey.h b/include/joey/joey.h index 6da8220..da0564f 100644 --- a/include/joey/joey.h +++ b/include/joey/joey.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // JoeyLib umbrella header. // // Game code includes this single file: diff --git a/include/joey/palette.h b/include/joey/palette.h index ba19536..aa817c7 100644 --- a/include/joey/palette.h +++ b/include/joey/palette.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Palette and SCB (scanline control byte) accessors. // // Colors are 12-bit $0RGB (bits 11..8 red, 7..4 green, 3..0 blue). diff --git a/include/joey/platform.h b/include/joey/platform.h index dae7512..b67dc94 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // JoeyLib platform, CPU, endian, and capability defines. // // Exactly one JOEYLIB_PLATFORM_* must be defined per build. The build @@ -15,6 +19,7 @@ !defined(JOEYLIB_PLATFORM_DOS) && \ !defined(JOEYLIB_PLATFORM_X68000) && \ !defined(JOEYLIB_PLATFORM_DVX) && \ + !defined(JOEYLIB_PLATFORM_HOSTED) && \ !defined(JOEYLIB_PLATFORM_BLANK) #if defined(__DJGPP__) || defined(__MSDOS__) @@ -40,6 +45,15 @@ // JOEYLIB_PLATFORM_DVX (a program running in a window of the DVX desktop, // src/dvx/) is built with the same DJGPP compiler as DOS, so it cannot be // told apart by compiler macros: make/dvx.mk passes -DJOEYLIB_PLATFORM_DVX. +// +// JOEYLIB_PLATFORM_HOSTED (a program running under a desktop operating +// system, src/hosted/) is built with the host's own compiler, so it is set +// with -D too, along with the backend that opens its window: make/sdl3.mk +// passes -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_SDL3 (as do make/web.mk +// and make/psp.mk, the same backend for a browser and the PSP), make/win32.mk +// -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_WIN32, make/win16.mk (Windows +// 3.x) -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_WIN16, make/be.mk (Haiku) +// -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_BE. // ----- Validate exactly one platform is defined ----- @@ -49,6 +63,7 @@ defined(JOEYLIB_PLATFORM_DOS) + \ defined(JOEYLIB_PLATFORM_X68000) + \ defined(JOEYLIB_PLATFORM_DVX) + \ + defined(JOEYLIB_PLATFORM_HOSTED) + \ defined(JOEYLIB_PLATFORM_BLANK)) != 1 #error "JoeyLib: exactly one JOEYLIB_PLATFORM_* must be defined" #endif @@ -71,7 +86,13 @@ #define JOEYLIB_CPU_68000 #define JOEYLIB_ENDIAN_BIG #define JOEYLIB_NATIVE_PLANAR - #define JOEYLIB_PLATFORM_NAME "Atari ST" + // JOEYLIB_ATARI_STE is the STe build (make ste): the same port using the + // BLiTTER, DMA sound and the 4-bit palette, for STe and Mega STe only. + #if defined(JOEYLIB_ATARI_STE) + #define JOEYLIB_PLATFORM_NAME "Atari STe" + #else + #define JOEYLIB_PLATFORM_NAME "Atari ST" + #endif #elif defined(JOEYLIB_PLATFORM_DOS) #define JOEYLIB_CPU_X86 #define JOEYLIB_ENDIAN_LITTLE @@ -95,15 +116,47 @@ #define JOEYLIB_ENDIAN_LITTLE #define JOEYLIB_NATIVE_CHUNKY #define JOEYLIB_PLATFORM_NAME "DVX" +#elif defined(JOEYLIB_PLATFORM_HOSTED) + // The generic chunky stage, resolved through its SCB palettes into a + // window's RGB pixels by one backend (src/hosted/). Whatever CPU + // the host has: no machine code is generated, sprites are interpreted. + #if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ + #define JOEYLIB_ENDIAN_BIG + #else + #define JOEYLIB_ENDIAN_LITTLE + #endif + #define JOEYLIB_NATIVE_CHUNKY + #define JOEYLIB_SPRITE_INTERPRETED + #if defined(JOEYLIB_HOSTED_SDL3) && defined(__PSP__) + #define JOEYLIB_PLATFORM_NAME "PSP" + #elif defined(JOEYLIB_HOSTED_SDL3) + #define JOEYLIB_PLATFORM_NAME "SDL3" + #elif defined(JOEYLIB_HOSTED_WIN32) + #define JOEYLIB_PLATFORM_NAME "Windows" + #elif defined(JOEYLIB_HOSTED_WIN16) + #define JOEYLIB_PLATFORM_NAME "Windows 3.x" + #elif defined(JOEYLIB_HOSTED_BE) && defined(__HAIKU__) + #define JOEYLIB_PLATFORM_NAME "Haiku" + #elif defined(JOEYLIB_HOSTED_BE) + #define JOEYLIB_PLATFORM_NAME "BeOS" + #else + #error "JoeyLib: JOEYLIB_PLATFORM_HOSTED needs a backend, e.g. -DJOEYLIB_HOSTED_SDL3" + #endif #elif defined(JOEYLIB_PLATFORM_BLANK) // Chunky little-endian is the simplest baseline for a new port: the generic // backend gives you fully-working software rendering out of the box. Adjust // these (or switch to JOEYLIB_NATIVE_PLANAR) once you know your hardware. #define JOEYLIB_ENDIAN_LITTLE #define JOEYLIB_NATIVE_CHUNKY + #define JOEYLIB_SPRITE_INTERPRETED #define JOEYLIB_PLATFORM_NAME "Blank Template" #endif +// JOEYLIB_SPRITE_INTERPRETED: the port has no sprite code emitter (it links +// src/generic/spriteEmitStub.c), so every sprite is drawn by the +// interpreter. A pre-compiled bank (.spc) still gives it the cels' pixels; +// their machine code is skipped. + // ----- Per-machine op override registry (JL_HAS_) ----- // // A machine #define-s JL_HAS_ for each overridable platform op it replaces @@ -256,6 +309,8 @@ #define JL_HAS_SAVE_DIR_ENSURE #define JL_HAS_SAVE_DELETE #define JL_HAS_DISK_FREE + // compiled code written: clear the 68020+'s caches (exec CacheClearE) + #define JL_HAS_CODE_WRITTEN // millisElapsed: generic frameCount*1000/frameHz (no override) #elif defined(JOEYLIB_PLATFORM_ATARIST) #define JL_HAS_SURFACE_CLEAR // planar clear, function @@ -314,6 +369,9 @@ #define JL_HAS_INPUT_SHUTDOWN #define JL_HAS_INPUT_POLL #define JL_HAS_JOYSTICK_RESET + #if defined(JOEYLIB_ATARI_STE) + #define JL_HAS_LAST_ERROR // STe build: why jlInit refused a non-STe + #endif // serial add-on (optional; BIOS Aux / XBIOS Rsconf on the Modem port) #define JL_HAS_SERIAL_OPEN #define JL_HAS_SERIAL_CLOSE @@ -512,6 +570,59 @@ #define JL_HAS_SERIAL_FLUSH #define JL_HAS_SERIAL_SET_DTR #define JL_HAS_SERIAL_SET_RTS +#elif defined(JOEYLIB_PLATFORM_HOSTED) + // A window under a desktop OS (src/hosted). The graphics are the chunky + // generics; the hosted core (src/hosted/*.c) resolves the stage into RGB, + // keeps a virtual frame clock, turns the backend's events into input state + // and mixes the audio, and the backend (src/hosted//) is the OS. + #define JL_HAS_INIT // the backend's window + #define JL_HAS_SHUTDOWN + #define JL_HAS_LAST_ERROR // why the backend could not open it + #define JL_HAS_PRESENT // dirty rows through per-palette tables into an RGB frame + // (input shutdown and the joystick reset stay the generic no-ops: the OS + // owns the devices and calibrates the sticks) + #define JL_HAS_INPUT_INIT + #define JL_HAS_INPUT_POLL // the backend's key, mouse and gamepad events + #define JL_HAS_WAIT_VBL // a virtual 60 Hz frame clock + #define JL_HAS_FRAME_COUNT + #define JL_HAS_FRAME_HZ + #define JL_HAS_MILLIS_ELAPSED + // save files + disk space (the backend's file system calls) + #define JL_HAS_SAVE_DIR_ENSURE + #define JL_HAS_SAVE_DELETE + #define JL_HAS_DISK_FREE + // audio: a software mixer (MOD through libxmp-lite, the SFX slots, three + // square-wave voices, YM-style noise and the speaker tone) run on the + // program's thread, so the tick callback and the stream fills run there + // too and the critical sections stay the generic no-ops. + #define JL_HAS_AUDIO_INIT + #define JL_HAS_AUDIO_SHUTDOWN + #define JL_HAS_AUDIO_PLAY_MOD + #define JL_HAS_AUDIO_STOP_MOD + #define JL_HAS_AUDIO_IS_PLAYING_MOD + #define JL_HAS_AUDIO_PLAY_SFX + #define JL_HAS_AUDIO_PLAY_SFX_STREAM + #define JL_HAS_AUDIO_SET_PAN + #define JL_HAS_AUDIO_STOP_SFX + #define JL_HAS_AUDIO_TONE + #define JL_HAS_AUDIO_VOICE + #define JL_HAS_AUDIO_NOISE + #define JL_HAS_AUDIO_TICK_REGISTER + #define JL_HAS_AUDIO_FRAME_TICK + #if defined(JOEYLIB_HOSTED_WIN32) || defined(JOEYLIB_HOSTED_WIN16) + // serial add-on (optional; a COM port through the Win32 communications + // calls, src/hosted/win32/win32Serial.c, or Windows 3.1's, + // src/hosted/win16/win16Serial.c; the driver buffers RX, so the poll + // stays the generic no-op) + #define JL_HAS_SERIAL_OPEN + #define JL_HAS_SERIAL_CLOSE + #define JL_HAS_SERIAL_READ + #define JL_HAS_SERIAL_WRITE + #define JL_HAS_SERIAL_AVAILABLE + #define JL_HAS_SERIAL_FLUSH + #define JL_HAS_SERIAL_SET_DTR + #define JL_HAS_SERIAL_SET_RTS + #endif #elif defined(JOEYLIB_PLATFORM_BLANK) // Copy-to-start template. It overrides ONLY the platform SERVICES (TODO // stubs in src/blank/blank.c). Everything graphical -- draw / tile / sprite diff --git a/include/joey/present.h b/include/joey/present.h index 7efa3fb..729b43e 100644 --- a/include/joey/present.h +++ b/include/joey/present.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Stage present. // // jlStagePresent flips the library-owned stage (back-buffer) to the diff --git a/include/joey/serial.h b/include/joey/serial.h index 227cbad..099e174 100644 --- a/include/joey/serial.h +++ b/include/joey/serial.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Serial communications add-on -- portable RS-232 byte streams. // // OPTIONAL add-on: this header is NOT pulled in by . Include it diff --git a/include/joey/sprite.h b/include/joey/sprite.h index fa13f92..8fada6b 100644 --- a/include/joey/sprite.h +++ b/include/joey/sprite.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Sprites: rectangles of 8x8 tiles drawn at arbitrary pixel positions // with color-0 transparency. // diff --git a/include/joey/surface.h b/include/joey/surface.h index b158bc3..46f51a0 100644 --- a/include/joey/surface.h +++ b/include/joey/surface.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Surface type and allocation. // // All surfaces are 320x200 16-color images. In-memory storage is diff --git a/include/joey/tile.h b/include/joey/tile.h index f656f7f..a2019c0 100644 --- a/include/joey/tile.h +++ b/include/joey/tile.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Tiles: 8x8 pixel blocks aligned on the 8-pixel grid of any surface. // // A "tile" in JoeyLib isn't a separate object -- it's just the 8x8 diff --git a/include/joey/types.h b/include/joey/types.h index 6f5e765..0bb2080 100644 --- a/include/joey/types.h +++ b/include/joey/types.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // JoeyLib shared types and enums. #ifndef JOEYLIB_TYPES_H diff --git a/install.sh b/install.sh index 75bffe0..d6172b0 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ # # Targets are component names (see `list`) or groups: # toolchain:PORT emulator:PORT PORT toolchains emulators all -# where PORT is iigs, amiga, atarist, dos, x68000 or dvx. `all`, `toolchains` +# where PORT is iigs, amiga, atarist, dos, x68000, dvx, sdl3, web, win32, win16, psp, haiku, haiku32 or beos. `all`, `toolchains` # and `emulators` leave out optional components (dvx, which unpacks a DVX # tree's dist/ zips you have); name those explicitly. # @@ -48,6 +48,10 @@ # make reads toolchains/config.mk by itself. For interactive shells, # `source toolchains/env.sh` sets the same variables. +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT + set -euo pipefail # shellcheck source=installer/lib/common.sh source "$(dirname "${BASH_SOURCE[0]}")/installer/lib/common.sh" @@ -58,7 +62,7 @@ source "$JOEY_INSTALLER/lib/config.sh" # shellcheck source=installer/lib/bundle.sh source "$JOEY_INSTALLER/lib/bundle.sh" -JOEY_PORTS="iigs amiga atarist dos x68000 dvx" +JOEY_PORTS="iigs amiga atarist dos x68000 dvx sdl3 web win32 win16 psp haiku haiku32 beos" depsInstall=0 @@ -90,7 +94,7 @@ groupMembers() { printf '%s\n' "$id" fi ;; - iigs|amiga|atarist|dos|x68000|dvx) + iigs|amiga|atarist|dos|x68000|dvx|sdl3|web|win32|win16|psp) if [[ "$groups" == *" toolchain:$group "* || "$groups" == *" emulator:$group "* ]]; then printf '%s\n' "$id" fi diff --git a/installer/components/amigaGcc.sh b/installer/components/amigaGcc.sh index f27a6ea..b660135 100644 --- a/installer/components/amigaGcc.sh +++ b/installer/components/amigaGcc.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Amiga C toolchain: Bebbo's m68k-amigaos-gcc 6.5 (AmigaPorts), with # binutils, libnix, clib2, the NDK 3.2 headers and vasm. Built from source # (~15-40 minutes). diff --git a/installer/components/amigaLowlevel.sh b/installer/components/amigaLowlevel.sh new file mode 100644 index 0000000..414daee --- /dev/null +++ b/installer/components/amigaLowlevel.sh @@ -0,0 +1,39 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Amiga lowlevel.library (40.35, from Workbench 3.1's Libs drawer), which +# JoeyLib's Amiga port reads both joystick ports through (ReadJoyPort). +# Kickstart 3.1 has no lowlevel.library in ROM: AmigaOS loads it from LIBS:, +# so without it on the boot drive a program sees no joysticks, and joey-run +# and the Amiga joystick gate put it there. Cloanto still sells Workbench 3.1 +# (Amiga Forever), so it is never downloaded from a public source: it comes +# from the private bundle or `install.sh import`, which also finds it inside +# a Workbench .adf disk image. + +compDescription="Amiga firmware: lowlevel.library from Workbench 3.1 (private: bundle or import)" +compGroups="emulator:amiga" +compRequires="" +compHostDeps="" +compPrivateFile="toolchains/firmware/amiga/Libs/lowlevel.library" +compPin="$(privateSha1For "$compPrivateFile")" + + +compInstall() { + privateEnsure "$compPrivateFile" +} + + +compInstalled() { + privatePresent "$compPrivateFile" +} + + +compVerify() { + privatePresent "$compPrivateFile" +} + + +compEnv() { + envVar AMIGA_LOWLEVEL_LIBRARY "$JOEY_REPO/$compPrivateFile" +} diff --git a/installer/components/amigaSerial.sh b/installer/components/amigaSerial.sh index a3fc0e6..d99f5f4 100644 --- a/installer/components/amigaSerial.sh +++ b/installer/components/amigaSerial.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Amiga serial.device (37.4, from Workbench 3.1's Devs drawer). Kickstart 3.1 # on the A500 has no serial.device in ROM: AmigaOS loads it from DEVS:, so a # JoeyLib program that opens the serial port needs it on the boot drive, and diff --git a/installer/components/beosR5.sh b/installer/components/beosR5.sh new file mode 100644 index 0000000..8975878 --- /dev/null +++ b/installer/components/beosR5.sh @@ -0,0 +1,65 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# BeOS R5's own headers and libraries (make beos), copied from the x86 +# system on your BeOS 5 Professional CD (installer/lib/beos.sh): the +# Development package's headers and start files and the system libraries, +# into toolchains/opt/beosR5/sysroot. The compiler is +# Haiku's gcc 2.95 (component haikuGcc2), the BeOS ABI; +# make/support/be/gcc2.sh points it at this sysroot. + +compDescription="toolchain: BeOS R5 headers + libraries from your BeOS 5 Pro CD (beos; private media: import)" +compGroups="toolchain:beos" +compRequires="haikuGcc2 haikuTools" +compHostDeps="python3" +# shellcheck source=installer/lib/beos.sh +source "$JOEY_INSTALLER/lib/beos.sh" +# What the sysroot is made of, as "path on the CD -> folder in sysroot". +beosSysrootParts=( + "_packages_/Development/develop/headers headers" + "_packages_/Development/develop/lib develop-lib" + "beos/system/lib system-lib" +) + + +compInstall() { + local build + local part + local from + local to + local sysroot + build="$(freshBuildDir "$compId")" + beosSystemVolume "$build/system.bfs" + rm -rf "$compDir" + mkdir -p "$compDir/sysroot" + # bfs_shell takes host paths whole. + sysroot="$(cd "$compDir/sysroot" && pwd)" + for part in "${beosSysrootParts[@]}"; do + read -r from to <<< "$part" + printf 'cp -r /myfs/%s :%s\n' "$from" "$sysroot/$to" + done | beosBfs "$build/system.bfs" > "$build/bfs.log" 2>&1 + compInstalled || die "the BeOS sysroot is incomplete; bfs_shell's log: $build/bfs.log" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -f "$compDir/sysroot/headers/be/AppKit.h" ] && + [ -f "$compDir/sysroot/system-lib/libbe.so" ] && + [ -f "$compDir/sysroot/develop-lib/x86/start_dyn.o" ] +} + + +compVerify() { + compInstalled +} + + +compEnv() { + envVar BEOS_R5_SYSROOT "$compDir/sysroot" +} + + +# The CD and the steps that read it (after them: declare -f needs them). +compPin="$(privateSha1For "$BEOS_CD_FILE"):$(declare -f beosSystemVolume beosBfs compInstall | sha256sum | cut -d' ' -f1)" diff --git a/installer/components/beosVm.sh b/installer/components/beosVm.sh new file mode 100644 index 0000000..fc84ec5 --- /dev/null +++ b/installer/components/beosVm.sh @@ -0,0 +1,39 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The BeOS R5 virtual machine the beos port's programs run in under QEMU +# (the gates): your BeOS 5 Professional CD's boot floppy and x86 system, +# made to run the job runner (installer/lib/beos.sh), which runs what a +# gate hands it on a second disk. Into toolchains/opt/beosVm; runs never +# write to it (QEMU's snapshot mode). + +compDescription="emulator: BeOS R5 VM from your BeOS 5 Pro CD (beos; private media: import)" +compGroups="emulator:beos" +compRequires="qemu haikuTools" +compHostDeps="python3" + +# shellcheck source=installer/lib/beos.sh +source "$JOEY_INSTALLER/lib/beos.sh" + +compPin="$(beosVmPin)" + + +compInstall() { + beosVmPrepare "$compDir" +} + + +compInstalled() { + [ -s "$compDir/$BEOS_VM_SYSTEM_DISK" ] && [ -s "$compDir/$BEOS_VM_FLOPPY" ] +} + + +compVerify() { + beosVmVerify "$compDir" +} + + +compEnv() { + envVar BEOS_VM_DIR "$compDir" +} diff --git a/installer/components/cadius.sh b/installer/components/cadius.sh index 72cb26d..c6a10db 100644 --- a/installer/components/cadius.sh +++ b/installer/components/cadius.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # cadius: ProDOS disk-image tool used by every IIgs disk script. The # scripts parse its CATALOG output, so it stays pinned to the mach-kernel # fork commit they were written against (v1.4.6). diff --git a/installer/components/cwsdpmi.sh b/installer/components/cwsdpmi.sh index 10fde39..ad2d871 100644 --- a/installer/components/cwsdpmi.sh +++ b/installer/components/cwsdpmi.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # CWSDPMI r7: the DPMI host embedded into every DOS executable # (make/support/dos/embed-dpmi.sh prepends CWSDSTUB.EXE), so programs run # without a separate CWSDPMI.EXE. diff --git a/installer/components/djgpp.sh b/installer/components/djgpp.sh index 8bb323e..48389d3 100644 --- a/installer/components/djgpp.sh +++ b/installer/components/djgpp.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # DOS C toolchain: DJGPP with gcc 12.2.0, prebuilt for Linux x86-64 by # andrewwutw/build-djgpp (release v3.4). diff --git a/installer/components/dosbox.sh b/installer/components/dosbox.sh index 0f8a73f..0485624 100644 --- a/installer/components/dosbox.sh +++ b/installer/components/dosbox.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # DOSBox Staging 0.83.0: the DOS emulator (for DOS and for DVX, which runs # on it), as the project's own prebuilt Linux release (bundled libraries), # because Debian/Ubuntu only package the old 0.74 and the gates are written diff --git a/installer/components/dvx.sh b/installer/components/dvx.sh index 5890ce1..5736076 100644 --- a/installer/components/dvx.sh +++ b/installer/components/dvx.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # DVX, the DOS desktop JoeyLib's dvx port runs in: the runtime the gates and # joey-run boot (dvx.exe with its libraries, widgets, programs, settings and # system files) and its SDK (the headers, sdk.mk and the host tools every DVX diff --git a/installer/components/elf2x68k.sh b/installer/components/elf2x68k.sh index 2878070..1dfd626 100644 --- a/installer/components/elf2x68k.sh +++ b/installer/components/elf2x68k.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # X68000 C toolchain: yunkya2's elf2x68k (m68k-xelf gcc 13.4.0, binutils # 2.44, newlib 4.5.0), prebuilt for Linux x86-64. m68k-xelf-gcc emits ELF; # its linker script runs elf2x68k.py (python3) to produce Human68k .X files. diff --git a/installer/components/emsdk.sh b/installer/components/emsdk.sh new file mode 100644 index 0000000..7a9408b --- /dev/null +++ b/installer/components/emsdk.sh @@ -0,0 +1,51 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Emscripten 6.0.11 through emsdk: the compiler the web port (make web) +# builds JoeyLib programs to WebAssembly with. emsdk is fetched at the +# release tag's commit and installs that one release of its toolchain +# (clang, binaryen, node) into toolchains/opt/emsdk; nothing reaches the +# user's home directory (EM_CACHE stays inside it too). + +compDescription="web: Emscripten 6.0.11 (emsdk)" +compGroups="toolchain:web" +compRequires="host" +compHostDeps="git python3 xz" +compUrl="https://github.com/emscripten-core/emsdk.git" +compCommit="dd8e25632640cfc1fb570c7fa4cc374e8a5e5a72" +emsdkVersion="6.0.11" +compPin="$compCommit:$emsdkVersion" + + +compInstall() { + rm -rf "$compDir" + gitFetchCommit "$compUrl" "$compCommit" "$compDir" + (cd "$compDir" && ./emsdk install "$emsdkVersion" && ./emsdk activate --embedded "$emsdkVersion") +} + + +compInstalled() { + [ -x "$compDir/upstream/emscripten/emcc" ] +} + + +compVerify() { + local tmp + local status=1 + tmp="$(mktemp -d)" + printf 'int answer(int x) { return x * 42; }\n' > "$tmp/t.c" + if EM_CONFIG="$compDir/.emscripten" EM_CACHE="$compDir/cache" "$compDir/upstream/emscripten/emcc" -c "$tmp/t.c" -o "$tmp/t.o" && [ -s "$tmp/t.o" ]; then + status=0 + fi + rm -rf "$tmp" + return $status +} + + +compEnv() { + envVar EMSDK_DIR "$compDir" + envVar EMCC "$compDir/upstream/emscripten/emcc" + envVar EM_CONFIG "$compDir/.emscripten" + envVar EM_CACHE "$compDir/cache" +} diff --git a/installer/components/emutos.sh b/installer/components/emutos.sh index 1318d80..38075e4 100644 --- a/installer/components/emutos.sh +++ b/installer/components/emutos.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # EmuTOS 1.4 (GPL): a free TOS replacement for the Atari ST emulator, the # 512 KB US-English image. The gates and joey-run boot it instead of # Atari's copyrighted TOS. diff --git a/installer/components/fsuae.sh b/installer/components/fsuae.sh index 41a0a07..caf2f6c 100644 --- a/installer/components/fsuae.sh +++ b/installer/components/fsuae.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # FS-UAE 3.1.66: the Amiga emulator. Debian and Ubuntu package exactly this # version; Fedora does not package FS-UAE at all, so on dnf systems it is # built from the upstream release tarball into toolchains/opt/fsuae. diff --git a/installer/components/haikuGcc.sh b/installer/components/haikuGcc.sh new file mode 100644 index 0000000..b554ccf --- /dev/null +++ b/installer/components/haikuGcc.sh @@ -0,0 +1,78 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The Haiku x86_64 cross compiler (make haiku): gcc 13.3.0 and binutils for +# x86_64-unknown-haiku, Linux-hosted, as Haiku's own CI builds them from its +# buildtools (the toolchain layer of Haiku's published cross-compiler image, +# ghcr.io/haiku/cross-compiler:x86_64-r1beta5, pinned by its digest; the +# compiler is the same 13.3.0 2023_08_10 that R1/beta6 ships). Its sysroot +# is R1/beta6's: the system libraries out of the release image, haiku_devel +# from the release's repository and the compiler's runtime from HaikuPorts. +# Into toolchains/opt/haikuGcc: cross/ (the compiler) and sysroot/. + +compDescription="haiku: gcc 13.3 cross compiler + R1/beta6 sysroot (Haiku x86_64)" +compGroups="toolchain:haiku" +compRequires="haikuTools" +compHostDeps="curl tar python3" +ociRepository="haiku/cross-compiler" +ociToolchainLayer="f6c3231b856d16aac56bf2ffe89a93c315a7bf03e6f62de3859ec2d899d5e815" +haikuArch="x86_64" +systemPackages="haiku-r1~beta6_hrev59866_79-1-x86_64.hpkg gcc_syslibs-13.3.0_2023_08_10-6-x86_64.hpkg" +develPackage="haiku_devel-r1~beta6_hrev59866_79-1-x86_64.hpkg" +develSha256="22fca2d887d394edf6f2af38b8a59d68fc693f41c759cad30576d2b48018a14f" +runtimeDevelPackage="gcc_syslibs_devel-13.3.0_2023_08_10-6-x86_64.hpkg" +runtimeDevelSha256="3db3465b7e390eb7f559690a83fa9244683582ba91322bc186d9b6f4d518ed2d" +compPin="$ociToolchainLayer:$develSha256:$runtimeDevelSha256" + +# shellcheck source=installer/lib/haiku.sh +source "$JOEY_INSTALLER/lib/haiku.sh" + + +compInstall() { + local layer + local src + local package + local packages=() + layer="$(fetchOciBlob "$ociRepository" "$ociToolchainLayer" "haiku-cross-x86_64-r1beta5.tar.gz")" + src="$(freshBuildDir "$compId")" + tar -xzf "$layer" -C "$src" tools/cross-tools-x86_64 + for package in $systemPackages; do + haikuIsoPackage "$haikuArch" "$package" "$src/$package" + packages+=("$src/$package") + done + packages+=("$(haikuRepoPackage "$HAIKU_REPO_BASE" "$haikuArch" "$HAIKU_REVISION" "$develPackage" "$develSha256")") + packages+=("$(haikuRepoPackage "$HAIKU_PORTS_BASE" "$haikuArch" current "$runtimeDevelPackage" "$runtimeDevelSha256")") + rm -rf "$compDir" + mkdir -p "$compDir" + mv "$src/tools/cross-tools-x86_64" "$compDir/cross" + # The image's own sysroot is an empty one from its build. + rm -rf "$compDir/cross/sysroot" + haikuSysroot "$compDir/sysroot" "${packages[@]}" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -x "$compDir/cross/bin/x86_64-unknown-haiku-g++" ] && [ -e "$compDir/sysroot/boot/system/develop/lib/libbe.so" ] +} + + +# Link a C++ program against the Be API (libbe.so) as make/haiku.mk does. +compVerify() { + local tmp + local status=1 + tmp="$(mktemp -d)" + printf '#include \nint main(void) { BApplication app("application/x-vnd.JoeyLib-check"); return app.InitCheck() == B_OK ? 0 : 1; }\n' > "$tmp/t.cpp" + if "$compDir/cross/bin/x86_64-unknown-haiku-g++" --sysroot="$compDir/sysroot" "$tmp/t.cpp" -o "$tmp/t" -lbe && [ -s "$tmp/t" ]; then + status=0 + fi + rm -rf "$tmp" + return $status +} + + +compEnv() { + envVar HAIKU_CROSS "$compDir/cross/bin/x86_64-unknown-haiku-" + envVar HAIKU_SYSROOT "$compDir/sysroot" +} diff --git a/installer/components/haikuGcc2.sh b/installer/components/haikuGcc2.sh new file mode 100644 index 0000000..6c831ab --- /dev/null +++ b/installer/components/haikuGcc2.sh @@ -0,0 +1,85 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The Haiku x86_gcc2 cross compiler (make haiku32): gcc 2.95.3, the BeOS +# C++ ABI that 32-bit Haiku keeps as its primary compiler, Linux-hosted, as +# Haiku's own CI builds it from its buildtools (the toolchain layer of +# Haiku's published cross-compiler image, +# ghcr.io/haiku/cross-compiler:x86_gcc2h-r1beta5, pinned by its digest; the +# compiler is the same 2.95.3-haiku-2017_07_20 that R1/beta6 ships). Its +# sysroot is R1/beta6's: the system libraries out of the release image and +# haiku_devel from the release's repository. Into toolchains/opt/haikuGcc2: +# cross/ (the compiler) and sysroot/; make/support/be/gcc2.sh drives it. +# +# gcc 2.95 builds only as a 32-bit program, so it needs the host's 32-bit C +# library (Debian and Ubuntu: libc6-i386 or libc6:i386; Fedora: glibc.i686). +# Nothing else 32-bit: its binutils are 64-bit. + +compDescription="haiku: gcc 2.95.3 cross compiler + R1/beta6 sysroot (Haiku x86, gcc2h)" +compGroups="toolchain:haiku32" +compRequires="haikuTools" +compHostDeps="curl tar python3" +ociRepository="haiku/cross-compiler" +ociToolchainLayer="ca91ed20dd950c53d8f85eb23f37f21c8634843d068896795c2a7d09bd365241" +haikuArch="x86_gcc2" +systemPackages="haiku-r1~beta6_hrev59866_79-1-x86_gcc2.hpkg" +develPackage="haiku_devel-r1~beta6_hrev59866_79-1-x86_gcc2.hpkg" +develSha256="fd091d128b0002b13ade7651a0a5d2fc259b8d9c09be45be56dbe2ec50aba0f4" +compPin="$ociToolchainLayer:$develSha256" +# The 32-bit program loader the compiler needs. +hostLoader32="/lib/ld-linux.so.2" +gcc2Driver="$JOEY_BUILD_SUPPORT/be/gcc2.sh" + +# shellcheck source=installer/lib/haiku.sh +source "$JOEY_INSTALLER/lib/haiku.sh" + + +compInstall() { + local layer + local src + local package + local packages=() + [ -e "$hostLoader32" ] || die "gcc 2.95 is a 32-bit program and this machine cannot run one: install the 32-bit C library (Debian/Ubuntu: libc6-i386; Fedora: glibc.i686)" + layer="$(fetchOciBlob "$ociRepository" "$ociToolchainLayer" "haiku-cross-x86_gcc2h-r1beta5.tar.gz")" + src="$(freshBuildDir "$compId")" + tar -xzf "$layer" -C "$src" tools/cross-tools-x86_gcc2 + for package in $systemPackages; do + haikuIsoPackage "$haikuArch" "$package" "$src/$package" + packages+=("$src/$package") + done + packages+=("$(haikuRepoPackage "$HAIKU_REPO_BASE" "$haikuArch" "$HAIKU_REVISION" "$develPackage" "$develSha256")") + rm -rf "$compDir" + mkdir -p "$compDir" + mv "$src/tools/cross-tools-x86_gcc2" "$compDir/cross" + # The image's own sysroot is an empty one from its build. + rm -rf "$compDir/cross/sysroot" + haikuSysroot "$compDir/sysroot" "${packages[@]}" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -x "$compDir/cross/bin/i586-pc-haiku-g++" ] && [ -e "$compDir/sysroot/boot/system/develop/lib/libbe.so" ] +} + + +# Link a C++ program against the Be API (libbe.so) through the driver +# make/haiku32.mk uses. +compVerify() { + local tmp + local status=1 + tmp="$(mktemp -d)" + printf '#include \nint main(void) { BApplication app("application/x-vnd.JoeyLib-check"); return app.InitCheck() == B_OK ? 0 : 1; }\n' > "$tmp/t.cpp" + if "$gcc2Driver" "$compDir" g++ "$tmp/t.cpp" -o "$tmp/t" -lbe && [ -s "$tmp/t" ]; then + status=0 + fi + rm -rf "$tmp" + return $status +} + + +compEnv() { + envVar HAIKU32_ROOT "$compDir" + envVar HAIKU32_GCC2 "$gcc2Driver" +} diff --git a/installer/components/haikuTools.sh b/installer/components/haikuTools.sh new file mode 100644 index 0000000..17f5e84 --- /dev/null +++ b/installer/components/haikuTools.sh @@ -0,0 +1,87 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Haiku's own build-host tools, built from the R1/beta6 source: package +# (unpacks .hpkg packages: the Haiku sysroots are made from them) and +# bfs_shell (reads and writes Be File System volumes: the Haiku VM images +# get their job runner through it, and the gates hand programs to the VM +# and take results back on a BFS disk). jam, which builds them, comes from +# Haiku's buildtools at the same release. Installed into +# toolchains/opt/haikuTools; bin/ holds wrappers that find the tools' +# libraries. + +compDescription="haiku: Haiku R1/beta6 host tools (package, bfs_shell; also read BeOS volumes)" +compGroups="toolchain:haiku toolchain:haiku32 toolchain:beos emulator:haiku emulator:haiku32 emulator:beos" +compRequires="host" +compHostDeps="build-tools git python3 zlib-dev zstd-dev bison flex" +compUrl="https://github.com/haiku/haiku.git" +compCommit="f6d86031a107e16f4fff6814e9aea20a39450842" +buildtoolsUrl="https://github.com/haiku/buildtools.git" +buildtoolsCommit="bfa561e3708c0e2d0155fb403b988ba515c9b16d" +compPin="$compCommit:$buildtoolsCommit" +hostTools="bfs_shell package" + + +compInstall() { + local src + local objects + local tool + src="$(freshBuildDir "$compId")" + gitFetchCommit "$compUrl" "$compCommit" "$src/haiku" + # Only jam is wanted from buildtools (the rest is the compilers' + # sources, gigabytes of them). + mkdir -p "$src/buildtools" + git -C "$src/buildtools" init -q + git -C "$src/buildtools" remote add origin "$buildtoolsUrl" + git -C "$src/buildtools" fetch -q --depth=1 --filter=blob:none origin "$buildtoolsCommit" || die "could not fetch $buildtoolsCommit from $buildtoolsUrl" + git -C "$src/buildtools" sparse-checkout set jam + git -C "$src/buildtools" checkout -q -f --detach FETCH_HEAD + make -C "$src/buildtools/jam" -j"$JOEY_JOBS" + mkdir -p "$src/haiku/generated" + (cd "$src/haiku/generated" && ../configure --host-only) + # The tools keep the attributes of the host's files (which have none + # here) in a folder named when they are built: theirs, not the build + # tree's, which goes. + (cd "$src/haiku/generated" && PATH="$src/buildtools/jam/bin.linuxx86:$PATH" jam -q -j"$JOEY_JOBS" -sHAIKU_BUILD_ATTRIBUTES_DIR="$compDir/attributes" 'bfs_shell' 'package') + objects="$src/haiku/generated/objects/linux" + rm -rf "$compDir" + mkdir -p "$compDir/bin" "$compDir/libexec" "$compDir/lib" "$compDir/attributes" + cp "$objects"/lib/*.so "$compDir/lib/" + for tool in $hostTools; do + cp "$(find "$objects" -type f -name "$tool" -perm -u+x -print -quit)" "$compDir/libexec/$tool" + printf '#!/bin/sh\n# Haiku build-host tool: its libraries are in ../lib.\ndir="$(cd "$(dirname "$0")/.." && pwd)"\nLD_LIBRARY_PATH="$dir/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" exec "$dir/libexec/%s" "$@"\n' "$tool" > "$compDir/bin/$tool" + chmod +x "$compDir/bin/$tool" + done + dropBuildDir "$compId" +} + + +compInstalled() { + [ -x "$compDir/bin/bfs_shell" ] && [ -x "$compDir/bin/package" ] && [ -x "$compDir/libexec/bfs_shell" ] && [ -x "$compDir/libexec/package" ] && [ -d "$compDir/attributes" ] +} + + +# Make a small BFS volume, write a file onto it and read it back; package +# starts. (Outputs are captured, not piped into grep: under pipefail a tool's +# own exit status, or SIGPIPE from an early grep, would fail the check.) +compVerify() { + local tmp + local readBack + local usage + tmp="$(mktemp -d)" + printf 'JoeyLib\n' > "$tmp/in.txt" + truncate -s 8M "$tmp/v.img" + "$compDir/bin/bfs_shell" --initialize "$tmp/v.img" Check > /dev/null 2>&1 || true + # After a copy, quit reports the volume busy; sync has written it. + printf 'cp :%s /myfs/in.txt\nsync\nquit\n' "$tmp/in.txt" | "$compDir/bin/bfs_shell" "$tmp/v.img" > /dev/null 2>&1 || true + readBack="$(printf 'cat /myfs/in.txt\nquit\n' | "$compDir/bin/bfs_shell" "$tmp/v.img" 2>&1 || true)" + usage="$("$compDir/bin/package" 2>&1 || true)" + rm -rf "$tmp" + [[ "$readBack" == *JoeyLib* && "$usage" == *"Usage: package"* ]] +} + + +compEnv() { + envVar HAIKU_TOOLS "$compDir/bin" +} diff --git a/installer/components/haikuVm.sh b/installer/components/haikuVm.sh new file mode 100644 index 0000000..0bd6ad9 --- /dev/null +++ b/installer/components/haikuVm.sh @@ -0,0 +1,42 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The Haiku x86_64 virtual machine the haiku port's programs run in under +# QEMU (joey-run --port haiku, and the gates): the R1/beta6 anyboot image, +# made to boot straight to the desktop and run the job runner +# (installer/lib/haiku.sh), which runs what a gate or joey-run hands it on a +# second disk. Into toolchains/opt/haikuVm/haiku.img; runs never write to it +# (QEMU's snapshot mode). + +compDescription="emulator: Haiku R1/beta6 x86_64 VM image (haiku)" +compGroups="emulator:haiku" +compRequires="qemu haikuTools" +compHostDeps="python3" +haikuArch="x86_64" + +# shellcheck source=installer/lib/haiku.sh +source "$JOEY_INSTALLER/lib/haiku.sh" + +compPin="$(haikuVmPin "$haikuArch")" + + +compInstall() { + mkdir -p "$compDir" + haikuVmPrepare "$haikuArch" "$compDir/haiku.img" +} + + +compInstalled() { + [ -s "$compDir/haiku.img" ] +} + + +compVerify() { + haikuVmVerify "$compDir/haiku.img" +} + + +compEnv() { + envVar HAIKU_VM_IMAGE "$compDir/haiku.img" +} diff --git a/installer/components/haikuVm32.sh b/installer/components/haikuVm32.sh new file mode 100644 index 0000000..0bfbc75 --- /dev/null +++ b/installer/components/haikuVm32.sh @@ -0,0 +1,42 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The Haiku x86 (gcc2h, 32-bit) virtual machine the haiku32 port's programs +# run in under QEMU (joey-run --port haiku32, and the gates): the R1/beta6 +# anyboot image, made to boot straight to the desktop and run the job runner +# (installer/lib/haiku.sh), which runs what a gate or joey-run hands it on a +# second disk. Into toolchains/opt/haikuVm32/haiku.img; runs never write to +# it (QEMU's snapshot mode). + +compDescription="emulator: Haiku R1/beta6 x86 gcc2h VM image (haiku32)" +compGroups="emulator:haiku32" +compRequires="qemu haikuTools" +compHostDeps="python3" +haikuArch="x86_gcc2" + +# shellcheck source=installer/lib/haiku.sh +source "$JOEY_INSTALLER/lib/haiku.sh" + +compPin="$(haikuVmPin "$haikuArch")" + + +compInstall() { + mkdir -p "$compDir" + haikuVmPrepare "$haikuArch" "$compDir/haiku.img" +} + + +compInstalled() { + [ -s "$compDir/haiku.img" ] +} + + +compVerify() { + haikuVmVerify "$compDir/haiku.img" +} + + +compEnv() { + envVar HAIKU32_VM_IMAGE "$compDir/haiku.img" +} diff --git a/installer/components/hatari.sh b/installer/components/hatari.sh index 3273233..1e7279c 100644 --- a/installer/components/hatari.sh +++ b/installer/components/hatari.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Hatari 2.4.1: the Atari ST emulator. The ST gates parse its trace and # debugger output as Hatari 2.4 prints them. Debian and Ubuntu package # 2.4.1; Fedora ships a different version, so on dnf systems 2.4.1 is built diff --git a/installer/components/headless.sh b/installer/components/headless.sh index b2b2bda..6d254c8 100644 --- a/installer/components/headless.sh +++ b/installer/components/headless.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # What the test gates need to run the SDL/X11 emulators without a screen: # a virtual X server, key injection, screenshots, and PulseAudio's # capture tools for the audio gates. diff --git a/installer/components/host.sh b/installer/components/host.sh index 837a5bd..565d910 100644 --- a/installer/components/host.sh +++ b/installer/components/host.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Host tools every JoeyLib setup needs: a native compiler for the in-repo # tools, GNU make, git with LFS (the sample assets live in LFS), the # archive tools the other components unpack with, Python 3 with Pillow diff --git a/installer/components/iigsFirmware.sh b/installer/components/iigsFirmware.sh index b29a7b6..f66d33e 100644 --- a/installer/components/iigsFirmware.sh +++ b/installer/components/iigsFirmware.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Apple IIgs firmware and system software for MAME: # - the MAME apple2gs (ROM 03) and apple2gsr1 (ROM 01) ROM sets, and a2ssc # (the Super Serial Card firmware the serial gates' slot card needs), from diff --git a/installer/components/kickstart.sh b/installer/components/kickstart.sh index 2956fca..bcba908 100644 --- a/installer/components/kickstart.sh +++ b/installer/components/kickstart.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Amiga Kickstart 3.1 ROM. Cloanto still sells it (Amiga Forever), so it is # never downloaded from a public source: it comes from the private bundle # or `install.sh import`. The Amiga gates need it; AROS, FS-UAE's free diff --git a/installer/components/kickstartA1200.sh b/installer/components/kickstartA1200.sh new file mode 100644 index 0000000..e4281d1 --- /dev/null +++ b/installer/components/kickstartA1200.sh @@ -0,0 +1,36 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Amiga Kickstart 3.1 r40.68 ROM for the A1200, an AGA Amiga (68EC020), for +# running the Amiga gates on that machine as well as the A500 (JOEY_AMIGA_MODEL +# in scripts/lib/amigaGate.sh). Cloanto still sells it (Amiga Forever), so it +# is never downloaded from a public source: it comes from the private bundle +# or `install.sh import`. Optional: the A500 is the port's baseline. + +compDescription="Amiga firmware: Kickstart 3.1 r40.68 for the A1200 (private: bundle or import)" +compGroups="emulator:aga optional" +compRequires="" +compHostDeps="" +compPrivateFile="toolchains/firmware/amiga/kickstartA1200.rom" +compPin="$(privateSha1For "$compPrivateFile")" + + +compInstall() { + privateEnsure "$compPrivateFile" +} + + +compInstalled() { + privatePresent "$compPrivateFile" +} + + +compVerify() { + privatePresent "$compPrivateFile" +} + + +compEnv() { + envVar KICKSTART_A1200_ROM "$JOEY_REPO/$compPrivateFile" +} diff --git a/installer/components/kickstartA4000.sh b/installer/components/kickstartA4000.sh new file mode 100644 index 0000000..ee25fc4 --- /dev/null +++ b/installer/components/kickstartA4000.sh @@ -0,0 +1,36 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Amiga Kickstart 3.1 r40.70 ROM for the A4000T, an AGA Amiga (68040), for +# running the Amiga gates on that machine as well as the A500 (JOEY_AMIGA_MODEL +# in scripts/lib/amigaGate.sh). Cloanto still sells it (Amiga Forever), so it +# is never downloaded from a public source: it comes from the private bundle +# or `install.sh import`. Optional: the A500 is the port's baseline. + +compDescription="Amiga firmware: Kickstart 3.1 r40.70 for the A4000T (private: bundle or import)" +compGroups="emulator:aga optional" +compRequires="" +compHostDeps="" +compPrivateFile="toolchains/firmware/amiga/kickstartA4000.rom" +compPin="$(privateSha1For "$compPrivateFile")" + + +compInstall() { + privateEnsure "$compPrivateFile" +} + + +compInstalled() { + privatePresent "$compPrivateFile" +} + + +compVerify() { + privatePresent "$compPrivateFile" +} + + +compEnv() { + envVar KICKSTART_A4000_ROM "$JOEY_REPO/$compPrivateFile" +} diff --git a/installer/components/kpcomm.sh b/installer/components/kpcomm.sh new file mode 100644 index 0000000..b89e062 --- /dev/null +++ b/installer/components/kpcomm.sh @@ -0,0 +1,43 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# KPCOMM.DRV, the communications driver the win16 port's Windows runs with +# in place of Windows 3.1's COMM.DRV: both 16550A FIFOs, 115200 baud, any +# common IRQ. Built (installer/lib/winDriver.sh) from the mscomm project's +# drv folder, a checkout beside this repository (../mscomm/drv, or +# KPCOMM_DIR), with its Open Watcom makefile (watcom.mk), into +# toolchains/opt/kpcomm/kpcomm.drv; scripts/lib/win16Gate.sh installs it in +# each run's Windows as comm.drv. + +compDescription="win16: KPCOMM.DRV comm driver for its Windows, built from ../mscomm/drv (KPCOMM_DIR)" +compGroups="emulator:win16" +compRequires="openWatcom" +compHostDeps="make" + +# shellcheck source=installer/lib/winDriver.sh +source "$JOEY_INSTALLER/lib/winDriver.sh" + +kpcommDir="${KPCOMM_DIR:-$(cd "$JOEY_REPO/.." && pwd)/mscomm/drv}" +compPin="dir:$kpcommDir:$(winDriverPin "$kpcommDir")" + + +compInstall() { + winDriverBuild "$kpcommDir" watcom.mk kpcomm.drv +} + + +compInstalled() { + [ -s "$compDir/kpcomm.drv" ] +} + + +compVerify() { + # A Windows module whose name is COMM (USER imports it by that name). + [ "$(head -c 2 "$compDir/kpcomm.drv")" = "MZ" ] && LC_ALL=C grep -q -a 'KPCOMM' "$compDir/kpcomm.drv" +} + + +compEnv() { + envVar KPCOMM_DRV "$compDir/kpcomm.drv" +} diff --git a/installer/components/kpjoy.sh b/installer/components/kpjoy.sh new file mode 100644 index 0000000..bce0705 --- /dev/null +++ b/installer/components/kpjoy.sh @@ -0,0 +1,43 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# KPJOY.DRV, the joystick driver the win16 port's Windows runs with: +# Windows 3.1 has the joystick API (joyGetPos and the rest) but ships no +# driver for it, so without one no stick is ever seen. Built +# (installer/lib/winDriver.sh) from the kpjoy project, a checkout beside +# this repository (../kpjoy, or KPJOY_DIR), into +# toolchains/opt/kpjoy/kpjoy.drv; scripts/lib/win16Gate.sh installs it in +# each run's Windows as MMSYSTEM's joystick driver. + +compDescription="win16: KPJOY.DRV joystick driver for its Windows, built from ../kpjoy (KPJOY_DIR)" +compGroups="emulator:win16" +compRequires="openWatcom" +compHostDeps="make" + +# shellcheck source=installer/lib/winDriver.sh +source "$JOEY_INSTALLER/lib/winDriver.sh" + +kpjoyDir="${KPJOY_DIR:-$(cd "$JOEY_REPO/.." && pwd)/kpjoy}" +compPin="dir:$kpjoyDir:$(winDriverPin "$kpjoyDir")" + + +compInstall() { + winDriverBuild "$kpjoyDir" Makefile kpjoy.drv +} + + +compInstalled() { + [ -s "$compDir/kpjoy.drv" ] +} + + +compVerify() { + # A Windows module whose name is KPJOY. + [ "$(head -c 2 "$compDir/kpjoy.drv")" = "MZ" ] && LC_ALL=C grep -q -a 'KPJOY' "$compDir/kpjoy.drv" +} + + +compEnv() { + envVar KPJOY_DRV "$compDir/kpjoy.drv" +} diff --git a/installer/components/libxmp.sh b/installer/components/libxmp.sh index cf9ad0f..52d4b6e 100644 --- a/installer/components/libxmp.sh +++ b/installer/components/libxmp.sh @@ -1,8 +1,13 @@ -# libxmp-lite 4.7.0: the MOD decoder the DOS, Atari ST and X68000 audio -# HALs compile in. Each port's makefile builds it from this source tree. +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# libxmp-lite 4.7.0: the MOD decoder the DOS, Atari ST, X68000, SDL3, PSP, +# web, Win16, Win32, Haiku and BeOS audio HALs compile in. Each port's +# makefile builds it from this source tree. -compDescription="audio: libxmp-lite 4.7.0 source (DOS, Atari ST, X68000)" -compGroups="toolchain:dos toolchain:atarist toolchain:x68000" +compDescription="audio: libxmp-lite 4.7.0 source (DOS, Atari ST, X68000, SDL3, PSP, web, Win16, Win32, Haiku, BeOS)" +compGroups="toolchain:dos toolchain:atarist toolchain:x68000 toolchain:sdl3 toolchain:psp toolchain:web toolchain:win16 toolchain:win32 toolchain:haiku toolchain:haiku32 toolchain:beos" compRequires="host" compHostDeps="tar" compUrl="https://github.com/libxmp/libxmp/releases/download/libxmp-4.7.0/libxmp-lite-4.7.0.tar.gz" diff --git a/installer/components/llvm816.sh b/installer/components/llvm816.sh index d3ff5b5..e88121e 100644 --- a/installer/components/llvm816.sh +++ b/installer/components/llvm816.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Apple IIgs C toolchain: llvm816, a W65816 backend for llvm-mos, plus its # linker (link816 + omfEmit) and runtime. Cloned at a pinned commit and # built by the repo's own setup.sh; the first build is ~30-60 minutes. diff --git a/installer/components/llvmMingw.sh b/installer/components/llvmMingw.sh new file mode 100644 index 0000000..edec261 --- /dev/null +++ b/installer/components/llvmMingw.sh @@ -0,0 +1,42 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# llvm-mingw 20260922, msvcrt flavour: the cross compiler the win32 port +# (make win32) builds Windows programs with -- clang, lld and the mingw-w64 +# headers and libraries, prebuilt for Linux x86-64. The msvcrt flavour links +# against MSVCRT.DLL, the C library every Windows since 95 OSR2 carries (the +# ucrt one needs Windows 10's), so a program runs on the old Windows the +# port aims at. Programs are 32-bit (i686). + +compDescription="win32: llvm-mingw 20260922 (msvcrt, i686 target)" +compGroups="toolchain:win32" +compRequires="host" +compHostDeps="tar xz" +compUrl="https://github.com/mstorsjo/llvm-mingw/releases/download/20260922/llvm-mingw-20260922-msvcrt-ubuntu-22.04-x86_64.tar.xz" +compSha256="17bbc667b96f7b1f02ce4a172d8cac4fa480927a1f649e788dee5914919566a1" +compPin="$compSha256" + + +compInstall() { + local archive + archive="$(fetchFile "$compUrl" "$compSha256")" + rm -rf "$compDir" + extractArchive "$archive" "$compDir" 1 +} + + +compInstalled() { + [ -x "$compDir/bin/i686-w64-mingw32-clang" ] +} + + +compVerify() { + verifyCompiles "$compDir/bin/i686-w64-mingw32-clang" +} + + +compEnv() { + envVar WIN32_CC "$compDir/bin/i686-w64-mingw32-clang" + envVar WIN32_AR "$compDir/bin/i686-w64-mingw32-ar" +} diff --git a/installer/components/mame.sh b/installer/components/mame.sh index cbe277f..72cf7e7 100644 --- a/installer/components/mame.sh +++ b/installer/components/mame.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # MAME 0.264 with JoeyLib's patches (installer/patches/mame), built from # source as one binary holding only the two drivers the ports need: apple2gs # (Apple IIgs) and x68k (Sharp X68000). Always built, never taken from the diff --git a/installer/components/merlin32.sh b/installer/components/merlin32.sh index 44ce4cf..be11840 100644 --- a/installer/components/merlin32.sh +++ b/installer/components/merlin32.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Merlin32 1.1: 65816 assembler, used only for the NinjaTrackerPlus audio # replayer (third-party Merlin source). The release zip carries a prebuilt # x86-64 Linux binary. diff --git a/installer/components/mintGcc.sh b/installer/components/mintGcc.sh index 939eec9..0d0bb13 100644 --- a/installer/components/mintGcc.sh +++ b/installer/components/mintGcc.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Atari ST C toolchain: Vincent Riviere's m68k-atari-mint gcc 15.2 + # binutils 2.45, with MiNTLib 0.60.1, as prebuilt Linux x86-64 tarballs # from Thorsten Otto's mirror. Each tarball is rooted at usr/, so stripping diff --git a/installer/components/nasm.sh b/installer/components/nasm.sh index a14d369..978d718 100644 --- a/installer/components/nasm.sh +++ b/installer/components/nasm.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # NASM: assembler for the DOS port's x86 sources. Any 2.x release works, # so it comes from the distribution. diff --git a/installer/components/ntp.sh b/installer/components/ntp.sh index 42f3172..779e560 100644 --- a/installer/components/ntp.sh +++ b/installer/components/ntp.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # NinjaTrackerPlus: Ninjaforce's IIgs MOD replayer, shipped as Merlin # source plus the PHP converter tools/joeymod uses for .MOD -> .NTP. # Redistribution is granted by the copyright holder. The URL is not diff --git a/installer/components/openWatcom.sh b/installer/components/openWatcom.sh new file mode 100644 index 0000000..9743fa5 --- /dev/null +++ b/installer/components/openWatcom.sh @@ -0,0 +1,59 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Open Watcom v2, the 2026-10-01 build for Linux x86-64: the compiler the +# win16 port (make win16) builds Windows 3.x programs with. Its Win386 +# target compiles 32-bit flat-model C and binds it to Watcom's Windows +# supervisor (WIN386.EXT), which makes it a Windows 3.x NE executable that +# calls the 16-bit Windows API through the supervisor's covers. Its 16-bit +# Windows target builds the Windows 3.x drivers the win16 port's Windows +# runs with (components kpjoy and kpcomm). The release is the +# self-extracting installer, which is a zip archive; only the Linux tools, +# the headers, the 32- and 16-bit libraries and the supervisor are unpacked. + +compDescription="win16: Open Watcom v2 2026-10-01 (Win386 and 16-bit Windows targets)" +compGroups="toolchain:win16" +compRequires="host" +compHostDeps="unzip" +compUrl="https://github.com/open-watcom/open-watcom-v2/releases/download/2026-10-01-Build/open-watcom-2_0-c-linux-x64" +compSha256="a961f3e02ce27bcd88428345dc57483457a623b6e718b6cee52c48d24ab8065f" +compPin="$compSha256" + + +compInstall() { + local archive + local file + # Every dated build's file has the same name; the cached copy carries + # the date, and .zip says how to unpack it. + archive="$(fetchFile "$compUrl" "$compSha256" open-watcom-2_0-c-linux-x64-2026-10-01.zip)" + rm -rf "$compDir" + mkdir -p "$compDir" + unzip -oq "$archive" 'binl64/*' 'binw/win386.ext' 'h/*' 'lib386/*' 'lib286/*' -d "$compDir" + # The zip keeps no permissions: the ELF programs among binl64's files + # are made executable. + for file in "$compDir"/binl64/*; do + if [ "$(head -c 4 "$file" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ]; then + chmod 755 "$file" + fi + done +} + + +compInstalled() { + [ -x "$compDir/binl64/owcc" ] && [ -x "$compDir/binl64/wbind" ] && [ -f "$compDir/binw/win386.ext" ] && + [ -f "$compDir/lib286/win/windows.lib" ] +} + + +compVerify() { + # owcc finds its compilers and its specs.owc on the PATH. + export WATCOM="$compDir" + export PATH="$compDir/binl64:$PATH" + verifyCompiles owcc -bwin386 "-I$compDir/h" +} + + +compEnv() { + envVar WIN16_WATCOM "$compDir" +} diff --git a/installer/components/ppsspp.sh b/installer/components/ppsspp.sh new file mode 100644 index 0000000..3303e05 --- /dev/null +++ b/installer/components/ppsspp.sh @@ -0,0 +1,73 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# PPSSPP 1.20.4 with JoeyLib's patch (installer/patches/ppsspp): the PSP +# emulator the psp gates and joey-run run programs under. Only its headless +# test runner, PPSSPPHeadless, is built (no window, no sound card), from the +# release's source tarball, which carries its submodules, into +# toolchains/opt/ppsspp. ~5 minutes. + +compDescription="emulator: PPSSPP 1.20.4 headless + JoeyLib patch (PSP)" +compGroups="emulator:psp" +compRequires="host" +compHostDeps="build-tools cmake python3 patch xz sdl2-dev gl-dev png-dev zlib-dev" +compUrl="https://github.com/hrydgard/ppsspp/releases/download/v1.20.4/ppsspp-1.20.4.tar.xz" +compSha256="4de21db3105d9d81a465a8a7e78c68ee3c0e0bf6597d1c1d530f7555f3ad8b31" +patchDir="$JOEY_PATCHES/ppsspp" +# Editing a patch rebuilds PPSSPP, so the patches are part of the pin. +compPin="$compSha256 $(cat "$patchDir"/*.patch | sha256sum | cut -d' ' -f1)" + + +compInstall() { + local src + local archive + local p + src="$(freshBuildDir "$compId")" + archive="$(fetchFile "$compUrl" "$compSha256")" + extractArchive "$archive" "$src" 1 + for p in "$patchDir"/*.patch; do + info "applying $(basename "$p")" + patch -d "$src" -p1 -s -F0 --no-backup-if-mismatch < "$p" + done + # No FFmpeg (the tarball has no prebuilt one; JoeyLib programs play no + # PSP video), no GLU headers (GLEW_NO_GLU), and no ccache, which would + # write to the home directory. + cmake -S "$src" -B "$src/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DHEADLESS=ON \ + -DUNITTEST=OFF \ + -DUSING_QT_UI=OFF \ + -DUSE_CCACHE=OFF \ + -DUSE_DISCORD=OFF \ + -DUSE_FFMPEG=OFF \ + -DUSE_MINIUPNPC=OFF \ + -DCMAKE_C_FLAGS=-DGLEW_NO_GLU \ + -DCMAKE_CXX_FLAGS=-DGLEW_NO_GLU + cmake --build "$src/build" --target PPSSPPHeadless -j"$JOEY_JOBS" + rm -rf "$compDir" + install -D -m 755 "$src/build/PPSSPPHeadless" "$compDir/bin/PPSSPPHeadless" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -x "$compDir/bin/PPSSPPHeadless" ] +} + + +# The usage text lists the patch's options; HOME is a scratch folder, as +# PPSSPP keeps its memory stick there. +compVerify() { + local tmp + local usage + tmp="$(mktemp -d)" + usage="$(HOME="$tmp" "$compDir/bin/PPSSPPHeadless" --help 2>&1 || true)" + rm -rf "$tmp" + [[ "$usage" == *"--strict-mem"* && "$usage" == *"--snapshot=FILE"* && "$usage" == *"--dump-audio"* && "$usage" == *"--input=FILE"* ]] && verifyLinked "$compDir/bin" +} + + +compEnv() { + envVar PPSSPP_HEADLESS "$compDir/bin/PPSSPPHeadless" +} diff --git a/installer/components/pspdev.sh b/installer/components/pspdev.sh new file mode 100644 index 0000000..7609d4f --- /dev/null +++ b/installer/components/pspdev.sh @@ -0,0 +1,54 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# pspdev v20261001: the PSP homebrew toolchain the psp port (make psp) +# builds with -- psp-gcc, the PSPSDK, newlib, and the tools that make an +# EBOOT.PBP (mksfo, pack-pbp, psp-fixup-imports) -- prebuilt by the pspdev +# project for each distribution family, unpacked into toolchains/opt/pspdev. +# Fedora gets the Fedora build, Debian the Debian one and every other apt +# system (Ubuntu, Mint) the Ubuntu one: each needs its own glibc. + +compDescription="psp: pspdev v20261001 (psp-gcc, PSPSDK)" +compGroups="toolchain:psp" +compRequires="host" +compHostDeps="tar" +pspdevRelease="v20261001" +pspdevHostId="$( (. /etc/os-release 2>/dev/null && printf '%s' "${ID:-}") || true)" +if [ "$(distroFamily)" = "dnf" ]; then + pspdevFlavour="fedora-latest" + compSha256="7874d4d696f1abe58d3d17fd8ae365e9b129c4bea16b383bf708655037c0e21c" +elif [ "$pspdevHostId" = "debian" ]; then + pspdevFlavour="debian-latest" + compSha256="39b20efc7c8492cdbd9929178f45afc801dabb0967b7c882573520f22536920c" +else + pspdevFlavour="ubuntu-latest-x86_64" + compSha256="a86efe624e770005290919617859e9df24bd8fcd3e364f4587f23ec7364b0acd" +fi +compUrl="https://github.com/pspdev/pspdev/releases/download/$pspdevRelease/pspdev-$pspdevFlavour.tar.gz" +compPin="$compSha256" + + +compInstall() { + local archive + # Every flavour's file has the same name: cache it under the release's. + archive="$(fetchFile "$compUrl" "$compSha256" "pspdev-$pspdevRelease-$pspdevFlavour.tar.gz")" + rm -rf "$compDir" + extractArchive "$archive" "$compDir" 1 +} + + +compInstalled() { + [ -x "$compDir/bin/psp-gcc" ] && [ -x "$compDir/bin/pack-pbp" ] +} + + +compVerify() { + PSPDEV="$compDir" verifyCompiles "$compDir/bin/psp-gcc" && verifyLinked "$compDir/bin" +} + + +compEnv() { + envVar PSPDEV "$compDir" + envVar PSP_CC "$compDir/bin/psp-gcc" +} diff --git a/installer/components/ptplayer.sh b/installer/components/ptplayer.sh index bbf3003..0c164a4 100644 --- a/installer/components/ptplayer.sh +++ b/installer/components/ptplayer.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # PTPlayer 6.4: Frank Wille's ProTracker replayer for the Amiga, used by # the Amiga audio HAL. Aminet's URL is not versioned, so the pin is the # archive's checksum. diff --git a/installer/components/qemu.sh b/installer/components/qemu.sh new file mode 100644 index 0000000..87b6a5e --- /dev/null +++ b/installer/components/qemu.sh @@ -0,0 +1,87 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# QEMU 11.1.2: the PC emulator the Haiku ports (src/hosted/be) are run and +# gated in, x86_64 and i386 system emulation only, with KVM when /dev/kvm is +# usable. Built from the release source into toolchains/opt/qemu, with +# pixman 0.46.4 (its display library, which distributions split into a -dev +# package that may be missing) built first as a static library and linked +# in. SDL2 gives joey-run its window; the wav audio backend the audio gate +# records through is always built in. + +compDescription="emulator: QEMU 11.1.2 + pixman 0.46.4 (Haiku, BeOS)" +compGroups="emulator:haiku emulator:haiku32 emulator:beos" +compRequires="host" +compHostDeps="build-tools pkg-config python3 python3-venv ninja meson glib-dev zlib-dev sdl2-dev pulse-dev asound-dev" +compUrl="https://download.qemu.org/qemu-11.1.2.tar.xz" +compSha256="731b5681e4bb18be313231579b8efd0296c5b015fa36dc533874b639ba838016" +pixmanUrl="https://cairographics.org/releases/pixman-0.46.4.tar.xz" +pixmanSha256="a098c33924754ad43f981b740f6d576c70f9ed1006e12221b1845431ebce1239" +compPin="$compSha256:$pixmanSha256" + + +compInstall() { + local archive + local pixmanArchive + local src + local pixmanSrc + local pixmanPrefix + archive="$(fetchFile "$compUrl" "$compSha256")" + pixmanArchive="$(fetchFile "$pixmanUrl" "$pixmanSha256")" + src="$(freshBuildDir "$compId")" + pixmanSrc="$src/pixman" + pixmanPrefix="$src/pixman-install" + extractArchive "$pixmanArchive" "$pixmanSrc" 1 + meson setup "$pixmanSrc/build" "$pixmanSrc" \ + --prefix="$pixmanPrefix" \ + --libdir=lib \ + --buildtype=release \ + -Ddefault_library=static \ + -Dtests=disabled \ + -Ddemos=disabled \ + -Dgtk=disabled \ + -Dlibpng=disabled + ninja -C "$pixmanSrc/build" -j"$JOEY_JOBS" install + extractArchive "$archive" "$src/qemu" 1 + ( + cd "$src/qemu" + PKG_CONFIG_PATH="$pixmanPrefix/lib/pkgconfig" ./configure \ + --prefix="$compDir" \ + --target-list=x86_64-softmmu,i386-softmmu \ + --enable-kvm \ + --enable-sdl \ + --disable-gtk \ + --disable-docs \ + --disable-werror \ + --disable-guest-agent \ + --audio-drv-list=pa,alsa,sdl + make -j"$JOEY_JOBS" + rm -rf "$compDir" + make install + ) + dropBuildDir "$compId" +} + + +compInstalled() { + [ -x "$compDir/bin/qemu-system-x86_64" ] && [ -x "$compDir/bin/qemu-system-i386" ] && [ -x "$compDir/bin/qemu-img" ] +} + + +# Both emulators start and know the devices the Haiku VMs use: the HD audio +# controller and codec, and the VGA card. +compVerify() { + local machine + local devices + for machine in x86_64 i386; do + devices="$("$compDir/bin/qemu-system-$machine" -device help 2>&1)" || return 1 + [[ "$devices" == *'"intel-hda"'* && "$devices" == *'"hda-duplex"'* && "$devices" == *'"VGA"'* ]] || return 1 + done + "$compDir/bin/qemu-system-x86_64" -audiodev help 2>&1 | grep -qx wav +} + + +compEnv() { + envVar QEMU_DIR "$compDir" +} diff --git a/installer/components/sdl3.sh b/installer/components/sdl3.sh new file mode 100644 index 0000000..8dc492d --- /dev/null +++ b/installer/components/sdl3.sh @@ -0,0 +1,68 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# SDL 3.4.18: the library the sdl3 port (src/hosted/sdl3) opens its window, +# reads input and plays audio through. Distributions package SDL3 too late +# or not at all (Linux Mint 22.3 has only SDL2), so it is built from the +# release source as a static library into toolchains/opt/sdl3; the programs +# link it in and run without an SDL3 on the machine. SDL loads the X11, +# Wayland, PulseAudio, PipeWire and ALSA client libraries at run time, so +# their headers must be present when it is built or those backends are left +# out (the dummy video and audio drivers the gates use are always there). + +compDescription="hosted: SDL 3.4.18, static (sdl3 port)" +compGroups="toolchain:sdl3" +compRequires="host" +compHostDeps="build-tools cmake pkg-config x11-dev xext-dev xi-dev xrandr-dev xcursor-dev xfixes-dev xss-dev xkbcommon-dev wayland-dev wayland-protocols decor-dev pulse-dev asound-dev pipewire-dev udev-dev dbus-dev drm-dev gbm-dev egl-dev gl-dev gles-dev" +compUrl="https://github.com/libsdl-org/SDL/releases/download/release-3.4.18/SDL3-3.4.18.tar.gz" +compSha256="9c75cf16330322c217dedd2e0609f1124f1b54b8633e763467b4684d0f4334a3" +compPin="$compSha256" + + +compInstall() { + local archive + local src + archive="$(fetchFile "$compUrl" "$compSha256")" + src="$(freshBuildDir "$compId")" + extractArchive "$archive" "$src" 1 + cmake -S "$src" -B "$src/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$compDir" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DSDL_SHARED=OFF \ + -DSDL_STATIC=ON \ + -DSDL_TESTS=OFF \ + -DSDL_EXAMPLES=OFF + cmake --build "$src/build" -j"$JOEY_JOBS" + rm -rf "$compDir" + cmake --install "$src/build" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -f "$compDir/lib/libSDL3.a" ] && [ -f "$compDir/lib/pkgconfig/sdl3.pc" ] +} + + +# Link a program against the static library the way make/sdl3.mk does and +# run it: it prints the version of the SDL it carries. +compVerify() { + local tmp + local status=1 + tmp="$(mktemp -d)" + printf '#include \n#include \nint main(void) { int v = SDL_GetVersion(); printf("%%d.%%d.%%d\\n", SDL_VERSIONNUM_MAJOR(v), SDL_VERSIONNUM_MINOR(v), SDL_VERSIONNUM_MICRO(v)); return 0; }\n' > "$tmp/t.c" + # shellcheck disable=SC2046 + if cc "$tmp/t.c" -o "$tmp/t" $(PKG_CONFIG_PATH="$compDir/lib/pkgconfig" pkg-config --cflags --static --libs sdl3) \ + && [ "$("$tmp/t")" = "3.4.18" ]; then + status=0 + fi + rm -rf "$tmp" + return $status +} + + +compEnv() { + envVar SDL3_DIR "$compDir" +} diff --git a/installer/components/sdl3Psp.sh b/installer/components/sdl3Psp.sh new file mode 100644 index 0000000..9635312 --- /dev/null +++ b/installer/components/sdl3Psp.sh @@ -0,0 +1,55 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# SDL 3 built with pspdev, static, for the psp port (make psp): the same +# release source the sdl3 component builds for the host (its pin is read from +# sdl3.sh, so there is one), cross-compiled with pspdev's CMake toolchain +# file into toolchains/opt/sdl3Psp. pspdev carries an SDL3 of its own; the +# port links this one, so every SDL3 port runs the same SDL. + +compDescription="psp: SDL 3 for the PSP, static (psp port)" +compGroups="toolchain:psp" +compRequires="host pspdev" +compHostDeps="cmake" +sdl3Url="$(compQuery sdl3 compUrl)" +sdl3Sha256="$(compQuery sdl3 compSha256)" +compPin="$sdl3Sha256:$(compQuery pspdev compPin)" + + +compInstall() { + local archive + local src + archive="$(fetchFile "$sdl3Url" "$sdl3Sha256")" + src="$(freshBuildDir "$compId")" + extractArchive "$archive" "$src" 1 + export PSPDEV="$JOEY_OPT/pspdev" + cmake -S "$src" -B "$src/build" \ + -DCMAKE_TOOLCHAIN_FILE="$PSPDEV/psp/share/pspdev.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$compDir" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DSDL_SHARED=OFF \ + -DSDL_STATIC=ON \ + -DSDL_TESTS=OFF \ + -DSDL_EXAMPLES=OFF + cmake --build "$src/build" -j"$JOEY_JOBS" + rm -rf "$compDir" + cmake --install "$src/build" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -f "$compDir/lib/libSDL3.a" ] && [ -f "$compDir/include/SDL3/SDL.h" ] +} + + +compVerify() { + compInstalled +} + + +compEnv() { + envVar SDL3_PSP_DIR "$compDir" +} diff --git a/installer/components/sdl3Web.sh b/installer/components/sdl3Web.sh new file mode 100644 index 0000000..58863ef --- /dev/null +++ b/installer/components/sdl3Web.sh @@ -0,0 +1,63 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# SDL 3 built with Emscripten, static, for the web port (make web): the same +# release source the sdl3 component builds for the host (its pin is read from +# sdl3.sh, so there is one), with JoeyLib's patches for SDL's Emscripten code +# (installer/patches/sdl3Web), compiled to WebAssembly into +# toolchains/opt/sdl3Web. + +compDescription="web: SDL 3 for Emscripten, static (web port)" +compGroups="toolchain:web" +compRequires="host emsdk" +compHostDeps="cmake patch" +sdl3Url="$(compQuery sdl3 compUrl)" +sdl3Sha256="$(compQuery sdl3 compSha256)" +patchDir="$JOEY_PATCHES/sdl3Web" +# Editing a patch rebuilds SDL, so the patches are part of the pin. +compPin="$sdl3Sha256:$(compQuery emsdk compPin):$(cat "$patchDir"/*.patch | sha256sum | cut -d' ' -f1)" + + +compInstall() { + local archive + local src + local p + local emsdk="$JOEY_OPT/emsdk" + archive="$(fetchFile "$sdl3Url" "$sdl3Sha256")" + src="$(freshBuildDir "$compId")" + extractArchive "$archive" "$src" 1 + for p in "$patchDir"/*.patch; do + info "applying $(basename "$p")" + patch -d "$src" -p1 -s -F0 --no-backup-if-mismatch < "$p" + done + export EM_CONFIG="$emsdk/.emscripten" + export EM_CACHE="$emsdk/cache" + "$emsdk/upstream/emscripten/emcmake" cmake -S "$src" -B "$src/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$compDir" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DSDL_SHARED=OFF \ + -DSDL_STATIC=ON \ + -DSDL_TESTS=OFF \ + -DSDL_EXAMPLES=OFF + cmake --build "$src/build" -j"$JOEY_JOBS" + rm -rf "$compDir" + cmake --install "$src/build" + dropBuildDir "$compId" +} + + +compInstalled() { + [ -f "$compDir/lib/libSDL3.a" ] && [ -f "$compDir/include/SDL3/SDL.h" ] +} + + +compVerify() { + compInstalled +} + + +compEnv() { + envVar SDL3_WEB_DIR "$compDir" +} diff --git a/installer/components/win311.sh b/installer/components/win311.sh new file mode 100644 index 0000000..bec5c31 --- /dev/null +++ b/installer/components/win311.sh @@ -0,0 +1,41 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Windows for Workgroups 3.11, set up unattended on a DOSBox Staging PC with +# the S3 Trio64V+ driver at 800x600 in 256 colours and Windows' own Sound +# Blaster driver (installer/lib/win16.sh): what the win16 port's programs +# run on for joey-run and the gates (scripts/lib/win16Gate.sh). Made from +# your own install disc and S3 driver disk -- private files, never +# downloaded: ./install.sh import PATH (the ISO and the driver's .zip) or +# the private bundle. Into toolchains/opt/win311/c, drive C:; runs copy it. + +compDescription="emulator: Windows for Workgroups 3.11 on DOSBox (win16; private media: import)" +compGroups="emulator:win16" +compRequires="dosbox" +compHostDeps="python3 xvfb" + +# shellcheck source=installer/lib/win16.sh +source "$JOEY_INSTALLER/lib/win16.sh" + +compPin="$(win16Pin)" + + +compInstall() { + win16Prepare "$compDir" +} + + +compInstalled() { + [ -f "$compDir/c/WINDOWS/WIN.COM" ] +} + + +compVerify() { + win16Verify "$compDir/c" +} + + +compEnv() { + envVar WIN16_C "$compDir/c" +} diff --git a/installer/components/wine.sh b/installer/components/wine.sh new file mode 100644 index 0000000..3a78e2c --- /dev/null +++ b/installer/components/wine.sh @@ -0,0 +1,42 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Wine 11.19 (Kron4ek's portable build, new WoW64 mode): runs the win32 +# port's programs for joey-run and the gates. Relocatable, so it is unpacked +# into toolchains/opt/wine and needs no system install; the WoW64 build runs +# 32-bit programs without any 32-bit Linux libraries. Every run uses a +# private Wine prefix (WINEPREFIX) in a temporary directory, never ~/.wine. + +compDescription="emulator: Wine 11.19 portable (win32 port)" +compGroups="emulator:win32" +compRequires="host" +compHostDeps="tar xz" +compUrl="https://github.com/Kron4ek/Wine-Builds/releases/download/11.19/wine-11.19-amd64-wow64.tar.xz" +compSha256="a1fe858b45d12c9b16b91c7601d9b934f68430f7b6b95e240212ab6e2a198d8a" +compPin="$compSha256" + + +compInstall() { + local archive + archive="$(fetchFile "$compUrl" "$compSha256")" + rm -rf "$compDir" + extractArchive "$archive" "$compDir" 1 +} + + +compInstalled() { + [ -x "$compDir/bin/wine" ] +} + + +compVerify() { + local version + version="$("$compDir/bin/wine" --version 2>/dev/null || true)" + [[ "$version" == wine-11.19* ]] +} + + +compEnv() { + envVar WINE "$compDir/bin/wine" +} diff --git a/installer/components/x68kCgrom.sh b/installer/components/x68kCgrom.sh index 1606cdc..6c35df9 100644 --- a/installer/components/x68kCgrom.sh +++ b/installer/components/x68kCgrom.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # X68000 character-generator (font) ROM. Not part of Sharp's free release, # so it comes from the private bundle or `install.sh import`. MAME's x68000 # driver will not start without it (it warns about the checksum of this diff --git a/installer/components/x68kFirmware.sh b/installer/components/x68kFirmware.sh index 5496f9e..06d9e36 100644 --- a/installer/components/x68kFirmware.sh +++ b/installer/components/x68kFirmware.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Sharp X68000 firmware from Sharp's own free release (2000, published by # the X68000 LIBRARY): the original IPL-ROM (all `-bios ipl10` needs) and # the Human68k 3.02 system disk. The CG (font) ROM is not part of that diff --git a/installer/distroPackages.txt b/installer/distroPackages.txt index fd71d34..fac6568 100644 --- a/installer/distroPackages.txt +++ b/installer/distroPackages.txt @@ -73,6 +73,24 @@ xext-dev libxext-dev libXext-devel xinerama-dev libxinerama-dev libXinerama-devel gl-dev libgl-dev mesa-libGL-devel +# ---- SDL3 build (the sdl3 port): the headers of every client library SDL +# loads at run time, or it leaves that video/audio backend out +xrandr-dev libxrandr-dev libXrandr-devel +xcursor-dev libxcursor-dev libXcursor-devel +xfixes-dev libxfixes-dev libXfixes-devel +xss-dev libxss-dev libXScrnSaver-devel +xkbcommon-dev libxkbcommon-dev libxkbcommon-devel +wayland-dev libwayland-dev wayland-devel +wayland-protocols wayland-protocols wayland-protocols-devel +decor-dev libdecor-0-dev libdecor-devel +pipewire-dev libpipewire-0.3-dev pipewire-devel +udev-dev libudev-dev systemd-devel +dbus-dev libdbus-1-dev dbus-devel +drm-dev libdrm-dev libdrm-devel +gbm-dev libgbm-dev mesa-libgbm-devel +egl-dev libegl-dev mesa-libEGL-devel +gles-dev libgles-dev mesa-libGLES-devel + # ---- emulators packaged by the distribution hatari hatari hatari fs-uae fs-uae - @@ -89,3 +107,8 @@ zip zip zip glib-dev libglib2.0-dev glib2-devel openal-dev libopenal-dev openal-soft-devel png-dev libpng-dev libpng-devel + +# ---- QEMU and pixman builds (the Haiku ports' emulator); QEMU's configure +# makes a Python venv for its own meson +meson meson meson +python3-venv python3-venv python3 diff --git a/installer/files.sha1 b/installer/files.sha1 index 5a18873..80eb868 100644 --- a/installer/files.sha1 +++ b/installer/files.sha1 @@ -5,5 +5,13 @@ # # sha1 destination (repository-relative) what it is 3b7f1493b27e212830f989f26ca76c02049f09ca toolchains/firmware/amiga/kickstart.rom Amiga Kickstart 3.1 r40.63 (A500/A600/A2000) +e21545723fe8374e91342617604f1b3d703094f1 toolchains/firmware/amiga/kickstartA1200.rom Amiga Kickstart 3.1 r40.68 (A1200) +b0ec8b84d6768321e01209f11e6248f2f5281a21 toolchains/firmware/amiga/kickstartA4000.rom Amiga Kickstart 3.1 r40.70 (A4000T) d814d6ce8efb4a87966dae581eddf4dca528094a toolchains/firmware/amiga/Devs/serial.device Amiga serial.device 37.4 (Workbench 3.1 Devs/) +be25df4493d2d7bf296e640c2eb878779c7c904e toolchains/firmware/amiga/Libs/lowlevel.library Amiga lowlevel.library 40.35 (Workbench 3.1 Libs/) 8d72c5b4d63bb14c5dbdac495244d659aa1498b6 toolchains/firmware/x68000/roms/x68000/cgrom.dat X68000 character-generator (font) ROM +5b78b2227b5144236f082c6594b988c3198c7fc4 toolchains/firmware/win16/wfw311.iso Windows for Workgroups 3.11 install disc (ISO, 11238971 bytes) +6cab1d3f694b75cb185273619e2bfa4484d4b051 toolchains/firmware/win16/s3/S3TRIO.DRV S3 Trio64V+ 1.70.04 Windows 3.1 display driver +f432e53217965941e69a65345b8c2f9c88c1a1bc toolchains/firmware/win16/s3/VDDS3764.386 S3 Trio64V+ 1.70.04 virtual display device +da1dc6518baa8f91de1210dd2630b6fec19d76f2 toolchains/firmware/win16/s3/VGA_ENG.3GR S3 Trio64V+ 1.70.04 DOS-box grabber +2158c54a8be0a979e4c551b58fbacc0c89e84fd4 toolchains/firmware/beos/beos5pro.img BeOS 5 Professional install CD (CloneCD .img, raw sectors, 717056592 bytes) diff --git a/installer/lib/beos.sh b/installer/lib/beos.sh new file mode 100644 index 0000000..118b70b --- /dev/null +++ b/installer/lib/beos.sh @@ -0,0 +1,137 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# BeOS R5 from your own BeOS 5 Professional CD (a private file, never +# downloaded: ./install.sh import the CloneCD .img, or the .7z holding it), +# shared by the components that make something of it: beosR5 (the headers +# and libraries make beos builds against) and beosVm (the machine its +# programs run in). Source from a component. +# +# The CD is three Mode 1 data tracks: the ISO 9660 one that boots a BeOS +# floppy image (El Torito), the x86 BeOS system as a BFS volume ("BeOS 5 Pro +# Edition"), and the PowerPC one. The x86 track is read with Haiku's +# bfs_shell (component haikuTools), run in a user namespace of its own +# (unshare -r): it makes each file it copies out with the file's BeOS mode +# before writing it, and a read-only one could not be written otherwise. +# +# The VM (beosVmPrepare) boots the CD's floppy image, which boots the first +# BFS system volume it finds: a copy of the x86 track, on the machine's +# first disk, with the job runner as its UserBootscript. The runner is the +# Haiku VMs' (installer/lib/haiku.sh), so the gates drive both alike: a disk +# labelled HAIKU_JOB_VOLUME holding job.sh (Tracker mounts it), job.sh run +# there with its output in job.log, then the machine powered off (the +# kernel's apm and enable_shutdown settings), so QEMU exits. R5 has no +# serial port to say so on: its serial driver finds the ports through the +# ISA plug and play bus, which is taken out (below). Changes to the system: +# - the ISA plug and play bus (busses/config_manager/isa) is removed: in +# the fail-safe (VESA) video mode it crashes into the kernel debugger +# the first time a driver asks the configuration manager for a device. +# QEMU's PC has no ISA plug and play cards for it to find. +# - the swap file and unsupported graphics card alerts are switched off. +# +# beosBfs IMAGE run the bfs_shell commands on stdin against IMAGE +# beosJobRunner the job runner (the VM's UserBootscript) +# beosSystemVolume OUT the x86 track as a BFS volume image +# beosVmPin what the VM is rebuilt for: the CD and the steps +# beosVmPrepare DIR the VM's disks: DIR/system.img and DIR/floppy.img +# beosVmVerify DIR the job runner is on DIR's system disk + +# shellcheck source=installer/lib/haiku.sh +source "$JOEY_INSTALLER/lib/haiku.sh" + +BEOS_CD_FILE="toolchains/firmware/beos/beos5pro.img" +# The x86 track's first sector and length, from the CD's table of contents +# (the image is pinned by its checksum, so they cannot move). +BEOS_SYSTEM_TRACK_FIRST=35109 +BEOS_SYSTEM_TRACK_SECTORS=148780 +BEOS_RAW_CD_TOOL="tools/rawCd.py" +# The VM's disks, in the component's folder. +BEOS_VM_SYSTEM_DISK="system.img" +BEOS_VM_FLOPPY="floppy.img" +# Seconds the runner waits for Tracker to mount the job disk. +BEOS_JOB_WAIT_SECONDS=30 +# The kernel's settings: power off at shutdown. +BEOS_KERNEL_SETTINGS="apm true +enable_shutdown true" +BEOS_ISA_BUS="beos/system/add-ons/kernel/busses/config_manager/isa" +BEOS_NAG_SETTINGS="stop_swap_nagging stop_vga_nagging" + + +beosBfs() { + unshare -r true 2> /dev/null || + die "reading the BeOS volume needs user namespaces (unshare -r), which this system does not allow" + { cat; printf 'quit\n'; } | unshare -r "$JOEY_OPT/haikuTools/bin/bfs_shell" "$1" +} + + +# beosJobRunner -- the UserBootscript the VM runs at every boot (after R5's +# Bootscript has started the servers, Tracker and Deskbar). +beosJobRunner() { + cat < job.log 2>&1 +cd / +sync +shutdown -q +EOF +} + + +beosSystemVolume() { + privateEnsure "$BEOS_CD_FILE" + python3 "$JOEY_REPO/$BEOS_RAW_CD_TOOL" track "$JOEY_REPO/$BEOS_CD_FILE" "$BEOS_SYSTEM_TRACK_FIRST" "$BEOS_SYSTEM_TRACK_SECTORS" "$1" || + die "could not read the x86 track of $BEOS_CD_FILE" +} + + +beosVmPin() { + local steps + steps="$({ beosJobRunner; printf '%s\n' "$BEOS_KERNEL_SETTINGS" "$BEOS_ISA_BUS" "$BEOS_NAG_SETTINGS"; declare -f beosSystemVolume beosVmPrepare; } | sha256sum | cut -d' ' -f1)" + printf '%s:%s\n' "$(privateSha1For "$BEOS_CD_FILE")" "$steps" +} + + +beosVmPrepare() { + local dir="$1" + local part="$1.part" + local nag + rm -rf "$part" + mkdir -p "$part" + beosSystemVolume "$part/$BEOS_VM_SYSTEM_DISK" + python3 "$JOEY_REPO/$BEOS_RAW_CD_TOOL" eltorito "$JOEY_REPO/$BEOS_CD_FILE" "$part/$BEOS_VM_FLOPPY" || + die "could not read the boot floppy image of $BEOS_CD_FILE" + beosJobRunner > "$part/UserBootscript" + printf '%s\n' "$BEOS_KERNEL_SETTINGS" > "$part/kernel" + : > "$part/empty" + { + printf 'cp :%s /myfs/home/config/boot/UserBootscript\n' "$part/UserBootscript" + printf 'cp :%s /myfs/home/config/settings/kernel/drivers/kernel\n' "$part/kernel" + for nag in $BEOS_NAG_SETTINGS; do + printf 'cp :%s /myfs/home/config/settings/%s\n' "$part/empty" "$nag" + done + printf 'rm /myfs/%s\n' "$BEOS_ISA_BUS" + } | haikuBfs "$part/$BEOS_VM_SYSTEM_DISK" > "$part/bfs.log" || die "cannot prepare the BeOS system disk; bfs_shell's log: $part/bfs.log" + rm -f "$part/UserBootscript" "$part/kernel" "$part/empty" "$part/bfs.log" + rm -rf "$dir" + mv "$part" "$dir" +} + + +beosVmVerify() { + local runner + [ -s "$1/$BEOS_VM_FLOPPY" ] || return 1 + runner="$(haikuBfs "$1/$BEOS_VM_SYSTEM_DISK" <<< "cat /myfs/home/config/boot/UserBootscript" || true)" + [[ "$runner" == *"$HAIKU_JOB_VOLUME"* ]] +} diff --git a/installer/lib/bundle.sh b/installer/lib/bundle.sh index bd0c177..dd705f2 100644 --- a/installer/lib/bundle.sh +++ b/installer/lib/bundle.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Private files: the ones in files.sha1, which are never fetched from a # public source. They arrive from the owner's bundle (JOEY_BUNDLE_URL) or # from `install.sh import`. Sourced after lib/common.sh. @@ -43,10 +47,10 @@ importOneFile() { } -# importPaths PATH... -- scan files, directories, .zip archives and Amiga -# .adf disk images and install every private file found. Prints what was -# installed. (Workbench's own disk is how most people own AmigaOS files -# such as Devs/serial.device.) +# importPaths PATH... -- scan files, directories, .zip and .7z archives and +# Amiga .adf disk images and install every private file found. Prints what +# was installed. (Workbench's own disk is how most people own AmigaOS files +# such as Devs/serial.device; a .7z is opened with 7z, when installed.) importPaths() { local path local file @@ -65,6 +69,20 @@ importPaths() { done fi ;; + *.7z|*.7Z) + if ! command -v 7z > /dev/null 2>&1; then + warn "skipping $file: opening a .7z needs 7z (Debian/Ubuntu: p7zip-full, Fedora: p7zip-plugins)" + continue + fi + rm -rf "$tmp/7z" + mkdir -p "$tmp/7z" + if 7z x -y -o"$tmp/7z" "$file" > /dev/null 2>&1; then + find "$tmp/7z" -type f -print0 | while IFS= read -r -d '' inner; do + importOneFile "$inner" + done + fi + rm -rf "$tmp/7z" + ;; *.adf|*.ADF) rm -rf "$tmp/adf" mkdir -p "$tmp/adf" diff --git a/installer/lib/common.sh b/installer/lib/common.sh index 5e0f350..bb3140a 100644 --- a/installer/lib/common.sh +++ b/installer/lib/common.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Shared helpers for install.sh and the component files in # installer/components/. Sourced, not executed. # @@ -86,15 +90,16 @@ sha256Of() { } -# fetchFile URL SHA256 [NAME] -- download URL into the cache as NAME -# (default: the URL's basename), verify it, and print its path. A cached -# file is reused only if its checksum still matches; downloads go to a -# .part file first, so an interrupted fetch is never mistaken for a -# complete one. +# fetchFile URL SHA256 [NAME [HEADER]] -- download URL into the cache as +# NAME (default: the URL's basename), verify it, and print its path. A +# cached file is reused only if its checksum still matches; downloads go to +# a .part file first, so an interrupted fetch is never mistaken for a +# complete one. HEADER is sent with the request (an authorization). fetchFile() { local url="$1" local want="$2" local name="${3:-$(basename "$url")}" + local header="${4:-}" local dest="$JOEY_CACHE/$name" local got mkdir -p "$JOEY_CACHE" @@ -108,7 +113,7 @@ fetchFile() { rm -f "$dest" fi info "downloading $name" >&2 - curl -fL --retry 3 --connect-timeout 30 --progress-bar -o "$dest.part" "$url" \ + curl -fL --retry 3 --connect-timeout 30 --progress-bar ${header:+-H "$header"} -o "$dest.part" "$url" \ || die "download failed: $url" got="$(sha256Of "$dest.part")" if [ "$got" != "$want" ]; then @@ -123,6 +128,20 @@ The upstream file changed. Check it, then update the pin in the component file." } +# fetchOciBlob REPOSITORY DIGEST NAME -- fetchFile for one blob (an image +# layer) of a public image on the GitHub container registry, by its sha256 +# digest, which is also its checksum. The registry wants an anonymous token +# even for public images; none is asked for when the blob is cached. +fetchOciBlob() { + local token="" + if [ "$(sha256Of "$JOEY_CACHE/$3" 2>/dev/null || true)" != "$2" ]; then + token="$(curl -fsSL "https://ghcr.io/token?scope=repository:$1:pull" | python3 -c 'import json, sys; print(json.load(sys.stdin)["token"])')" \ + || die "cannot get a registry token for $1" + fi + fetchFile "https://ghcr.io/v2/$1/blobs/sha256:$2" "$2" "$3" "Authorization: Bearer $token" +} + + # gitFetchCommit URL SHA DIR -- make DIR a shallow checkout of exactly SHA. # Reuses an existing clone when it can. gitFetchCommit() { diff --git a/installer/lib/config.sh b/installer/lib/config.sh index 321c673..4268d52 100644 --- a/installer/lib/config.sh +++ b/installer/lib/config.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Generates toolchains/env.sh (for shells and scripts) and # toolchains/config.mk (for make) from the compEnv output of every installed # component, so both see exactly the same tool paths. Sourced after diff --git a/installer/lib/distro.sh b/installer/lib/distro.sh index ec73e51..826c798 100644 --- a/installer/lib/distro.sh +++ b/installer/lib/distro.sh @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Host package handling: maps the logical names in distroPackages.txt to # apt or dnf packages, reports which are missing, and installs them. # Sourced after lib/common.sh. @@ -64,12 +68,17 @@ distroMissing() { # aptInstalled PACKAGE NATIVE-ARCH -- PACKAGE is installed for this machine. # dpkg-query prints one line per architecture present (libc6 on a system -# with i386 multiarch has two), so look for a native or arch-all line. +# with i386 multiarch has two), so look for a native or arch-all line; a +# PACKAGE that names its architecture (libc6:i386) needs that one. aptInstalled() { local arch local status + local want="$2" + if [[ "$1" == *:* ]]; then + want="${1##*:}" + fi while read -r arch status; do - if [ "$status" = "installed" ] && { [ "$arch" = "$2" ] || [ "$arch" = "all" ]; }; then + if [ "$status" = "installed" ] && { [ "$arch" = "$want" ] || [ "$arch" = "all" ]; }; then return 0 fi done <<< "$(dpkg-query -W -f='${Architecture} ${db:Status-Status}\n' "$1" 2>/dev/null || true)" diff --git a/installer/lib/haiku.sh b/installer/lib/haiku.sh new file mode 100644 index 0000000..3f3c389 --- /dev/null +++ b/installer/lib/haiku.sh @@ -0,0 +1,200 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Shared by the Haiku components (haikuGcc, haikuGcc2, haikuVm, haikuVm32), +# the BeOS VM (installer/lib/beos.sh, which uses its BFS helpers and job +# runner protocol) and the Be API ports' gates (scripts/lib/beGate.sh): the +# Haiku R1/beta6 release they are all made from, getting files into and out +# of its disk images and packages, and the job runner its VM images boot +# into. Sourced, not executed. ARCH is a Haiku architecture: x86_64, or +# x86_gcc2 (the 32-bit hybrid, gcc2h, whose primary compiler is gcc 2.95 +# for BeOS compatibility). +# +# The job runner: a VM image's UserBootscript (written by haikuVmPrepare) +# looks for a disk labelled HAIKU_JOB_VOLUME, mounts it, waits for the +# desktop and the media services (the boot script runs before they are up, +# and a program opening its sound before the media server has started gets +# none), runs its job.sh there with the output going to job.log beside it, +# then shuts the machine down, so QEMU exits. A boot without that disk is an +# ordinary desktop. Lines on the first serial port mark the job's progress +# (HAIKU_JOB_STARTED, HAIKU_JOB_FINISHED). + +# The anyboot images: a mirror listed on haiku-os.org/get-haiku/r1beta6, and +# the SHA256 sums that page gives. +HAIKU_ISO_BASE="https://mirrors.rit.edu/haiku/r1beta6" +HAIKU_ISO_NAME_x86_64="haiku-r1beta6-x86_64-anyboot.iso" +HAIKU_ISO_SHA256_x86_64="bb7743474fd175aea511be1799764ba054465285bbdf54c4722ca61d1fb4f5c9" +HAIKU_ISO_NAME_x86_gcc2="haiku-r1beta6-x86_gcc2h-anyboot.iso" +HAIKU_ISO_SHA256_x86_gcc2="5a55ad9429f1de9fb5ca5cbcd54de3733ff247cc7919ce5d690fb1271cb1dc76" +# The release's packages (hrev59866_79, what the images were built from) in +# Haiku's repository, and HaikuPorts' (the compiler's runtime), by the +# checksums the repositories' indexes give. +HAIKU_REVISION="r1~beta6_hrev59866_79" +HAIKU_REPO_BASE="https://eu.hpkg.haiku-os.org/haiku/r1beta6" +HAIKU_PORTS_BASE="https://eu.hpkg.haiku-os.org/haikuports/master" + +# The job runner's disk label and serial port lines, and the applications +# it waits for: Deskbar, the media server and its add-on host. +HAIKU_JOB_VOLUME="JoeyJob" +HAIKU_JOB_WAIT_APPS="application/x-vnd.Be-TSKB application/x-vnd.Be.media-server application/x-vnd.Be.addon-host" +HAIKU_JOB_STARTED="JOEY: job started" +HAIKU_JOB_FINISHED="JOEY: job finished" +# Seconds the runner waits for the job disk to appear while booting. +HAIKU_JOB_WAIT_SECONDS=10 + +# Haiku's build-host tools (component haikuTools). +HAIKU_TOOLS="${HAIKU_TOOLS:-${JOEY_OPT:-}/haikuTools/bin}" + + +# haikuBfs IMAGE [OFFSET] -- run the bfs_shell commands on stdin against the +# BFS volume in IMAGE (at byte OFFSET, for a partitioned disk), the volume +# at /myfs and host files as :PATH. Its output is printed; returns 1 when a +# command failed. sync is run before the volume is let go: bfs_shell's quit +# reports the volume busy after a copy and would not write it otherwise. +haikuBfs() { + local image="$1" + local offset="${2:-}" + local out + local errors + out="$({ cat; printf 'sync\nquit\n'; } | "$HAIKU_TOOLS/bfs_shell" ${offset:+--start-offset "$offset"} "$image" 2>&1 || true)" + printf '%s\n' "$out" + errors="$(printf '%s\n' "$out" | grep -i -e 'error' | grep -v 'Unmounting FS failed: Device or resource busy' || true)" + [ -z "$errors" ] +} + + +# haikuBfsNew IMAGE SIZE LABEL -- a new BFS volume of SIZE (truncate's +# suffixes: 64M) in the file IMAGE. +haikuBfsNew() { + rm -f "$1" + truncate -s "$2" "$1" + "$HAIKU_TOOLS/bfs_shell" --initialize "$1" "$3" > /dev/null +} + + +# haikuBootOffset IMAGE -- the byte offset of an anyboot image's first +# partition, its BFS system volume (from the MBR: entry 1's start sector). +haikuBootOffset() { + python3 - "$1" <<'PY' +import struct +import sys + +MBR_ENTRY_1 = 446 +ENTRY_START_LBA = 8 +SECTOR_BYTES = 512 + +with open(sys.argv[1], 'rb') as image: + mbr = image.read(SECTOR_BYTES) +print(struct.unpack_from(' /dev/null || die "cannot copy $2 out of $iso" + [ -s "$3" ] || die "$2 is not in $iso" +} + + +# haikuRepoPackage BASE ARCH PATH FILE SHA256 -- download (once) and print +# a package from a Haiku repository: BASE/ARCH/PATH/packages/FILE. +haikuRepoPackage() { + fetchFile "$1/$2/$3/packages/$4" "$5" "$4" +} + + +# haikuSysroot DEST PACKAGE... -- the parts of the packages a cross compiler +# needs (lib and develop) unpacked into DEST/boot/system, where a Haiku +# compiler looks for them. +haikuSysroot() { + local dest="$1" + local package + shift + mkdir -p "$dest/boot/system" + for package in "$@"; do + # A package with only one of the two warns about the other. + "$HAIKU_TOOLS/package" extract -C "$dest/boot/system" "$package" lib develop 2> /dev/null || die "cannot unpack $package" + done +} + + +# haikuVmPrepare ARCH IMAGE -- the release's anyboot image copied to IMAGE, +# booting straight to the desktop (no first-boot prompt) and running the job +# runner. +haikuVmPrepare() { + local iso + local tmp + iso="$(haikuIso "$1")" + tmp="$(mktemp -d)" + haikuJobRunner > "$tmp/UserBootscript" + # The first-boot prompt (language and keymap, or the installer) comes + # up while there are no locale settings. + : > "$tmp/Locale settings" + cp "$iso" "$2.part" + haikuBfs "$2.part" "$(haikuBootOffset "$2.part")" > /dev/null < /dev/null 2>&1 + tries=\$((tries + 1)) + if [ \$tries -gt $HAIKU_JOB_WAIT_SECONDS ]; then + exit 0 + fi + sleep 1 +done +for app in $HAIKU_JOB_WAIT_APPS; do + waitfor -m \$app +done +echo "$HAIKU_JOB_STARTED" > \$serial +cd /$HAIKU_JOB_VOLUME +/bin/sh ./job.sh > job.log 2>&1 +echo "$HAIKU_JOB_FINISHED \$?" > \$serial +cd / +sync +shutdown -q +EOF +} diff --git a/installer/lib/win16.sh b/installer/lib/win16.sh new file mode 100644 index 0000000..c9f9d36 --- /dev/null +++ b/installer/lib/win16.sh @@ -0,0 +1,170 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The Win16 port's Windows: Windows for Workgroups 3.11 set up on a DOSBox +# Staging PC (scripts/dosbox-win16.conf) as a host folder that is drive C:, +# for the win311 component. Sourced, not executed. +# +# Windows is not downloaded from anywhere: its files are private ones +# (installer/files.sha1), the owner's install disc and display driver disk, +# brought in with ./install.sh import or the private bundle. From them: +# +# 1. Windows' own Setup runs unattended in DOSBox (SETUP /H: with the +# answers in win16SetupAnswers): no network, the stock VGA driver and +# PS/2 mouse, nothing optional, Setup back to DOS at the end. Then the +# Sound Blaster 1.5 driver and its virtual device are expanded from the +# disc with its own EXPAND.EXE. +# 2. The S3 Trio64V+ 1.70.04 driver is put in at 800x600 in 256 colours, +# and the Sound Blaster driver turned on, editing SYSTEM.INI the way +# the driver disk's OEMSETUP.INF (its D808S profile) and Control Panel +# would. The swap file is off. +# +# The win16 gates and joey-run (scripts/lib/win16Gate.sh) copy the result +# for each run and name the program as Windows' shell, so Windows ends when +# the program does. Win16 code only ever runs on DOSBox's emulated CPU: +# running it natively (the 32-bit Wine's winevdm) crashed the host's kernel. + +WIN16_FIRMWARE="toolchains/firmware/win16" +WIN16_ISO="$WIN16_FIRMWARE/wfw311.iso" +WIN16_S3_FILES="S3TRIO.DRV VDDS3764.386 VGA_ENG.3GR" +WIN16_DOSBOX_CONF="scripts/dosbox-win16.conf" +WIN16_INI_TOOL="tools/winIni.py" +# Setup takes about 20 s at full speed. +WIN16_SETUP_SECONDS=300 + + +# win16Configure CDIR -- step 2: the S3 driver, the Sound Blaster, no swap +# file. +win16Configure() { + local windows="$1/WINDOWS" + local name + for name in $WIN16_S3_FILES; do + cp "$JOEY_REPO/$WIN16_FIRMWARE/s3/$name" "$windows/SYSTEM/$name" + done + python3 "$JOEY_REPO/$WIN16_INI_TOOL" "$windows/SYSTEM.INI" \ + set boot display.drv s3trio.drv \ + set boot 386grabber vga_eng.3gr \ + set boot.description display.drv "S3 Trio64V 1.70.04 800x600 256 C SF" \ + set 386Enh display vdds3764.386 \ + set 386Enh Paging no \ + add 386Enh device vsbd.386 \ + set DISPLAY dac-type nbt \ + set DISPLAY polygon-support on \ + set DISPLAY ellipse-support on \ + set DISPLAY scache on \ + set DISPLAY textrmw 0 \ + set DISPLAY fastmmio on \ + set DISPLAY screen-size 800 \ + set DISPLAY color-format 8 \ + set DISPLAY dpi 96 \ + set Debug OutputTo NUL \ + set drivers wave sndblst2.drv \ + set sndblst.drv port 220 \ + set sndblst.drv int 7 +} + + +# win16Files -- every private file the image is made from. +win16Files() { + local name + printf '%s\n' "$WIN16_ISO" + for name in $WIN16_S3_FILES; do + printf '%s\n' "$WIN16_FIRMWARE/s3/$name" + done +} + + +# win16Pin -- changes with the private files and with the recipe. +win16Pin() { + local file + { + for file in $(win16Files); do + privateSha1For "$file" + done + declare -f win16Configure win16Prepare win16Setup win16SetupAnswers + cat "$JOEY_REPO/$WIN16_DOSBOX_CONF" "$JOEY_REPO/$WIN16_INI_TOOL" + } | sha256sum | cut -d' ' -f1 +} + + +# win16Prepare OUTDIR -- make OUTDIR/c, Windows set up and configured. +win16Prepare() { + local out="$1" + local build + local file + for file in $(win16Files); do + privateEnsure "$file" + done + build="$(freshBuildDir "$compId")" + mkdir -p "$build/c" + win16SetupAnswers > "$build/c/SETUP.SHH" + info "setting up Windows for Workgroups 3.11 in DOSBox (unattended)" + win16Setup "$build" || die "Windows Setup did not finish; its DOSBox log: $build/dosbox.log" + rm -f "$build/c/SETUP.SHH" + win16Configure "$build/c" + win16Verify "$build/c" || die "the Windows set up in $build/c is incomplete" + rm -rf "${out:?}/c" + mkdir -p "$out" + mv "$build/c" "$out/c" + dropBuildDir "$compId" +} + + +# win16Setup BUILD -- step 1, on a private display: Setup, then the Sound +# Blaster driver off the disc. DOSBox exits when the last command has run. +win16Setup() { + local build="$1" + local windows='C:\WINDOWS\SYSTEM' + local xvfb + local display + local rc + Xvfb -displayfd 3 -screen 0 1024x768x24 -nolisten tcp 3> "$build/display" > /dev/null 2>&1 & + xvfb=$! + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$build/display" ] && break + sleep 0.5 + done + display=":$(cat "$build/display")" + XDG_CONFIG_HOME="$build/xdg" DISPLAY="$display" SDL_AUDIODRIVER=dummy \ + timeout -s KILL "$WIN16_SETUP_SECONDS" "$JOEY_OPT/dosbox/dosbox" --noprimaryconf --nolocalconf \ + --conf "$JOEY_REPO/$WIN16_DOSBOX_CONF" --set "nosound=true" \ + -c "MOUNT C \"$build/c\"" \ + -c "IMGMOUNT D \"$JOEY_REPO/$WIN16_ISO\" -t iso" \ + -c "C:" \ + -c 'D:\SETUP /H:C:\SETUP.SHH' \ + -c "D:\\EXPAND D:\\SNDBLST2.DR_ $windows\\SNDBLST2.DRV" \ + -c "D:\\EXPAND D:\\VSBD.38_ $windows\\VSBD.386" \ + -c "EXIT" > "$build/dosbox.log" 2>&1 + rc=$? + kill "$xvfb" 2> /dev/null || true + wait "$xvfb" 2> /dev/null || true + [ "$rc" -eq 0 ] && [ -f "$build/c/WINDOWS/WIN.COM" ] +} + + +# win16SetupAnswers -- Setup's answer file (SETUP.SHH, the format of the +# sample on the disc), CRLF. +win16SetupAnswers() { + printf '%s\r\n' \ + '[sysinfo]' 'showsysinfo=no' '' \ + '[configuration]' 'machine=ibm_compatible' 'display=vga' 'mouse=ps2mouse' 'network=nonet' \ + 'keyboard=t4s0enha' 'language=enu' 'kblayout=nodll' '' \ + '[windir]' 'c:\windows' '' \ + '[userinfo]' '"JoeyLib"' '' \ + '[dontinstall]' 'accessories' 'readmes' 'games' 'screensavers' 'bitmaps' '' \ + '[endinstall]' 'configfiles=save' 'endopt=exit' +} + + +# win16Verify CDIR -- Windows is there and set up for the port. +win16Verify() { + local windows="$1/WINDOWS" + local name + [ -f "$windows/WIN.COM" ] || return 1 + for name in $WIN16_S3_FILES SNDBLST2.DRV VSBD.386; do + [ -s "$windows/SYSTEM/$name" ] || return 1 + done + tr -d '\r' < "$windows/SYSTEM.INI" | grep -qx 'display.drv=s3trio.drv' || return 1 + tr -d '\r' < "$windows/SYSTEM.INI" | grep -qx 'wave=sndblst2.drv' +} diff --git a/installer/lib/winDriver.sh b/installer/lib/winDriver.sh new file mode 100644 index 0000000..6cb17ca --- /dev/null +++ b/installer/lib/winDriver.sh @@ -0,0 +1,61 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Shared by the components that build a Windows 3.x driver for the win16 +# port's Windows (kpjoy: KPJOY.DRV, the joystick driver; kpcomm: +# KPCOMM.DRV, the communications driver) from a project of its own beside +# this repository, with Open Watcom's 16-bit Windows target (component +# openWatcom). The project's own makefile builds it into the installer's +# build tree, so the project itself is never written to. The project's +# LICENSE.md (MIT) goes beside the driver, so a copy taken from there to +# ship with a program has its notice. +# scripts/lib/win16Gate.sh puts the drivers in each run's Windows. Source +# from a component. +# +# winDriverBuild DIR MAKEFILE FILE build FILE with DIR's MAKEFILE (its +# OBJ and BIN set to the build tree) +# into compDir, with DIR's LICENSE.md +# winDriverPin DIR the project's sources' checksum: a +# change there makes the component out +# of date +# winDriverSources DIR the files a driver is made from, +# and its licence + +# The source files' names: C sources and headers, link and module +# definition files, makefiles, the licence. +WIN_DRIVER_LICENSE=LICENSE.md +WIN_DRIVER_SOURCES=(-name '*.c' -o -name '*.h' -o -name '*.lnk' -o -name '*.def' -o -name 'Makefile' -o -name '*.mk' -o -name "$WIN_DRIVER_LICENSE") + + +winDriverBuild() { + local dir="$1" + local makefile="$2" + local file="$3" + local build + [ -f "$dir/$makefile" ] || die "no $makefile in $dir: the driver's project must be beside this repository (or named by its *_DIR variable)" + [ -f "$dir/$WIN_DRIVER_LICENSE" ] || die "no $WIN_DRIVER_LICENSE in $dir: the driver ships with its licence" + build="$(freshBuildDir "$compId")" + make -C "$dir" -f "$makefile" WATCOM="$JOEY_OPT/openWatcom" OBJ="$build/obj" BIN="$build/bin" > "$build/make.log" 2>&1 || + die "building $file failed; the log: $build/make.log" + [ -s "$build/bin/$file" ] || die "$makefile in $dir made no $file; the log: $build/make.log" + rm -rf "$compDir" + mkdir -p "$compDir" + cp "$build/bin/$file" "$compDir/$file" + cp "$dir/$WIN_DRIVER_LICENSE" "$compDir/$WIN_DRIVER_LICENSE" + dropBuildDir "$compId" +} + + +winDriverPin() { + local file + while IFS= read -r file; do + printf '%s %s\n' "${file#"$1"/}" "$(sha256Of "$file")" + done < <(winDriverSources "$1") | sha256sum | cut -d' ' -f1 +} + + +winDriverSources() { + [ -d "$1" ] || return 0 + find "$1" -maxdepth 1 -type f \( "${WIN_DRIVER_SOURCES[@]}" \) | sort +} diff --git a/installer/patches/mame/README.md b/installer/patches/mame/README.md index 4e5c469..9905157 100644 --- a/installer/patches/mame/README.md +++ b/installer/patches/mame/README.md @@ -47,3 +47,10 @@ every file applies with zero fuzz and matches byte for byte). `lua/` holds the MAME Lua probes used while developing the X68000 patches (boot check, CRTC timing, wait states). Run one with `mame x68000 -bios ipl10 -autoboot_script lua/.lua`. + +## License + +The patches modify MAME and contain its code, so they are under MAME's +license, the GNU GPL v2 or later, not JoeyLib's MIT License. The files 0004 +adds from upstream MAME keep their own notices (BSD-3-Clause, Patrick +Mackinlay). The Lua probes in `lua/` are JoeyLib's own (MIT). diff --git a/installer/patches/mame/lua/x68k-boot-check.lua b/installer/patches/mame/lua/x68k-boot-check.lua index 19172b4..37ef8fa 100644 --- a/installer/patches/mame/lua/x68k-boot-check.lua +++ b/installer/patches/mame/lua/x68k-boot-check.lua @@ -1,3 +1,7 @@ +-- Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +-- See LICENSE.md for license information. +-- SPDX-License-Identifier: MIT +-- -- Boot regression: confirm Human68k still reaches a live console after the -- CRTC clear-timing and VRAM wait-state changes. Counts non-zero bytes in the -- text plane (TVRAM plane 0 at $E00000) -- a real console lights up hundreds diff --git a/installer/patches/mame/lua/x68k-crtc-probe.lua b/installer/patches/mame/lua/x68k-crtc-probe.lua index 8d81079..9b8958f 100644 --- a/installer/patches/mame/lua/x68k-crtc-probe.lua +++ b/installer/patches/mame/lua/x68k-crtc-probe.lua @@ -1,3 +1,7 @@ +-- Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +-- See LICENSE.md for license information. +-- SPDX-License-Identifier: MIT +-- -- CRTC high-speed-clear timing probe. -- -- Discriminator 1 (unambiguous): write a pattern into GVRAM, reserve the diff --git a/installer/patches/mame/lua/x68k-wait-probe.lua b/installer/patches/mame/lua/x68k-wait-probe.lua index a0167a9..f663d5e 100644 --- a/installer/patches/mame/lua/x68k-wait-probe.lua +++ b/installer/patches/mame/lua/x68k-wait-probe.lua @@ -1,3 +1,7 @@ +-- Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +-- See LICENSE.md for license information. +-- SPDX-License-Identifier: MIT +-- -- VRAM wait-state measurement. -- -- Assembles a tight 68000 loop into RAM and gets it executed by hooking the diff --git a/installer/patches/ppsspp/0001-headless-gate-options.patch b/installer/patches/ppsspp/0001-headless-gate-options.patch new file mode 100644 index 0000000..7f86cfa --- /dev/null +++ b/installer/patches/ppsspp/0001-headless-gate-options.patch @@ -0,0 +1,236 @@ +--- a/headless/Headless.cpp ++++ b/headless/Headless.cpp +@@ -10,6 +10,9 @@ + #include + #include + #include ++#include ++#include ++#include + #if PPSSPP_PLATFORM(ANDROID) + #include + #endif +@@ -42,9 +45,12 @@ + #include "Core/CoreTiming.h" + #include "Core/System.h" + #include "Core/WebServer.h" ++#include "Core/HLE/sceCtrl.h" + #include "Core/HLE/sceUtility.h" ++#include "Core/Screenshot.h" + #include "Core/SaveState.h" + #include "GPU/GPUCommon.h" ++#include "GPU/Common/GPUDebugInterface.h" + #include "GPU/Common/FramebufferManagerCommon.h" + #include "Common/Log.h" + #include "Common/Log/LogManager.h" +@@ -152,6 +158,14 @@ + fprintf(stderr, " -j use jit (default)\n"); + fprintf(stderr, " -c, --compare compare with output in file.expected\n"); + fprintf(stderr, " --bench run multiple times and output speed\n"); ++ fprintf(stderr, " --strict-mem stop at an unaligned or invalid memory access, as a PSP\n"); ++ fprintf(stderr, " does, and say where (use with --jit-ir or --ir)\n"); ++ fprintf(stderr, " --snapshot=FILE save the screen as a PNG when the run ends\n"); ++ fprintf(stderr, " --dump-audio record what the program plays to the memory stick's\n"); ++ fprintf(stderr, " PSP/AUDIO folder as a WAV\n"); ++ fprintf(stderr, " --input=FILE play an input script: lines of MS buttons NAME...|none,\n"); ++ fprintf(stderr, " MS analog X Y (-1..1) or MS snapshot FILE, MS being\n"); ++ fprintf(stderr, " emulated milliseconds since boot\n"); + fprintf(stderr, "\nSee headless.txt for details.\n"); + + return 1; +@@ -171,14 +185,122 @@ + } + } + ++// One step of an --input script, from atUs (emulated time since boot) on. ++enum class InputKind { ++ BUTTONS, ++ ANALOG, ++ SNAPSHOT, ++}; ++ ++struct InputStep { ++ u64 atUs; ++ InputKind kind; ++ u32 buttons; ++ float x; ++ float y; ++ std::string file; ++}; ++ + struct AutoTestOptions { + double timeout; + double maxScreenshotError; + bool compare : 1; + bool verbose : 1; + bool bench : 1; ++ bool strictMem : 1; ++ const char *snapshot; ++ const std::vector *input; + }; + ++// The PSP's screen as it is now, as a PNG (--snapshot). ++static void SaveSnapshot(const char *filename) { ++ GPUDebugBuffer buffer; ++ u8 *converted = nullptr; ++ u32 w = (u32)-1; ++ u32 h = (u32)-1; ++ if (!gpuDebug || !gpuDebug->GetCurrentFramebuffer(buffer, GPU_DBG_FRAMEBUF_DISPLAY)) { ++ fprintf(stderr, "Snapshot: no display framebuffer\n"); ++ return; ++ } ++ const u8 *rgb = ConvertBufferToScreenshot(buffer, false, converted, w, h); ++ if (!rgb || !Save888RGBScreenshot(Path(std::string(filename)), ScreenshotFormat::PNG, rgb, w, h)) ++ fprintf(stderr, "Snapshot: could not write %s\n", filename); ++ delete [] converted; ++} ++ ++// Reads an --input script into steps, in the order given (times must not go ++// back). False, saying why, for a line it cannot read. ++static bool LoadInputScript(const char *filename, std::vector *steps) { ++ static const struct { ++ const char *name; ++ u32 bit; ++ } buttonNames[] = { ++ { "select", CTRL_SELECT }, { "start", CTRL_START }, { "up", CTRL_UP }, { "right", CTRL_RIGHT }, ++ { "down", CTRL_DOWN }, { "left", CTRL_LEFT }, { "ltrigger", CTRL_LTRIGGER }, { "rtrigger", CTRL_RTRIGGER }, ++ { "triangle", CTRL_TRIANGLE }, { "circle", CTRL_CIRCLE }, { "cross", CTRL_CROSS }, { "square", CTRL_SQUARE }, ++ }; ++ FILE *fp = File::OpenCFile(Path(std::string(filename)), "rt"); ++ if (!fp) { ++ fprintf(stderr, "Input script: cannot open %s\n", filename); ++ return false; ++ } ++ char line[1024]; ++ int lineNumber = 0; ++ bool ok = true; ++ while (ok && fgets(line, sizeof(line), fp)) { ++ lineNumber++; ++ std::istringstream words(line); ++ double ms = 0.0; ++ std::string kind; ++ if (line[0] == '#' || !(words >> ms >> kind)) ++ continue; ++ InputStep step{}; ++ step.atUs = (u64)(ms * 1000.0); ++ if (kind == "buttons") { ++ step.kind = InputKind::BUTTONS; ++ std::string name; ++ while (ok && words >> name) { ++ bool known = name == "none"; ++ for (const auto &b : buttonNames) { ++ if (name == b.name) { ++ step.buttons |= b.bit; ++ known = true; ++ } ++ } ++ ok = known; ++ } ++ } else if (kind == "analog") { ++ step.kind = InputKind::ANALOG; ++ ok = (bool)(words >> step.x >> step.y); ++ } else if (kind == "snapshot") { ++ step.kind = InputKind::SNAPSHOT; ++ ok = (bool)(words >> step.file); ++ } else { ++ ok = false; ++ } ++ if (ok) ++ steps->push_back(step); ++ else ++ fprintf(stderr, "Input script: cannot read line %d of %s\n", lineNumber, filename); ++ } ++ fclose(fp); ++ return ok; ++} ++ ++static void RunInputStep(const InputStep &step) { ++ switch (step.kind) { ++ case InputKind::BUTTONS: ++ __CtrlUpdateButtons(step.buttons, CTRL_MASK_USER & ~step.buttons); ++ break; ++ case InputKind::ANALOG: ++ __CtrlSetAnalogXY(CTRL_STICK_LEFT, step.x, step.y); ++ break; ++ case InputKind::SNAPSHOT: ++ SaveSnapshot(step.file.c_str()); ++ break; ++ } ++} ++ + bool RunAutoTest(HeadlessHost *headlessHost, CoreParameter &coreParameter, const AutoTestOptions &opt) { + // Kinda ugly, trying to guesstimate the test name from filename... + currentTestName = GetTestName(coreParameter.fileToStart); +@@ -228,11 +350,14 @@ + + bool passed = true; + double deadline = time_now_d() + opt.timeout; ++ size_t nextInput = 0; + coreState = coreParameter.startBreak ? CORE_STEPPING_CPU : CORE_RUNNING_CPU; + while (coreState == CORE_RUNNING_CPU || coreState == CORE_STEPPING_CPU) + { + int blockTicks = (int)usToCycles(1000000 / 10); + PSP_RunLoopFor(blockTicks); ++ while (opt.input && nextInput < opt.input->size() && (*opt.input)[nextInput].atUs <= CoreTiming::GetGlobalTimeUs()) ++ RunInputStep((*opt.input)[nextInput++]); + + // If we were rendering, this might be a nice time to do something about it. + if (coreState == CORE_NEXTFRAME) { +@@ -261,6 +386,13 @@ + Core_Stop(); + } + } ++ if (opt.strictMem && Core_GetExceptionInfo().type == MIPSExceptionType::MEMORY) { ++ const MIPSExceptionInfo &e = Core_GetExceptionInfo(); ++ printf("STRICT-MEM: %s exception at %08x (size %u), pc %08x\n%s\n", MemoryExceptionTypeAsString(e.memory_type), e.address, e.accessSize, e.pc, e.stackTrace.c_str()); ++ passed = false; ++ } ++ if (opt.snapshot) ++ SaveSnapshot(opt.snapshot); + if (gpu) { + gpu->EndHostFrame(); + } +@@ -370,6 +502,8 @@ + int debuggerPort = -1; + bool oldAtrac = false; + bool outputDebugStringLog = false; ++ bool dumpAudio = false; ++ std::vector inputSteps; + + std::vector testFilenames; + std::vector ignoredTests; +@@ -411,6 +545,17 @@ + testOptions.verbose = true; + else if (!strcmp(argv[i], "--old-atrac")) + oldAtrac = true; ++ else if (!strcmp(argv[i], "--strict-mem")) ++ testOptions.strictMem = true; ++ else if (!strcmp(argv[i], "--dump-audio")) ++ dumpAudio = true; ++ else if (!strncmp(argv[i], "--snapshot=", strlen("--snapshot=")) && strlen(argv[i]) > strlen("--snapshot=")) ++ testOptions.snapshot = argv[i] + strlen("--snapshot="); ++ else if (!strncmp(argv[i], "--input=", strlen("--input=")) && strlen(argv[i]) > strlen("--input=")) { ++ if (!LoadInputScript(argv[i] + strlen("--input="), &inputSteps)) ++ return printUsage(argv[0], "Unreadable --input script"); ++ testOptions.input = &inputSteps; ++ } + else if (!strncmp(argv[i], "--graphics=", strlen("--graphics=")) && strlen(argv[i]) > strlen("--graphics=")) + { + const char *gpuName = argv[i] + strlen("--graphics="); +@@ -558,6 +703,16 @@ + g_Config.internalDataDirectory.clear(); + g_Config.bUseOldAtrac = oldAtrac; + g_Config.iForceEnableHLE = 0xFFFFFFFF; // Run all modules as HLE. We don't have anything to load in this context. ++ if (testOptions.strictMem) { ++ // Every access checked, and a bad one stops the run, as on a PSP. ++ g_Config.bFastMemory = false; ++ g_Config.bIgnoreBadMemAccess = false; ++ } ++ if (dumpAudio) { ++ // The mix is only made, and so recorded, with sound on. ++ g_Config.bEnableSound = true; ++ g_Config.bDumpAudio = true; ++ } + + // g_Config.bUseOldAtrac = true; + diff --git a/installer/patches/ppsspp/README.md b/installer/patches/ppsspp/README.md new file mode 100644 index 0000000..9299eb1 --- /dev/null +++ b/installer/patches/ppsspp/README.md @@ -0,0 +1,34 @@ +# PPSSPP 1.20.4 patches + +install.sh (component `ppsspp`) applies these to the PPSSPP 1.20.4 release +source in filename order, then builds only its headless test runner, +PPSSPPHeadless. The psp gates depend on every one of them. + +| Patch | What it adds | Needed by | +|---|---|---| +| 0001-headless-gate-options | Four PPSSPPHeadless options (below) | `bench-psp.sh`, every `verify-psp-*.sh`, `joey-run --port psp --headless` | + +## The options (0001) + +| Option | What it does | +|---|---| +| `--strict-mem` | Every memory access is checked, and an unaligned or out-of-range one stops the run, as it raises an address error on a real PSP. The run prints `STRICT-MEM: exception at
(size ), pc ` and the emulated call stack; `psp-addr2line -f -e build/psp/elf/NAME.elf ` names the function. Stock headless runs with fast memory and ignores bad accesses, so an unaligned `lw` simply works there. Use it with `--jit-ir` (or `--ir`): PPSSPP checks alignment only in its IR cores, not in the default x86 JIT. | +| `--snapshot=FILE` | When the run ends (the program exits, or `--timeout` stops it), the PSP's display as a 480x272 PNG. Stock headless can only compare screenshots a test program asks for itself. | +| `--dump-audio` | Records what the program plays, as PPSSPP's own "dump audio" does: a WAV in the memory stick's `PSP/AUDIO` folder. Stock headless runs with sound off, and the mix is only made with sound on. | +| `--input=FILE` | Plays an input script: one step a line, `MS buttons NAME...` (the buttons held from then on: up down left right cross circle square triangle ltrigger rtrigger start select, or `none`), `MS analog X Y` (the analog stick, -1 to 1 an axis) or `MS snapshot FILE` (a PNG of the screen then), MS being emulated milliseconds since boot; `#` starts a comment line. Steps run in order at the first 100 ms slice of emulated time at or after their time. Stock headless has no input at all; its WebSocket debugger has, but it listens on every network interface and registers itself with report.ppsspp.org. | + +## Running it + +PPSSPPHeadless keeps the emulated memory stick in `$HOME/.ppsspp`, so the +gates and joey-run always run it with `HOME` pointing at a scratch folder: +the program goes in `$HOME/.ppsspp/PSP/GAME/NAME/EBOOT.PBP`, where a PSP +keeps it, and runs from there (`ms0:/PSP/GAME/NAME`). It runs unthrottled: +emulated time passes as fast as the host can run it, much faster than real +time while the program only waits, so a recording made with `--dump-audio` +grows by many emulated seconds every real second, and `--timeout` (real +seconds) is no measure of emulated time; an `--input` script is. + +## License + +The patch modifies PPSSPP and contains its code, so it is under PPSSPP's +license, the GNU GPL v2 or later, not JoeyLib's MIT License. diff --git a/installer/patches/sdl3Web/0001-emscripten-initial-axes.patch b/installer/patches/sdl3Web/0001-emscripten-initial-axes.patch new file mode 100644 index 0000000..229c211 --- /dev/null +++ b/installer/patches/sdl3Web/0001-emscripten-initial-axes.patch @@ -0,0 +1,17 @@ +--- a/src/joystick/emscripten/SDL_sysjoystick.c ++++ b/src/joystick/emscripten/SDL_sysjoystick.c +@@ -653,6 +653,14 @@ + if (item->hat_is_axis && i == 9) { + continue; // this axis is a hat + } ++ // An axis SDL has no value for yet rests where it was: ++ // report that first. Without it SDL takes the first ++ // value an axis reports as its rest and drops it, so a ++ // stick's first push each way was lost. (Other drivers ++ // report the axes' values when the device is opened.) ++ if (!joystick->axes[i].has_initial_value) { ++ SDL_SendJoystickAxis(timestamp, item->joystick, i, (Sint16)(32767.0f * item->axis[i])); ++ } + if (item->axis[i] != gamepadState.axis[i]) { + SDL_SendJoystickAxis(timestamp, item->joystick, i, (Sint16)(32767.0f * gamepadState.axis[i])); + item->axis[i] = gamepadState.axis[i]; diff --git a/installer/patches/sdl3Web/README.md b/installer/patches/sdl3Web/README.md new file mode 100644 index 0000000..ed15f98 --- /dev/null +++ b/installer/patches/sdl3Web/README.md @@ -0,0 +1,14 @@ +# SDL 3 patches for the web port + +install.sh (component `sdl3Web`) applies these to the SDL 3 release source +in filename order before building it with Emscripten. The web port's gates +depend on every one of them. + +| Patch | What it fixes | Needed by | +|---|---|---| +| 0001-emscripten-initial-axes | SDL's Emscripten joystick driver reports no axis values when a gamepad is opened, so SDL takes the first value each axis reports as its resting value and drops it: the first push of a stick in each direction never arrived. The patch reports an axis's resting value before its first change (SDL allocates the axes only after the driver's open, so it cannot be done there, as SDL's other drivers do). | `verify-web-joy.sh` | + +## License + +The patch modifies SDL and contains its code, so it is under SDL's zlib +license, not JoeyLib's MIT License. diff --git a/joey-run b/joey-run index 0e7c832..7e680c0 100755 --- a/joey-run +++ b/joey-run @@ -1,14 +1,20 @@ #!/usr/bin/env bash # -# joey-run: run a built JoeyLib program in the right emulator. +# joey-run: run a built JoeyLib program in the right emulator (or, for the +# SDL3 port, natively, for the web port, in a browser, for the Win32 port, +# under Wine, and for the Win16 port, on Windows 3.11 in DOSBox; the PSP +# port's in headless PPSSPP; the Haiku and BeOS ports' in their QEMU VMs). # # Usage: # ./joey-run [options] PROGRAM [-- ARGS...] # # PROGRAM is any of: -# a program file build/dos/bin/DRAW.EXE, build/atarist/bin/DRAW.PRG, +# a program file build/dos/bin/DRAW.EXE, build/atarist/bin/DRAW.PRG (or build/ste), # build/amiga/bin/Draw, build/x68000/bin/DRAW.X, -# build/iigs/bin/DRAW, build/dvx/bin/apps/joeylib/draw/draw.app +# build/iigs/bin/DRAW, build/dvx/bin/apps/joeylib/draw/draw.app, +# build/sdl3/bin/DRAW, build/web/bin/DRAW/DRAW.html, +# build/psp/bin/DRAW/EBOOT.PBP, build/haiku/bin/DRAW +# (or build/haiku32, build/beos) # an app folder a program in a folder of its own with its DATA: # build/dvx/bin/apps/joeylib/audio # an IIgs disk image build/iigs/bin/DRAW.2mg (make iigs-game-disk), or @@ -19,7 +25,8 @@ # image in the project's build/, or give its name with --project and --port. # # Options: -# --port PORT iigs, amiga, atarist, dos, x68000 or dvx +# --port PORT iigs, amiga, atarist, ste, dos, x68000, dvx, sdl3, web, win32, win16, +# psp, haiku, haiku32 or beos # --project DIR look a program name up in the project at DIR (its # build/PORT/bin) instead of JoeyLib's examples; DVX runs # keep their DVX in DIR's build/dvx/run @@ -31,6 +38,15 @@ # --snapshot FILE save a PNG of the screen when the run stops # (needs --seconds or --headless) # --machine rom01 IIgs: the ROM 01 machine instead of ROM 03 +# --machine ste Atari: the machine Hatari emulates: st, megast, ste +# or megaste. By default an ST-build program (make +# atarist) gets a plain ST and an STe-build one (make +# ste) an STe; the ST build runs on all four, the STe +# build only on ste and megaste +# --machine a1200 Amiga: the machine FS-UAE emulates: a500 (the +# default), a1200 (68EC020, AGA) or a4000 (68040, +# AGA), each on its own Kickstart (install emulator:aga +# for the AGA ones) # --ram SIZE IIgs: memory size for MAME (default 4M) # --debug IIgs and X68000: start MAME's debugger # --serial HOST:PORT connect the machine's serial port to the TCP server @@ -44,13 +60,53 @@ # always 8N1); the other emulators pass bytes at any rate # -h, --help this text # -# Arguments after -- are passed to the program (DOS only: the other -# machines' autostart cannot pass arguments). +# Arguments after -- are passed to the program (DOS, SDL3, Win32 and Win16 +# only: the other machines' autostart cannot pass arguments). # -# The serial port is the machine's usual one: the IIgs modem port (the -# printer port and a Super Serial Card are not connected), the X68000's -# RS-232C port, the ST's Modem port, the Amiga's serial port (serial.device -# unit 0) and DOS's (and DVX's) COM1. +# An SDL3 program is a native program: it runs on this machine directly, in +# a window (JOEYLIB_SCALE, JOEYLIB_ASPECT and JOEYLIB_FULLSCREEN in the +# environment size it; see docs). --headless runs it on a private virtual +# display at one pixel per stage pixel, so --snapshot is the 320x200 stage. +# It has no serial port. +# +# A web program is a page: joey-run serves its folder over HTTP on this +# machine (127.0.0.1 only) and opens it in your browser; stop with Ctrl+C. +# --headless runs it in headless Chromium instead, at one canvas pixel per +# stage pixel, so --snapshot is the stage. Its DATA is packed in when it is +# built, so --data does not apply, and it has no serial port. +# +# A Win16 program runs on Windows for Workgroups 3.11 in DOSBox Staging (a +# fresh copy, each run, of the one the installer set up from your own disc: +# install emulator:win16), as Windows' shell, so Windows ends when the +# program does: in DOSBox's window, at 800x600, with sound, DOSBox's game +# port through KPJOY.DRV and COM1 through KPCOMM.DRV (JoeyLib's joystick and +# communications drivers for Windows 3.1). Its folder is +# drive D:. JOEYLIB_SCALE, JOEYLIB_ASPECT and JOEYLIB_FULLSCREEN in the +# environment go into Windows' DOS environment, so they size it as they do +# an SDL3 or Win32 program; Alt with the keypad's Enter switches full screen +# (DOSBox keeps Alt+Enter). --headless runs it on a private virtual display +# at one window pixel per stage pixel, and --snapshot is DOSBox's own +# picture of the 800x600 screen. +# +# A PSP program runs only with --headless here: under PPSSPPHeadless (the +# installer's patched build, emulator:psp), from its folder on a private +# memory stick (PSP/GAME/NAME, as on a PSP), with every memory access checked +# as a PSP checks it; a bad one stops the run and joey-run names the line. +# --snapshot is the PSP's 480x272 screen. To play it, copy its folder into +# PSP/GAME/ on a PSP's memory stick, or open its EBOOT.PBP in PPSSPP. It has +# no serial port. +# +# A Haiku (haiku: x86_64; haiku32: x86) or BeOS R5 (beos) program runs in +# its VM under QEMU (install emulator:PORT; BeOS's is made from your own +# BeOS 5 Professional CD), from a copy of its folder on a second disk: in +# QEMU's window, with sound (Haiku's; BeOS's VM has none). The machine +# powers off when the program ends, and what the program wrote in its +# folder (saves, its log) is copied back. JOEYLIB_SCALE, JOEYLIB_ASPECT and +# JOEYLIB_FULLSCREEN in the environment size its window, as they do an SDL3 +# program's. --headless runs it at one window pixel per stage pixel without +# a window; --seconds counts from when the job starts, after the boot, and +# --snapshot is QEMU's picture of the whole screen. They have no serial +# port here. # # A DVX program runs in DOSBox Staging on a copy of the DVX the installer # unpacked from a DVX tree's dist/dvx.zip (install dvx), which boots @@ -59,21 +115,32 @@ # with the keypad's Enter (DOSBox keeps Alt+Enter for its own full screen), # puts it on the whole screen. Interactive runs keep that DVX in # build/dvx/run (the project's with --project), so what the program saves -# (in DVX's CONFIG folder), and -# whether it was left on the whole screen, is still there next time. The link is raw TCP, without telnet negotiation. -# socat relays it where the emulator cannot make it itself: the ST, the -# Amiga with --serial, and the IIgs and X68000 with --serial-listen. When +# (in DVX's CONFIG folder), and whether it was left on the whole screen, is +# still there next time. +# +# The serial port is the machine's usual one: the IIgs modem port (the +# printer port and a Super Serial Card are not connected), the X68000's +# RS-232C port, the ST's Modem port, the Amiga's serial port (serial.device +# unit 0) and COM1 on DOS, DVX, Win32 and Win16. +# +# The serial link is raw TCP, without telnet negotiation. socat relays it +# where the emulator cannot make it itself: the ST, Win32, the Amiga with +# --serial, and the IIgs and X68000 with --serial-listen. When # --serial's server is not there, joey-run stops, except on the IIgs and # X68000, where MAME prints "Unable to load image" and runs with the port # unplugged. Listening, what the program sends before the connection -# arrives waits for it, except on DOS, which drops it; the Amiga holds the -# whole machine until the connection arrives. +# arrives waits for it, except in DOSBox (DOS, DVX and Win16), which drops +# it; the Amiga holds the whole machine until the connection arrives. # # The emulators and firmware come from ./install.sh # (install emulator:PORT). Interactive runs use the program's own folder # or disk image, so saves and logs persist; --headless and --data runs # work on a private copy. Nothing is written to your home directory. +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT + set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -84,9 +151,12 @@ port="" projectRoot="$repo" dataDir="" headless=0 +snapshotWindow=0 # --snapshot takes the program's window, not the screen seconds="" snapshot="" machine="apple2gs" +stMachine="" # Hatari's --machine for an Atari program (default: by build) +amigaModel="" # an Amiga program's machine (scripts/lib/amigaGate.sh; default A500) ramSize="4M" debug=0 program="" @@ -99,7 +169,7 @@ serialHost="" serialPort="" serialBaud=9600 # MAME's null modem's own default rate serialArgs=() # MAME's options for the link (mameSerial) -serialPty="" # the pty socat hands Hatari and FS-UAE +serialPty="" # the pty socat hands Hatari, FS-UAE and Wine relayPid="" tcpPortMax=65535 # How long socat keeps trying to reach --serial's server before the run @@ -107,6 +177,23 @@ tcpPortMax=65535 serialConnectSeconds=10 # DOSBox logs whether COM1 reached --serial's server as it starts. dosSerialLogSeconds=5 +# The key a headless Win16 run takes DOSBox's snapshot with (xdotool's name +# and SDL's scancode for it), and how long DOSBox gets to write it. +dosboxShotKey=F11 +dosboxShotScancode=68 +dosboxShotSeconds=5 +# The settings a hosted program's window reads from its environment. +joeyWindowSettings=(JOEYLIB_SCALE JOEYLIB_ASPECT JOEYLIB_FULLSCREEN) +# A Be API VM: how long its boot may take before the job starts, how much +# longer the job runs than --seconds (for the snapshot), how long the +# machine takes to power off, and the job's limit when nothing sets one. +beBootSeconds=120 +beSnapshotSeconds=5 +beShutdownSeconds=30 +beForeverSeconds=86400 +# The mouse move that takes a VM's pointer to the screen's top left +# corner from anywhere. +bePointerCorner=4000 pollSeconds=0.1 @@ -148,10 +235,45 @@ magicPort() { magic="$(head -c 16 "$file" | od -An -tx1 | tr -d ' \n')" case "$magic" in 000003f3*) printf 'amiga\n' ;; - 601a*) printf 'atarist\n' ;; - 4d5a*) printf 'dos\n' ;; + 601a*) + # The ST and STe builds share the .PRG format; an STe build is + # the one whose platform name (jlPlatformName) is "Atari STe". + if LC_ALL=C grep -q -a -P 'Atari STe\x00' "$file"; then + printf 'ste\n' + else + printf 'atarist\n' + fi + ;; + 4d5a*) + # A Windows program is an MZ file whose header (e_lfanew, at + # 0x3C) points at a "PE\0\0" signature (Win32) or an "NE" one + # (Windows 3.x); a DOS one has neither. + case "$(od -An -tx1 -j "$(od -An -tu4 -j 60 -N 4 "$file" | tr -d ' ')" -N 4 "$file" 2> /dev/null | tr -d ' \n')" in + 50450000) printf 'win32\n' ;; + 4e45*) printf 'win16\n' ;; + *) printf 'dos\n' ;; + esac + ;; 4855*) printf 'x68000\n' ;; 44584531*) printf 'dvx\n' ;; # "DXE1": a DVX module (.app) + 7f454c46*) + # ELF: a native program (the SDL3 port), or a Be API one, which + # names its system (jlPlatformName): BeOS, or Haiku, 64-bit + # (ELF class 2) or 32-bit. + if LC_ALL=C grep -q -a -P 'BeOS\x00' "$file"; then + printf 'beos\n' + elif LC_ALL=C grep -q -a -P 'Haiku\x00' "$file"; then + if [ "${magic:8:2}" = "02" ]; then + printf 'haiku\n' + else + printf 'haiku32\n' + fi + else + printf 'sdl3\n' + fi + ;; + 3c21646f*) printf 'web\n' ;; # "": a web program's page + 00504250*) printf 'psp\n' ;; # "\0PBP": a PSP program's EBOOT.PBP *) # Apple IIgs OMF: segment header byte 14 (NUMLEN) is 4 and # byte 15 (VERSION) is 2. @@ -225,7 +347,7 @@ waitOrStop() { waited=$((waited + 1)) done if [ -n "$snapshot" ]; then - if [ "$headless" = "1" ]; then + if [ "$headless" = "1" ] && [ "$snapshotWindow" = "0" ]; then import -window root "$snapshot" elif win="$(joeyFindWindow "$DISPLAY" "$emuPid")"; then import -window "$win" "$snapshot" @@ -449,6 +571,19 @@ runIigs() { } +# amigaBootFile HD VAR PATH COMPONENT LOSS -- the installed file named by +# $VAR at PATH on the boot drive HD, or say that without it LOSS. +amigaBootFile() { + local file="${!2:-}" + if [ -n "$file" ] && [ -f "$file" ]; then + mkdir -p "$1/$(dirname "$3")" + cp "$file" "$1/$3" + else + echo "joey-run: no $(basename "$3") installed (./install.sh install $4); $5 on the Amiga" >&2 + fi +} + + runAmiga() { local name local hd="$work/hd" @@ -460,22 +595,24 @@ runAmiga() { stageFolder "$hd" mkdir -p "$hd/s" printf ':%s\n' "$name" > "$hd/s/startup-sequence" - # Kickstart 3.1 has no serial.device in ROM; AmigaOS loads it from - # DEVS: (SYS:Devs), so a program that opens the serial port needs it on - # this boot drive. Harmless for everything else. - if [ -n "${AMIGA_SERIAL_DEVICE:-}" ] && [ -f "$AMIGA_SERIAL_DEVICE" ]; then - mkdir -p "$hd/Devs" - cp "$AMIGA_SERIAL_DEVICE" "$hd/Devs/serial.device" - else - echo "joey-run: no serial.device installed (./install.sh install amigaSerial); serial will not open on the Amiga" >&2 - fi - args=(--amiga_model=A500 --fast_memory=2048 + # Kickstart 3.1 has neither in ROM; AmigaOS loads them from this boot + # drive. Harmless for programs that use neither. + amigaBootFile "$hd" AMIGA_SERIAL_DEVICE Devs/serial.device amigaSerial "serial will not open" + amigaBootFile "$hd" AMIGA_LOWLEVEL_LIBRARY Libs/lowlevel.library amigaLowlevel "no joystick will be seen" + args=(--fast_memory=2048 --hard_drive_0="$hd" --hard_drive_0_label=JOEYLIB --automatic_input_grab=0 --middle_click_ungrab=1 --mouse_integration=1 --floppy_drive_speed=800 --base_dir="$work/fs-uae") - if [ -n "${KICKSTART_ROM:-}" ] && [ -f "$KICKSTART_ROM" ]; then - args+=(--kickstart_file="$KICKSTART_ROM") + if [ -n "$amigaModel" ]; then + # shellcheck source=scripts/lib/amigaGate.sh + source "$repo/scripts/lib/amigaGate.sh" + AMIGA_MODEL="$amigaModel" + amigaMachine + args+=("${AMIGA_MACHINE_ARGS[@]}") + elif [ -n "${KICKSTART_ROM:-}" ] && [ -f "$KICKSTART_ROM" ]; then + args+=(--amiga_model=A500 --kickstart_file="$KICKSTART_ROM") else + args+=(--amiga_model=A500) echo "joey-run: no Kickstart installed (./install.sh install kickstart); booting FS-UAE's AROS, which may not run every program" >&2 fi if [ "$headless" = "1" ]; then @@ -520,7 +657,10 @@ runAtarist() { stageFolder "$work/hd" dir="$work/hd" fi - args=(--tos "$EMUTOS_IMG" --harddrive "$dir" --gemdos-drive C --auto "C:\\$(basename "$program")") + if [ -z "$stMachine" ]; then + stMachine="$([ "$detected" = "ste" ] && echo ste || echo st)" + fi + args=(--machine "$stMachine" --tos "$EMUTOS_IMG" --harddrive "$dir" --gemdos-drive C --auto "C:\\$(basename "$program")") if [ "$headless" = "1" ]; then startDisplay args+=(--sound off) @@ -531,7 +671,7 @@ runAtarist() { relaySerial "pty,rawer,link=$serialPty" "$serialPty" args+=(--rs232-in "$serialPty" --rs232-out "$serialPty") fi - echo "joey-run: booting the Atari ST (EmuTOS); $(basename "$program") starts from drive C. Close Hatari to stop." + echo "joey-run: booting the Atari ($stMachine, EmuTOS); $(basename "$program") starts from drive C. Close Hatari to stop." serialNote "the Modem port" "$HATARI" "${args[@]}" >"$work/hatari.log" 2>&1 & emuPid=$! @@ -654,11 +794,321 @@ runX68000() { } +# runSdl3 -- the program natively. Interactive runs use its own folder, so +# its saves and log persist; --headless and --data runs a private copy. +runSdl3() { + local dir + [ -z "$serialMode" ] || fail "the SDL3 port has no serial port" + dir="$(dirname "$program")" + if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then + stageFolder "$work/run" + dir="$work/run" + fi + if [ "$headless" = "1" ]; then + startDisplay + export SDL_VIDEO_DRIVER=x11 SDL_AUDIO_DRIVER=dummy JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square + snapshotWindow=1 + fi + echo "joey-run: running $(basename "$program") natively (SDL3). Close its window to stop." + "$dir/$(basename "$program")" "${progArgs[@]}" & + emuPid=$! + waitOrStop +} + + +# runWindows -- the Win32 program under Wine, in a window on your display; +# with --headless on a private virtual display at one pixel per stage pixel, +# so --snapshot is the stage. The Wine prefix (its C: drive and settings) is +# kept in the project's build/win32/prefix for interactive runs, so it is +# set up once; --headless and serial runs use a private one, so the kept +# prefix's COM1 stays whatever device of this machine it was. +runWindows() { + local dir + local title + local win + # shellcheck source=scripts/lib/wineGate.sh + source "$repo/scripts/lib/wineGate.sh" + wineNeed + dir="$(dirname "$program")" + if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then + stageFolder "$work/run" + dir="$work/run" + fi + title="$(basename "$program")" + title="${title%.*}" + if [ -n "$serialMode" ]; then + # Wine's COM1 can only be a device, so socat stands between a pty + # and the network; before Wine, so an unreachable server stops the + # run before Wine starts. + relaySerial "pty,rawer,link=$serialPty" "$serialPty" + fi + if [ "$headless" = "1" ]; then + wineStart "$work" + xvfbPid="$WINE_XVFB_PID" + elif [ -n "$serialMode" ]; then + winePrefix "$work" + else + wineEnv "$projectRoot/build/$detected/prefix" + fi + if [ -n "$serialMode" ]; then + wineComPort 1 "$serialPty" + fi + if [ "$headless" = "1" ]; then + echo "joey-run: running $title under Wine (headless)." + serialNote "COM1" + JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square wineRun "$dir/$(basename "$program")" "${progArgs[@]}" + emuPid="$WINE_PID" + sleep "$seconds" + if [ -n "$snapshot" ]; then + if win="$(wineWindow "$title")"; then + import -window "$win" "$snapshot" + else + echo "joey-run: no $title window to snapshot" >&2 + fi + fi + wineStop + emuPid="" + xvfbPid="" + return 0 + fi + echo "joey-run: running $title under Wine. Close its window to stop." + serialNote "COM1" + "$WINE" "$dir/$(basename "$program")" "${progArgs[@]}" & + emuPid=$! + waitOrStop + if [ -n "$serialMode" ]; then + # The private prefix's Wine, before its folder goes. + wineStop + emuPid="" + fi +} + + +# runWin16 -- the program on Windows 3.11 in DOSBox (scripts/lib/win16Gate.sh), +# as Windows' shell, its folder drive D:. Interactive runs use the program's +# own folder, so saves and logs persist; --headless and --data runs a copy. +runWin16() { + local dir + local name + local win + local png="" + local found + local size="" + local deadline + local setting + local settings=() + # shellcheck source=scripts/lib/win16Gate.sh + source "$repo/scripts/lib/win16Gate.sh" + win16Need + dir="$(dirname "$program")" + if [ "$headless" = "1" ] || [ -n "$dataDir" ]; then + stageFolder "$work/run" + dir="$work/run" + fi + name="$(basename "$program")" + # transparent:1 -- data bytes only, as for DOS. + case "$serialMode" in + connect) WIN16_DOSBOX_ARGS+=(--set "serial1=nullmodem server:$serialHost port:$serialPort transparent:1") ;; + listen) WIN16_DOSBOX_ARGS+=(--set "serial1=nullmodem port:$serialPort transparent:1") ;; + esac + win16Stage "$work" "$dir" "$name" "${progArgs[@]}" + # The program reads its settings from the DOS environment Windows + # started from: this one's go there, as a Win32 or SDL3 program inherits + # them. + for setting in "${joeyWindowSettings[@]}"; do + [ -z "${!setting:-}" ] || settings+=("$setting=${!setting}") + done + if [ "$headless" = "0" ]; then + echo "joey-run: booting Windows 3.11 in DOSBox; ${name%.*} runs from drive D:. Close it to stop." + serialNote "COM1" + WIN16_HEADLESS=0 WIN16_SOUND=1 WIN16_WALL=0 win16Start "$work" "${settings[@]}" + emuPid="$WIN16_PID" + dosSerialCheck + waitOrStop + return 0 + fi + mkdir -p "$work/shots" + # DOSBox's raw snapshot on a key of its own: its Ctrl+F5 would hand the + # program a Ctrl press, which ends one that waits for any key while the + # snapshot is being taken. A mapper file replaces every binding, which a + # headless run, which types nothing, does not miss. + printf 'hand_rawshot "key %s"\n' "$dosboxShotScancode" > "$work/mapper.txt" + WIN16_DOSBOX_ARGS+=(--set "capture_dir=$work/shots" --set "mapperfile=$work/mapper.txt") + echo "joey-run: running ${name%.*} on Windows 3.11 in DOSBox (headless)." + serialNote "COM1" + win16Start "$work" "${settings[@]}" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square + emuPid="$WIN16_PID" + xvfbPid="$WIN16_XVFB_PID" + dosSerialCheck + sleep "$seconds" + if [ -n "$snapshot" ]; then + if win="$(win16Window)"; then + DISPLAY="$WIN16_DISPLAY" xdotool key --window "$win" "$dosboxShotKey" + # DOSBox makes the file before it writes it: it is done when it + # has bytes and one more poll adds none. + deadline=$((SECONDS + dosboxShotSeconds)) + while [ "$SECONDS" -lt "$deadline" ]; do + sleep "$pollSeconds" + found="$(find "$work/shots" -name '*.png' -print -quit)" + [ -s "$found" ] || continue + if [ "$(stat -c %s "$found")" = "$size" ]; then + png="$found" + break + fi + size="$(stat -c %s "$found")" + done + if [ -n "$png" ]; then + cp "$png" "$snapshot" + else + echo "joey-run: DOSBox took no snapshot" >&2 + fi + else + echo "joey-run: no DOSBox window to snapshot" >&2 + fi + fi + win16Stop + emuPid="" + xvfbPid="" +} + + +# runWeb -- the program's page, served from its folder: in your browser, or +# with --headless in headless Chromium. +runWeb() { + local page + [ -z "$serialMode" ] || fail "the web port has no serial port" + [ -z "$dataDir" ] || fail "--data does not apply to the web port: its DATA is packed in when it is built" + # shellcheck source=scripts/lib/webGate.sh + source "$repo/scripts/lib/webGate.sh" + page="$(basename "$program")" + if [ "$headless" = "1" ]; then + webNeed + webServe "$(dirname "$program")" + echo "joey-run: running $page in headless Chromium." + webDrive "$page?scale=1&aspect=square" --step "wait:$seconds" ${snapshot:+--screenshot "$snapshot"} || true + webStop + return 0 + fi + webServe "$(dirname "$program")" + echo "joey-run: serving $page at http://127.0.0.1:$WEB_PORT/$page -- Ctrl+C stops." + if command -v xdg-open > /dev/null; then + xdg-open "http://127.0.0.1:$WEB_PORT/$page" > /dev/null 2>&1 || true + fi + if [ -n "$seconds" ]; then + sleep "$seconds" + webStop + else + wait "$WEB_SERVER_PID" || true + fi +} + + +# runPsp -- the program headless under PPSSPPHeadless, from a copy of its +# folder (and --data) on a private memory stick. +runPsp() { + local name + local folder + [ -z "$serialMode" ] || fail "the PSP port has no serial port" + [ "$headless" = "1" ] || fail "the PSP port runs here only with --headless; to play it, copy $(dirname "$program") into PSP/GAME/ on a PSP's memory stick, or open its EBOOT.PBP in PPSSPP" + # shellcheck source=scripts/lib/pspGate.sh + source "$repo/scripts/lib/pspGate.sh" + pspNeed + name="$(basename "$(dirname "$program")")" + stageFolder "$work/stage/$name" + folder="$(pspStage "$work" "$work/stage/$name")" + echo "joey-run: running $name in PPSSPP (headless)." + pspRun "$work" "$folder" "$seconds" ${snapshot:+--snapshot="$snapshot"} + emuPid="$PSP_PID" + pspWait + emuPid="" + pspStrictCheck "$work" "$(dirname "$program")" || true +} + + +# runBe -- a Be API program (haiku, haiku32, beos) in its VM under QEMU +# (scripts/lib/beGate.sh), from a copy of its folder on the VM's job disk: +# in QEMU's window, with sound; the machine powers off when the program +# ends, and what it wrote in its folder (saves, its log) is copied back. +# --headless: no window and no sound, at one window pixel per stage pixel, +# and --snapshot is QEMU's picture of the whole screen. --seconds counts +# from when the job starts, not from the boot. +runBe() { + local name + local dir + local entry + local settings=() + local setting + local jobSeconds + [ -z "$serialMode" ] || fail "the $detected port has no serial port here" + # shellcheck source=scripts/lib/beGate.sh + source "$repo/scripts/lib/beGate.sh" + beNeed "$detected" + name="$(basename "$program")" + dir="$(dirname "$program")" + stageFolder "$work/job/$name" + if [ "$headless" = "1" ]; then + settings=(JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square) + else + BE_DISPLAY=sdl + BE_AUDIODEV="sdl,id=beSound" + for setting in "${joeyWindowSettings[@]}"; do + if [ -n "${!setting:-}" ]; then + settings+=("$setting=${!setting}") + fi + done + fi + # The job outlasts --seconds a little, so the snapshot finds the + # program still up. + jobSeconds=$((${seconds:-$beForeverSeconds} + beSnapshotSeconds)) + beJobScript "$name" "$name" "" "$jobSeconds" "${settings[@]}" > "$work/job/job.sh" + beJobDisk "$work/job.img" "$work/job" || fail "could not make the VM's job disk" + echo "joey-run: running $name in the $detected VM (QEMU); it powers off when the program ends." + beStart "$work" "$work/job.img" || fail "the $detected VM did not start (QEMU's log: $work/qemu.log)" + emuPid="$BE_PID" + beWaitJob "$work" "$beBootSeconds" || fail "the $detected VM did not start the job within $beBootSeconds s" + if [ -n "$snapshot" ]; then + # Nothing moves the VM's pointer, so it would stay in the middle of + # the screen, over the window (which hides it only once it moves + # there): put it in the corner before the window opens. + beQmp "$work" move "-$bePointerCorner" "-$bePointerCorner" || true + fi + if [ -n "$seconds" ]; then + sleep "$seconds" + if [ -n "$snapshot" ]; then + beQmp "$work" screendump "$snapshot" || echo "joey-run: no screen to snapshot" >&2 + fi + beWaitExit "$work" "$((beSnapshotSeconds + beShutdownSeconds))" || beStop + else + wait "$emuPid" || true + fi + emuPid="" + BE_PID="" + if [ "$headless" = "0" ] && [ -z "$dataDir" ]; then + if ! beJobGet "$work/job.img" "$name" "$work/back"; then + echo "joey-run: could not copy the program's files back off the job disk (was QEMU closed before the machine powered off?)" >&2 + return 0 + fi + # Only what the run made or changed: the folder may hold other + # programs, which keep their files and times. + for entry in "$work/back"/*; do + case "$(basename "$entry")" in + "$name"|DATA) ;; + *) + if [ -d "$entry" ] || ! cmp -s "$entry" "$dir/$(basename "$entry")"; then + cp -r "$entry" "$dir/" + fi + ;; + esac + done + fi +} + + # ---- main --------------------------------------------------------------------- while [ $# -gt 0 ]; do case "$1" in - --port) port="${2:?--port needs iigs, amiga, atarist, dos, x68000 or dvx}"; shift ;; + --port) port="${2:?--port needs iigs, amiga, atarist, ste, dos, x68000, dvx, sdl3, web, win32, win16, psp, haiku, haiku32 or beos}"; shift ;; --project) [ -d "${2:-}" ] || fail "--project needs a project folder (one built on make/project.mk)" projectRoot="$(cd "$2" && pwd)" @@ -672,7 +1122,9 @@ while [ $# -gt 0 ]; do case "${2:-}" in rom01) machine="apple2gsr1" ;; rom03) machine="apple2gs" ;; - *) fail "--machine takes rom01 or rom03" ;; + st|megast|ste|megaste) stMachine="$2" ;; + a500|a1200|a4000) amigaModel="${2^^}" ;; + *) fail "--machine takes rom01 or rom03 (IIgs), st, megast, ste or megaste (Atari), or a500, a1200 or a4000 (Amiga)" ;; esac shift ;; @@ -739,8 +1191,8 @@ detected="$(magicPort "$program")" if [ -n "$port" ] && [ "$port" != "$detected" ]; then fail "$program is a $detected program, not $port" fi -if [ ${#progArgs[@]} -gt 0 ] && [ "$detected" != "dos" ]; then - fail "program arguments only work on DOS" +if [ ${#progArgs[@]} -gt 0 ] && [ "$detected" != "dos" ] && [ "$detected" != "sdl3" ] && [ "$detected" != "win32" ] && [ "$detected" != "win16" ]; then + fail "program arguments only work on DOS, SDL3, Win32 and Win16" fi work="$(mktemp -d -t joey-run.XXXXXX)" @@ -750,10 +1202,16 @@ trap cleanup EXIT INT TERM case "$detected" in iigs) runIigs ;; amiga) runAmiga ;; - atarist) runAtarist ;; + atarist|ste) runAtarist ;; dos) runDos ;; x68000) runX68000 ;; dvx) runDvx ;; + sdl3) runSdl3 ;; + web) runWeb ;; + win32) runWindows ;; + win16) runWin16 ;; + psp) runPsp ;; + haiku|haiku32|beos) runBe ;; esac if [ -n "$snapshot" ] && [ -f "$snapshot" ]; then echo "joey-run: snapshot saved to $snapshot" diff --git a/make/amiga.mk b/make/amiga.mk index bdcd639..85721b0 100644 --- a/make/amiga.mk +++ b/make/amiga.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Amiga (Bebbo m68k-amigaos-gcc) build rules: JoeyLib's library for the # Amiga, and programRules, the Amiga's rules for one program, which # make/programs.mk instantiates for every program (a project reaches both diff --git a/make/atarist.mk b/make/atarist.mk index 34ad7cb..3929c42 100644 --- a/make/atarist.mk +++ b/make/atarist.mk @@ -1,9 +1,19 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Atari ST (m68k-atari-mint-gcc) build rules: JoeyLib's library for the ST, # and programRules, the ST's rules for one program, which make/programs.mk # instantiates for every program (a project reaches both through # make/project.mk). JoeyLib's own examples and gates are at the end. +# +# The same rules build the STe variant: make/ste.mk sets ATARI_VARIANT to +# ste and includes this file. That build lands in build/ste, compiles +# src/atarist/ste as well, and defines JOEYLIB_ATARI_STE (BLiTTER, DMA +# sound, 4-bit palette; its jlInit refuses anything but an STe or Mega +# STe). The ST build never compiles src/atarist/ste. -PLATFORM := atarist +PLATFORM := $(or $(ATARI_VARIANT),atarist) include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk $(call requireTool,$(ST_CC),toolchain:atarist) @@ -11,8 +21,15 @@ $(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:atarist) ST_SHIM := $(REPO_DIR)/make/support/atarist/include-shim +# STe-only sources and flags (empty in the ST build). +ifeq ($(PLATFORM),ste) +STE_SRC_DIR := $(SRC_DIR)/atarist/ste +STE_CFLAGS := -DJOEYLIB_ATARI_STE -I$(STE_SRC_DIR) +STE_C_SRCS := $(wildcard $(STE_SRC_DIR)/*.c) +endif + # so flag experiments never edit a tracked file (perf-hunt-2 rank 17). -CFLAGS := $(COMMON_CFLAGS) -m68000 -fomit-frame-pointer -I$(LIBXMP_DIR)/include -I$(ST_SHIM) -I$(SRC_DIR)/atarist -I$(SRC_68K) -MMD -MP +CFLAGS := $(COMMON_CFLAGS) $(STE_CFLAGS) -m68000 -fomit-frame-pointer -I$(LIBXMP_DIR)/include -I$(ST_SHIM) -I$(SRC_DIR)/atarist -I$(SRC_68K) -MMD -MP LDFLAGS := # libxmp-lite shared with the DOS port. Built as a static archive that @@ -43,6 +60,7 @@ LIB_OBJS := \ $(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \ $(patsubst $(SRC_DIR)/atarist/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \ $(patsubst $(SRC_DIR)/atarist/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \ + $(patsubst $(STE_SRC_DIR)/%.c,$(BUILD)/obj/ste/%.o,$(STE_C_SRCS)) \ $(patsubst $(SRC_68K)/%.s,$(BUILD)/obj/68k/%.o,$(SHARED_S)) \ $(BUILD)/obj/68k/spriteEmitInterleaved68k.o \ $(BUILD)/obj/codegen/spriteCompile.o \ @@ -62,6 +80,12 @@ $(BUILD)/obj/port/%.o: $(SRC_DIR)/atarist/%.c @mkdir -p $(dir $@) $(ST_CC) $(CFLAGS) -c $< -o $@ +ifeq ($(PLATFORM),ste) +$(BUILD)/obj/ste/%.o: $(STE_SRC_DIR)/%.c + @mkdir -p $(dir $@) + $(ST_CC) $(CFLAGS) -c $< -o $@ +endif + # Hand-written 68k assembly: GAS syntax, fed through the gcc driver # so the bundled m68k-atari-mint-as (binutils) does the work. No vasm # dependency on the ST path. @@ -131,20 +155,24 @@ include $(REPO_DIR)/make/examples.mk include $(REPO_DIR)/make/programs.mk .DEFAULT_GOAL := all -.PHONY: all atarist clean-atarist clean atarist-dirtymark atarist-verify-dirtymark -all atarist: $(LIB) $(LIBXMP_AR) programs +.PHONY: all $(PLATFORM) clean-$(PLATFORM) clean +all $(PLATFORM): $(LIB) $(LIBXMP_AR) programs +clean-$(PLATFORM): + rm -rf $(BUILD) + +# `clean` as an alias for `clean-atarist` (or `clean-ste`). Without it a bare +# `make -f make/atarist.mk clean` FAILS -- and with stderr discarded that +# looks like success while leaving stale objects the next build links. +clean: clean-$(PLATFORM) + +# The dirty-mark check is the ST build's (DIRTYMRK_PORTS). +ifeq ($(PLATFORM),atarist) +.PHONY: atarist-dirtymark atarist-verify-dirtymark atarist-dirtymark: $(call programBin,DIRTYMRK) atarist-verify-dirtymark: $(call programBin,DIRTYMRK) $(REPO_DIR)/scripts/verify-atarist-dirtymark.sh - -clean-atarist: - rm -rf $(BUILD) - -# `clean` as an alias for `clean-atarist`. Without it a bare -# `make -f make/atarist.mk clean` FAILS -- and with stderr discarded that -# looks like success while leaving stale objects the next build links. -clean: clean-atarist +endif endif diff --git a/make/be.mk b/make/be.mk new file mode 100644 index 0000000..3d9f472 --- /dev/null +++ b/make/be.mk @@ -0,0 +1,28 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Be API build rules: JoeyLib programs as Haiku programs, through the hosted +# core and its Be API backend (src/hosted, src/hosted/be, C++). The rules are +# make/hosted.mk's; this file says what the Be ports share, and +# make/haiku.mk (x86_64, gcc 13) and make/haiku32.mk (32-bit x86, gcc 2.95: +# the BeOS C++ ABI) what their compilers are. A project reaches them through +# make/project.mk. +# +# A program is a Haiku executable NAME (no extension), in bin/ or in its own +# bin/NAME/ with its DATA beside it: the port makes the executable's folder +# the current directory when the window opens. Cross-compiled on this +# machine; joey-run and the gates run it in a Haiku VM under QEMU. Each +# includes common.mk, sets HOSTED_CC, HOSTED_CXX and HOSTED_AR, and +# BE_CXX_RUNTIME (its C++ runtime library), then includes this file. + +HOSTED_BACKEND := be +HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_BE +HOSTED_SDK_CFLAGS := +# The Interface and Application Kits (libbe), the Media Kit's BSoundPlayer +# (libmedia), the Device Kit's BJoystick (libdevice); the backend's C++ is +# linked by the C compiler, so its runtime is named too. +HOSTED_LINK := -lbe -lmedia -ldevice $(BE_CXX_RUNTIME) +PROGRAM_EXT := + +include $(REPO_DIR)/make/hosted.mk diff --git a/make/beos.mk b/make/beos.mk new file mode 100644 index 0000000..7d5f5a7 --- /dev/null +++ b/make/beos.mk @@ -0,0 +1,24 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# BeOS R5 build rules (make beos): the Be API port, make/be.mk, built with +# gcc 2.95 -- the BeOS C++ ABI, the compiler make haiku32 uses (component +# haikuGcc2) -- against BeOS R5's own headers and libraries from your +# BeOS 5 Pro CD (component beosR5), through make/support/be/gcc2.sh in its +# R5 mode (BE_R5_SYSROOT). R5's C++ runtime is libstdc++.r4, as Haiku's +# gcc 2.95 one is. +PLATFORM := beos +include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk + +$(call requireTool,$(HAIKU32_ROOT)/cross/bin/i586-pc-haiku-g++,toolchain:beos) +$(call requireTool,$(BEOS_R5_SYSROOT)/system-lib/libbe.so,toolchain:beos) +$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:beos) + +BEOS_GCC2 := BE_R5_SYSROOT=$(BEOS_R5_SYSROOT) $(HAIKU32_GCC2) $(HAIKU32_ROOT) +HOSTED_CC := $(BEOS_GCC2) gcc +HOSTED_CXX := $(BEOS_GCC2) g++ +HOSTED_AR := $(BEOS_GCC2) ar +BE_CXX_RUNTIME := -lstdc++.r4 + +include $(REPO_DIR)/make/be.mk diff --git a/make/common.mk b/make/common.mk index 143f8ce..9325242 100644 --- a/make/common.mk +++ b/make/common.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Shared variables used by per-target Makefile fragments. # # Source this only via the per-target make file (make/iigs.mk etc), which @@ -25,13 +29,22 @@ capFirst = $(patsubst a%,A%,$(patsubst b%,B%,$(patsubst c%,C%,$(patsubst d%,D%,$ # The ports, and which of them can build here: a port is available when its # real compiler binary is installed (DVX also needs the DVX SDK). The top # Makefile's `make` and a project's `make` build exactly these. -JOEY_PORTS := iigs amiga atarist dos x68000 dvx +JOEY_PORTS := iigs amiga atarist ste dos x68000 dvx sdl3 web win32 win16 psp haiku haiku32 beos HAVE_IIGS := $(if $(wildcard $(LLVM816_ROOT)/tools/llvm-mos-build/bin/clang),1) HAVE_AMIGA := $(if $(wildcard $(AMIGA_CC)),1) HAVE_ATARIST := $(if $(wildcard $(ST_CC)),1) +HAVE_STE := $(HAVE_ATARIST) HAVE_DOS := $(if $(wildcard $(DOS_CC)),1) HAVE_X68000 := $(if $(wildcard $(X68K_ROOT)/bin/m68k-xelf-gcc),1) HAVE_DVX := $(if $(and $(wildcard $(DOS_CC)),$(wildcard $(DVX_SDK)/sdk.mk)),1) +HAVE_SDL3 := $(if $(wildcard $(SDL3_DIR)/lib/libSDL3.a),1) +HAVE_WEB := $(if $(and $(wildcard $(EMCC)),$(wildcard $(SDL3_WEB_DIR)/lib/libSDL3.a)),1) +HAVE_WIN32 := $(if $(wildcard $(WIN32_CC)),1) +HAVE_WIN16 := $(if $(wildcard $(WIN16_WATCOM)/binl64/owcc),1) +HAVE_PSP := $(if $(and $(wildcard $(PSP_CC)),$(wildcard $(SDL3_PSP_DIR)/lib/libSDL3.a)),1) +HAVE_HAIKU := $(if $(wildcard $(HAIKU_CROSS)g++),1) +HAVE_HAIKU32 := $(if $(wildcard $(HAIKU32_ROOT)/cross/bin/i586-pc-haiku-g++),1) +HAVE_BEOS := $(if $(and $(HAVE_HAIKU32),$(wildcard $(BEOS_R5_SYSROOT)/system-lib/libbe.so)),1) JOEY_PORTS_INSTALLED := $(foreach p,$(JOEY_PORTS),$(if $(HAVE_$(call uc,$(p))),$(p))) # Where things are built. JOEY_BUILD holds what is JoeyLib's -- each port's @@ -49,7 +62,9 @@ export JOEY_BUILD INCLUDE_DIR := $(REPO_DIR)/include # Machine-specific code lives in src// (iigs, amiga, atarist, dos, # x68000, dvx); code shared across machines of one CPU lives in a processor -# folder (src/m68k, src/x86). +# folder (src/m68k, src/x86); the hosted core and its backends in src/hosted +# (sdl3, web and psp build the same SDL3 backend, natively, for a browser +# and for the PSP; win32 and win16 their own). SRC_DIR := $(REPO_DIR)/src SRC_CORE := $(REPO_DIR)/src/core SRC_CG := $(REPO_DIR)/src/codegen @@ -87,8 +102,12 @@ BINDIR := $(PROGRAM_BUILD)/bin PORT_TAG := $(call uc,$(PLATFORM)) # Baked assets (sprite and tile banks) carry the target they were baked for. # DVX shows the DOS port's stage and runs its x86 code, so it takes the DOS -# bakes; every other port its own. -ASSET_PLATFORM := $(if $(filter dvx,$(PLATFORM)),dos,$(PLATFORM)) +# bakes; the hosted ports' stage (SDL3, the web, Win32, Win16, the PSP, +# Haiku, BeOS) is chunky like the DOS one, so they take them too (the cels of a +# pre-compiled bank, without their code); the STe build is the ST port, so it +# takes the ST's (same planes, same compiled sprites); every other port its +# own. +ASSET_PLATFORM := $(if $(filter dvx sdl3 web win32 win16 psp haiku haiku32 beos,$(PLATFORM)),dos,$(if $(filter ste,$(PLATFORM)),atarist,$(PLATFORM))) # The host sprite pre-compiler for this port's bakes (.spr -> .spc); its rule # is make/programs.mk's. SPRITEBAKE := $(JOEY_BUILD)/tools/spritebake-$(ASSET_PLATFORM) @@ -100,7 +119,7 @@ endif # PROGRAM_EXT, PROGRAM_FOLDERS (programs may ship in a folder of their own), # PROGRAM_DATA_APART (DATA is staged apart from the program, for the # packager) and PROGRAM_OPTIONAL_APART (optional DATA apart again), and may -# redefine programNameRule, programDir and programTarget. +# redefine programNameRule, programDir, programBin and programTarget. # $(call programVar,ID,VAR): the program's VAR on this port -- ID__VAR # when that is defined (even empty), else ID_VAR. diff --git a/make/dos.mk b/make/dos.mk index 1f4d552..2b8a7de 100644 --- a/make/dos.mk +++ b/make/dos.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # DOS (DJGPP) build rules: JoeyLib's library for DOS, and programRules, the # DOS rules for one program, which make/programs.mk instantiates for every # program (a project reaches both through make/project.mk). JoeyLib's own diff --git a/make/dvx.mk b/make/dvx.mk index 5a2ee30..5504c6a 100644 --- a/make/dvx.mk +++ b/make/dvx.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # DVX build rules: every example as a DVX program (.app). # # A JoeyLib program on DVX runs in a window of the DVX desktop, or on the diff --git a/make/examples.mk b/make/examples.mk index 7fb02d2..4871b30 100644 --- a/make/examples.mk +++ b/make/examples.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # JoeyLib's own example programs, described for make/programs.mk (which # lists what each variable means). Every port's makefile includes this file, # then make/programs.mk, which makes each port's rules for them. @@ -11,8 +15,8 @@ # palettes, so a misplaced band boundary is immediately visible. # AUDIO every audio path: chip music with noise drums, MOD and SFX # (ADPCM on the X68000), a raw-format stream and a noise sweep. -# AUDIOPRB is AUDIO pressing its own keys, for the IIgs and -# X68000 gates. +# AUDIOPRB is AUDIO pressing its own keys, for the IIgs, +# X68000 and PSP gates. # KEYS the input and mouse vehicle: key predicates, the typed line # (jlInputGetChar) and its verification row. # SERIAL the serial gates' echo program. @@ -25,16 +29,19 @@ # changes a frame (every line for 128 of them), for the raster # interrupt that reloads the text plane's one palette as the beam # scans. +# PALPROBE Amiga: the palette gate's program: how fast palette and SCB +# changes reach the screen through the copper, and a held screen +# of bands to check that they reach it right. PROGRAMS := HELLO PATTERN DRAW KEYS SERIAL SERTEST SERMOUSE SAVE TILEBANK SCB JOY SPRITE AUDIO UBER ADV ADV2 -TEST_PROGRAMS := SCCPROBE SERLOSS SERLOSSS DIRTYMRK UBERPROB AUDIOPRB +TEST_PROGRAMS := SCCPROBE SERLOSS SERLOSSS DIRTYMRK UBERPROB AUDIOPRB PALPROBE # DVX's help and resource texts call these JoeyLib examples. DVX_KIND := JoeyLib example # The IIgs build has never included HELLO. HELLO_SRCS := $(EXAMPLES)/hello/hello.c -HELLO_PORTS := amiga atarist dos x68000 dvx +HELLO_PORTS := amiga atarist ste dos x68000 dvx HELLO_TITLE := Hello HELLO_HELP := The smallest JoeyLib program: it clears the screen and waits for a key. PATTERN_SRCS := $(EXAMPLES)/pattern/pattern.c @@ -113,6 +120,9 @@ DIRTYMARK_LOG := DIRTYMRK.TXT DIRTYMRK_SRCS := $(REPO_DIR)/tests/host/dirtyMarkHost.c DIRTYMRK_PORTS := atarist DIRTYMRK_BINDIR := $(PROGRAM_BUILD)/test +PALPROBE_SRCS := $(EXAMPLES)/palprobe/palprobe.c +PALPROBE_PORTS := amiga +PALPROBE_BINDIR := $(PROGRAM_BUILD)/test DIRTYMRK_CFLAGS := -DDIRTY_MARK_SCENARIOS=$(DIRTYMARK_SCENARIOS) -DDIRTY_MARK_LOG='"$(DIRTYMARK_LOG)"' # UBER with its UBER_PROBE rows (the present checked against the stage), @@ -133,11 +143,12 @@ UBERPROB_DVX_BINDIR := $(PROGRAM_BUILD)/probe # AUDIO with its keys pressed on a timetable (-DAUDIO_PROBE): N once, S every # 1.5 seconds, under the tracker song. The IIgs and X68000 audio gates read -# their sound chips under MAME and cannot type, so this is their subject -# (iigs-verify-audio, x68000-verify-audio). Built apart, so neither bin/ nor -# the shipped AUDIO changes; it needs no DATA. +# their sound chips under MAME and cannot type, and the PSP has no keys to +# type with, so this is their subject (iigs-verify-audio, x68000-verify-audio, +# psp-verify-audio). Built apart, so neither bin/ nor the shipped AUDIO +# changes; it needs no DATA. AUDIOPRB_SRCS := $(AUDIO_SRCS) -AUDIOPRB_PORTS := iigs x68000 +AUDIOPRB_PORTS := iigs x68000 psp AUDIOPRB_CFLAGS := -DAUDIO_PROBE AUDIOPRB_BINDIR := $(PROGRAM_BUILD)/probe AUDIOPRB_IIGS_BINDIR := $(PROGRAM_BUILD)/probe/bin diff --git a/make/haiku.mk b/make/haiku.mk new file mode 100644 index 0000000..b9f9e2f --- /dev/null +++ b/make/haiku.mk @@ -0,0 +1,20 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Haiku x86_64 build rules (make haiku): the Be API port, make/be.mk, built +# with Haiku's gcc 13 cross compiler against the R1/beta6 sysroot (the +# installer's haikuGcc component). + +PLATFORM := haiku +include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk + +$(call requireTool,$(HAIKU_CROSS)g++,toolchain:haiku) +$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:haiku) + +HOSTED_CC := $(HAIKU_CROSS)gcc --sysroot=$(HAIKU_SYSROOT) +HOSTED_CXX := $(HAIKU_CROSS)g++ --sysroot=$(HAIKU_SYSROOT) +HOSTED_AR := $(HAIKU_CROSS)ar +BE_CXX_RUNTIME := -lstdc++ + +include $(REPO_DIR)/make/be.mk diff --git a/make/haiku32.mk b/make/haiku32.mk new file mode 100644 index 0000000..ab3328e --- /dev/null +++ b/make/haiku32.mk @@ -0,0 +1,22 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Haiku x86 (32-bit) build rules (make haiku32): the Be API port, +# make/be.mk, built with gcc 2.95 -- the BeOS C++ ABI, 32-bit Haiku's +# primary compiler (x86_gcc2h) -- against the R1/beta6 sysroot (the +# installer's haikuGcc2 component), through make/support/be/gcc2.sh, which +# gives it the command line the shared rules give a modern gcc. + +PLATFORM := haiku32 +include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk + +$(call requireTool,$(HAIKU32_ROOT)/cross/bin/i586-pc-haiku-g++,toolchain:haiku32) +$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:haiku32) + +HOSTED_CC := $(HAIKU32_GCC2) $(HAIKU32_ROOT) gcc +HOSTED_CXX := $(HAIKU32_GCC2) $(HAIKU32_ROOT) g++ +HOSTED_AR := $(HAIKU32_GCC2) $(HAIKU32_ROOT) ar +BE_CXX_RUNTIME := -lstdc++.r4 + +include $(REPO_DIR)/make/be.mk diff --git a/make/hosted.mk b/make/hosted.mk new file mode 100644 index 0000000..d3bba3b --- /dev/null +++ b/make/hosted.mk @@ -0,0 +1,140 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# The hosted ports' shared build rules (src/hosted): JoeyLib's library for +# the hosted core and one backend, libxmp-lite, and programRules, the rules +# for one program, which make/programs.mk instantiates for every program. +# Included by make/sdl3.mk (a native program), make/web.mk (the same +# SDL3 backend compiled to WebAssembly), make/psp.mk (the same again, for +# the PSP), make/win32.mk, make/win16.mk and make/be.mk (Haiku), after each +# has included common.mk and set: +# HOSTED_CC the compiler +# HOSTED_AR the archiver +# HOSTED_BACKEND the backend folder under src/hosted +# HOSTED_DEFINES the platform and backend macros +# HOSTED_SDK_CFLAGS the backend's library headers (the backend only) +# HOSTED_LINK what a program links after the library and libxmp-lite +# PROGRAM_EXT the program file's extension +# and, when the backend is C++ (src/hosted//*.cpp, the Be API's), +# HOSTED_CXX its C++ compiler +# and may set HOSTED_PROGRAM_DEPS, files every program is rebuilt after (the +# web page's shell), and, when a program needs its DATA at link time (the web +# port packs it into the program), HOSTED_LINK_DATA: the link flags for one +# program's DATA folder, $(1) the program's id. When the program file is +# packed from what is linked (the PSP's EBOOT.PBP, from an ELF), the port +# sets HOSTED_LINK_OUT, the file one program is linked to, and HOSTED_PACK, +# the command that packs it into the program file ($(1) the id in both). +# +# The stage is the chunky generic one, as on the BLANK port: the core, every +# generic (the sprite interpreter stub among them -- no machine code is +# generated), the hosted core and the backend. + +HOSTED_DIR := $(SRC_DIR)/hosted +BACKEND_DIR := $(HOSTED_DIR)/$(HOSTED_BACKEND) + +# LIBXMP_STATIC: libxmp-lite is linked in, never a DLL (xmp.h marks its +# functions dllimport on Windows without it). +CFLAGS := $(COMMON_CFLAGS) $(HOSTED_DEFINES) -DLIBXMP_STATIC -I$(HOSTED_DIR) -I$(LIBXMP_DIR)/include -MMD -MP + +# libxmp-lite (MIT, libxmp.org): the MOD player the hosted mixer runs, the +# same sources and flags as the DOS port's -- and its win32.c, libxmp's own +# snprintf for a C library without a safe one (Open Watcom's, the win16 +# port's), which compiles to nothing for the others. +LIBXMP_SRC := $(wildcard $(LIBXMP_DIR)/src/*.c) $(wildcard $(LIBXMP_DIR)/src/loaders/*.c) +LIBXMP_OBJDIR := $(BUILD)/obj/libxmp-lite +LIBXMP_OBJS := $(patsubst $(LIBXMP_DIR)/src/%.c,$(LIBXMP_OBJDIR)/%.o,$(LIBXMP_SRC)) +LIBXMP_AR := $(LIBDIR)/libxmplite.a +LIBXMP_CFLAGS := -DJOEY_LIBXMP_LITE -DLIBXMP_CORE_PLAYER -DHAVE_FNMATCH=0 -I$(LIBXMP_DIR)/include -I$(LIBXMP_DIR)/include/libxmp-lite -I$(LIBXMP_DIR)/src -Wno-error -w + +HOSTED_C_SRCS := $(wildcard $(HOSTED_DIR)/*.c) +BACKEND_C_SRCS := $(wildcard $(BACKEND_DIR)/*.c) +BACKEND_CXX_SRCS := $(wildcard $(BACKEND_DIR)/*.cpp) + +LIB_OBJS := \ + $(patsubst $(SRC_CORE)/%.c,$(BUILD)/obj/core/%.o,$(CORE_C_SRCS)) \ + $(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS) $(SRC_DIR)/generic/spriteEmitStub.c) \ + $(patsubst $(HOSTED_DIR)/%.c,$(BUILD)/obj/hosted/%.o,$(HOSTED_C_SRCS)) \ + $(patsubst $(BACKEND_DIR)/%.c,$(BUILD)/obj/backend/%.o,$(BACKEND_C_SRCS)) \ + $(patsubst $(BACKEND_DIR)/%.cpp,$(BUILD)/obj/backend/%.o,$(BACKEND_CXX_SRCS)) + +LIB := $(LIBDIR)/libjoey.a + +$(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c + @mkdir -p $(dir $@) + $(HOSTED_CC) $(CFLAGS) -c $< -o $@ + +$(BUILD)/obj/generic/%.o: $(SRC_DIR)/generic/%.c + @mkdir -p $(dir $@) + $(HOSTED_CC) $(CFLAGS) -c $< -o $@ + +$(BUILD)/obj/hosted/%.o: $(HOSTED_DIR)/%.c + @mkdir -p $(dir $@) + $(HOSTED_CC) $(CFLAGS) -c $< -o $@ + +# Only the backend sees its library's headers. +$(BUILD)/obj/backend/%.o: $(BACKEND_DIR)/%.c + @mkdir -p $(dir $@) + $(HOSTED_CC) $(CFLAGS) $(HOSTED_SDK_CFLAGS) -c $< -o $@ + +$(BUILD)/obj/backend/%.o: $(BACKEND_DIR)/%.cpp + @mkdir -p $(dir $@) + $(HOSTED_CXX) $(CFLAGS) $(HOSTED_SDK_CFLAGS) -c $< -o $@ + +# Archived from scratch, so the members follow LIB_OBJS rather than the order +# earlier builds added them in. +$(LIB): $(LIB_OBJS) + @mkdir -p $(dir $@) + rm -f $@ + $(HOSTED_AR) rcs $@ $^ + +$(LIBXMP_OBJDIR)/%.o: $(LIBXMP_DIR)/src/%.c + @mkdir -p $(dir $@) + $(HOSTED_CC) $(CFLAGS) $(LIBXMP_CFLAGS) -c $< -o $@ + +$(LIBXMP_AR): $(LIBXMP_OBJS) + @mkdir -p $(dir $@) + rm -f $@ + $(HOSTED_AR) rcs $@ $^ + +# Pull in per-object header-dependency files generated by -MMD/-MP. +-include $(LIB_OBJS:.o=.d) + +# ---- programs ----------------------------------------------------------------- +PROGRAM_FOLDERS := yes + +# The file one program, $(1) its id, is linked to: the program file itself +# unless the port packs it afterwards. +hostedLinkOut = $(or $(call HOSTED_LINK_OUT,$(1)),$(call programBin,$(1))) + +# One program, $(1) its id: compiled and linked in one step with the library, +# libxmp-lite and the backend's libraries (and the maths library the mixer's +# tables use), its DATA too where the port packs it in, then packed where the +# port packs it. -MMD leaves the dependency file beside what is linked, for +# header edits. +define programRules +$(call programBin,$(1)): $(call programVar,$(1),SRCS) $(LIB) $(LIBXMP_AR) $(HOSTED_PROGRAM_DEPS) $(if $(HOSTED_LINK_DATA),$(call programVar,$(1),DATA)) + @mkdir -p $$(dir $$@) $(dir $(call hostedLinkOut,$(1))) + $(HOSTED_CC) $(CFLAGS) $(call programVar,$(1),CFLAGS) $(call programVar,$(1),SRCS) $(LIB) $(LIBXMP_AR) $(HOSTED_LINK) $(if $(HOSTED_LINK_DATA),$(call HOSTED_LINK_DATA,$(1))) -lm -o $(call hostedLinkOut,$(1)) $(call programVar,$(1),LDFLAGS) + $(call HOSTED_PACK,$(1)) +-include $(basename $(call hostedLinkOut,$(1))).d +endef + +# ---- JoeyLib's own build: its examples ------------------------------------------ +ifndef JOEY_PROJECT + +include $(REPO_DIR)/make/examples.mk +include $(REPO_DIR)/make/programs.mk + +.DEFAULT_GOAL := all +.PHONY: all $(PLATFORM) clean-$(PLATFORM) clean +all $(PLATFORM): $(LIB) $(LIBXMP_AR) programs + +clean-$(PLATFORM): + rm -rf $(BUILD) + +# `clean` as an alias for `clean-`, so a bare +# `make -f make/.mk clean` cleans instead of failing. +clean: clean-$(PLATFORM) + +endif diff --git a/make/iigs.mk b/make/iigs.mk index c7684e7..b1fca29 100644 --- a/make/iigs.mk +++ b/make/iigs.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Apple IIgs build rules (llvm-mos w65816: clang + llvm-mc + link816). # # make/support/iigs/clang-build.sh drives compile (clang) / assemble diff --git a/make/programs.mk b/make/programs.mk index 2ef8370..f348ca8 100644 --- a/make/programs.mk +++ b/make/programs.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Every program's rules, for JoeyLib's own examples and for a project's # programs alike: one description per program, one set of per-port rules. # diff --git a/make/project.mk b/make/project.mk index b1b4977..28191f6 100644 --- a/make/project.mk +++ b/make/project.mk @@ -1,3 +1,7 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Build programs that live outside JoeyLib, with JoeyLib's toolchains, # library, asset tools and packaging -- the same rules JoeyLib's own examples # use. A project's Makefile: @@ -16,7 +20,7 @@ # # make every port the programs build for whose toolchain # is installed (ports without one are noted, skipped) -# make one port: iigs amiga atarist dos x68000 dvx +# make one port: iigs amiga atarist ste dos x68000 dvx sdl3 web win32 win16 psp # make - one of that port's goals: iigs-game-disk GAME=NAME, # iigs-verify-game-disk, iigs-game-disks # make JOEY_PORT= diff --git a/make/psp.mk b/make/psp.mk new file mode 100644 index 0000000..f82afdc --- /dev/null +++ b/make/psp.mk @@ -0,0 +1,65 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# PSP build rules: JoeyLib programs as PSP homebrew, through the hosted core +# and the same SDL3 backend the sdl3 port uses (src/hosted, +# src/hosted/sdl3), cross-compiled with pspdev's psp-gcc against SDL3's PSP +# port (the installer's sdl3Psp component). The rules are make/hosted.mk's; +# this file says what is the PSP's. A project reaches it through +# make/project.mk. +# +# A program is a folder of its own, bin/NAME/: EBOOT.PBP, titled ID_TITLE +# (or NAME) in the PSP's menu, and its DATA/ beside it. The folder goes into +# PSP/GAME/ on the memory stick as it is: the program's folder is its +# current directory, as on every port, so DATA/, SAVES/ and joeylog.txt are +# there. The ELF the EBOOT.PBP is packed from (make/support/psp/packEboot.sh) +# stays in build/psp/elf, for psp-addr2line. + +PLATFORM := psp +include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk + +$(call requireTool,$(PSP_CC),toolchain:psp) +SDL3_PSP_LIB := $(SDL3_PSP_DIR)/lib/libSDL3.a +$(call requireTool,$(SDL3_PSP_LIB),toolchain:psp) +$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:psp) + +# Only this SDL's own .pc: a host SDL3 must not answer for the PSP's. +SDL3_PSP_PKG_CONFIG := PKG_CONFIG_LIBDIR=$(SDL3_PSP_DIR)/lib/pkgconfig pkg-config +PSP_SDK := $(PSPDEV)/psp/sdk +# The firmware the PSPSDK's headers are read for (pspdev's default). +PSP_FW_VERSION := 600 +PSP_PACK := $(REPO_DIR)/make/support/psp/packEboot.sh + +HOSTED_CC := $(PSP_CC) +HOSTED_AR := $(PSPDEV)/bin/psp-ar +HOSTED_BACKEND := sdl3 +HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_SDL3 +HOSTED_SDK_CFLAGS := $(shell $(SDL3_PSP_PKG_CONFIG) --cflags sdl3) -I$(PSP_SDK)/include -D_PSP_FW_VERSION=$(PSP_FW_VERSION) +# Section alignment as the PSPSDK links: smaller programs. Every program +# takes the module information the PSP loads it by from the backend +# (PSP_MODULE_INFO), even one that never opens the window. +HOSTED_LINK := $(shell $(SDL3_PSP_PKG_CONFIG) --static --libs sdl3) -L$(PSP_SDK)/lib -Wl,-zmax-page-size=128 -Wl,-u,module_info +PROGRAM_EXT := +# A program is relinked when SDL changes. +HOSTED_PROGRAM_DEPS := $(SDL3_PSP_LIB) +# A program is linked to its ELF, then packed into its folder's EBOOT.PBP. +HOSTED_LINK_OUT = $(PROGRAM_BUILD)/elf/$(call programName,$(1)).elf +HOSTED_PACK = $(PSP_PACK) $(call HOSTED_LINK_OUT,$(1)) '$(or $(call programVar,$(1),TITLE),$(call programName,$(1)))' $(call programBin,$(1)) + +# Every program in a folder of its own, as PSP/GAME/ holds it. +programDir = $(or $(call programVar,$(1),BINDIR),$(BINDIR))/$(call programName,$(1)) +programBin = $(call programDir,$(1))/EBOOT.PBP + +include $(REPO_DIR)/make/hosted.mk + +# ---- gates -------------------------------------------------------------------- +ifndef JOEY_PROJECT + +# Audio gate: AUDIOPRB (AUDIO pressing its own keys, make/examples.mk) under +# PPSSPPHeadless, its sound recorded and graded (a few seconds). +.PHONY: psp-verify-audio +psp-verify-audio: $(call programBin,AUDIOPRB) + $(REPO_DIR)/scripts/verify-psp-audio.sh $(call programDir,AUDIOPRB) + +endif diff --git a/make/sdl3.mk b/make/sdl3.mk new file mode 100644 index 0000000..c3cf4e0 --- /dev/null +++ b/make/sdl3.mk @@ -0,0 +1,35 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# SDL3 build rules: JoeyLib programs as native programs for the machine that +# builds them, through the hosted core and its SDL3 backend (src/hosted, +# src/hosted/sdl3). The rules are make/hosted.mk's; this file says what is +# SDL3's. A project reaches it through make/project.mk. +# +# Built with the host's own compiler. Programs link the static SDL3 the +# installer builds (SDL3_DIR), so they run without an SDL3 on the machine. A +# program is a native executable NAME (no extension), in bin/ or in its own +# bin/NAME/ with its DATA beside it, JoeyLib's install convention: the port +# makes the executable's folder the current directory when the window opens. + +PLATFORM := sdl3 +include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk + +SDL3_LIB := $(SDL3_DIR)/lib/libSDL3.a +$(call requireTool,$(SDL3_LIB),toolchain:sdl3) +$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:sdl3) + +SDL3_PKG_CONFIG := PKG_CONFIG_PATH=$(SDL3_DIR)/lib/pkgconfig pkg-config + +HOSTED_CC := $(HOST_CC) +HOSTED_AR := ar +HOSTED_BACKEND := sdl3 +HOSTED_DEFINES := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_SDL3 +HOSTED_SDK_CFLAGS := $(shell $(SDL3_PKG_CONFIG) --cflags sdl3) +HOSTED_LINK := $(shell $(SDL3_PKG_CONFIG) --static --libs sdl3) +PROGRAM_EXT := +# A program is relinked when SDL changes. +HOSTED_PROGRAM_DEPS := $(SDL3_LIB) + +include $(REPO_DIR)/make/hosted.mk diff --git a/make/ste.mk b/make/ste.mk new file mode 100644 index 0000000..33a67a5 --- /dev/null +++ b/make/ste.mk @@ -0,0 +1,15 @@ +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Atari STe / Mega STe build rules: the ST port (src/atarist) built as its +# STe variant, with src/atarist/ste and JOEYLIB_ATARI_STE -- the BLiTTER for +# fills and copies, DMA sound, the exact 4-bit palette, and a Mega STe's +# 16 MHz clock and cache. The rules are make/atarist.mk's; this file says +# what is the STe's. Programs land in build/ste/bin. The STe build's jlInit +# refuses any machine that is not an STe or Mega STe; the ST build (make +# atarist) runs everywhere and never uses STe hardware. A project reaches +# it through make/project.mk (make ste). + +ATARI_VARIANT := ste +include $(dir $(lastword $(MAKEFILE_LIST)))/atarist.mk diff --git a/make/support/atarist/include-shim/math.h b/make/support/atarist/include-shim/math.h index c56128d..156751c 100644 --- a/make/support/atarist/include-shim/math.h +++ b/make/support/atarist/include-shim/math.h @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Minimal math.h shim for the m68k-atari-mint toolchain (mintlib // doesn't ship or libm). Declares only what libxmp-lite // reaches for; the implementations live in math_shim.c next to this diff --git a/make/support/atarist/include-shim/math_shim.c b/make/support/atarist/include-shim/math_shim.c index cc49138..f46be82 100644 --- a/make/support/atarist/include-shim/math_shim.c +++ b/make/support/atarist/include-shim/math_shim.c @@ -1,3 +1,7 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// // Tiny libm replacement used by libxmp-lite on the m68k-atari-mint // toolchain. Only the functions libxmp-lite calls are implemented. // Accuracy targets: a few hundred parts per million, plenty for diff --git a/make/support/be/gcc2.sh b/make/support/be/gcc2.sh new file mode 100755 index 0000000..a51ded1 --- /dev/null +++ b/make/support/be/gcc2.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# gcc2.sh - Haiku's gcc 2.95 cross compiler (component haikuGcc2, the +# x86_gcc2 primary compiler, kept for BeOS binary compatibility) behind the +# command line the shared build rules give a modern gcc, so make/hosted.mk +# drives it unchanged. +# +# gcc2.sh ROOT gcc|g++|ar ARGS... +# +# ROOT is the component: cross/ holds the compiler, sysroot/ R1/beta6's +# headers and libraries. With BE_R5_SYSROOT set (make beos: component +# beosR5's sysroot) it builds for BeOS R5 instead, which the same ABI runs +# on: R5's own header tree, start files and libraries in place of Haiku's. +# Programs stay position-independent (-pie), as R5's own are (ET_DYN), but +# their relocations are not combined (-z nocombreloc): the DT_RELCOUNT tag +# that comes with combined ones is newer than R5, whose loader will not run +# a program with a tag it does not know ("cannot execute binary file"). R5's +# libroot leaves memcpy_internal and memset_internal to the loader, which +# the driver already allows (--allow-shlib-undefined). The compiler was +# built for /tools/cross-tools-x86_gcc2 and is not relocatable, so it is +# pointed at its own programs (-B), and +# gcc 2.95 has no --sysroot, so the headers it would find in +# /boot/system/develop/headers on Haiku are named one by one. Its binutils +# (ar too) find their shared libraries through LD_LIBRARY_PATH. As Haiku's +# own gcc 2.95 does, the Be API's four-character constants ('BAPP') are not +# warned about; strict aliasing stays off, and gcc2Attributes.h, beside this +# script, is included first: the GCC attributes gcc 2.95 does not know. The +# modern options are translated: +# -Wextra is gcc 2.95's -W in C; in C++ it is left out, as -W warns +# about the Be API's own headers +# -MMD -MP gcc 2.95 has neither: the dependency file is made after a +# successful compile by a second, -M, pass -- named and +# targeted as a modern gcc would (X.d for -o X.o, NAME.d for a +# program linked as -o NAME), every header also a target of +# its own (-MP), so a deleted header does not stop make. The +# toolchain's own headers are left out, as -MMD leaves out +# system headers (gcc 2.95's -MM would leave out every +#
, JoeyLib's own among them). +set -euo pipefail + +root="$1" +driver="$2" +shift 2 + +machine="i586-pc-haiku" +version="2.95.3-haiku-2017_07_20" +cross="$root/cross" +r5="${BE_R5_SYSROOT:-}" +if [ -n "$r5" ]; then + headerRoot="$r5/headers" + # R5's gcc's include directories, under develop/headers. + headerDirs="be be/app be/device be/drivers be/game be/interface be/kernel be/mail be/media be/midi be/midi2 be/net be/opengl be/storage be/support be/translation be/add-ons/graphics be/add-ons/input_server be/add-ons/screen_saver be/add-ons/tracker cpp gnu posix" + libSetup=(-B"$r5/develop-lib/x86/" -L"$r5/develop-lib/x86" -L"$r5/system-lib") + # What R5 lacks of C99's headers (stdint.h), beside this script, and + # R5's gcc's system macro in place of Haiku's: the compiler says + # __HAIKU__, as Haiku's own, where R5's said __BEOS__. + compatSetup=(-isystem "$(dirname "$0")/r5" -U__HAIKU__ -D__BEOS__) + # Where R5's libraries find each other's, when linking, and no combined + # relocations (above). + linkFlags=(-Wl,-rpath-link,"$r5/system-lib" -Wl,-z,nocombreloc) +else + develop="$root/sysroot/boot/system/develop" + headerRoot="$develop/headers" + # The include directories of Haiku's own gcc 2.95, in its order + # (buildtools' legacy/gcc/gcc/config/i386/haiku.h), under develop/headers. + headerDirs="os os/app os/device os/drivers os/game os/interface os/kernel os/locale os/mail os/media os/midi os/midi2 os/net os/opengl os/storage os/support os/translation os/add-ons/graphics os/add-ons/input_server os/add-ons/screen_saver os/add-ons/tracker os/be_apps/Deskbar os/be_apps/NetPositive os/be_apps/Tracker 3rdparty bsd glibc gnu posix" + libSetup=(-B"$develop/lib/" -L"$develop/lib") + compatSetup=() + linkFlags=() +fi + +export LD_LIBRARY_PATH="$cross/x86_64-unknown-linux-gnu/$machine/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + +if [ "$driver" = ar ]; then + exec "$cross/bin/$machine-ar" "$@" +fi + +setup=(-B"$cross/lib/gcc-lib/$machine/$version/" -B"$cross/$machine/bin/" "${compatSetup[@]}") +for dir in $headerDirs; do + setup+=(-isystem "$headerRoot/$dir") +done +setup+=(-isystem "$headerRoot" -isystem "$headerRoot/c++" -include "$(dirname "$0")/gcc2Attributes.h") + +args=() # the compile, translated +cppArgs=() # what the dependency pass needs: the preprocessor's options and the sources +sources=() +output="" +compileOnly=0 +depFile=0 +while [ $# -gt 0 ]; do + case "$1" in + -Wextra) + if [ "$driver" = gcc ]; then + args+=(-W) + fi + ;; + -MMD) + depFile=1 + ;; + -MP) + ;; + -lm) + # R5 keeps the maths in libroot and has no libm. + if [ -z "$r5" ]; then + args+=(-lm) + fi + ;; + -o) + output="$2" + args+=(-o "$2") + shift + ;; + -c) + compileOnly=1 + args+=(-c) + ;; + -D*|-U*|-I*) + args+=("$1") + cppArgs+=("$1") + ;; + -isystem|-include|-imacros) + args+=("$1" "$2") + cppArgs+=("$1" "$2") + shift + ;; + *.c|*.cc|*.cpp) + args+=("$1") + sources+=("$1") + ;; + *) + args+=("$1") + ;; + esac + shift +done + +# After the caller's options: -Wall would turn the multi-character warning +# back on. +if [ "$compileOnly" = 1 ]; then + linkFlags=() +fi +"$cross/bin/$machine-$driver" "${setup[@]}" "${libSetup[@]}" "${args[@]}" "${linkFlags[@]}" -Wno-multichar -fno-strict-aliasing + +if [ "$depFile" = 0 ] || [ ${#sources[@]} -eq 0 ] || [ -z "$output" ]; then + exit 0 +fi +if [ "$compileOnly" = 1 ]; then + dep="${output%.*}.d" +else + dep="$output.d" +fi +# One rule for the output from every source's rule, then each header as a +# target of its own; nothing from the toolchain (or R5's sysroot). +"$cross/bin/$machine-$driver" "${setup[@]}" -M "${cppArgs[@]}" "${sources[@]}" | awk -v target="$output" -v toolchain="$root/" -v sysroot="${r5:+$r5/}" ' + { + line = $0 + sub(/\\$/, "", line) + sub(/^[^:]*:/, "", line) + count = split(line, words, " ") + for (i = 1; i <= count; i++) { + if (index(words[i], toolchain) != 1 && (sysroot == "" || index(words[i], sysroot) != 1) && !(words[i] in seen)) { + seen[words[i]] = 1 + order[++total] = words[i] + } + } + } + END { + printf "%s:", target + for (i = 1; i <= total; i++) { + printf " %s", order[i] + } + printf "\n" + for (i = 1; i <= total; i++) { + if (order[i] !~ /\.(c|cc|cpp)$/) { + printf "\n%s:\n", order[i] + } + } + }' > "$dep" diff --git a/make/support/be/gcc2Attributes.h b/make/support/be/gcc2Attributes.h new file mode 100644 index 0000000..e06563b --- /dev/null +++ b/make/support/be/gcc2Attributes.h @@ -0,0 +1,21 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// gcc2Attributes.h - force-included into every gcc 2.95 compile by +// make/support/be/gcc2.sh. JoeyLib's sources use three GCC attributes that +// came with gcc 3.1 and 3.3, which gcc 2.95 would warn away (and -Werror +// stop). Each name becomes __unused__, an attribute gcc 2.95 knows and that +// changes nothing here (an empty name would leave an empty slot in the +// attribute list, which crashes gcc 2.95). Nothing is lost: gcc 2.95 +// inlines no differently for them, and strict aliasing, which may_alias +// guards against, is off for every gcc 2.95 compile. + +#ifndef JOEYLIB_GCC2_ATTRIBUTES_H +#define JOEYLIB_GCC2_ATTRIBUTES_H + +#define always_inline __unused__ +#define may_alias __unused__ +#define noinline __unused__ + +#endif diff --git a/make/support/be/r5/stdint.h b/make/support/be/r5/stdint.h new file mode 100644 index 0000000..b54204b --- /dev/null +++ b/make/support/be/r5/stdint.h @@ -0,0 +1,32 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// stdint.h for BeOS R5 (make beos): R5 predates C99 and has none. Its +// posix/inttypes.h has the exact-width and pointer-width types; this adds +// the limits C99's gives with them. make/support/be/gcc2.sh puts +// this folder on the include path in its R5 mode only. + +#ifndef JOEYLIB_R5_STDINT_H +#define JOEYLIB_R5_STDINT_H + +#include + +#define INT8_MIN (-128) +#define INT8_MAX 127 +#define UINT8_MAX 255 +#define INT16_MIN (-32767 - 1) +#define INT16_MAX 32767 +#define UINT16_MAX 65535 +#define INT32_MIN (-2147483647 - 1) +#define INT32_MAX 2147483647 +#define UINT32_MAX 4294967295U +#define INT64_MIN (-9223372036854775807LL - 1) +#define INT64_MAX 9223372036854775807LL +#define UINT64_MAX 18446744073709551615ULL +#define INTPTR_MIN INT32_MIN +#define INTPTR_MAX INT32_MAX +#define UINTPTR_MAX UINT32_MAX +#define SIZE_MAX UINT32_MAX + +#endif diff --git a/make/support/dos/embed-dpmi.sh b/make/support/dos/embed-dpmi.sh index 732725d..593425e 100755 --- a/make/support/dos/embed-dpmi.sh +++ b/make/support/dos/embed-dpmi.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Embed the CWSDPMI DPMI server into a DJGPP-built DOS executable so # the resulting .EXE is self-contained and does not require a separate # CWSDPMI.EXE on the target machine. Run as a post-link step from the diff --git a/make/support/dvx/writeIfChanged.sh b/make/support/dvx/writeIfChanged.sh index faa0c1c..d2fae02 100755 --- a/make/support/dvx/writeIfChanged.sh +++ b/make/support/dvx/writeIfChanged.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # Write standard input to FILE only when it differs from what FILE holds, so # make/dvx.mk can regenerate a program's SDK project files on every build # without touching their times (sdk.mk would otherwise rebuild the help and diff --git a/make/support/iigs/clang-build.sh b/make/support/iigs/clang-build.sh index a50801b..9adc13b 100755 --- a/make/support/iigs/clang-build.sh +++ b/make/support/iigs/clang-build.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # clang-build.sh - Build an Apple IIgs binary with the llvm-mos w65816 # toolchain (clang + llvm-mc + link816). # diff --git a/make/support/iigs/clang-smoke.sh b/make/support/iigs/clang-smoke.sh index 6c072bb..3585093 100755 --- a/make/support/iigs/clang-smoke.sh +++ b/make/support/iigs/clang-smoke.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # clang-smoke.sh - End-to-end smoke test of the llvm-mos w65816 IIgs # build path. Compiles a tiny C main that calls JoeyLib's ported asm # (iigsInitRowLut), links it with clang-build.sh, runs it under MAME's diff --git a/make/support/iigs/ntpIrqWindow.py b/make/support/iigs/ntpIrqWindow.py index 0b7d680..a5fd30b 100755 --- a/make/support/iigs/ntpIrqWindow.py +++ b/make/support/iigs/ntpIrqWindow.py @@ -1,4 +1,8 @@ #!/usr/bin/env python3 +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# # ntpIrqWindow.py SRC DST - bound NinjaTrackerPlus's interrupts-off stretches. # # NinjaTrackerPlus moves sample data into the DOC with interrupts masked, diff --git a/make/support/psp/packEboot.sh b/make/support/psp/packEboot.sh new file mode 100755 index 0000000..9c0c1f9 --- /dev/null +++ b/make/support/psp/packEboot.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +# See LICENSE.md for license information. +# SPDX-License-Identifier: MIT +# +# Pack a linked PSP program into the EBOOT.PBP the PSP runs, the way the +# PSPSDK's own build.mak does: fix up its module imports in place, write a +# PARAM.SFO with its title, and pack that and a stripped copy of the ELF +# (no icon, pictures or sound) into the EBOOT.PBP. The unstripped ELF stays +# where it is, for psp-addr2line. Run as make/psp.mk's packing step. +# +# Usage: packEboot.sh PROGRAM.elf TITLE EBOOT.PBP +# +# Requires $PSPDEV (exported by make from the installer's +# toolchains/config.mk; toolchains/env.sh for a shell). + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 PROGRAM.elf TITLE EBOOT.PBP" >&2 + exit 2 +fi + +elf=$1 +title=$2 +eboot=$3 +# PARAM.SFO's MEMSIZE: the PSPSDK's default for firmware after 3.90 (the +# whole user memory a model has). +memSize=2 + +if [[ -z "${PSPDEV:-}" ]]; then + echo "PSPDEV not set; run ./install.sh install toolchain:psp" >&2 + exit 1 +fi + +stem=${elf%.elf} +"$PSPDEV/bin/psp-fixup-imports" "$elf" +"$PSPDEV/bin/psp-strip" "$elf" -o "$stem.stripped.elf" +"$PSPDEV/bin/mksfoex" -d MEMSIZE="$memSize" "$title" "$stem.sfo" +"$PSPDEV/bin/pack-pbp" "$eboot" "$stem.sfo" NULL NULL NULL NULL NULL "$stem.stripped.elf" NULL > /dev/null diff --git a/make/support/web/shell.html b/make/support/web/shell.html new file mode 100644 index 0000000..950610a --- /dev/null +++ b/make/support/web/shell.html @@ -0,0 +1,71 @@ + + + + + + + +JoeyLib + + + + +
Click the picture to give it the keyboard and sound.
+

+
+{{{ SCRIPT }}}
+
+
diff --git a/make/support/win16/noAttributes.h b/make/support/win16/noAttributes.h
new file mode 100644
index 0000000..93d2e55
--- /dev/null
+++ b/make/support/win16/noAttributes.h
@@ -0,0 +1,11 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Force-included into every file the win16 port compiles (owcc.sh beside
+// this file). Open Watcom has no GCC attributes, and the ones JoeyLib uses
+// are hints for other compilers -- noinline, always_inline, may_alias (Watcom
+// assumes any pointer may alias unless told otherwise) and the 68000 ports'
+// aligned(2) -- that a Watcom build does without.
+
+#define __attribute__(x)
diff --git a/make/support/win16/owcc.sh b/make/support/win16/owcc.sh
new file mode 100755
index 0000000..82c9091
--- /dev/null
+++ b/make/support/win16/owcc.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# owcc.sh - Open Watcom (component openWatcom) behind the command lines the
+# shared build rules give a gcc and an ar, so make/hosted.mk drives it
+# unchanged for the win16 port: 32-bit flat-model C for Watcom's Win386
+# target, bound to its Windows 3.x supervisor.
+#
+#   owcc.sh cc ARGS...          compile (-c), or compile and link a program
+#   owcc.sh ar rcs LIB OBJ...   make a library (Watcom's wlib)
+#
+# WIN16_WATCOM is the Open Watcom tree (make exports it from
+# toolchains/config.mk). Every file is compiled with the Watcom headers,
+# floating point as calls into the library (-fpc: no 387 and no emulator
+# VxD needed), noAttributes.h force-included (Watcom has no GCC attributes)
+# and Watcom's warning W202 off: it reports a static that is never used even
+# when a header defines it (a table some of its includers do not read),
+# which gcc and clang leave alone, and a project that builds on every other
+# port must build here too. The gcc options that mean something else to
+# owcc:
+#   -O2 -O3   owcc's include -oa, which assumes no global is ever reached
+#             through a pointer (JoeyLib's are), and -on; this is -ox
+#             (branch, inline, loop and scheduling optimisation, no stack
+#             checks) and -ot instead
+#   -MMD -MP  Watcom's dependency file names the object and its source as
+#             given (-ad, -adt, -add); each header is then made a target of
+#             its own (-MP), so a deleted header does not stop make
+#   -Wno-error dropped (owcc misreads it); -w is owcc's -w0
+#   -lm       dropped: the maths is in Watcom's C library
+# A program (no -c): each source is compiled as above, the objects and
+# libraries are linked into a 32-bit image (NAME.rex, its stack STACK_BYTES)
+# and that is bound to the supervisor (WIN386.EXT), which makes NAME.EXE, a
+# Windows 3.x NE executable whose module name is NAME -- Windows takes two
+# programs with one module name for two copies of one program.
+set -euo pipefail
+
+STACK_BYTES=262144
+
+: "${WIN16_WATCOM:?WIN16_WATCOM is not set; run: ./install.sh install toolchain:win16}"
+export WATCOM="$WIN16_WATCOM"
+export PATH="$WATCOM/binl64:$PATH"
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+mode="$1"
+shift
+
+if [ "$mode" = "ar" ]; then
+    # ar rcs LIB OBJ...: the library is made new each time (-n), quietly, with
+    # case-sensitive names and no .bak copy.
+    shift
+    exec wlib -q -n -b -c "$@"
+fi
+[ "$mode" = "cc" ] || { echo "owcc.sh: cc or ar, not '$mode'" >&2; exit 2; }
+
+flags=(-bwin386 -fpc -I"$WATCOM/h" -include "$here/noAttributes.h" -Wc,-wcd=202)
+sources=()
+libraries=()
+output=""
+compileOnly=0
+depFile=0
+while [ $# -gt 0 ]; do
+    case "$1" in
+        -c)          compileOnly=1 ;;
+        -o)          output="$2"; shift ;;
+        -MMD)        depFile=1 ;;
+        -MP|-Wno-error|-lm) ;;
+        -O2|-O3)     flags+=(-Wc,-obmiler -Wc,-ot -Wc,-s) ;;
+        *.c)         sources+=("$1") ;;
+        *.a)         libraries+=("$1") ;;
+        *)           flags+=("$1") ;;
+    esac
+    shift
+done
+[ -n "$output" ] && [ ${#sources[@]} -gt 0 ] || { echo "owcc.sh: cc needs -o and a source" >&2; exit 2; }
+
+
+# compileOne SOURCE OBJECT DEPS -- compile one source; with -MMD, append its
+# dependency rule, made a rule for TARGET (the object, or the program), to
+# DEPS.
+compileOne() {
+    local depArgs=()
+    if [ "$depFile" = 1 ]; then
+        depArgs=(-MD -MF "$2.d" -MT "$2" -Wc,-add="$1")
+    fi
+    owcc "${flags[@]}" "${depArgs[@]}" -c "$1" -o "$2"
+    if [ "$depFile" = 1 ]; then
+        sed "s|^[^:]*:|$target:|" "$2.d" >> "$3"
+        rm -f "$2.d"
+    fi
+}
+
+
+# phonyHeaders DEPS -- every prerequisite in DEPS that is not a source, as a
+# target of its own (gcc's -MP).
+phonyHeaders() {
+    awk '{ for (i = 3; i <= NF; i++) if ($i !~ /\.c$/ && !($i in seen)) { seen[$i] = 1; printf "\n%s:\n", $i } }' "$1" >> "$1"
+}
+
+
+if [ "$compileOnly" = 1 ]; then
+    target="$output"
+    deps="${output%.*}.d"
+    rm -f "$deps"
+    compileOne "${sources[0]}" "$output" "$deps"
+    if [ "$depFile" = 1 ]; then
+        phonyHeaders "$deps"
+    fi
+    exit 0
+fi
+
+name="$(basename "${output%.*}")"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+target="$output"
+deps="${output%.*}.d"
+rm -f "$deps"
+objects=()
+for source in "${sources[@]}"; do
+    objects+=("$work/${#objects[@]}.o")
+    compileOne "$source" "${objects[-1]}" "$deps"
+done
+if [ "$depFile" = 1 ]; then
+    phonyHeaders "$deps"
+fi
+owcc -bwin386 -o "$work/$name.rex" "${objects[@]}" "${libraries[@]}" "-Wl,option stack=$STACK_BYTES"
+(cd "$work" && wbind "$name" -n -q -s "$WATCOM/binw/win386.ext")
+mv "$work/$name.exe" "$output"
diff --git a/make/tools.mk b/make/tools.mk
index 12bacf8..fe1608a 100644
--- a/make/tools.mk
+++ b/make/tools.mk
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Host-side tools for the JoeyLib asset pipeline.
 #
 # These build with the host's default `cc` (no cross-toolchain) into
@@ -27,6 +31,9 @@ MODLOOPEND_BIN := $(BUILD_DIR)/modloopend
 # byte-identical to the target JIT (see tools/spritebake/spritebake.c).
 SPRITEBAKE_SRC   := $(TOOLS_DIR)/spritebake/spritebake.c $(SRC_CG)/spriteStage.c
 SPRITEBAKE_INC   := -I$(INCLUDE_DIR) -I$(SRC_CORE) -I$(SRC_CG)
+# The headers that carry the .spc format and the emitted-code ABI (a bump of
+# SPC_MAGIC must rebuild every baker, or its .spc files would be refused).
+SPRITEBAKE_HDRS  := $(wildcard $(INCLUDE_DIR)/joey/*.h $(SRC_CORE)/*.h $(SRC_CG)/*.h)
 
 .PHONY: all tools clean-tools modloopend \
         spritebake-dos spritebake-amiga spritebake-atarist spritebake-iigs spritebake-x68000
@@ -42,27 +49,27 @@ $(MODLOOPEND_BIN): $(MODLOOPEND_SRC)
 	@mkdir -p $(dir $@)
 	$(HOST_CC) $(HOST_CFLAGS) $< -o $@
 
-$(BUILD_DIR)/spritebake-dos: $(SPRITEBAKE_SRC) $(SRC_X86)/spriteEmitX86.c
+$(BUILD_DIR)/spritebake-dos: $(SPRITEBAKE_SRC) $(SRC_X86)/spriteEmitX86.c $(SPRITEBAKE_HDRS)
 	@mkdir -p $(dir $@)
-	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_DOS $(SPRITEBAKE_INC) -I$(SRC_X86) $^ -o $@
+	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_DOS $(SPRITEBAKE_INC) -I$(SRC_X86) $(filter %.c,$^) -o $@
 
-$(BUILD_DIR)/spritebake-amiga: $(SPRITEBAKE_SRC) $(SRC_68K)/spriteEmitPlanar68k.c
+$(BUILD_DIR)/spritebake-amiga: $(SPRITEBAKE_SRC) $(SRC_68K)/spriteEmitPlanar68k.c $(SPRITEBAKE_HDRS)
 	@mkdir -p $(dir $@)
-	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_AMIGA $(SPRITEBAKE_INC) -I$(SRC_DIR)/amiga -I$(SRC_68K) $^ -o $@
+	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_AMIGA $(SPRITEBAKE_INC) -I$(SRC_DIR)/amiga -I$(SRC_68K) $(filter %.c,$^) -o $@
 
 # The X68000 runs the Amiga's planar 68k emitter unchanged (see
 # make/x68000.mk), so its bake is the same source under its own target byte.
-$(BUILD_DIR)/spritebake-x68000: $(SPRITEBAKE_SRC) $(SRC_68K)/spriteEmitPlanar68k.c
+$(BUILD_DIR)/spritebake-x68000: $(SPRITEBAKE_SRC) $(SRC_68K)/spriteEmitPlanar68k.c $(SPRITEBAKE_HDRS)
 	@mkdir -p $(dir $@)
-	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_X68000 $(SPRITEBAKE_INC) -I$(SRC_DIR)/x68000 -I$(SRC_68K) $^ -o $@
+	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_X68000 $(SPRITEBAKE_INC) -I$(SRC_DIR)/x68000 -I$(SRC_68K) $(filter %.c,$^) -o $@
 
-$(BUILD_DIR)/spritebake-atarist: $(SPRITEBAKE_SRC) $(SRC_68K)/spriteEmitInterleaved68k.c
+$(BUILD_DIR)/spritebake-atarist: $(SPRITEBAKE_SRC) $(SRC_68K)/spriteEmitInterleaved68k.c $(SPRITEBAKE_HDRS)
 	@mkdir -p $(dir $@)
-	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_ATARIST $(SPRITEBAKE_INC) -I$(SRC_DIR)/atarist -I$(SRC_68K) $^ -o $@
+	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_ATARIST $(SPRITEBAKE_INC) -I$(SRC_DIR)/atarist -I$(SRC_68K) $(filter %.c,$^) -o $@
 
-$(BUILD_DIR)/spritebake-iigs: $(SPRITEBAKE_SRC) $(SRC_DIR)/iigs/spriteEmitIigs.c
+$(BUILD_DIR)/spritebake-iigs: $(SPRITEBAKE_SRC) $(SRC_DIR)/iigs/spriteEmitIigs.c $(SPRITEBAKE_HDRS)
 	@mkdir -p $(dir $@)
-	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_IIGS $(SPRITEBAKE_INC) $^ -o $@
+	$(HOST_CC) $(HOST_CFLAGS) -DJOEYLIB_PLATFORM_IIGS $(SPRITEBAKE_INC) $(filter %.c,$^) -o $@
 
 clean-tools:
 	rm -rf $(BUILD_DIR)
diff --git a/make/web.mk b/make/web.mk
new file mode 100644
index 0000000..aac102c
--- /dev/null
+++ b/make/web.mk
@@ -0,0 +1,56 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Web build rules: JoeyLib programs as web pages, through the hosted core and
+# the same SDL3 backend the native SDL3 port uses (src/hosted,
+# src/hosted/sdl3), compiled to WebAssembly with Emscripten. The rules are
+# make/hosted.mk's; this file says what is the web's. A project reaches it
+# through make/project.mk.
+#
+# A program is a folder of its own, bin/NAME/: NAME.html (the page, from
+# make/support/web/shell.html), NAME.js and NAME.wasm, and NAME.data, its
+# DATA folder packed in (Emscripten's --preload-file: the program finds it
+# at /DATA, its current directory being /). Serve the folder over HTTP and
+# open NAME.html; joey-run --port web does both.
+#
+# A JoeyLib program owns its loop and blocks in jlWaitVBL, which a browser
+# does not allow, so programs are built with ASYNCIFY: the backend's waits
+# hand the browser its turn (emscripten_sleep) and the program carries on
+# where it was, unmodified.
+
+PLATFORM := web
+include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk
+
+$(call requireTool,$(EMCC),toolchain:web)
+SDL3_WEB_LIB := $(SDL3_WEB_DIR)/lib/libSDL3.a
+$(call requireTool,$(SDL3_WEB_LIB),toolchain:web)
+$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:web)
+
+# Emscripten's own environment (where its config and cache are), for every
+# recipe.
+export EM_CONFIG
+export EM_CACHE
+
+WEB_SHELL := $(REPO_DIR)/make/support/web/shell.html
+
+# The asyncify unwind stack: deep enough for a wait inside a program's
+# own call chain several levels down.
+WEB_ASYNCIFY_STACK := 65536
+
+HOSTED_CC         := $(EMCC)
+HOSTED_AR         := $(dir $(EMCC))emar
+HOSTED_BACKEND    := sdl3
+HOSTED_DEFINES    := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_SDL3
+HOSTED_SDK_CFLAGS := -I$(SDL3_WEB_DIR)/include
+HOSTED_LINK       := $(SDL3_WEB_LIB) -sASYNCIFY -sASYNCIFY_STACK_SIZE=$(WEB_ASYNCIFY_STACK) -sALLOW_MEMORY_GROWTH=1 -lidbfs.js -sEXPORTED_RUNTIME_METHODS=FS,ENV --shell-file $(WEB_SHELL)
+PROGRAM_EXT       := .html
+# A program's page is rebuilt when the shell or SDL changes.
+HOSTED_PROGRAM_DEPS := $(WEB_SHELL) $(SDL3_WEB_LIB)
+
+# Every program in a folder of its own, with only its own DATA beside it:
+# the DATA folder is packed into the program.
+programDir       = $(or $(call programVar,$(1),BINDIR),$(BINDIR))/$(call programName,$(1))
+HOSTED_LINK_DATA = $(if $(call programVar,$(1),DATA),--preload-file $(call programDataDir,$(1))@/DATA)
+
+include $(REPO_DIR)/make/hosted.mk
diff --git a/make/win16.mk b/make/win16.mk
new file mode 100644
index 0000000..ba1eb37
--- /dev/null
+++ b/make/win16.mk
@@ -0,0 +1,40 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Win16 build rules: JoeyLib programs as Windows 3.x programs, through the
+# hosted core and its Win16 backend (src/hosted, src/hosted/win16), for
+# Windows 3.1 on a 386 or better. The rules are make/hosted.mk's; this file
+# says what is the Win16 port's. A project reaches it through
+# make/project.mk.
+#
+# Built with Open Watcom's Win386 target (the installer's openWatcom
+# component) through make/support/win16/owcc.sh, which gives hosted.mk the
+# gcc and ar it expects: 32-bit flat-model code -- libxmp-lite and the shared
+# SFX mixer need a 32-bit int, and a 386 runs it faster than 16-bit far
+# pointers -- bound to Watcom's supervisor, so a program is one Windows 3.x
+# NE executable NAME.EXE, in bin/ or in its own bin/NAME/ with its DATA
+# beside it, calling USER, GDI, KERNEL and MMSYSTEM through the supervisor.
+# The frame is 8-bit indexed (a 256-colour DIB holding all 16 palettes) and
+# the sound 22.05 kHz mono, what Windows 3.1 and a 386 handle well. The port
+# makes the program's folder the current directory when the window opens.
+
+PLATFORM := win16
+include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk
+
+$(call requireTool,$(WIN16_WATCOM)/binl64/owcc,toolchain:win16)
+$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:win16)
+
+WIN16_OWCC        := $(REPO_DIR)/make/support/win16/owcc.sh
+
+HOSTED_CC         := $(WIN16_OWCC) cc
+HOSTED_AR         := $(WIN16_OWCC) ar
+HOSTED_BACKEND    := win16
+HOSTED_DEFINES    := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_WIN16 -DHOSTED_FRAME_INDEXED -DHOSTED_FRAME_BOTTOM_UP -DHOSTED_AUDIO_RATE=22050u -DHOSTED_AUDIO_CHANNELS=1u
+# Watcom's Windows headers, and the set-1 scan code table the DOS port reads
+# its keys through.
+HOSTED_SDK_CFLAGS := -I$(WIN16_WATCOM)/h/win -I$(SRC_X86)
+HOSTED_LINK       :=
+PROGRAM_EXT       := .EXE
+
+include $(REPO_DIR)/make/hosted.mk
diff --git a/make/win32.mk b/make/win32.mk
new file mode 100644
index 0000000..7508c15
--- /dev/null
+++ b/make/win32.mk
@@ -0,0 +1,34 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Win32 build rules: JoeyLib programs as Windows programs, through the hosted
+# core and its Win32 backend (src/hosted, src/hosted/win32), for Windows 95,
+# 98, ME, NT 4 and 2000 and everything since. The rules are make/hosted.mk's;
+# this file says what is the Win32 port's. A project reaches it through
+# make/project.mk.
+#
+# Built with llvm-mingw's i686 clang against MSVCRT.DLL (the installer's
+# llvmMingw component): a 32-bit GUI program NAME.EXE, in bin/ or in its own
+# bin/NAME/ with its DATA beside it, linked statically so it needs nothing
+# but the system's own DLLs. The port makes the program's folder the current
+# directory when the window opens.
+
+PLATFORM := win32
+include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk
+
+$(call requireTool,$(WIN32_CC),toolchain:win32)
+$(call requireTool,$(LIBXMP_DIR)/include/libxmp-lite/xmp.h,toolchain:win32)
+
+HOSTED_CC         := $(WIN32_CC)
+HOSTED_AR         := $(WIN32_AR)
+HOSTED_BACKEND    := win32
+HOSTED_DEFINES    := -DJOEYLIB_PLATFORM_HOSTED -DJOEYLIB_HOSTED_WIN32
+# The set-1 scan code table the DOS port reads its keys through.
+HOSTED_SDK_CFLAGS := -I$(SRC_X86)
+# Subsystem and OS version 4.0 in the PE header: Windows 95, 98 and NT 4
+# refuse to load anything marked newer (the default is 6.0, Vista).
+HOSTED_LINK       := -mwindows -static -lwinmm -lgdi32 -Wl,--subsystem,windows:4.0 -Wl,--major-os-version,4 -Wl,--minor-os-version,0
+PROGRAM_EXT       := .EXE
+
+include $(REPO_DIR)/make/hosted.mk
diff --git a/make/x68000.mk b/make/x68000.mk
index e548278..718c3e9 100644
--- a/make/x68000.mk
+++ b/make/x68000.mk
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Sharp X68000 (elf2x68k / m68k-xelf-gcc) build rules.
 #
 # The port draws planar into the text plane (src/x68000/halPlanar.c), shares
diff --git a/scripts/bench-amiga.sh b/scripts/bench-amiga.sh
index 9b19787..6b498c7 100755
--- a/scripts/bench-amiga.sh
+++ b/scripts/bench-amiga.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Headless UBER perf capture for the Amiga (FS-UAE).
 #
 # Stages the built Amiga binaries + DATA into a scratch directory-HD
@@ -19,9 +23,9 @@ set -euo pipefail
 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 joeyNeed FSUAE fsuae
-joeyNeed KICKSTART_ROM kickstart
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
 bin_dir=$repo/build/amiga/bin
-kickstart=$KICKSTART_ROM
 timeout_s=${TIMEOUT:-150}
 
 if [[ ! -f $bin_dir/Uber ]]; then
@@ -57,11 +61,10 @@ trap cleanup EXIT
 
 echo "bench-amiga: running UBER headless (timeout ${timeout_s}s)..." >&2
 DISPLAY=$display "$FSUAE" \
-    --amiga_model=A500 \
+    "${AMIGA_MACHINE_ARGS[@]}" \
     --fast_memory=2048 \
     --hard_drive_0="$work" \
     --hard_drive_0_label=JOEYLIB \
-    --kickstart_file="$kickstart" \
     --automatic_input_grab=0 \
     --fullscreen=0 \
     --fade_out_duration=0 \
diff --git a/scripts/bench-atarist.sh b/scripts/bench-atarist.sh
index 7ffdd2f..74a0bea 100755
--- a/scripts/bench-atarist.sh
+++ b/scripts/bench-atarist.sh
@@ -1,18 +1,33 @@
 #!/usr/bin/env bash
-# Headless UBER perf capture for the Atari ST (Hatari).
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
 #
-# Runs build/atarist/bin/UBER.PRG under Hatari (fast-forward, no sound)
-# on a private Xvfb display, polling joeylog.txt until UBER prints its
-# "press any key to exit" completion marker (or a wall-clock timeout),
-# then kills the emulator.
+# Headless UBER perf capture for the Atari ST family (Hatari).
+#
+# Runs UBER.PRG under Hatari (fast-forward, no sound) on a private Xvfb
+# display, polling joeylog.txt until UBER prints its "press any key to
+# exit" completion marker (or a wall-clock timeout), then kills the
+# emulator.
+#
+# The machine picks the build: a plain ST or a Mega ST runs the ST build
+# (make atarist, build/atarist), an STe or Mega STe the STe build (make
+# ste, build/ste: BLiTTER, DMA sound, 4-bit palette, and the Mega STe's
+# 16 MHz). Both builds' hashes must match the ST's golden; the rates
+# differ.
 #
 # UBER writes "UBER: : N iters / M frames = K ops/sec" lines to
 # joeylog.txt in its CWD == the GEMDOS drive C: == the bin dir, so the
-# log lands directly in build/atarist/bin/joeylog.txt where
-# tools/uber-perf-table expects it.
+# log lands directly in build//bin/joeylog.txt (for the ST build
+# where tools/uber-perf-table expects it). A copy is kept as
+# joeylog-MACHINE.txt beside it, so runs on several machines can be
+# compared afterwards.
 #
-#   scripts/bench-atarist.sh             # default 90s timeout
-#   TIMEOUT=120 scripts/bench-atarist.sh # override
+#   scripts/bench-atarist.sh               # the ST build on a plain ST, default 90s timeout
+#   scripts/bench-atarist.sh megast        # the ST build on a Mega ST
+#   scripts/bench-atarist.sh ste           # the STe build on an STe
+#   scripts/bench-atarist.sh megaste       # the STe build on a Mega STe
+#   TIMEOUT=120 scripts/bench-atarist.sh   # override
 #
 # NOTE: requires the ST supervisor-mode fix in src/atarist/hal.c
 # (jlpInit Super(0L)); without it UBER bus-errors on the first present.
@@ -23,13 +38,19 @@ repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 joeyNeed HATARI hatari
 joeyNeed EMUTOS_IMG emutos
-bin_dir=$repo/build/atarist/bin
+machine=${1:-st}
+case $machine in
+    st|megast)   build=atarist ;;
+    ste|megaste) build=ste ;;
+    *) echo "usage: scripts/bench-atarist.sh [st|megast|ste|megaste]" >&2; exit 2 ;;
+esac
+bin_dir=$repo/build/$build/bin
 tos=$EMUTOS_IMG
 log=$bin_dir/joeylog.txt
 timeout_s=${TIMEOUT:-90}
 
 if [[ ! -f $bin_dir/UBER.PRG ]]; then
-    echo "UBER.PRG not built. Run 'make atarist' first." >&2
+    echo "UBER.PRG not built. Run 'make $build' first." >&2
     exit 1
 fi
 if [[ ! -f $tos ]]; then
@@ -50,8 +71,9 @@ cleanup() {
 }
 trap cleanup EXIT
 
-echo "bench-atarist: running UBER headless (timeout ${timeout_s}s)..." >&2
+echo "bench-atarist: running the $build build's UBER headless on a $machine (timeout ${timeout_s}s)..." >&2
 DISPLAY=$display "$HATARI" \
+    --machine      "$machine" \
     --tos          "$tos" \
     --harddrive    "$bin_dir" \
     --gemdos-drive C \
@@ -82,6 +104,7 @@ done_marker=$(grep -c "press any key to exit" "$log" || true)
 if (( done_marker == 0 )); then
     echo "bench-atarist: WARNING -- timed out before completion marker (partial capture)" >&2
 fi
-echo "bench-atarist: captured $ops op lines (complete=$done_marker) in ${waited}s" >&2
-echo "  -> $log" >&2
+cp "$log" "$bin_dir/joeylog-$machine.txt"
+echo "bench-atarist: captured $ops op lines (complete=$done_marker) on a $machine in ${waited}s" >&2
+echo "  -> $log (and joeylog-$machine.txt)" >&2
 grep "UBER:" "$log" | sed 's/ | hash=.*//' || true
diff --git a/scripts/bench-be.sh b/scripts/bench-be.sh
new file mode 100755
index 0000000..fc677ae
--- /dev/null
+++ b/scripts/bench-be.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Headless UBER run for a Be API port (haiku, haiku32, beos): UBER in the
+# port's VM under QEMU (scripts/lib/beGate.sh), until its joeylog.txt says
+# "press any key to exit"; the log is then copied beside the program,
+# build//bin.
+#
+# The stage is the generic chunky one, so the hashes must equal the DOS
+# port's:
+#   scripts/bench-be.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt
+#   scripts/bench-be.sh haiku32 && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt
+#   scripts/bench-be.sh beos && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/beos/bin/joeylog.txt
+# The ops/sec are this machine's in a VM: compare them only run against run.
+#
+#   scripts/bench-be.sh [haiku|haiku32|beos]   # default haiku (x86_64)
+#   TIMEOUT=600 scripts/bench-be.sh            # default 300 s for UBER itself
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/beGate.sh"
+
+port=${1:-haiku}
+label="bench-be ($port)"
+beNeed "$port"
+program=$(joeyExampleProgram uber "$port")
+[ -x "$program" ] || { echo "$label: build UBER first (make $port)" >&2; exit 2; }
+log=$(dirname "$program")/joeylog.txt
+timeoutSeconds=${TIMEOUT:-300}
+bootSeconds=120
+
+work=$(mktemp -d -t joeylib-be-bench.XXXXXX)
+cleanup() {
+    beStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/job/UBER"
+cp "$program" "$work/job/UBER/UBER"
+beJobScript UBER UBER "UBER: press any key to exit" "$timeoutSeconds" > "$work/job/job.sh"
+beJobDisk "$work/job.img" "$work/job"
+
+rm -f "$log"
+beStart "$work" "$work/job.img"
+if ! beWaitJob "$work" "$bootSeconds"; then
+    echo "$label: FAIL - the VM did not start the job within ${bootSeconds} s" >&2
+    exit 1
+fi
+if ! beWaitExit "$work" "$timeoutSeconds"; then
+    echo "$label: FAIL - UBER did not finish within ${timeoutSeconds} s" >&2
+    exit 1
+fi
+beJobGet "$work/job.img" UBER/joeylog.txt "$log" || true
+if grep -q "press any key to exit" "$log" 2>/dev/null; then
+    echo "$label: UBER finished; log in ${log#"$repo"/}"
+    exit 0
+fi
+echo "$label: FAIL - UBER ended without finishing (job log below)" >&2
+beJobGet "$work/job.img" job.log "$work/job.log" && cat "$work/job.log" >&2
+exit 1
diff --git a/scripts/bench-dos.sh b/scripts/bench-dos.sh
index d43f6fd..787a994 100755
--- a/scripts/bench-dos.sh
+++ b/scripts/bench-dos.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Headless UBER perf capture for DOS (DOSBox, 386SX-16 timing).
 #
 # Mounts build/dos/bin as C: in a headless DOSBox (Xvfb), runs UBER.EXE
diff --git a/scripts/bench-dvx.sh b/scripts/bench-dvx.sh
index 88cc065..72b0213 100755
--- a/scripts/bench-dvx.sh
+++ b/scripts/bench-dvx.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Headless UBER run for DVX (DOSBox Staging, DVX's own emulator setup).
 #
 # Stages a scratch DVX (scripts/lib/dvxGate.sh) whose desktop is UBER, boots
diff --git a/scripts/bench-iigs.sh b/scripts/bench-iigs.sh
index 541eb39..2d6fbae 100755
--- a/scripts/bench-iigs.sh
+++ b/scripts/bench-iigs.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # bench-iigs.sh - Headless UBER perf capture for the IIgs (the reference
 # port). Until this script existed, claudeDocs/PERF.md's IIgs column was carried over
 # from old captures because nothing could get joeylog.txt off the emulated
diff --git a/scripts/bench-psp.sh b/scripts/bench-psp.sh
new file mode 100755
index 0000000..b4ca181
--- /dev/null
+++ b/scripts/bench-psp.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Headless UBER run for the PSP port: UBER's EBOOT.PBP under PPSSPPHeadless
+# (scripts/lib/pspGate.sh), from a private memory stick, with every memory
+# access checked as a PSP checks it -- an unaligned or wild access stops the
+# run and fails it, naming the line -- until UBER's joeylog.txt says "press
+# any key to exit". The log is copied to build/psp/bin/UBER/joeylog.txt.
+#
+# The stage is the generic chunky one, so the hashes must equal the DOS
+# port's:
+#   scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
+# The ops/sec are PPSSPP's emulated clock, which runs unthrottled: compare
+# them only run against run.
+#
+#   scripts/bench-psp.sh               # default 300 s timeout
+#   TIMEOUT=600 scripts/bench-psp.sh   # override
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/pspGate.sh"
+pspNeed
+
+program=$(joeyExampleProgram uber psp)
+[ -f "$program" ] || { echo "bench-psp: build UBER first (make psp)" >&2; exit 2; }
+dir=$(dirname "$program")
+timeoutSeconds=${TIMEOUT:-300}
+
+work=$(mktemp -d -t joeylib-psp-bench.XXXXXX)
+cleanup() {
+    pspStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+rm -f "$dir/joeylog.txt"
+folder=$(pspStage "$work" "$dir")
+pspRun "$work" "$folder" "$timeoutSeconds"
+while joeyAlive "$PSP_PID"; do
+    if grep -q "press any key to exit" "$folder/joeylog.txt" 2>/dev/null; then
+        break
+    fi
+    sleep 1
+done
+pspStop
+pspStrictCheck "$work" "$dir" || { echo "bench-psp: FAIL - a bad memory access" >&2; exit 1; }
+[ -f "$folder/joeylog.txt" ] && cp "$folder/joeylog.txt" "$dir/joeylog.txt"
+if grep -q "press any key to exit" "$dir/joeylog.txt" 2>/dev/null; then
+    echo "bench-psp: UBER finished; log in ${dir#"$repo"/}/joeylog.txt"
+    exit 0
+fi
+echo "bench-psp: FAIL - UBER ended (or timed out after ${timeoutSeconds} s) without finishing" >&2
+exit 1
diff --git a/scripts/bench-sdl3.sh b/scripts/bench-sdl3.sh
new file mode 100755
index 0000000..84fdce5
--- /dev/null
+++ b/scripts/bench-sdl3.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Headless UBER run for the SDL3 port: no X server, no emulator. SDL's dummy
+# video driver gives the window and its dummy audio driver plays nothing at
+# the real rate, so UBER runs exactly as it does on a desktop, at the host's
+# speed. It waits for UBER's "press any key to exit" line in its joeylog.txt
+# (the program's folder, build/sdl3/bin) and stops it.
+#
+# The stage is the generic chunky one, so the hashes must equal the DOS
+# port's:
+#   scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
+# The ops/sec are this machine's, not a reference machine's: compare them
+# only run against run.
+#
+#   scripts/bench-sdl3.sh               # default 300 s timeout
+#   TIMEOUT=600 scripts/bench-sdl3.sh   # override
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+
+program=$(joeyExampleProgram uber sdl3)
+[ -x "$program" ] || { echo "bench-sdl3: build UBER first (make sdl3)" >&2; exit 2; }
+log=$(dirname "$program")/joeylog.txt
+timeoutSeconds=${TIMEOUT:-300}
+
+rm -f "$log"
+SDL_VIDEO_DRIVER=dummy SDL_AUDIO_DRIVER=dummy timeout -s KILL "$timeoutSeconds" "$program" > /dev/null 2>&1 &
+uberPid=$!
+trap 'joeyStop "$uberPid"' EXIT
+
+while joeyAlive "$uberPid"; do
+    if grep -q "press any key to exit" "$log" 2>/dev/null; then
+        echo "bench-sdl3: UBER finished; log in ${log#"$repo"/}"
+        exit 0
+    fi
+    sleep 1
+done
+echo "bench-sdl3: FAIL - UBER ended (or timed out after ${timeoutSeconds} s) without finishing" >&2
+exit 1
diff --git a/scripts/bench-web.sh b/scripts/bench-web.sh
new file mode 100755
index 0000000..7712f74
--- /dev/null
+++ b/scripts/bench-web.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Headless UBER run for the web port: UBER's page in headless Chromium,
+# served from build/web/bin/UBER over HTTP on this machine, driven by
+# scripts/lib/webDriver.mjs until UBER's joeylog.txt (in the page's
+# in-memory file system) says "press any key to exit". The log is written to
+# build/web/bin/UBER/joeylog.txt.
+#
+# The stage is the generic chunky one, so the hashes must equal the DOS
+# port's:
+#   scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
+# The ops/sec are this machine's browser's: compare them only run against run.
+#
+#   scripts/bench-web.sh               # default 300 s timeout
+#   TIMEOUT=600 scripts/bench-web.sh   # override
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/webGate.sh"
+webNeed
+
+dir=$repo/build/web/bin/UBER
+[ -f "$dir/UBER.html" ] || { echo "bench-web: build UBER first (make web)" >&2; exit 2; }
+log=$dir/joeylog.txt
+timeoutSeconds=${TIMEOUT:-300}
+
+rm -f "$log"
+webServe "$dir"
+trap webStop EXIT
+if ! webDrive "UBER.html?scale=1&aspect=square" --seconds "$timeoutSeconds" --until "press any key to exit" --log "$log"; then
+    echo "bench-web: FAIL - UBER did not finish within ${timeoutSeconds} s" >&2
+    exit 1
+fi
+echo "bench-web: UBER finished; log in ${log#"$repo"/}"
diff --git a/scripts/bench-win16.sh b/scripts/bench-win16.sh
new file mode 100755
index 0000000..60dcd85
--- /dev/null
+++ b/scripts/bench-win16.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Headless UBER run for the Win16 port: UBER.EXE on Windows for Workgroups
+# 3.11 under DOSBox (scripts/lib/win16Gate.sh), its folder build/win16/bin
+# as drive D:, until UBER's JOEYLOG.TXT there says "press any key to exit".
+#
+# The stage is the generic chunky one, so the hashes must equal the DOS
+# port's:
+#   scripts/bench-win16.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win16/bin/JOEYLOG.TXT
+# The ops/sec are this machine's with DOSBox at full speed: compare them
+# only run against run.
+#
+#   scripts/bench-win16.sh               # default 300 s timeout
+#   TIMEOUT=600 scripts/bench-win16.sh   # override
+
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+
+label=bench-win16
+win16Need
+program=$(joeyExampleProgram uber win16)
+[ -f "$program" ] || { echo "$label: build UBER first (make win16)" >&2; exit 2; }
+folder=$(dirname "$program")
+timeoutSeconds=${TIMEOUT:-300}
+
+work=$(mktemp -d -t joeylib-win16-bench.XXXXXX)
+cleanup() {
+    win16Stop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+rm -f "$folder/JOEYLOG.TXT" "$folder/joeylog.txt"
+win16Stage "$work" "$folder" "$(basename "$program")"
+win16Start "$work"
+for ((waited = 0; waited < timeoutSeconds; waited++)); do
+    log=$(win16Log "$folder")
+    if [ -n "$log" ] && grep -q "press any key to exit" "$log"; then
+        echo "$label: UBER finished in about ${waited} s; log in ${log#"$repo"/}"
+        exit 0
+    fi
+    joeyAlive "$WIN16_GUARD" || break
+    sleep 1
+done
+echo "$label: FAIL - UBER did not finish within ${timeoutSeconds} s" >&2
+tail -5 "$work/dosbox.log" >&2
+exit 1
diff --git a/scripts/bench-win32.sh b/scripts/bench-win32.sh
new file mode 100755
index 0000000..ff0e332
--- /dev/null
+++ b/scripts/bench-win32.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Headless UBER run for the Win32 port: UBER.EXE under Wine (a private prefix
+# on a private Xvfb display, scripts/lib/wineGate.sh), until UBER's
+# joeylog.txt -- beside the program, build/win32/bin -- says "press any key
+# to exit".
+#
+# The stage is the generic chunky one, so the hashes must equal the DOS
+# port's:
+#   scripts/bench-win32.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win32/bin/joeylog.txt
+# The ops/sec are this machine's under Wine: compare them only run against run.
+#
+#   scripts/bench-win32.sh               # default 300 s timeout
+#   TIMEOUT=600 scripts/bench-win32.sh   # override
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/wineGate.sh"
+label=bench-win32
+wineNeed
+
+program=$(joeyExampleProgram uber win32)
+[ -f "$program" ] || { echo "$label: build UBER first (make win32)" >&2; exit 2; }
+log=$(dirname "$program")/joeylog.txt
+timeoutSeconds=${TIMEOUT:-300}
+
+work=$(mktemp -d -t "joeylib-win32-bench.XXXXXX")
+cleanup() {
+    wineStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+rm -f "$log"
+wineStart "$work"
+wineRun "$program"
+for ((waited = 0; waited < timeoutSeconds; waited++)); do
+    if grep -q "press any key to exit" "$log" 2>/dev/null; then
+        echo "$label: UBER finished; log in ${log#"$repo"/}"
+        exit 0
+    fi
+    sleep 1
+done
+echo "$label: FAIL - UBER did not finish within ${timeoutSeconds} s" >&2
+exit 1
diff --git a/scripts/bench-x68000.sh b/scripts/bench-x68000.sh
index ac9ba81..fbdf280 100755
--- a/scripts/bench-x68000.sh
+++ b/scripts/bench-x68000.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # bench-x68000.sh - X68000 performance run, the twin of bench-atarist.sh.
 #
 # The sibling of verify-x68000-golden.sh: same staging and same
diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh
index d46bbee..e579a30 100755
--- a/scripts/build-docs.sh
+++ b/scripts/build-docs.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # build-docs.sh - render the JoeyLib manual to HTML and PDF.
 #
 # Sources are docs/Manual.adoc plus the chapter files it includes, all in
diff --git a/scripts/check-blank.sh b/scripts/check-blank.sh
index f99cca9..c04a5f1 100755
--- a/scripts/check-blank.sh
+++ b/scripts/check-blank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-blank.sh - Smoke-test the new-port template (src/blank/blank.c).
 #
 # A blank port has no toolchain, so we compile it with the HOST cc just to
diff --git a/scripts/check-dirtymark.sh b/scripts/check-dirtymark.sh
index 51baee6..1164efe 100755
--- a/scripts/check-dirtymark.sh
+++ b/scripts/check-dirtymark.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-dirtymark.sh - Host-cc gate for the stage's dirty-band writers: the
 # multi-row mark (surfaceMarkDirtyRows, whose four-row groups reuse a group's
 # result for the next group that held the same bytes) and the whole-stage
diff --git a/scripts/check-fileroots.sh b/scripts/check-fileroots.sh
index 84e4feb..b0321f4 100755
--- a/scripts/check-fileroots.sh
+++ b/scripts/check-fileroots.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-fileroots.sh - Host-cc gate for the run-time file roots
 # (src/core/fileRoot.h, JL_HAS_FILE_ROOTS, which the DVX port defines): with
 # the flag, DATA/ is read under the port's read root and SAVES/ and
diff --git a/scripts/check-input.sh b/scripts/check-input.sh
index 7a51023..f03a92a 100755
--- a/scripts/check-input.sh
+++ b/scripts/check-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-input.sh - Host-cc harness for the typed-character queue
 # (jlInputGetChar / jlInputCharPush in src/core/input.c). Compiles the
 # core input module with a mocked jlpInputPoll (tests/host/inputHost.c)
diff --git a/scripts/check-millis.sh b/scripts/check-millis.sh
index d84c41c..00015b0 100755
--- a/scripts/check-millis.sh
+++ b/scripts/check-millis.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-millis.sh - Host-cc harness for the generic millisecond clock
 # (jlpGenericMillisElapsed, the IIgs/Amiga path). Compiles genericPort.c
 # with mocked jlpFrameCount/jlpFrameHz (tests/host/millisHost.c) and runs
diff --git a/scripts/check-save.sh b/scripts/check-save.sh
index 9ab58ba..5222b69 100755
--- a/scripts/check-save.sh
+++ b/scripts/check-save.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-save.sh - Host-cc harness for the save-file core (src/core/save.c).
 # Compiles it with a POSIX save HAL (tests/host/saveHost.c) and runs real
 # save/read/exists/delete/disk-free round-trips inside a throwaway temp cwd, so
diff --git a/scripts/check-serial.sh b/scripts/check-serial.sh
index 96c1ad8..a28ecf4 100755
--- a/scripts/check-serial.sh
+++ b/scripts/check-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-serial.sh - Host-cc harness for the shared serial gate/normalize layer
 # (src/core/serial.c). Compiles it with a loopback mock HAL (tests/host/
 # serialHost.c) and runs the open/close/gate/normalize/read/write checks.
diff --git a/scripts/check-sfxmix.sh b/scripts/check-sfxmix.sh
index 7605e41..b2d6a56 100755
--- a/scripts/check-sfxmix.sh
+++ b/scripts/check-sfxmix.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-sfxmix.sh - Host-cc differential harness for the shared SFX
 # overlay mixer (src/core/audioSfxMix.c), built on the check-blank.sh /
 # check-millis.sh pattern. Compiles the CURRENT mixer TU with the host
diff --git a/scripts/check-tilebank.sh b/scripts/check-tilebank.sh
index 194b895..96b296c 100755
--- a/scripts/check-tilebank.sh
+++ b/scripts/check-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # check-tilebank.sh - Host-cc gate for the baked tile-bank loaders
 # (jlTileBankLoad / jlTileBankLoadToSurface in src/core/assetLoad.c), end to
 # end: assetbake.py bakes tests/host/tileBank.png, the real loader reads it,
diff --git a/scripts/dosbox-win16.conf b/scripts/dosbox-win16.conf
new file mode 100644
index 0000000..b02bfe9
--- /dev/null
+++ b/scripts/dosbox-win16.conf
@@ -0,0 +1,42 @@
+# DOSBox Staging config for the Win16 port: the PC that Windows for
+# Workgroups 3.11 runs on, both when the installer's win311 component sets
+# Windows up (installer/lib/win16.sh) and when joey-run and the win16 gates
+# run a program on it (scripts/lib/win16Gate.sh).
+#
+#   machine = svga_s3          an S3 Trio64, the card the S3 Trio64V+
+#                              Windows driver the image is set up with
+#                              drives (800x600, 256 colours)
+#   memsize = 16               16 MB, plenty for Windows 3.11; the image
+#                              runs without a swap file
+#   cputype = 386              the port's floor is a 386: an instruction a
+#                              386 lacks faults here as it would there
+#   cpu_cycles = max           as fast as the host goes. The CPU is
+#                              emulated, never run natively: running Win16
+#                              code natively (the 32-bit Wine's winevdm)
+#                              crashed the host's kernel
+#   sbtype = sb16              Windows' own Sound Blaster 1.5 driver
+#                              (sndblst2.drv, 8-bit, what the port plays)
+#                              drives it at the defaults, port 220 IRQ 7
+#                              DMA 1
+#
+# Launch with --noprimaryconf --nolocalconf (JOEY_DOSBOX_ISOLATE) so nothing
+# from the user's own DOSBox configuration leaks into a run.
+
+[sdl]
+fullscreen = false
+
+[dosbox]
+machine = svga_s3
+memsize = 16
+
+[cpu]
+core                 = auto
+cputype              = 386
+cpu_cycles           = max
+cpu_cycles_protected = max
+
+[sblaster]
+sbtype = sb16
+sbbase = 220
+irq    = 7
+dma    = 1
diff --git a/scripts/lib/amigaGate.sh b/scripts/lib/amigaGate.sh
new file mode 100644
index 0000000..a6a52e2
--- /dev/null
+++ b/scripts/lib/amigaGate.sh
@@ -0,0 +1,44 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the Amiga gates (scripts/bench-amiga.sh, scripts/verify-amiga-*.sh)
+# and joey-run: which Amiga FS-UAE emulates. JOEY_AMIGA_MODEL picks it; the
+# A500 is the port's baseline, and the AGA machines prove the same programs
+# on a 68020 and a 68040 (their instruction caches, the 68040's copyback data
+# cache) and the AGA chipset's OCS modes:
+#
+#   A500    68000, OCS: Kickstart 3.1 r40.63 (component kickstart)
+#   A1200   68EC020, AGA: r40.68 (component kickstartA1200)
+#   A4000   68040, AGA: r40.70 (component kickstartA4000), FS-UAE's A4000/040
+#
+# Source after joeyEnv.sh.
+#
+#   amigaMachine    exit 2 unless the model's Kickstart is installed; sets
+#                   AMIGA_MACHINE_ARGS, FS-UAE's --amiga_model and
+#                   --kickstart_file
+
+AMIGA_MODEL="${JOEY_AMIGA_MODEL:-A500}"
+AMIGA_MACHINE_ARGS=()
+
+
+amigaMachine() {
+    case "$AMIGA_MODEL" in
+        A500)
+            joeyNeed KICKSTART_ROM kickstart
+            AMIGA_MACHINE_ARGS=(--amiga_model=A500 --kickstart_file="$KICKSTART_ROM")
+            ;;
+        A1200)
+            joeyNeed KICKSTART_A1200_ROM kickstartA1200
+            AMIGA_MACHINE_ARGS=(--amiga_model=A1200 --kickstart_file="$KICKSTART_A1200_ROM")
+            ;;
+        A4000)
+            joeyNeed KICKSTART_A4000_ROM kickstartA4000
+            AMIGA_MACHINE_ARGS=(--amiga_model=A4000/040 --kickstart_file="$KICKSTART_A4000_ROM")
+            ;;
+        *)
+            echo "JOEY_AMIGA_MODEL is A500, A1200 or A4000, not $AMIGA_MODEL" >&2
+            exit 2
+            ;;
+    esac
+}
diff --git a/scripts/lib/beGate.sh b/scripts/lib/beGate.sh
new file mode 100644
index 0000000..845530b
--- /dev/null
+++ b/scripts/lib/beGate.sh
@@ -0,0 +1,302 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the Be API ports' gates (scripts/bench-be.sh,
+# scripts/verify-be-*.sh): run a program of a Be API port -- haiku, haiku32
+# (Haiku R1/beta6) or beos (BeOS R5) -- in that port's VM under QEMU. The VM
+# (component haikuVm, haikuVm32 or beosVm) boots its system disk in QEMU's
+# snapshot mode, so nothing is ever written to it, and runs its job runner
+# (installer/lib/haiku.sh and installer/lib/beos.sh say how): the program
+# and a job.sh go to the machine on a second disk, a BFS volume made here,
+# and the program's files come back on it. The machine powers off when the
+# job is done, so QEMU exits. Haiku runs under KVM when /dev/kvm can be
+# opened; BeOS R5 on QEMU's own Pentium II, a processor it knew. Source
+# after joeyEnv.sh.
+#
+#   beNeed PORT                  exit 2 unless PORT's VM, QEMU and the BFS
+#                                tools are installed; sets BE_PORT
+#   beJobDisk DISK DIR           a job disk holding DIR's files and folders
+#   beJobGet DISK PATH DEST      copy PATH off the job disk to DEST
+#   beJobScript FOLDER PROGRAM UNTIL SECONDS [ENV...]
+#                                print a job.sh that runs FOLDER/PROGRAM with
+#                                the ENV settings (NAME=VALUE) until its
+#                                joeylog.txt has a line starting UNTIL (empty:
+#                                until it exits) or SECONDS pass
+#   beQmp WORK ARGS...           scripts/lib/qmp.py on the VM's QMP socket
+#   beStart WORK DISK [QEMU-ARGS...]
+#                                boot the VM with the job disk, on BE_DISPLAY
+#                                (default none: headless); sets BE_PID. A
+#                                Haiku VM's HD Audio card plays to
+#                                BE_AUDIODEV (default none: nowhere; a QEMU
+#                                -audiodev whose id is beSound). BeOS R5 is
+#                                taken through its boot loader's menu to the
+#                                fail-safe (VESA) video mode BEOS_VIDEO_MODE:
+#                                its own drivers know none of QEMU's cards,
+#                                and without it the screen is 16 greys.
+#                                Serial port: WORK/serial.log, QMP:
+#                                WORK/qmp.sock
+#   beStop                       stop QEMU
+#   beWaitExit WORK SECONDS      wait for QEMU to end (the job runner powers
+#                                the machine off)
+#   beWaitJob WORK SECONDS       wait until the job is about to run: Haiku's
+#                                runner says so on the serial port
+#                                (HAIKU_JOB_STARTED); BeOS R5's has no port
+#                                to say it on, so its desktop up in
+#                                BEOS_VIDEO_MODE, Deskbar and all, stands in
+#                                (the runner starts the job a few seconds
+#                                later)
+# The waits fail when QEMU ends first, time runs out or the machine stops
+# in its kernel debugger.
+
+# shellcheck source=installer/lib/haiku.sh
+source "$JOEY_REPO/installer/lib/haiku.sh"
+
+BE_PORT=""
+BE_PID=""
+BE_QEMU=""
+BE_DISPLAY="${BE_DISPLAY:-none}"
+BE_AUDIODEV="${BE_AUDIODEV:-none,id=beSound}"
+# Haiku's machine: memory (MB) and processors.
+HAIKU_VM_MEMORY_MB=1024
+HAIKU_VM_CPUS=2
+# BeOS R5's machine: memory (MB) and processor.
+BEOS_VM_MEMORY_MB=256
+BEOS_VM_CPU="pentium2"
+# Its boot loader's menu: open it (space, pressed while the loader starts),
+# go to "Select fail-safe video mode", choose the mode BEOS_VIDEO_ROWS rows
+# below Default in the mode list (the list QEMU 11.1.2's VGA BIOS gives:
+# 1024x768x16, room for a program's window at its default scale, 3), back
+# on the main menu choose "Continue booting".
+BEOS_VIDEO_MODE="1024x768"
+BEOS_VIDEO_ROWS=23
+BEOS_MENU_OPEN_PRESSES=10
+BEOS_MENU_SETTLE_SECONDS=2
+BEOS_MENU_TO_VIDEO="down down ret"
+BEOS_MENU_CONTINUE="ret down ret"
+# What a kernel debugger session starts with, on the serial port.
+BE_KDL_BANNER="Welcome to Kernel Debugging Land"
+
+
+# beCrashed WORK -- the machine stopped in its kernel debugger.
+beCrashed() {
+    if grep -a -q "$BE_KDL_BANNER" "$1/serial.log" 2> /dev/null; then
+        echo "$(basename "$0"): the VM stopped in its kernel debugger (${1}/serial.log)" >&2
+        return 0
+    fi
+    return 1
+}
+
+
+# beDesktopUp WORK -- BeOS R5's desktop is up in BEOS_VIDEO_MODE: the
+# screen is that size and Deskbar, which R5's boot starts last, shows its
+# logo in the top right corner (the boot shows a bare desktop for some
+# seconds before it).
+beDesktopUp() {
+    beQmp "$1" screendump "$1/mode.png" 2> /dev/null || return 1
+    python3 - "$1/mode.png" "$BEOS_VIDEO_MODE" <<'PY'
+import sys
+from PIL import Image
+
+# The logo's red, in the 16-bit mode, and how many of its pixels show in
+# the corner when Deskbar is there (about 280).
+LOGO_RED = (206, 0, 49)
+CORNER_W = 120
+CORNER_H = 24
+LOGO_PIXELS_MIN = 100
+
+width, height = (int(n) for n in sys.argv[2].split('x'))
+screen = Image.open(sys.argv[1]).convert('RGB')
+if screen.size != (width, height):
+    sys.exit(1)
+corner = screen.crop((width - CORNER_W, 0, width, CORNER_H))
+sys.exit(0 if list(corner.getdata()).count(LOGO_RED) >= LOGO_PIXELS_MIN else 1)
+PY
+}
+
+
+# The disk is sized for what goes on it, plus room for what the program
+# writes (saves, its log).
+beJobDisk() {
+    local disk="$1"
+    local dir="$2"
+    local entry
+    local sizeMb
+    local commands=""
+    local slackMb=32
+    sizeMb=$(( $(du -sm "$dir" | cut -f1) * 2 + slackMb ))
+    haikuBfsNew "$disk" "${sizeMb}M" "$HAIKU_JOB_VOLUME" || return 1
+    for entry in "$dir"/*; do
+        commands+="cp -r :$entry /myfs/$(basename "$entry")"$'\n'
+    done
+    haikuBfs "$disk" <<< "$commands" > /dev/null
+}
+
+
+beJobGet() {
+    haikuBfs "$1" <<< "cp -r /myfs/$2 :$3" > /dev/null
+}
+
+
+beJobScript() {
+    local folder="$1"
+    local program="$2"
+    local until="$3"
+    local seconds="$4"
+    local setting
+    shift 4
+    printf 'cd %s\n' "$folder"
+    for setting in "$@"; do
+        printf 'export %s\n' "$setting"
+    done
+    cat < /dev/null; do
+    if [ -n "$until" ] && grep -q "^$until" joeylog.txt 2> /dev/null; then
+        break
+    fi
+    sleep 1
+    waited=\$((waited + 1))
+done
+kill \$pid 2> /dev/null
+wait \$pid
+EOF
+}
+
+
+beNeed() {
+    joeyNeed QEMU_DIR qemu
+    joeyNeed HAIKU_TOOLS haikuTools
+    case "$1" in
+        haiku)
+            joeyNeed HAIKU_VM_IMAGE haikuVm
+            BE_QEMU="$QEMU_DIR/bin/qemu-system-x86_64"
+            ;;
+        haiku32)
+            joeyNeed HAIKU32_VM_IMAGE haikuVm32
+            BE_QEMU="$QEMU_DIR/bin/qemu-system-i386"
+            ;;
+        beos)
+            joeyNeed BEOS_VM_DIR beosVm
+            BE_QEMU="$QEMU_DIR/bin/qemu-system-i386"
+            ;;
+        *)
+            echo "$(basename "$0"): unknown Be API port '$1' (haiku, haiku32 or beos)" >&2
+            exit 2
+            ;;
+    esac
+    BE_PORT="$1"
+}
+
+
+beQmp() {
+    local work="$1"
+    shift
+    python3 "$JOEY_REPO/scripts/lib/qmp.py" "$work/qmp.sock" "$@"
+}
+
+
+beStart() {
+    local work="$1"
+    local disk="$2"
+    shift 2
+    rm -f "$work/serial.log" "$work/qmp.sock"
+    case "$BE_PORT" in
+        beos)
+            beStartBeos "$work" "$disk" "$@"
+            ;;
+        *)
+            beStartHaiku "$work" "$disk" "$@"
+            ;;
+    esac
+}
+
+
+# beStartBeos WORK DISK [QEMU-ARGS...] -- boot the BeOS R5 VM (its CD's
+# boot floppy, which boots the system disk) and take it through the boot
+# menu.
+beStartBeos() {
+    local work="$1"
+    local disk="$2"
+    local press
+    local presses=()
+    local rows=()
+    shift 2
+    "$BE_QEMU" -machine pc -cpu "$BEOS_VM_CPU" -m "$BEOS_VM_MEMORY_MB" \
+        -drive "file=$BEOS_VM_DIR/floppy.img,format=raw,if=floppy,readonly=on" \
+        -drive "file=$BEOS_VM_DIR/system.img,format=raw,index=0,media=disk,snapshot=on" \
+        -drive "file=$disk,format=raw,index=1,media=disk" -boot a -vga std \
+        -serial "file:$work/serial.log" -qmp "unix:$work/qmp.sock,server,nowait" -no-reboot \
+        -display "$BE_DISPLAY" "$@" > "$work/qemu.log" 2>&1 &
+    BE_PID=$!
+    while [ ! -S "$work/qmp.sock" ] && joeyAlive "$BE_PID"; do
+        sleep 0.1
+    done
+    for ((press = 0; press < BEOS_MENU_OPEN_PRESSES; press++)); do
+        presses+=(spc)
+    done
+    for ((press = 0; press < BEOS_VIDEO_ROWS; press++)); do
+        rows+=(down)
+    done
+    beQmp "$work" keys "${presses[@]}" || return 1
+    sleep "$BEOS_MENU_SETTLE_SECONDS"
+    # shellcheck disable=SC2086
+    beQmp "$work" keys $BEOS_MENU_TO_VIDEO "${rows[@]}" $BEOS_MENU_CONTINUE
+}
+
+
+beStartHaiku() {
+    local work="$1"
+    local disk="$2"
+    local image="$HAIKU_VM_IMAGE"
+    local accel=(-accel tcg)
+    shift 2
+    if [ "$BE_PORT" = haiku32 ]; then
+        image="$HAIKU32_VM_IMAGE"
+    fi
+    if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
+        accel=(-enable-kvm -cpu host)
+    fi
+    "$BE_QEMU" "${accel[@]}" -m "$HAIKU_VM_MEMORY_MB" -smp "$HAIKU_VM_CPUS" \
+        -drive "file=$image,format=raw,if=ide,snapshot=on" \
+        -drive "file=$disk,format=raw,if=ide" \
+        -serial "file:$work/serial.log" -qmp "unix:$work/qmp.sock,server,nowait" -no-reboot \
+        -display "$BE_DISPLAY" -audiodev "$BE_AUDIODEV" -device intel-hda -device hda-duplex,audiodev=beSound \
+        "$@" > "$work/qemu.log" 2>&1 &
+    BE_PID=$!
+}
+
+
+beStop() {
+    joeyStop "$BE_PID"
+    BE_PID=""
+}
+
+
+beWaitExit() {
+    local waited
+    for ((waited = 0; waited < $2; waited++)); do
+        joeyAlive "$BE_PID" || return 0
+        beCrashed "$1" && return 1
+        sleep 1
+    done
+    return 1
+}
+
+
+beWaitJob() {
+    local waited
+    for ((waited = 0; waited < $2; waited++)); do
+        if [ "$BE_PORT" = beos ]; then
+            beDesktopUp "$1" && return 0
+        elif grep -q "^$HAIKU_JOB_STARTED" "$1/serial.log" 2> /dev/null; then
+            return 0
+        fi
+        joeyAlive "$BE_PID" || return 1
+        beCrashed "$1" && return 1
+        sleep 1
+    done
+    return 1
+}
diff --git a/scripts/lib/dosboxJoy.sh b/scripts/lib/dosboxJoy.sh
new file mode 100644
index 0000000..a4b55d0
--- /dev/null
+++ b/scripts/lib/dosboxJoy.sh
@@ -0,0 +1,127 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the joystick gates that run in DOSBox Staging (DOS, DVX,
+# Win16): how a stick is moved and how JOY is photographed there. DOSBox's
+# joysticktype 4axis puts four axes and four buttons on the game port with
+# no host joystick, which a two-stick reader takes as sticks A (axes 0-1,
+# buttons 0-1) and B (axes 2-3, buttons 2-3), as a Y cable gives them. A
+# mapper file binds a host key to each line and xdotool holds the keys; the
+# raw screenshot is on a key of its own (F11), so no Ctrl reaches the
+# program. The mapper replaces every DOSBox binding: only these keys and
+# Escape reach the machine. Source after joyGate.sh.
+#
+#   dosboxJoyMapper FILE        write the mapper file
+#   dosboxJoyTap NAME           press and let go of a key of the table
+#                               (escape, shot)
+#   joyPress / joyRelease       joyGate.sh's: hold the controls' keys
+#   joyShot PNG                 joyGate.sh's: the raw screenshot as PNG
+#
+# The gate sets DOSBOX_JOY_DISPLAY and DOSBOX_JOY_WINDOW (DOSBox's X
+# display and window) and DOSBOX_JOY_SHOTS (its capture_dir).
+
+DOSBOX_JOY_DISPLAY=""
+DOSBOX_JOY_WINDOW=""
+DOSBOX_JOY_SHOTS=""
+DOSBOX_JOY_SHOT_SECONDS=5       # the screenshot key until DOSBox has written the file
+DOSBOX_JOY_TAP_SECONDS=0.3
+
+# Control or name, its host key (xdotool's name), the key's SDL scancode and
+# the DOSBox mapper event it drives.
+DOSBOX_JOY_KEYS=(
+    "A-left j 13 jaxis_0_0-"     "A-right l 15 jaxis_0_0+"
+    "A-up i 12 jaxis_0_1-"       "A-down k 14 jaxis_0_1+"
+    "A-button0 a 4 jbutton_0_0"  "A-button1 s 22 jbutton_0_1"
+    "B-left f 9 jaxis_0_2-"      "B-right h 11 jaxis_0_2+"
+    "B-up t 23 jaxis_0_3-"       "B-down g 10 jaxis_0_3+"
+    "B-button0 q 20 jbutton_0_2" "B-button1 w 26 jbutton_0_3"
+    "shot F11 68 hand_rawshot"
+    "escape Escape 41 key_esc"
+)
+
+
+# dosboxJoyKey NAME -- the host key for a control or name of the table.
+dosboxJoyKey() {
+    local entry
+    local name
+    local key
+    for entry in "${DOSBOX_JOY_KEYS[@]}"; do
+        read -r name key _ <<< "$entry"
+        if [ "$name" = "$1" ]; then
+            printf '%s\n' "$key"
+            return 0
+        fi
+    done
+    return 1
+}
+
+
+dosboxJoyMapper() {
+    local entry
+    local key
+    local code
+    local event
+    for entry in "${DOSBOX_JOY_KEYS[@]}"; do
+        read -r _ key code event <<< "$entry"
+        printf '%s "key %s"\n' "$event" "$code"
+    done > "$1"
+}
+
+
+dosboxJoyTap() {
+    local key
+    key=$(dosboxJoyKey "$1") || return 1
+    DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool keydown --window "$DOSBOX_JOY_WINDOW" "$key"
+    sleep "$DOSBOX_JOY_TAP_SECONDS"
+    # A key that ends the program (escape) can take DOSBox's window with it.
+    DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool keyup --window "$DOSBOX_JOY_WINDOW" "$key" 2> /dev/null || true
+}
+
+
+# dosboxJoyHold keydown|keyup CONTROL...
+dosboxJoyHold() {
+    local action="$1"
+    local control
+    local key
+    shift
+    for control in "$@"; do
+        key=$(dosboxJoyKey "$control") || { joyFail "no key for $control"; continue; }
+        DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool "$action" --window "$DOSBOX_JOY_WINDOW" "$key"
+    done
+}
+
+
+joyPress() {
+    dosboxJoyHold keydown "$@"
+}
+
+
+joyRelease() {
+    dosboxJoyHold keyup "$@"
+}
+
+
+# DOSBox makes the file before it writes it: it is done when it has bytes
+# and one more poll adds none.
+joyShot() {
+    local before
+    local found
+    local size=""
+    local deadline
+    before=$(find "$DOSBOX_JOY_SHOTS" -name '*.png' | wc -l)
+    dosboxJoyTap shot
+    deadline=$((SECONDS + DOSBOX_JOY_SHOT_SECONDS))
+    while [ "$SECONDS" -lt "$deadline" ]; do
+        sleep 0.2
+        [ "$(find "$DOSBOX_JOY_SHOTS" -name '*.png' | wc -l)" -gt "$before" ] || continue
+        found=$(ls -t "$DOSBOX_JOY_SHOTS"/*.png | head -1)
+        [ -s "$found" ] || continue
+        if [ "$(stat -c %s "$found")" = "$size" ]; then
+            mv "$found" "$1"
+            return 0
+        fi
+        size=$(stat -c %s "$found")
+    done
+    return 1
+}
diff --git a/scripts/lib/dvxGate.sh b/scripts/lib/dvxGate.sh
index 414fb96..5f3f46d 100644
--- a/scripts/lib/dvxGate.sh
+++ b/scripts/lib/dvxGate.sh
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Shared by the DVX gates (scripts/verify-dvx-*.sh, scripts/bench-dvx.sh) and
 # joey-run: a DVX installation staged in a scratch folder -- a copy of the
 # runtime the installer unpacked from DVX's dist/dvx.zip (toolchains/opt/dvx,
diff --git a/scripts/lib/joeyEnv.sh b/scripts/lib/joeyEnv.sh
index eac1313..8b2a133 100644
--- a/scripts/lib/joeyEnv.sh
+++ b/scripts/lib/joeyEnv.sh
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Sourced by the scripts in scripts/ (and tools/) so every one of them finds
 # toolchains the same way: from the environment ./install.sh generates. A script calls joeyNeed for each tool it uses, and gets a clear
 # "install this" message (exit 2, which the gate runners treat as SKIP)
@@ -55,14 +59,22 @@ joeyExampleProgram() {
             printf '%s\n' "$bin/$up"
             return 0
             ;;
+        psp)
+            # Every program is a folder of its own, as PSP/GAME/ holds it.
+            printf '%s\n' "$bin/$up/EBOOT.PBP"
+            return 0
+            ;;
         amiga)
             # Mixed-case names.
             up="${name,,}"
             up="${up^}"
             ;;
-        atarist) ext=".PRG" ;;
+        atarist|ste) ext=".PRG" ;;
         dos)     ext=".EXE" ;;
         x68000)  ext=".X" ;;
+        sdl3|haiku|haiku32|beos) ext="" ;;
+        web)     ext=".html" ;;
+        win32|win16) ext=".EXE" ;;
         dvx)
             # Every program is a folder of its own, as it installs in DVX:
             # apps///, whatever the vendor.
@@ -72,7 +84,7 @@ joeyExampleProgram() {
             return 0
             ;;
         *)
-            echo "$(basename "$0"): unknown port '$2' (iigs, amiga, atarist, dos, x68000 or dvx)" >&2
+            echo "$(basename "$0"): unknown port '$2' (iigs, amiga, atarist, ste, dos, x68000, dvx, sdl3, web, win32, win16, psp, haiku, haiku32 or beos)" >&2
             return 2
             ;;
     esac
diff --git a/scripts/lib/joyGate.sh b/scripts/lib/joyGate.sh
new file mode 100644
index 0000000..1442f2d
--- /dev/null
+++ b/scripts/lib/joyGate.sh
@@ -0,0 +1,139 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the joystick gates that read examples/joy's screen
+# (scripts/verify-*-joy.sh): the steps, the check of each screenshot
+# (scripts/lib/joyScreen.py) and the tally. Source after joeyEnv.sh. The gate
+# defines three functions first:
+#
+#   joyShot PNG         save a screenshot of JOY as PNG; non-zero if none
+#   joyPress CONTROL... hold the controls (below)
+#   joyRelease CONTROL... let them go
+#
+# A control is a stick (A or B) and a line: A-left A-right A-up A-down
+# A-button0 A-button1, and the same for B. A screenshot reads as
+# "A= B=", each state five characters: connected (1/0), X and
+# Y (- 0 +), button 0 and button 1 (1/0).
+#
+#   joyCheck NAME WANT      a screenshot, which must read WANT ("A=... B=...");
+#                           an unreadable one ('?': JOY caught between
+#                           erasing a dot and drawing it again) is taken
+#                           once more before it counts
+#   joyRunSteps STEPS...    for each "name|controls|A's state|B's state":
+#                           hold the controls, check, let go, check at rest;
+#                           REST in A's state stands for JOY_REST, in B's
+#                           for JOY_REST_B (JOY_REST, or JOY_ABSENT where the
+#                           machine has one stick)
+#   joyFail WHAT            note a failure; the gate goes on to report the rest
+#   joyResult LABEL WHAT    print LABEL's PASS (with WHAT) or FAIL line;
+#                           exit 1 on a failure
+#
+# The gate sets JOY_LABEL (its name, for the lines it prints) and JOY_WORK
+# (its scratch folder). JOY_SCALE (joyScreen.py --scale) is the stage's
+# scale in the screenshot, empty to let joyScreen.py find it;
+# JOY_SCREEN_ARGS are more joyScreen.py options (--st); JOY_SHOTS, if
+# set, keeps every screenshot there; JOY_PRESS_SECONDS is how long a change
+# takes to show; JOY_SHOT_TRIES is 1 for a gate whose screenshots were all
+# taken before the checks (they cannot be taken again).
+
+JOY_REST="10000"        # a stick connected, in the middle, buttons up
+JOY_ABSENT="00000"      # no stick
+JOY_REST_B="$JOY_REST"  # stick B when nothing holds it
+JOY_SCALE="${JOY_SCALE:-}"
+JOY_SCREEN_ARGS=()
+JOY_PRESS_SECONDS="${JOY_PRESS_SECONDS:-1}"
+JOY_SHOT_TRIES="${JOY_SHOT_TRIES:-2}"
+JOY_RETRY_SECONDS=0.5
+JOY_FAILS=""
+JOY_CHECKED=0
+
+# Both sticks, every direction, two diagonals each, each button alone and
+# together, and a step holding different lines on both sticks at once, so
+# crosstalk between them shows up.
+JOY_STEPS_TWO_STICKS=(
+    "A left|A-left|1-000|REST"                 "A right|A-right|1+000|REST"
+    "A up|A-up|10-00|REST"                     "A down|A-down|10+00|REST"
+    "A up-left|A-left A-up|1--00|REST"         "A down-right|A-right A-down|1++00|REST"
+    "A button 0|A-button0|10010|REST"          "A button 1|A-button1|10001|REST"
+    "A both buttons|A-button0 A-button1|10011|REST"
+    "B left|B-left|REST|1-000"                 "B right|B-right|REST|1+000"
+    "B up|B-up|REST|10-00"                     "B down|B-down|REST|10+00"
+    "B up-right|B-right B-up|REST|1+-00"       "B down-left|B-left B-down|REST|1-+00"
+    "B button 0|B-button0|REST|10010"          "B button 1|B-button1|REST|10001"
+    "B both buttons|B-button0 B-button1|REST|10011"
+    "A left+button 1, B right+button 0|A-left A-button1 B-right B-button0|1-001|1+010"
+)
+
+# The same for sticks with one button (the Atari's fire button is button 0).
+JOY_STEPS_ONE_BUTTON=(
+    "A left|A-left|1-000|REST"                 "A right|A-right|1+000|REST"
+    "A up|A-up|10-00|REST"                     "A down|A-down|10+00|REST"
+    "A up-left|A-left A-up|1--00|REST"         "A down-right|A-right A-down|1++00|REST"
+    "A button 0|A-button0|10010|REST"
+    "B left|B-left|REST|1-000"                 "B right|B-right|REST|1+000"
+    "B up|B-up|REST|10-00"                     "B down|B-down|REST|10+00"
+    "B up-right|B-right B-up|REST|1+-00"       "B down-left|B-left B-down|REST|1-+00"
+    "B button 0|B-button0|REST|10010"
+    "A left+button 0, B right+button 0|A-left A-button0 B-right B-button0|1-010|1+010"
+)
+
+
+joyCheck() {
+    local png="$JOY_WORK/shot.png"
+    local got
+    local try
+    local args=("${JOY_SCREEN_ARGS[@]}")
+    JOY_CHECKED=$((JOY_CHECKED + 1))
+    [ -z "$JOY_SCALE" ] || args+=(--scale "$JOY_SCALE")
+    for ((try = 1; try <= JOY_SHOT_TRIES; try++)); do
+        [ "$try" = 1 ] || sleep "$JOY_RETRY_SECONDS"
+        rm -f "$png"
+        if ! joyShot "$png" || [ ! -s "$png" ]; then
+            joyFail "$1: no screenshot"
+            return
+        fi
+        got=$(python3 "$JOEY_REPO/scripts/lib/joyScreen.py" "$png" "${args[@]}")
+        [[ "${got% scale=*}" == *"?"* ]] || break
+    done
+    if [ -n "${JOY_SHOTS:-}" ]; then
+        mkdir -p "$JOY_SHOTS"
+        cp "$png" "$JOY_SHOTS/$(printf '%02d' "$JOY_CHECKED") $1.png"
+    fi
+    echo "$JOY_LABEL: $1: $got"
+    [ "${got% scale=*}" = "$2" ] || joyFail "$1: read ${got% scale=*}, want $2"
+}
+
+
+joyFail() {
+    JOY_FAILS="${JOY_FAILS:+$JOY_FAILS; }$1"
+}
+
+
+joyResult() {
+    if [ -n "$JOY_FAILS" ]; then
+        echo "$1: FAIL - $JOY_FAILS"
+        exit 1
+    fi
+    echo "$1: PASS ($JOY_CHECKED screenshots: $2)"
+}
+
+
+joyRunSteps() {
+    local step
+    local name
+    local held
+    local wantA
+    local wantB
+    local controls
+    for step in "$@"; do
+        IFS='|' read -r name held wantA wantB <<< "$step"
+        read -r -a controls <<< "$held"
+        joyPress "${controls[@]}"
+        sleep "$JOY_PRESS_SECONDS"
+        joyCheck "$name" "A=${wantA/REST/$JOY_REST} B=${wantB/REST/$JOY_REST_B}"
+        joyRelease "${controls[@]}"
+        sleep "$JOY_PRESS_SECONDS"
+        joyCheck "$name released" "A=$JOY_REST B=$JOY_REST_B"
+    done
+}
diff --git a/scripts/lib/joyScreen.py b/scripts/lib/joyScreen.py
index d85df0d..ef9cf5b 100644
--- a/scripts/lib/joyScreen.py
+++ b/scripts/lib/joyScreen.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """joyScreen.py - read both joysticks' state off a screenshot of JOY.
 
 JOY (examples/joy) shows each stick in a frame: a dot that sits in the
@@ -15,7 +19,10 @@ the middle, a colour that is not JOY's). Half way, as DVX's own joystick
 tests judge an axis: an uncalibrated DVX axis ranges itself as it moves,
 so an emulated stick's full push reads a little short of the end.
 
-    joyScreen.py PNG [--scale S]
+    joyScreen.py PNG [--scale S] [--st]
+
+--st: the screen shows the plain Atari ST's 3-bit palette, which drops each
+channel's low bit (Hatari shows 3-bit n as the STe's 4-bit 2n).
 
 Prints "A= B= scale= dots= "; exit 1
 when the stage is not found or a stick cannot be read.
@@ -58,13 +65,19 @@ def rgb(v):
     return (((v >> 8) & 0xF) * 17, ((v >> 4) & 0xF) * 17, (v & 0xF) * 17)
 
 
-PALETTE = {name: rgb(v) for name, v in COLOURS.items()}
+# A plain ST's 3-bit palette keeps a channel's top three bits.
+ST_CHANNEL_MASK = 0xEEE
+
+
+def palette(mask):
+    return {name: rgb(v & mask) for name, v in COLOURS.items()}
 
 
 class Screen:
-    def __init__(self, img):
+    def __init__(self, img, colours):
         self.w, self.h = img.size
         self.pixels = list(img.getdata())
+        self.colours = colours
         self.names = {}
 
     def name(self, x, y):
@@ -74,7 +87,7 @@ class Screen:
         c = self.pixels[y * self.w + x]
         if c not in self.names:
             best, bestDist = None, MAX_DIST + 1
-            for n, p in PALETTE.items():
+            for n, p in self.colours.items():
                 d = sum((a - b) ** 2 for a, b in zip(c, p))
                 if d < bestDist:
                     best, bestDist = n, d
@@ -165,16 +178,20 @@ def main(argv):
         sys.stderr.write(__doc__)
         return 2
     scales = SCALES
+    mask = 0xFFF
     args = argv[2:]
     while args:
         if args[0] == "--scale" and len(args) > 1:
             scales = (int(args[1]),)
+            args = args[2:]
+        elif args[0] == "--st":
+            mask = ST_CHANNEL_MASK
+            args = args[1:]
         else:
             sys.stderr.write(__doc__)
             return 2
-        args = args[2:]
 
-    scr = Screen(Image.open(argv[1]).convert("RGB"))
+    scr = Screen(Image.open(argv[1]).convert("RGB"), palette(mask))
     for scale in scales:
         origin = findStage(scr, scale)
         if origin:
diff --git a/scripts/lib/keysRow.py b/scripts/lib/keysRow.py
index 43d6df3..45eda34 100755
--- a/scripts/lib/keysRow.py
+++ b/scripts/lib/keysRow.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """keysRow.py - read KEYS's verification row off a screenshot.
 
 KEYS (examples/keys) draws every character it received on its row 198 as
diff --git a/scripts/lib/mameNullModemCfg.py b/scripts/lib/mameNullModemCfg.py
index cc54165..0977dc8 100644
--- a/scripts/lib/mameNullModemCfg.py
+++ b/scripts/lib/mameNullModemCfg.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """mameNullModemCfg.py - a MAME cfg file that sets a null_modem's line.
 
 MAME's null_modem clocks the wire itself, at 9600 8 N 1 unless its
diff --git a/scripts/lib/padGate.sh b/scripts/lib/padGate.sh
new file mode 100644
index 0000000..2c68cf5
--- /dev/null
+++ b/scripts/lib/padGate.sh
@@ -0,0 +1,120 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the joystick gates of the ports that read the host's gamepads
+# (SDL3, Win32 under Wine): two virtual Xbox 360 pads
+# (scripts/lib/virtualPad.py) as sticks A and B. The pads are the whole
+# machine's while they exist, so a real pad plugged in here would be seen
+# too: PAD_ONLY_ENV is the environment that hides every other pad from SDL
+# (and from Wine's SDL-backed bus driver). Source after joyGate.sh.
+#
+#   padStart WORK        make the pads (exit 2 when /dev/uinput cannot be
+#                        written)
+#   padOrder             the host numbers two identical pads in its own
+#                        order (Wine does B first): move pad A once with JOY
+#                        up, and if JOY shows it as stick B, swap the pads'
+#                        roles from then on
+#   padStop              remove them
+#   joyPress / joyRelease  joyGate.sh's: hold or let go of the controls
+
+PAD_PID=""
+PAD_FD=""
+PAD_OUT=""
+PAD_SENT=0
+PAD_SWAP=0
+PAD_READY_SECONDS=10
+PAD_REPLY_SECONDS=5
+# The virtual pads' USB vendor/product (virtualPad.py's).
+PAD_ONLY_ENV=(SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x045e/0x028e)
+
+
+# padWait COUNT SECONDS -- wait until the pad helper has printed COUNT
+# lines of "ok" (or, COUNT 0, "ready").
+padWait() {
+    local deadline=$((SECONDS + $2))
+    while [ "$SECONDS" -lt "$deadline" ]; do
+        if [ "$1" = 0 ]; then
+            grep -qx ready "$PAD_OUT" 2> /dev/null && return 0
+        elif [ "$(grep -cx ok "$PAD_OUT" 2> /dev/null)" -ge "$1" ]; then
+            return 0
+        fi
+        joeyAlive "$PAD_PID" || return 1
+        sleep 0.1
+    done
+    return 1
+}
+
+
+padOrder() {
+    local got
+    padSend press A-left
+    sleep "$JOY_PRESS_SECONDS"
+    joyShot "$JOY_WORK/order.png"
+    got=$(python3 "$JOEY_REPO/scripts/lib/joyScreen.py" "$JOY_WORK/order.png" "${JOY_SCREEN_ARGS[@]}")
+    padSend release A-left
+    sleep "$JOY_PRESS_SECONDS"
+    case "$got" in
+        *"A=1-000"*) PAD_SWAP=0 ;;
+        *"B=1-000"*) PAD_SWAP=1 ;;
+        *)           joyFail "pad A moved neither stick: $got" ;;
+    esac
+}
+
+
+# padSend WORD CONTROL... -- with the pads' roles swapped (PAD_SWAP), stick
+# A's controls go to pad B and B's to pad A.
+padSend() {
+    local words=("$1")
+    local control
+    shift
+    for control in "$@"; do
+        if [ "$PAD_SWAP" = 1 ]; then
+            case "$control" in
+                A-*) control="B-${control#A-}" ;;
+                B-*) control="A-${control#B-}" ;;
+            esac
+        fi
+        words+=("$control")
+    done
+    printf '%s\n' "${words[*]}" >&"$PAD_FD"
+    PAD_SENT=$((PAD_SENT + 1))
+    padWait "$PAD_SENT" "$PAD_REPLY_SECONDS" || joyFail "the virtual pads did not take: $*"
+}
+
+
+padStart() {
+    local fifo="$1/pad.in"
+    if [ ! -w /dev/uinput ]; then
+        echo "$JOY_LABEL: cannot write /dev/uinput, which the virtual gamepads need (a desktop session's user usually can)" >&2
+        exit 2
+    fi
+    PAD_OUT="$1/pad.out"
+    mkfifo "$fifo"
+    python3 "$JOEY_REPO/scripts/lib/virtualPad.py" "JoeyLib gate" < "$fifo" > "$PAD_OUT" 2>&1 &
+    PAD_PID=$!
+    exec {PAD_FD}> "$fifo"
+    if ! padWait 0 "$PAD_READY_SECONDS"; then
+        echo "$JOY_LABEL: FAIL - the virtual gamepads did not come up: $(cat "$PAD_OUT")" >&2
+        exit 1
+    fi
+}
+
+
+padStop() {
+    [ -n "$PAD_PID" ] || return 0
+    printf 'quit\n' >&"$PAD_FD" 2> /dev/null
+    exec {PAD_FD}>&-
+    wait "$PAD_PID" 2> /dev/null
+    PAD_PID=""
+}
+
+
+joyPress() {
+    padSend press "$@"
+}
+
+
+joyRelease() {
+    padSend release "$@"
+}
diff --git a/scripts/lib/pspAudioGrade.py b/scripts/lib/pspAudioGrade.py
new file mode 100644
index 0000000..a11801e
--- /dev/null
+++ b/scripts/lib/pspAudioGrade.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
+"""pspAudioGrade.py - grade what AUDIOPRB played on the PSP.
+
+AUDIOPRB is examples/audio built to press its own keys (make/examples.mk):
+the tracker song from the start, then S every PROBE_STREAM_FRAMES frames
+from PROBE_STREAM_FIRST (and N once, whose noise sweep the streams cover).
+Each S streams a falling square wave, each take STREAM_TAKE_HZ higher than
+the last. scripts/verify-psp-audio.sh records what reached the PSP's audio
+output (PPSSPP's own recording, 44.1 kHz stereo on the emulated clock), and
+this grades it from the moment the music starts:
+
+    music   the tracker song alone, before the first take: sound, and
+            quieter than a take
+    take N  each of the first three takes, a little way in: loud, and at
+            its pitch, from the zero crossings -- so the PSP plays the mix
+            at the rate it was made for
+    end     the recording stops within END_SLACK seconds of the program
+            being told to quit: it ran until then, and ended on it
+
+    pspAudioGrade.py LABEL WAV QUIT_SECONDS
+
+QUIT_SECONDS is when (since the recording began) the gate pressed Select,
+which the PSP port gives as Escape. Prints one line per check and PASS or
+FAIL; exit 1 on a failure.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tools"))
+from gradeAudioPhases import readMono, rms  # noqa: E402
+
+# examples/audio/audio.c: the probe's timetable and the streamed tone.
+FRAME_HZ = 60
+PROBE_STREAM_FIRST = 30
+PROBE_STREAM_FRAMES = 90
+STREAM_RATE_HZ = 8000
+STREAM_BLOCK = 256
+STREAM_BLOCKS = 48
+STREAM_START_HZ = 330
+STREAM_DROP_HZ = 220
+STREAM_TAKE_HZ = 55
+TAKES = 3
+
+# The music starts when its first sample rises above ONSET_LEVEL.
+ONSET_LEVEL = 200
+MUSIC_WINDOW = (0.05, 0.40)
+MUSIC_RMS = (1000, 8000)
+# A take is measured from TAKE_WINDOW seconds after it is due to start: far
+# enough in to miss the audio queue's latency, early in its fall.
+TAKE_WINDOW = (0.10, 0.20)
+TAKE_MIN_RMS = 15000
+PITCH_TOLERANCE = 0.10
+END_SLACK = 0.5
+
+
+def crossingsPerSecond(samples, rate, start, end):
+    a = int(start * rate)
+    b = min(int(end * rate), len(samples))
+    count = 0
+    for i in range(a + 1, b):
+        if (samples[i - 1] < 0) != (samples[i] < 0):
+            count += 1
+    return count / ((b - a) / rate)
+
+
+def takeHz(take, seconds):
+    """The pitch take is at, seconds after it starts."""
+    block = int(seconds * STREAM_RATE_HZ) // STREAM_BLOCK
+    return STREAM_START_HZ + STREAM_TAKE_HZ * take - STREAM_DROP_HZ * block / STREAM_BLOCKS
+
+
+def main(argv):
+    if len(argv) != 4:
+        print(__doc__, file=sys.stderr)
+        return 2
+    label, path, quitSeconds = argv[1], argv[2], float(argv[3])
+    samples, rate = readMono(path)
+    duration = len(samples) / rate
+    onset = next((i / rate for i, v in enumerate(samples) if abs(v) > ONSET_LEVEL), None)
+    if onset is None:
+        print(f"{label}: FAIL - {duration:.1f}s recorded and all of it silent", file=sys.stderr)
+        return 1
+    fails = []
+
+    level = rms(samples, rate, onset + MUSIC_WINDOW[0], onset + MUSIC_WINDOW[1])
+    print(f"{label}: music from {onset:.2f}s, RMS {level:.0f}")
+    if level is None or not MUSIC_RMS[0] <= level <= MUSIC_RMS[1]:
+        fails.append(f"music RMS {level}, want {MUSIC_RMS[0]}-{MUSIC_RMS[1]}")
+
+    for take in range(TAKES):
+        due = onset + (PROBE_STREAM_FIRST + PROBE_STREAM_FRAMES * take) / FRAME_HZ
+        start = due + TAKE_WINDOW[0]
+        end = due + TAKE_WINDOW[1]
+        level = rms(samples, rate, start, end)
+        if level is None:
+            fails.append(f"take {take + 1} is past the end of the recording")
+            continue
+        hz = crossingsPerSecond(samples, rate, start, end) / 2
+        want = takeHz(take, (TAKE_WINDOW[0] + TAKE_WINDOW[1]) / 2)
+        print(f"{label}: take {take + 1} at {start:.2f}s: RMS {level:.0f}, {hz:.0f} Hz (want {want:.0f})")
+        if level < TAKE_MIN_RMS:
+            fails.append(f"take {take + 1} RMS {level:.0f}, want at least {TAKE_MIN_RMS}")
+        if abs(hz - want) > want * PITCH_TOLERANCE:
+            fails.append(f"take {take + 1} at {hz:.0f} Hz, want {want:.0f}")
+
+    print(f"{label}: the recording ends at {duration:.2f}s, Select at {quitSeconds:.2f}s")
+    if abs(duration - quitSeconds) > END_SLACK:
+        fails.append(f"the recording ends at {duration:.2f}s, not within {END_SLACK}s of Select at {quitSeconds:.2f}s")
+
+    for f in fails:
+        print(f"{label}: FAIL - {f}", file=sys.stderr)
+    if fails:
+        return 1
+    print(f"{label}: PASS")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/scripts/lib/pspGate.sh b/scripts/lib/pspGate.sh
new file mode 100644
index 0000000..325f29d
--- /dev/null
+++ b/scripts/lib/pspGate.sh
@@ -0,0 +1,94 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the PSP gates (scripts/bench-psp.sh, scripts/verify-psp-*.sh) and
+# joey-run: run a PSP program (make psp: bin/NAME/EBOOT.PBP and its DATA)
+# under PPSSPPHeadless, the installer's patched headless PPSSPP (ppsspp
+# component, installer/patches/ppsspp). Source after joeyEnv.sh.
+#
+# PPSSPPHeadless keeps its memory stick in $HOME/.ppsspp, so it always runs
+# with HOME pointing at a work folder: the program folder goes on that
+# memory stick where a PSP keeps it, PSP/GAME/NAME, and runs from there
+# (ms0:/PSP/GAME/NAME is its current directory, as on a PSP). Every run
+# checks every memory access as a PSP does (--strict-mem, on the IR JIT,
+# the core that checks alignment): an unaligned or wild access stops the
+# program, and pspStrictCheck reports it.
+#
+#   pspNeed                      exit 2 unless PPSSPPHeadless is installed
+#   pspStage WORK DIR            copy the program folder DIR onto WORK's
+#                                memory stick; prints the folder's path there
+#   pspRun WORK FOLDER SECS ARGS start the program in FOLDER (pspStage's) in
+#                                the background for at most SECS seconds of
+#                                real time, with PPSSPPHeadless's ARGS too;
+#                                its output goes to WORK/ppsspp.log; sets
+#                                PSP_PID
+#   pspWait                      wait for the run to end by itself
+#   pspStrictCheck WORK DIR      0 unless the run of the program folder DIR
+#                                stopped at a bad memory access; then prints
+#                                where, and the line psp-addr2line finds for
+#                                it in the program's ELF (make/psp.mk keeps
+#                                bin/NAME's in elf/NAME.elf)
+#   pspStop                      stop the run
+
+PSP_PID=""
+# Real seconds a run is given beyond SECS before it is killed outright.
+PSP_KILL_GRACE=30
+
+
+pspNeed() {
+    joeyNeed PPSSPP_HEADLESS emulator:psp
+}
+
+
+pspStage() {
+    local folder
+    folder="$1/.ppsspp/PSP/GAME/$(basename "$2")"
+    mkdir -p "$folder"
+    cp -r "$2/." "$folder/"
+    printf '%s\n' "$folder"
+}
+
+
+pspRun() {
+    local work="$1"
+    local folder="$2"
+    local seconds="$3"
+    shift 3
+    HOME="$work" timeout -s KILL "$((seconds + PSP_KILL_GRACE))" "$PPSSPP_HEADLESS" "$folder/EBOOT.PBP" --strict-mem --jit-ir --timeout="$seconds" "$@" > "$work/ppsspp.log" 2>&1 &
+    PSP_PID=$!
+}
+
+
+pspWait() {
+    wait "$PSP_PID" 2> /dev/null || true
+    PSP_PID=""
+}
+
+
+pspStrictCheck() {
+    local line
+    local pc
+    local elf
+    line="$(grep -m1 '^STRICT-MEM: ' "$1/ppsspp.log" 2> /dev/null)" || return 0
+    echo "$(basename "$0"): the program stopped at a bad memory access, as a PSP would:" >&2
+    sed -n '/^STRICT-MEM: /,/^$/p' "$1/ppsspp.log" >&2
+    pc="$(sed -n 's/.*, pc \([0-9a-f]*\)$/\1/p' <<< "$line")"
+    elf="$(dirname "$(dirname "$2")")/elf/$(basename "$2").elf"
+    if [ -n "$pc" ] && [ -f "$elf" ] && [ -n "${PSPDEV:-}" ]; then
+        echo "  pc $pc: $("$PSPDEV/bin/psp-addr2line" -f -e "$elf" "0x$pc" | tr '\n' ' ')" >&2
+    fi
+    return 1
+}
+
+
+pspStop() {
+    local child
+    if [ -n "$PSP_PID" ]; then
+        if child="$(joeyChild "$PSP_PID")"; then
+            joeyStop "$child"
+        fi
+        joeyStop "$PSP_PID"
+    fi
+    PSP_PID=""
+}
diff --git a/scripts/lib/qmp.py b/scripts/lib/qmp.py
new file mode 100644
index 0000000..d1e775f
--- /dev/null
+++ b/scripts/lib/qmp.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
+"""qmp.py - drive a QEMU machine through its QMP socket.
+
+The Be API ports' gates (scripts/lib/beGate.sh) take the screen and press keys
+this way, as a person at the machine would: the keys arrive through the
+emulated PS/2 keyboard.
+
+    qmp.py SOCKET screendump FILE.png   the screen, as QEMU's display has it
+    qmp.py SOCKET type TEXT             type TEXT (US layout, shift as needed)
+    qmp.py SOCKET keys KEY...           press the KEYs, one after another
+    qmp.py SOCKET hold KEY MS           hold one key down for MS milliseconds
+    qmp.py SOCKET move DX DY            move the mouse DX, DY (relative, as a PS/2
+                                        mouse moves, in steps a PS/2 packet holds;
+                                        the system stops it at the screen's edge)
+    qmp.py SOCKET quit                  end QEMU
+
+KEY is a QEMU key name (a qcode: esc, ret, spc, a, 1, ...).
+"""
+
+import json
+import socket
+import sys
+import time
+
+# How long each typed key is held, and the pause after it: slow enough that
+# a program reading its input once a frame sees every key.
+TYPE_HOLD_MS = 100
+TYPE_GAP_SECONDS = 0.15
+MS_PER_SECOND = 1000.0
+# A mouse move goes in steps of at most this many counts on each axis, with
+# this pause after each: BeOS R5's mouse driver gets a single long move wrong.
+MOVE_STEP_MAX = 100
+MOVE_GAP_SECONDS = 0.02
+
+# Characters to (qcode, shifted) on a US keyboard.
+SHIFTED_DIGITS = ')!@#$%^&*('
+PUNCTUATION = {
+    ' ': ('spc', False), '.': ('dot', False), ',': ('comma', False),
+    '/': ('slash', False), ';': ('semicolon', False), "'": ('apostrophe', False),
+    '-': ('minus', False), '=': ('equal', False), '[': ('bracket_left', False),
+    ']': ('bracket_right', False), '\\': ('backslash', False), '`': ('grave_accent', False),
+    ':': ('semicolon', True), '?': ('slash', True), '>': ('dot', True), '<': ('comma', True),
+    '"': ('apostrophe', True), '_': ('minus', True), '+': ('equal', True),
+    '{': ('bracket_left', True), '}': ('bracket_right', True), '|': ('backslash', True),
+    '~': ('grave_accent', True),
+}
+
+
+class Qmp:
+    """One QMP connection: the greeting read and capabilities negotiated."""
+
+    def __init__(self, path):
+        self.sock = socket.socket(socket.AF_UNIX)
+        self.sock.connect(path)
+        self.file = self.sock.makefile('rw')
+        self.file.readline()
+        self.execute('qmp_capabilities')
+
+    def execute(self, command, arguments=None):
+        """Run one command; its return value. Events in between are skipped."""
+        request = {'execute': command}
+        if arguments is not None:
+            request['arguments'] = arguments
+        self.file.write(json.dumps(request) + '\n')
+        self.file.flush()
+        while True:
+            reply = json.loads(self.file.readline())
+            if 'error' in reply:
+                raise RuntimeError('%s: %s' % (command, reply['error'].get('desc', reply['error'])))
+            if 'return' in reply:
+                return reply['return']
+
+    def keys(self, qcodes, holdMs):
+        """Press qcodes together, hold them holdMs, let them go."""
+        self.execute('send-key', {
+            'keys': [{'type': 'qcode', 'data': code} for code in qcodes],
+            'hold-time': holdMs,
+        })
+
+
+def charKey(ch):
+    """(qcode, shifted) for one character."""
+    if 'a' <= ch <= 'z' or '0' <= ch <= '9':
+        return ch, False
+    if 'A' <= ch <= 'Z':
+        return ch.lower(), True
+    if ch in SHIFTED_DIGITS:
+        return str(SHIFTED_DIGITS.index(ch)), True
+    if ch in PUNCTUATION:
+        return PUNCTUATION[ch]
+    raise ValueError('no key types %r' % ch)
+
+
+def move(qmp, dx, dy):
+    """Move the mouse dx, dy in MOVE_STEP_MAX steps."""
+    steps = max(1, -(-max(abs(dx), abs(dy)) // MOVE_STEP_MAX))
+    for step in range(steps):
+        stepX = dx * (step + 1) // steps - dx * step // steps
+        stepY = dy * (step + 1) // steps - dy * step // steps
+        qmp.execute('input-send-event', {'events': [
+            {'type': 'rel', 'data': {'axis': 'x', 'value': stepX}},
+            {'type': 'rel', 'data': {'axis': 'y', 'value': stepY}},
+        ]})
+        time.sleep(MOVE_GAP_SECONDS)
+
+
+def tap(qmp, qcodes):
+    """Press qcodes together for a typed key's time, then pause after them."""
+    qmp.keys(qcodes, TYPE_HOLD_MS)
+    time.sleep(TYPE_HOLD_MS / MS_PER_SECOND + TYPE_GAP_SECONDS)
+
+
+def main(argv):
+    if len(argv) < 3:
+        sys.exit(__doc__)
+    qmp = Qmp(argv[1])
+    command = argv[2]
+    if command == 'screendump' and len(argv) == 4:
+        qmp.execute('screendump', {'filename': argv[3], 'format': 'png'})
+    elif command == 'type' and len(argv) == 4:
+        for ch in argv[3]:
+            code, shifted = charKey(ch)
+            tap(qmp, ['shift', code] if shifted else [code])
+    elif command == 'keys' and len(argv) >= 4:
+        for code in argv[3:]:
+            tap(qmp, [code])
+    elif command == 'hold' and len(argv) == 5:
+        holdMs = int(argv[4])
+        qmp.keys([argv[3]], holdMs)
+        time.sleep(holdMs / MS_PER_SECOND)
+    elif command == 'move' and len(argv) == 5:
+        move(qmp, int(argv[3]), int(argv[4]))
+    elif command == 'quit' and len(argv) == 3:
+        qmp.execute('quit')
+    else:
+        sys.exit(__doc__)
+
+
+if __name__ == '__main__':
+    main(sys.argv)
diff --git a/scripts/lib/serialLoopPeer.py b/scripts/lib/serialLoopPeer.py
index ddcb383..f72d3a1 100755
--- a/scripts/lib/serialLoopPeer.py
+++ b/scripts/lib/serialLoopPeer.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """serialLoopPeer.py - the host end of the serial loopback gates.
 
 The guest runs SERTEST (examples/sertest): it sends a banner, then echoes
diff --git a/scripts/lib/serialStreamPeer.py b/scripts/lib/serialStreamPeer.py
index 86d6b69..40828f5 100755
--- a/scripts/lib/serialStreamPeer.py
+++ b/scripts/lib/serialStreamPeer.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """serialStreamPeer.py - host end of a both-directions serial stream gate.
 
 The guest (examples/sermouse) transmits an endless stream and checks an
diff --git a/scripts/lib/tileBankGate.sh b/scripts/lib/tileBankGate.sh
index 1d01be8..3ddf0ea 100644
--- a/scripts/lib/tileBankGate.sh
+++ b/scripts/lib/tileBankGate.sh
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Shared by the tile-bank gates, scripts/verify--tilebank.sh. Each runs
 # examples/tilebank on its port with the DATA/tilebank make/.mk staged
 # (tests/host/tileBankStage.py: the port's own .tbk bake of
diff --git a/scripts/lib/virtualPad.py b/scripts/lib/virtualPad.py
new file mode 100644
index 0000000..3e16832
--- /dev/null
+++ b/scripts/lib/virtualPad.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
+"""virtualPad.py - two virtual gamepads for the joystick gates of the ports
+that read the host's gamepads (SDL3, Win32 under Wine, the web port in
+Chromium).
+
+Makes two Linux uinput devices laid out as Xbox 360 pads (the xpad driver's
+buttons and axes, Microsoft's USB ids), which SDL's, Wine's and Chromium's
+gamepad mappings all know: stick A is the first pad, stick B the second.
+Then reads lines on stdin and holds or lets go of the controls named:
+
+    press CONTROL...      CONTROL as joyGate.sh names them: A-left A-right
+    release CONTROL...    A-up A-down A-button0 A-button1, and the same for B
+    quit                  remove the pads (as does end of input)
+
+The left stick moves to the end of its travel (left/up -32767, right/down
++32767; holding both of an axis's directions is the positive one), button
+0 is the south button (A) and button 1 the east (B). Prints "ready" once
+both devices exist, and "ok" after each line it has applied.
+
+Needs write access to /dev/uinput (a desktop session's user usually has it
+through udev's uaccess rule). The pads are the whole machine's while they
+exist, not only the gate's.
+
+    virtualPad.py [NAME]     NAME prefixes the devices' names
+"""
+
+import fcntl
+import os
+import struct
+import sys
+import time
+
+UINPUT = "/dev/uinput"
+
+# linux/uinput.h and linux/input.h.
+UI_DEV_CREATE = 0x5501
+UI_DEV_DESTROY = 0x5502
+UI_DEV_SETUP = 0x405C5503      # _IOW('U', 3, struct uinput_setup), 92 bytes
+UI_ABS_SETUP = 0x401C5504      # _IOW('U', 4, struct uinput_abs_setup), 28 bytes
+UI_SET_EVBIT = 0x40045564
+UI_SET_KEYBIT = 0x40045565
+UI_SET_ABSBIT = 0x40045567
+EV_SYN = 0x00
+EV_KEY = 0x01
+EV_ABS = 0x03
+SYN_REPORT = 0
+BUS_USB = 0x03
+XBOX360_VENDOR = 0x045E
+XBOX360_PRODUCT = 0x028E
+XBOX360_VERSION = 0x0110
+NAME_BYTES = 80
+
+BTN_SOUTH = 0x130
+BTN_EAST = 0x131
+BTN_NORTH = 0x133
+BTN_WEST = 0x134
+BTN_TL = 0x136
+BTN_TR = 0x137
+BTN_SELECT = 0x13A
+BTN_START = 0x13B
+BTN_MODE = 0x13C
+BTN_THUMBL = 0x13D
+BTN_THUMBR = 0x13E
+BUTTONS = (BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST, BTN_TL, BTN_TR,
+           BTN_SELECT, BTN_START, BTN_MODE, BTN_THUMBL, BTN_THUMBR)
+
+ABS_X = 0x00
+ABS_Y = 0x01
+ABS_Z = 0x02
+ABS_RX = 0x03
+ABS_RY = 0x04
+ABS_RZ = 0x05
+ABS_HAT0X = 0x10
+ABS_HAT0Y = 0x11
+STICK_MAX = 32767
+STICK_MIN = -32768
+TRIGGER_MAX = 255
+# code: (min, max, fuzz, flat)
+AXES = {
+    ABS_X: (STICK_MIN, STICK_MAX, 16, 128), ABS_Y: (STICK_MIN, STICK_MAX, 16, 128),
+    ABS_RX: (STICK_MIN, STICK_MAX, 16, 128), ABS_RY: (STICK_MIN, STICK_MAX, 16, 128),
+    ABS_Z: (0, TRIGGER_MAX, 0, 0), ABS_RZ: (0, TRIGGER_MAX, 0, 0),
+    ABS_HAT0X: (-1, 1, 0, 0), ABS_HAT0Y: (-1, 1, 0, 0),
+}
+# How long the kernel and the readers get to see a new device.
+SETTLE_SECONDS = 1.0
+FULL = STICK_MAX
+LINE = {"button0": BTN_SOUTH, "button1": BTN_EAST}
+
+
+class Pad:
+    def __init__(self, name):
+        self.fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
+        fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
+        for b in BUTTONS:
+            fcntl.ioctl(self.fd, UI_SET_KEYBIT, b)
+        fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_ABS)
+        for code, (lo, hi, fuzz, flat) in AXES.items():
+            fcntl.ioctl(self.fd, UI_SET_ABSBIT, code)
+            fcntl.ioctl(self.fd, UI_ABS_SETUP, struct.pack("HH6i", code, 0, 0, lo, hi, fuzz, flat, 0))
+        setup = struct.pack("HHHH%dsI" % NAME_BYTES, BUS_USB, XBOX360_VENDOR, XBOX360_PRODUCT,
+                            XBOX360_VERSION, name.encode()[:NAME_BYTES - 1], 0)
+        fcntl.ioctl(self.fd, UI_DEV_SETUP, setup)
+        fcntl.ioctl(self.fd, UI_DEV_CREATE)
+        self.held = set()
+
+    def emit(self, kind, code, value):
+        now = time.time()
+        os.write(self.fd, struct.pack("qqHHi", int(now), int((now % 1) * 1e6), kind, code, value))
+
+    def apply(self):
+        """Send the axes and buttons the held lines make, then a report."""
+        x = FULL if "right" in self.held else (-FULL if "left" in self.held else 0)
+        y = FULL if "down" in self.held else (-FULL if "up" in self.held else 0)
+        self.emit(EV_ABS, ABS_X, x)
+        self.emit(EV_ABS, ABS_Y, y)
+        for line, code in LINE.items():
+            self.emit(EV_KEY, code, 1 if line in self.held else 0)
+        self.emit(EV_SYN, SYN_REPORT, 0)
+
+    def close(self):
+        fcntl.ioctl(self.fd, UI_DEV_DESTROY)
+        os.close(self.fd)
+
+
+def main(argv):
+    prefix = argv[1] if len(argv) > 1 else "JoeyLib gate"
+    pads = {}
+    try:
+        for stick in ("A", "B"):
+            pads[stick] = Pad("%s pad %s" % (prefix, stick))
+        time.sleep(SETTLE_SECONDS)
+        for pad in pads.values():
+            pad.apply()
+        print("ready", flush=True)
+        for raw in sys.stdin:
+            words = raw.split()
+            if not words:
+                continue
+            if words[0] == "quit":
+                break
+            if words[0] not in ("press", "release"):
+                print("error: unknown command %s" % words[0], flush=True)
+                continue
+            touched = set()
+            for control in words[1:]:
+                stick, _, line = control.partition("-")
+                if stick not in pads or line not in ("left", "right", "up", "down", "button0", "button1"):
+                    print("error: unknown control %s" % control, flush=True)
+                    continue
+                if words[0] == "press":
+                    pads[stick].held.add(line)
+                else:
+                    pads[stick].held.discard(line)
+                touched.add(stick)
+            for stick in touched:
+                pads[stick].apply()
+            print("ok", flush=True)
+    finally:
+        for pad in pads.values():
+            pad.close()
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/scripts/lib/webDriver.mjs b/scripts/lib/webDriver.mjs
new file mode 100644
index 0000000..d21097f
--- /dev/null
+++ b/scripts/lib/webDriver.mjs
@@ -0,0 +1,430 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// webDriver.mjs - run a JoeyLib web program (make web) in headless Chromium
+// and script it over the DevTools protocol, for the web gates and joey-run.
+//
+// Usage (Node 22 or later: global WebSocket and fetch; emsdk's node is):
+//   node webDriver.mjs --chrome PATH --url URL [options]
+// Options:
+//   --seconds N         give up after N seconds (default 60)
+//   --until TEXT        finish as soon as the program's joeylog.txt holds TEXT
+//   --step STEP         one step of a script run in order once the page is
+//                       open (instead of --until), given once per step:
+//                         wait:S        wait S seconds (fractions allowed)
+//                         type:TEXT     type TEXT, a character every 150 ms,
+//                                       as key events carrying their text
+//                         hold:KEY:MS   hold KEY (a DOM key name: p, Escape,
+//                                       " ") for MS milliseconds
+//                         reload        load the page again (what a program
+//                                       kept in IndexedDB comes back)
+//                         shot:FILE     a PNG of the canvas alone, now
+//                         pad:press:CONTROLS, pad:release:CONTROLS
+//                                       hold or let go of the --gamepads
+//                                       pads' controls, comma-separated as
+//                                       joyGate.sh names them (A-left,
+//                                       B-button1, ...)
+//                         pad:unplug    the --gamepads pads go away
+//   --screenshot FILE   at the end, a PNG of the canvas alone
+//   --gamepads          two gamepads (A and B) in place of the browser's own
+//                       (navigator.getGamepads), connected from the start
+//                       and driven by pad: steps: standard mapping, the left
+//                       stick to the end of its travel, button 0 the south
+//                       button and button 1 the east
+//   --log FILE          at the end, the program's joeylog.txt
+//   --file PATH:OUT     at the end, the page's file PATH (its in-memory file
+//                       system) into OUT; given once per file
+//   --audio FILE        record what the program plays, as a 16-bit stereo WAV:
+//                       SDL3 plays through a ScriptProcessorNode, so a script
+//                       put in the page before the program's own wraps the
+//                       node's onaudioprocess and keeps a copy of every buffer
+//                       it fills; the WAV, and the --step script, start with
+//                       the first one
+// Console lines and uncaught exceptions go to stderr as they happen. Exits 0
+// when it ran to the end (or reached --until), 1 when --until never came, the
+// page threw, or Chromium could not be driven.
+
+import { spawn } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const POLL_MS        = 250;
+const WAV_HEADER     = 44;
+const WAV_CHANNELS   = 2;
+const WAV_BYTES      = 2;
+const SAMPLE_SCALE   = 32767;
+const TYPE_DELAY_MS  = 150;
+const START_TRIES    = 80;
+const DEFAULT_SECS   = 60;
+const MS_PER_SECOND  = 1000;
+const DEBUG_PORT_MIN = 30000;
+const DEBUG_PORT_SPAN = 20000;
+
+// The program's log, read from the in-memory file system ("" until there is one).
+const READ_LOG = "(function () { try { return Module.FS.readFile('/joeylog.txt', { encoding: 'utf8' }); } catch (e) { return ''; } })()";
+// The recorder --audio puts in the page: every buffer SDL's ScriptProcessorNode
+// fills, kept as 16-bit stereo samples.
+const AUDIO_RECORDER = `(function () {
+    var original = AudioContext.prototype.createScriptProcessor;
+    window.joeyAudio = { rate: 0, chunks: [] };
+    AudioContext.prototype.createScriptProcessor = function (size, inputs, outputs) {
+        var node = original.call(this, size, inputs, outputs);
+        var context = this;
+        var handler = null;
+        Object.defineProperty(node, "onaudioprocess", {
+            get: function () { return handler; },
+            set: function (fn) {
+                handler = fn;
+                node.addEventListener("audioprocess", function (e) {
+                    var out = e.outputBuffer;
+                    var left = out.getChannelData(0);
+                    var right = out.numberOfChannels > 1 ? out.getChannelData(1) : left;
+                    var chunk = new Int16Array(left.length * 2);
+                    var i;
+                    fn(e);
+                    for (i = 0; i < left.length; i++) {
+                        chunk[i * 2] = Math.max(-1, Math.min(1, left[i])) * ${SAMPLE_SCALE};
+                        chunk[i * 2 + 1] = Math.max(-1, Math.min(1, right[i])) * ${SAMPLE_SCALE};
+                    }
+                    window.joeyAudio.rate = context.sampleRate;
+                    window.joeyAudio.chunks.push(chunk);
+                });
+            }
+        });
+        return node;
+    };
+})();`;
+// Whether the page has played its first buffer.
+const AUDIO_STARTED = "(function () { return !!(window.joeyAudio && window.joeyAudio.chunks.length > 0); })()";
+// The recording, as base64 of its little-endian samples.
+const AUDIO_TAKE = "(function () { var a = window.joeyAudio; var n = 0; var all; var at = 0; var bytes; var text = ''; var i; if (!a) { return null; } a.chunks.forEach(function (c) { n += c.length; }); all = new Int16Array(n); a.chunks.forEach(function (c) { all.set(c, at); at += c.length; }); bytes = new Uint8Array(all.buffer); for (i = 0; i < bytes.length; i++) { text += String.fromCharCode(bytes[i]); } return JSON.stringify({ rate: a.rate, data: btoa(text) }); })()";
+// The canvas's place on the page, for the screenshot's clip.
+// The --gamepads pads, put in the page before the program's own scripts:
+// navigator.getGamepads answers with them, and window.joeyPads holds and
+// lets go of their controls (stick A or B, then left/right/up/down/button0/
+// button1; holding both of an axis's directions is the positive one).
+const GAMEPADS = `(function () {
+    var BUTTONS = 17;
+    var AXES = 4;
+    var pads = [];
+    function make(index) {
+        var pad = { id: "JoeyLib gate pad " + "AB"[index] + " (STANDARD GAMEPAD)", index: index, connected: true, mapping: "standard", timestamp: 0, axes: [], buttons: [], held: {} };
+        var i;
+        for (i = 0; i < AXES; i++) { pad.axes.push(0); }
+        for (i = 0; i < BUTTONS; i++) { pad.buttons.push({ pressed: false, touched: false, value: 0 }); }
+        return pad;
+    }
+    function apply(pad) {
+        var h = pad.held;
+        pad.axes[0] = h.right ? 1 : (h.left ? -1 : 0);
+        pad.axes[1] = h.down ? 1 : (h.up ? -1 : 0);
+        [h.button0, h.button1].forEach(function (on, i) { pad.buttons[i] = { pressed: !!on, touched: !!on, value: on ? 1 : 0 }; });
+        pad.timestamp = performance.now();
+    }
+    function announce(type, pad) {
+        var e = new Event(type);
+        e.gamepad = pad;
+        window.dispatchEvent(e);
+    }
+    pads = [make(0), make(1)];
+    Object.defineProperty(navigator, "getGamepads", { configurable: true, value: function () { return [pads[0] || null, pads[1] || null, null, null]; } });
+    window.joeyPads = {
+        set: function (down, controls) {
+            controls.forEach(function (c) {
+                var pad = pads["AB".indexOf(c.charAt(0))];
+                if (pad) { pad.held[c.slice(2)] = down; apply(pad); }
+            });
+        },
+        unplug: function () {
+            var gone = pads;
+            pads = [];
+            gone.forEach(function (pad) { pad.connected = false; announce("gamepaddisconnected", pad); });
+        }
+    };
+})();`;
+
+const CANVAS_RECT = "(function () { var r = document.getElementById('canvas').getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height }); })()";
+
+
+function parseArgs(argv) {
+    const opts = { seconds: DEFAULT_SECS, steps: [], files: [] };
+    let i;
+
+    for (i = 2; i < argv.length; i++) {
+        const name = argv[i];
+        const value = argv[i + 1];
+
+        switch (name) {
+            case "--chrome":     opts.chrome = value; i++; break;
+            case "--url":        opts.url = value; i++; break;
+            case "--seconds":    opts.seconds = Number(value); i++; break;
+            case "--until":      opts.until = value; i++; break;
+            case "--step":       opts.steps.push(value); i++; break;
+            case "--screenshot": opts.screenshot = value; i++; break;
+            case "--gamepads":   opts.gamepads = true; break;
+            case "--log":        opts.log = value; i++; break;
+            case "--audio":      opts.audio = value; i++; break;
+            case "--file":       opts.files.push(value); i++; break;
+            default:
+                throw new Error("unknown option " + name);
+        }
+    }
+    if (!opts.chrome || !opts.url) {
+        throw new Error("--chrome and --url are required");
+    }
+    return opts;
+}
+
+
+function sleep(ms) {
+    return new Promise(function (resolve) {
+        setTimeout(resolve, ms);
+    });
+}
+
+
+// One DevTools connection: send() returns the command's result; events go to
+// the handler.
+function connect(wsUrl, onEvent) {
+    return new Promise(function (resolve, reject) {
+        const ws = new WebSocket(wsUrl);
+        const pending = new Map();
+        let nextId = 1;
+
+        ws.onopen = function () {
+            resolve({
+                send: function (method, params) {
+                    const id = nextId++;
+                    ws.send(JSON.stringify({ id: id, method: method, params: params || {} }));
+                    return new Promise(function (ok, fail) {
+                        pending.set(id, { ok: ok, fail: fail });
+                    });
+                },
+                close: function () {
+                    ws.close();
+                }
+            });
+        };
+        ws.onerror = function () {
+            reject(new Error("cannot connect to " + wsUrl));
+        };
+        ws.onmessage = function (msg) {
+            const data = JSON.parse(msg.data);
+            if (data.id !== undefined && pending.has(data.id)) {
+                const p = pending.get(data.id);
+                pending.delete(data.id);
+                if (data.error) {
+                    p.fail(new Error(data.error.message));
+                } else {
+                    p.ok(data.result);
+                }
+            } else if (data.method) {
+                onEvent(data.method, data.params);
+            }
+        };
+    });
+}
+
+
+// One --step.
+async function runStep(cdp, step) {
+    const at = step.indexOf(":");
+    const verb = step.slice(0, at);
+    const rest = step.slice(at + 1);
+
+    if (step === "reload") {
+        await cdp.send("Page.reload");
+    } else if (verb === "wait") {
+        await sleep(Number(rest) * MS_PER_SECOND);
+    } else if (verb === "type") {
+        for (const ch of rest) {
+            await keyEvent(cdp, "keyDown", ch);
+            await keyEvent(cdp, "keyUp", ch);
+            await sleep(TYPE_DELAY_MS);
+        }
+    } else if (verb === "shot") {
+        await canvasShot(cdp, rest);
+    } else if (verb === "pad") {
+        const sep = rest.indexOf(":");
+        const action = sep < 0 ? rest : rest.slice(0, sep);
+        const controls = sep < 0 ? [] : rest.slice(sep + 1).split(",");
+        if (action === "unplug") {
+            await evaluate(cdp, "window.joeyPads.unplug()");
+        } else if (action === "press" || action === "release") {
+            await evaluate(cdp, "window.joeyPads.set(" + (action === "press") + ", " + JSON.stringify(controls) + ")");
+        } else {
+            throw new Error("unknown step " + step);
+        }
+    } else if (verb === "hold") {
+        const last = rest.lastIndexOf(":");
+        const key = rest.slice(0, last);
+        await keyEvent(cdp, "keyDown", key);
+        await sleep(Number(rest.slice(last + 1)));
+        await keyEvent(cdp, "keyUp", key);
+    } else {
+        throw new Error("unknown step " + step);
+    }
+}
+
+
+// A PNG of the canvas alone into file.
+async function canvasShot(cdp, file) {
+    const clip = JSON.parse(await evaluate(cdp, CANVAS_RECT));
+    clip.scale = 1;
+    const shot = await cdp.send("Page.captureScreenshot", { format: "png", clip: clip });
+    writeFileSync(file, Buffer.from(shot.data, "base64"));
+}
+
+
+// A 16-bit stereo WAV of the samples.
+function wavFile(rate, samples) {
+    const header = Buffer.alloc(WAV_HEADER);
+
+    header.write("RIFF", 0);
+    header.writeUInt32LE(WAV_HEADER - 8 + samples.length, 4);
+    header.write("WAVEfmt ", 8);
+    header.writeUInt32LE(16, 16);
+    header.writeUInt16LE(1, 20);
+    header.writeUInt16LE(WAV_CHANNELS, 22);
+    header.writeUInt32LE(rate, 24);
+    header.writeUInt32LE(rate * WAV_CHANNELS * WAV_BYTES, 28);
+    header.writeUInt16LE(WAV_CHANNELS * WAV_BYTES, 32);
+    header.writeUInt16LE(WAV_BYTES * 8, 34);
+    header.write("data", 36);
+    header.writeUInt32LE(samples.length, 40);
+    return Buffer.concat([header, samples]);
+}
+
+
+async function evaluate(cdp, expression) {
+    const r = await cdp.send("Runtime.evaluate", { expression: expression, returnByValue: true });
+    return r.result.value;
+}
+
+
+// A key down and up; a printable key carries its text, so the page gets the
+// character too.
+async function keyEvent(cdp, type, key) {
+    const params = { type: type, key: key };
+    if (key.length === 1) {
+        params.text = key;
+        params.code = /[a-z]/i.test(key) ? "Key" + key.toUpperCase() : (/[0-9]/.test(key) ? "Digit" + key : (key === " " ? "Space" : undefined));
+    } else {
+        params.code = key;
+        params.windowsVirtualKeyCode = key === "Escape" ? 27 : (key === "Enter" ? 13 : 0);
+    }
+    await cdp.send("Input.dispatchKeyEvent", params);
+}
+
+
+async function main() {
+    const opts = parseArgs(process.argv);
+    const profile = mkdtempSync(join(tmpdir(), "joeylib-web-"));
+    const debugPort = DEBUG_PORT_MIN + Math.floor(Math.random() * DEBUG_PORT_SPAN);
+    const chrome = spawn(opts.chrome, [
+        "--headless=new", "--no-sandbox", "--no-first-run", "--mute-audio",
+        "--autoplay-policy=no-user-gesture-required",
+        "--use-angle=swiftshader", "--enable-unsafe-swiftshader",
+        "--window-size=1024,800",
+        "--user-data-dir=" + profile,
+        "--remote-debugging-port=" + debugPort,
+        "about:blank"
+    ], { stdio: "ignore" });
+    let failed = false;
+    let cdp = null;
+
+    try {
+        let version = null;
+        let tries;
+        for (tries = 0; tries < START_TRIES && version === null; tries++) {
+            try {
+                version = await (await fetch("http://127.0.0.1:" + debugPort + "/json/list")).json();
+            } catch (e) {
+                await sleep(POLL_MS);
+            }
+        }
+        if (version === null) {
+            throw new Error("Chromium did not start");
+        }
+        const page = version.find(function (t) { return t.type === "page"; });
+        cdp = await connect(page.webSocketDebuggerUrl, function (method, params) {
+            if (method === "Runtime.consoleAPICalled") {
+                process.stderr.write("console: " + params.args.map(function (a) { return a.value !== undefined ? a.value : a.description; }).join(" ") + "\n");
+            } else if (method === "Runtime.exceptionThrown") {
+                failed = true;
+                process.stderr.write("exception: " + (params.exceptionDetails.exception ? params.exceptionDetails.exception.description : params.exceptionDetails.text) + "\n");
+            }
+        });
+        await cdp.send("Runtime.enable");
+        await cdp.send("Page.enable");
+        if (opts.audio !== undefined) {
+            await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: AUDIO_RECORDER });
+        }
+        if (opts.gamepads) {
+            await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: GAMEPADS });
+        }
+        await cdp.send("Page.navigate", { url: opts.url });
+
+        const deadline = Date.now() + opts.seconds * MS_PER_SECOND;
+        let reached = false;
+        if (opts.until !== undefined) {
+            while (Date.now() < deadline && !reached) {
+                reached = (await evaluate(cdp, READ_LOG)).indexOf(opts.until) >= 0;
+                if (!reached) {
+                    await sleep(POLL_MS);
+                }
+            }
+            if (!reached) {
+                process.stderr.write("webDriver: the log never held '" + opts.until + "'\n");
+                failed = true;
+            }
+        } else {
+            // With --audio the steps are timed from the first buffer played,
+            // where the recording starts.
+            while (opts.audio !== undefined && Date.now() < deadline && !(await evaluate(cdp, AUDIO_STARTED))) {
+                await sleep(POLL_MS);
+            }
+            for (const step of opts.steps) {
+                await runStep(cdp, step);
+            }
+        }
+        if (opts.audio !== undefined) {
+            const taken = await evaluate(cdp, AUDIO_TAKE);
+            if (taken === null) {
+                throw new Error("the page has no audio recorder");
+            }
+            const audio = JSON.parse(taken);
+            writeFileSync(opts.audio, wavFile(audio.rate, Buffer.from(audio.data, "base64")));
+        }
+        if (opts.log !== undefined) {
+            writeFileSync(opts.log, await evaluate(cdp, READ_LOG));
+        }
+        for (const file of opts.files) {
+            const at = file.lastIndexOf(":");
+            const path = file.slice(0, at);
+            const data = await evaluate(cdp, "(function () { try { var b = Module.FS.readFile(" + JSON.stringify(path) + "); var t = ''; var i; for (i = 0; i < b.length; i++) { t += String.fromCharCode(b[i]); } return btoa(t); } catch (e) { return null; } })()");
+            if (data === null) {
+                throw new Error("the page has no file " + path);
+            }
+            writeFileSync(file.slice(at + 1), Buffer.from(data, "base64"));
+        }
+        if (opts.screenshot !== undefined) {
+            await canvasShot(cdp, opts.screenshot);
+        }
+    } catch (e) {
+        process.stderr.write("webDriver: " + e.message + "\n");
+        failed = true;
+    } finally {
+        if (cdp !== null) {
+            cdp.close();
+        }
+        chrome.kill("SIGKILL");
+        rmSync(profile, { recursive: true, force: true });
+    }
+    process.exit(failed ? 1 : 0);
+}
+
+
+main();
diff --git a/scripts/lib/webGate.sh b/scripts/lib/webGate.sh
new file mode 100644
index 0000000..65b19fb
--- /dev/null
+++ b/scripts/lib/webGate.sh
@@ -0,0 +1,61 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the web gates (scripts/bench-web.sh, scripts/verify-web-*.sh) and
+# joey-run: serve a web program's folder (make web: bin/NAME/NAME.html and
+# its files) over HTTP on this machine and drive it in headless Chromium with
+# scripts/lib/webDriver.mjs. Source after joeyEnv.sh.
+#
+#   webNeed               exit 2 unless Chromium and Node (emsdk's) are here
+#   webServe DIR          serve DIR; sets WEB_PORT and WEB_SERVER_PID
+#   webDrive PAGE ARGS... run webDriver.mjs on http://127.0.0.1:WEB_PORT/PAGE
+#   webStop               stop the server
+
+WEB_PORT=""
+WEB_SERVER_PID=""
+WEB_PORT_MIN=20000
+WEB_PORT_SPAN=20000
+
+
+webNeed() {
+    WEB_CHROME="$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)"
+    if [ -z "$WEB_CHROME" ]; then
+        echo "$(basename "$0"): no Chromium or Chrome to run web programs in" >&2
+        exit 2
+    fi
+    WEB_NODE="$(ls -d "${EMSDK_DIR:-}"/node/*/bin/node 2>/dev/null | head -1)"
+    if [ -z "$WEB_NODE" ]; then
+        echo "$(basename "$0"): emsdk's node is not installed; run: ./install.sh install toolchain:web" >&2
+        exit 2
+    fi
+}
+
+
+webServe() {
+    local tries
+    for ((tries = 0; tries < 10; tries++)); do
+        WEB_PORT=$((WEB_PORT_MIN + RANDOM % WEB_PORT_SPAN))
+        python3 -m http.server "$WEB_PORT" --bind 127.0.0.1 --directory "$1" > /dev/null 2>&1 &
+        WEB_SERVER_PID=$!
+        sleep 0.5
+        if joeyAlive "$WEB_SERVER_PID"; then
+            return 0
+        fi
+    done
+    echo "$(basename "$0"): could not start a web server" >&2
+    exit 1
+}
+
+
+webDrive() {
+    local page="$1"
+    shift
+    "$WEB_NODE" "$JOEY_REPO/scripts/lib/webDriver.mjs" --chrome "$WEB_CHROME" --url "http://127.0.0.1:$WEB_PORT/$page" "$@"
+}
+
+
+webStop() {
+    joeyStop "$WEB_SERVER_PID"
+    WEB_SERVER_PID=""
+}
diff --git a/scripts/lib/win16Gate.sh b/scripts/lib/win16Gate.sh
new file mode 100644
index 0000000..334fc9e
--- /dev/null
+++ b/scripts/lib/win16Gate.sh
@@ -0,0 +1,141 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the Win16 gates (scripts/bench-win16.sh, scripts/verify-win16-*.sh)
+# and joey-run: run a Win16 program (make win16) on Windows for Workgroups
+# 3.11 under DOSBox Staging (scripts/dosbox-win16.conf), headless on a
+# private Xvfb display (or, WIN16_HEADLESS=0, in a window on $DISPLAY).
+# Windows is a copy of the installer's (component win311, WIN16_C) made for
+# the run, so a run never changes it; the program's folder is drive D:, so
+# what it writes lands there, and it is Windows' shell, so Windows ends (and
+# DOSBox with it) when the program does. The copy runs JoeyLib's own drivers:
+# KPJOY.DRV (component kpjoy, KPJOY_DRV), the joystick driver Windows 3.1
+# lacks, and KPCOMM.DRV (component kpcomm, KPCOMM_DRV) in place of its
+# COMM.DRV. Source after joeyEnv.sh.
+#
+#   win16Need                   exit 2 unless Windows, its drivers and DOSBox
+#                               are installed
+#   win16Stage WORK FOLDER EXE [ARGS...]
+#                               WORK/c: a copy of Windows whose shell is EXE
+#                               in FOLDER (drive D:), given ARGS (WIN.COM
+#                               hands its command line to the shell; a shell=
+#                               line with arguments is not loaded at all),
+#                               with JoeyLib's drivers
+#   win16Start WORK [NAME=VALUE...]
+#                               boot it in the background, the NAME=VALUEs in
+#                               the DOS environment Windows starts from (the
+#                               port's JOEYLIB_SCALE and the like); sets
+#                               WIN16_DISPLAY, WIN16_GUARD (the timeout
+#                               wrapper, which is DOSBox itself when
+#                               WIN16_WALL=0: no cap) and WIN16_PID (DOSBox).
+#                               Sound is off unless WIN16_SOUND=1 (then SDL
+#                               plays it, through SDL_AUDIODRIVER if set);
+#                               WIN16_DOSBOX_ARGS are more DOSBox arguments
+#                               (--set serial1=...)
+#   win16Log FOLDER             print the program's joeylog in FOLDER, if any
+#                               (a DOS program makes it JOEYLOG.TXT)
+#   win16Window                 print DOSBox's X window
+#   win16Stop                   stop DOSBox and the display
+#
+# Win16 code only ever runs on DOSBox's emulated CPU: running it natively
+# (the 32-bit Wine's winevdm) crashed the host's kernel.
+
+WIN16_DISPLAY=""
+WIN16_GUARD=""
+WIN16_PID=""
+WIN16_XVFB_PID=""
+WIN16_FOLDER=""
+WIN16_ARGS=""
+WIN16_HEADLESS="${WIN16_HEADLESS:-1}"
+WIN16_SOUND="${WIN16_SOUND:-0}"
+WIN16_DOSBOX_ARGS=()
+# A hard cap on DOSBox in seconds, whatever happens to the script that
+# started it; 0 for none.
+WIN16_WALL="${WIN16_WALL:-900}"
+
+
+win16Need() {
+    joeyNeed DOSBOX dosbox
+    joeyNeed WIN16_C emulator:win16
+    joeyNeed KPJOY_DRV kpjoy
+    joeyNeed KPCOMM_DRV kpcomm
+}
+
+
+win16Stage() {
+    local work="$1"
+    WIN16_FOLDER="$2"
+    WIN16_ARGS="${*:4}"
+    rm -rf "${work:?}/c"
+    mkdir -p "$work"
+    cp -r "$WIN16_C" "$work/c"
+    cp "$KPJOY_DRV" "$work/c/WINDOWS/SYSTEM/KPJOY.DRV"
+    cp "$KPCOMM_DRV" "$work/c/WINDOWS/SYSTEM/KPCOMM.DRV"
+    python3 "$JOEY_REPO/tools/winIni.py" "$work/c/WINDOWS/SYSTEM.INI" \
+        set boot    shell    "D:\\$3" \
+        set boot    comm.drv kpcomm.drv \
+        set drivers joystick kpjoy.drv
+}
+
+
+win16Start() {
+    local work="$1"
+    local assign
+    local envArgs=()
+    local soundArgs=(--set "nosound=true")
+    local procEnv=(XDG_CONFIG_HOME="$work/xdg" SDL_AUDIODRIVER=dummy)
+    local wrapper=()
+    shift
+    for assign in "$@"; do
+        envArgs+=(-c "SET $assign")
+    done
+    if [ "$WIN16_SOUND" = "1" ]; then
+        soundArgs=()
+        procEnv=(XDG_CONFIG_HOME="$work/xdg")
+    fi
+    [ "$WIN16_WALL" = "0" ] || wrapper=(timeout -s KILL "$WIN16_WALL")
+    mkdir -p "$work/xdg"
+    if [ "$WIN16_HEADLESS" = "1" ]; then
+        # A second boot in the same WORK (the save gate's) must not read the
+        # last display's number before the new Xvfb has written its own.
+        rm -f "$work/display"
+        Xvfb -displayfd 3 -screen 0 1024x768x24 -nolisten tcp 3> "$work/display" > /dev/null 2>&1 &
+        WIN16_XVFB_PID=$!
+        for _ in 1 2 3 4 5 6 7 8 9 10; do
+            [ -s "$work/display" ] && break
+            sleep 0.5
+        done
+        WIN16_DISPLAY=":$(cat "$work/display")"
+    else
+        WIN16_DISPLAY="$DISPLAY"
+    fi
+    env "${procEnv[@]}" DISPLAY="$WIN16_DISPLAY" "${wrapper[@]}" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" \
+        --conf "$JOEY_REPO/scripts/dosbox-win16.conf" "${soundArgs[@]}" "${WIN16_DOSBOX_ARGS[@]}" \
+        -c "MOUNT C \"$work/c\"" -c "MOUNT D \"$WIN16_FOLDER\"" "${envArgs[@]}" \
+        -c "C:" -c 'CD \WINDOWS' -c "WIN${WIN16_ARGS:+ $WIN16_ARGS}" -c "EXIT" > "$work/dosbox.log" 2>&1 &
+    WIN16_GUARD=$!
+    if [ ${#wrapper[@]} -eq 0 ]; then
+        WIN16_PID=$WIN16_GUARD
+    else
+        WIN16_PID="$(joeyChild "$WIN16_GUARD")"
+    fi
+}
+
+
+win16Log() {
+    find "$1" -maxdepth 1 -iname joeylog.txt -print -quit 2> /dev/null
+}
+
+
+win16Window() {
+    joeyFindWindow "$WIN16_DISPLAY" "$WIN16_PID"
+}
+
+
+win16Stop() {
+    joeyStop "$WIN16_PID" "$WIN16_GUARD" "$WIN16_XVFB_PID" 2> /dev/null
+    WIN16_PID=""
+    WIN16_GUARD=""
+    WIN16_XVFB_PID=""
+}
diff --git a/scripts/lib/wineGate.sh b/scripts/lib/wineGate.sh
new file mode 100644
index 0000000..b96ea46
--- /dev/null
+++ b/scripts/lib/wineGate.sh
@@ -0,0 +1,110 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# Shared by the Win32 gates (scripts/bench-win32.sh, scripts/verify-win32-*.sh)
+# and joey-run: run a Win32 program (make win32) under Wine on a private Xvfb
+# display with a private Wine prefix, so nothing reaches ~/.wine. Source after
+# joeyEnv.sh. (Win16 programs run on Windows 3.11 in DOSBox instead:
+# scripts/lib/win16Gate.sh.)
+#
+#   wineNeed               exit 2 unless Wine is installed
+#   wineEnv PREFIX         Wine's environment for the prefix PREFIX, quiet;
+#                          Wine makes the prefix when it first runs
+#   winePrefix WORK        a prefix under WORK, made and settled before
+#                          anything runs in it (a program started alongside
+#                          Wine's first-run setup got no sound for its first
+#                          seconds); sets WINEPREFIX
+#   wineStart WORK         a display, then winePrefix WORK; sets DISPLAY,
+#                          WINEPREFIX and WINE_XVFB_PID
+#   wineComPort N PATH     the prefix's COMn opens the Unix device PATH (a pty
+#                          a gate's peer or joey-run's socat made), and its
+#                          wineserver is started; before wineRun
+#   wineRun EXE [ARGS...]  start EXE in the background; sets WINE_PID
+#   wineWindow TITLE       print the X window titled TITLE (waits for it)
+#   wineStop               stop the program, the prefix's wineserver and the
+#                          display
+
+WINE_PID=""
+WINE_XVFB_PID=""
+WINE_WINDOW_TRIES=60
+
+
+wineNeed() {
+    joeyNeed WINE wine
+}
+
+
+wineEnv() {
+    mkdir -p "$1"
+    export WINEPREFIX="$1"
+    export WINEDEBUG=-all
+    # No Mono or Gecko install prompts in a fresh prefix.
+    export WINEDLLOVERRIDES="mscoree,mshtml="
+}
+
+
+winePrefix() {
+    wineEnv "$1/prefix"
+    "$WINE" wineboot --init > "$1/wineboot.log" 2>&1
+    "$(dirname "$WINE")/wineserver" -w
+}
+
+
+wineStart() {
+    export DISPLAY=":$((RANDOM % 400 + 1800))"
+    Xvfb "$DISPLAY" -screen 0 1280x1024x24 -nolisten tcp > /dev/null 2>&1 &
+    WINE_XVFB_PID=$!
+    sleep 1
+    winePrefix "$1"
+}
+
+
+# Wine's mount manager makes dosdevices/comN from HKLM\Software\Wine\Ports
+# when Wine starts (a link made by hand is refused, access denied), so the
+# key is set and Wine restarted. The new wineserver is started here, its
+# output in a log: it opens the pty itself and, a daemon with no terminal,
+# makes it its own, so when the far end closes the pty it gets SIGHUP and
+# prints every object it holds (megabytes), which must not land where the
+# program's output goes.
+wineComPort() {
+    printf 'REGEDIT4\r\n\r\n[HKEY_LOCAL_MACHINE\\Software\\Wine\\Ports]\r\n"COM%s"="%s"\r\n' "$1" "$2" > "$WINEPREFIX/../ports.reg"
+    "$WINE" regedit /S "$WINEPREFIX/../ports.reg"
+    "$(dirname "$WINE")/wineserver" -k
+    "$(dirname "$WINE")/wineserver" -w
+    "$(dirname "$WINE")/wineserver" > "$WINEPREFIX/../wineserver.log" 2>&1
+}
+
+
+wineRun() {
+    "$WINE" "$@" > "$WINEPREFIX/../wine.log" 2>&1 &
+    WINE_PID=$!
+}
+
+
+wineWindow() {
+    local tries
+    local win
+    for ((tries = 0; tries < WINE_WINDOW_TRIES; tries++)); do
+        win="$(xdotool search --name "^$1\$" 2>/dev/null | head -1)"
+        if [ -n "$win" ]; then
+            printf '%s\n' "$win"
+            return 0
+        fi
+        sleep 1
+    done
+    return 1
+}
+
+
+wineStop() {
+    joeyStop "$WINE_PID"
+    WINE_PID=""
+    if [ -n "${WINEPREFIX:-}" ]; then
+        "$(dirname "$WINE")/wineserver" -k 2> /dev/null || true
+    fi
+    if [ -n "$WINE_XVFB_PID" ]; then
+        kill "$WINE_XVFB_PID" 2> /dev/null || true
+        WINE_XVFB_PID=""
+    fi
+}
diff --git a/scripts/lib/x68kDisk.sh b/scripts/lib/x68kDisk.sh
index f0df1a8..a0663d4 100644
--- a/scripts/lib/x68kDisk.sh
+++ b/scripts/lib/x68kDisk.sh
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # Human68k floppy staging shared by joey-run and the X68000 gates and tools.
 # Every run boots a copy of the stock system disk (HUMAN302.XDF from the
 # x68kFirmware component) trimmed to what it takes to boot and run one
diff --git a/scripts/make-iigs-boot-disk.sh b/scripts/make-iigs-boot-disk.sh
index 3d2edeb..b7fce3c 100755
--- a/scripts/make-iigs-boot-disk.sh
+++ b/scripts/make-iigs-boot-disk.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # make-iigs-boot-disk.sh - Build a GS/OS boot floppy that launches one
 # JoeyLib example DIRECTLY, with no Finder in the way.
 #
diff --git a/scripts/make-iigs-disk.sh b/scripts/make-iigs-disk.sh
index 37672ed..600316b 100755
--- a/scripts/make-iigs-disk.sh
+++ b/scripts/make-iigs-disk.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # make-iigs-disk.sh - Build the JOEYLIB ProDOS disk image (joey.2mg) that
 # the gates and joey-run mount as the data disk beside GS/OS. It holds
 # the launchable example binaries as ProDOS type $B3 (S16 / GS-OS apps) in a
diff --git a/scripts/make-iigs-game-disk.sh b/scripts/make-iigs-game-disk.sh
index a9450d5..d38df82 100755
--- a/scripts/make-iigs-game-disk.sh
+++ b/scripts/make-iigs-game-disk.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # make-iigs-game-disk.sh - Build ONE self-booting GS/OS volume that boots
 # straight into a JoeyLib program, with the program in its own folder so
 # that folder can be copied to a hard drive and run from there.
diff --git a/scripts/profile-x68000.sh b/scripts/profile-x68000.sh
index 1600b4f..1ca445b 100755
--- a/scripts/profile-x68000.sh
+++ b/scripts/profile-x68000.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # profile-x68000.sh - PC-sampling profiler for a running X68000 example.
 #
 # Human68k relocates a .X anywhere it likes and the ELF beside it is linked at
diff --git a/scripts/pulseRecordLib.sh b/scripts/pulseRecordLib.sh
index 8c2cf2a..a1d5bf1 100644
--- a/scripts/pulseRecordLib.sh
+++ b/scripts/pulseRecordLib.sh
@@ -1,3 +1,7 @@
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # pulseRecordLib.sh - shared PulseAudio capture helpers. SOURCE, do not run.
 #
 # Used by verify-amiga-audio.sh and verify-dos-audio.sh, the two gates that
diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh
index a6639cf..1c185ac 100755
--- a/scripts/verify-all.sh
+++ b/scripts/verify-all.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-all.sh - Run JoeyLib's gates, one kind or all of them, and summarise.
 #
 # Driven by `make verify` (all), `make verify-audio` and `make verify-serial`.
@@ -9,9 +13,20 @@
 #           millis, save, serial, sfxmix, tilebank
 #   golden  UBER on each port, its 51 per-op surface hashes against
 #           tests/goldens/uber (x68000 against the IIgs's, its own gate;
-#           dvx against the DOS port's, whose stage it shows)
+#           dvx, sdl3, web, win32, win16, psp, haiku, haiku32 and beos
+#           against the DOS port's, whose chunky stage they share; psp under
+#           PPSSPP with every memory access checked as a PSP checks it, so
+#           an unaligned one fails; haiku, haiku32 and beos in their VMs
+#           under QEMU, scripts/lib/beGate.sh)
+#           amiga: also on an A1200 (68EC020, AGA) and an A4000 (68040,
+#           AGA), each on its own Kickstart (scripts/lib/amigaGate.sh;
+#           optional components kickstartA1200/A4000: SKIP without them)
 #           atarist: also the dirty-band marker the three 68000 ports share
 #           (src/m68k/stageBands.s), held to the per-row widen under Hatari
+#           ste: the STe build (BLiTTER, DMA sound, 4-bit palette) on an STe
+#           and a Mega STe (16 MHz), held to the plain ST's hashes; and
+#           which build runs where -- the STe build refuses a plain ST and
+#           a Mega ST through jlLastError, the ST build runs on an STe
 #   visual  iigs: every launchable example on joey.2mg boots and draws
 #           dvx: full screen -- UBER's hashes through a suspend and a
 #           resume, and KEYS's row read off the 320x200 VGA screen, then
@@ -21,23 +36,64 @@
 #           checked against the stage
 #           x68000: the SCB -- every text-palette reload of a 147-change
 #           screen lands in the blanking before its band, timed on the beam
+#           atarist and ste: the palette -- every shifter colour the ST
+#           build writes is the 3-bit truncation, the STe build's (on an
+#           STe and a Mega STe) the exact 4-bit $0RGB
+#           amiga: the palette -- palette changes reach the screen at copper
+#           patch speed, and right, after a palette and an SCB change
 #   tilebank each port: examples/tilebank loads the port's own .tbk bake
 #            of the host fixture both ways, pixel-exact and with the host
 #            gate's surface hash, and refuses every other target's bake (the
 #            host half is check-tilebank.sh under `host`)
 #   input   each port: a typed string reaches jlInputGetChar byte-exact
+#           (sdl3: through SDL's X11 driver on a private Xvfb display; web:
+#           browser key events in headless Chromium; win32: X key events
+#           Wine turns into WM_CHAR; win16: X key events DOSBox turns into
+#           the PC keyboard's, which Windows 3.11 turns into WM_CHAR;
+#           haiku, haiku32, beos: QMP key events into the PS/2 keyboard of
+#           the port's VM)
 #           (dvx: and the present shows it, decoded from the desktop, also
 #           at 8/15/32 bpp and 2x/3x; Ctrl+Esc must not end the program)
-#           x68000 and dvx: also both joysticks -- every direction, both
-#           buttons and every release, read back from examples/joy's display
-#   audio   six gates, two kinds (the IIgs and X68000 on AUDIOPRB, AUDIO
-#           pressing its own keys; the rest drive AUDIO's keys):
+#           joy (every port but psp, whose input gate covers it, and
+#           haiku, haiku32 and beos, as QEMU's PC has no joystick to give
+#           them; win16 through KPJOY.DRV, JoeyLib's own joystick driver for
+#           Windows 3.1): the sticks --
+#           every direction, the buttons and every release, read back from
+#           examples/joy's display (scripts/lib/joyGate.sh), each driven
+#           the way its machine allows:
+#             iigs     MAME's joystick, from Lua (one stick)
+#             x68000   MAME's pads, from Lua
+#             atarist, ste  Hatari's keyboard joysticks (port 1; port 0 is
+#                      the mouse's in TOS's IKBD mode, kept)
+#             amiga    FS-UAE's ports from host keys (lowlevel.library on
+#                      the boot drive; unplugged until moved)
+#             dos, dvx, win16  DOSBox's 4-axis game port from host keys (and no
+#                      game port: no stick)
+#             sdl3, win32  two virtual uinput gamepads (scripts/lib/
+#                      virtualPad.py; needs /dev/uinput), then unplugged
+#             web      the page's Gamepad API, stood in for by the driver
+#           psp: no keyboard; its d-pad, nub, cross and circle as joystick
+#           0 (read back from examples/joy's display), Start as Return
+#           (held, and typed: examples/keys's received row) and Select as
+#           Escape, pressed through PPSSPP's input script
+#   audio   twelve gates, two kinds (the IIgs, X68000 and PSP on AUDIOPRB,
+#           AUDIO pressing its own keys; the rest drive AUDIO's keys):
 #             iigs     DOC wavetable + sound IRQ vector        (register tap)
 #             x68000   YM2151 noise + PPI port C + ADPCM rate  (register tap)
 #             atarist  YM2149 write trace, with the pc         (register tap)
+#             ste      DMA chip samples + YM channel A         (register tap)
 #             amiga    recorded sound, four loudness phases    (Paula has no tap)
 #             dos      recorded sound, four loudness phases    (SB has no tap)
 #             dvx      recorded sound, four loudness phases    (DVX's mixer, SB)
+#             sdl3     the mix itself (SDL's disk driver), six phases: also
+#                      the noise voice and the MOD
+#             web      what the page plays (headless Chromium), the same six
+#             win32    recorded sound under Wine (PulseAudio), the same six
+#             win16    the same on Windows 3.11 in DOSBox (Windows' Sound
+#                      Blaster driver; 22.05 kHz mono, 8-bit)
+#             psp      what reaches the PSP's audio output (PPSSPP's own
+#                      recording): the song, three streamed takes each at its
+#                      pitch, and the end on Select (Escape)
 #   serial  the host check of the shared layer, then each port's loopback
 #           (banner out, probe echoed back):
 #             host     scripts/check-serial.sh (no emulator)
@@ -47,9 +103,18 @@
 #             amiga    serial.device -> FS-UAE TCP
 #             dos      COM1 -> DOSBox nullmodem
 #             dvx      COM1 through DVX's rs232 library -> DOSBox nullmodem
+#             win32    COM1 through the Win32 comms calls -> Wine -> a pty
+#             win16    COM1 through Windows 3.11's OpenComm -> KPCOMM.DRV
+#                      -> DOSBox nullmodem
 #   mouse   x68000: deltas, clamps and buttons through the IOCS mouse
 #           (and with serial streaming both ways on the same SCC)
 #   save    iigs: a SAVES/ blob written and read back through GS/OS
+#           sdl3: SAVE's launch count beside the program after two runs
+#           web: SAVE's launch count back from IndexedDB after a reload
+#           win32 (Wine) and win16 (Windows 3.11 in DOSBox): SAVE's launch
+#           count beside the program after two runs
+#           psp: SAVE's launch count beside the program on the memory stick
+#           after two runs
 #   loss    iigs: 4096 bytes through full-screen presents, lossless on the
 #           modem port at 9600 and a Super Serial Card at 2400
 #           x68000: both serial streams byte-exact and the mouse right while
@@ -84,7 +149,8 @@
 #
 # Usage:   scripts/verify-all.sh KIND|all [port ...]
 #            with no ports, every port the kind has. Ports: host iigs
-#            x68000 atarist amiga dos dvx
+#            x68000 atarist ste amiga dos dvx sdl3 web win32 win16 psp
+#            haiku haiku32 beos
 # Env:     VERIFY_ALL_LOG  also append the full transcript to this file
 # Requires: whatever each selected gate requires; missing ones SKIP.
 set -uo pipefail
@@ -95,7 +161,7 @@ shift || true
 label="verify-all $kind"
 
 KINDS="host golden visual tilebank input audio serial mouse save loss disk"
-ALL_PORTS="host iigs x68000 atarist amiga dos dvx"
+ALL_PORTS="host iigs x68000 atarist ste amiga dos dvx sdl3 web win32 win16 psp haiku haiku32 beos"
 # UBER on the Amiga needs longer than bench-amiga.sh's interactive default.
 AMIGA_UBER_SECONDS=400
 
@@ -114,34 +180,80 @@ golden   iigs    uber     scripts/bench-iigs.sh && tools/diff-uber-hashes tests/
 golden   x68000  uber     scripts/verify-x68000-golden.sh
 golden   atarist uber     scripts/bench-atarist.sh && tools/diff-uber-hashes tests/goldens/uber/atarist.txt build/atarist/bin/joeylog.txt
 golden   atarist marks    scripts/verify-atarist-dirtymark.sh
+golden   ste     uber     scripts/bench-atarist.sh ste && tools/diff-uber-hashes tests/goldens/uber/atarist.txt build/ste/bin/joeylog-ste.txt
+golden   ste     megaste  scripts/bench-atarist.sh megaste && tools/diff-uber-hashes tests/goldens/uber/atarist.txt build/ste/bin/joeylog-megaste.txt
+golden   ste     machine  scripts/verify-ste-machine.sh
 golden   amiga   uber     TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && tools/diff-uber-hashes tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt
+golden   amiga   a1200    JOEY_AMIGA_MODEL=A1200 TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && tools/diff-uber-hashes tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt
+golden   amiga   a4000    JOEY_AMIGA_MODEL=A4000 TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && tools/diff-uber-hashes tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt
 golden   dos     uber     scripts/bench-dos.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/dos/bin/JOEYLOG.TXT
 golden   dvx     uber     scripts/bench-dvx.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt
+golden   sdl3    uber     scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
+golden   web     uber     scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
+golden   win32   uber     scripts/bench-win32.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win32/bin/joeylog.txt
+golden   win16   uber     scripts/bench-win16.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win16/bin/JOEYLOG.TXT
+golden   psp     uber     scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
+golden   haiku   uber     scripts/bench-be.sh haiku && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt
+golden   haiku32 uber     scripts/bench-be.sh haiku32 && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt
+golden   beos    uber     scripts/bench-be.sh beos && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/beos/bin/joeylog.txt
 visual   iigs    examples scripts/verify-iigs-all.sh
 visual   dvx     full     scripts/verify-dvx-fullscreen.sh
 visual   dvx     display  scripts/verify-dvx-display.sh
 visual   x68000  scb      scripts/verify-x68000-scb.sh
+visual   atarist palette  scripts/verify-atarist-palette.sh st
+visual   ste     palette  scripts/verify-atarist-palette.sh ste megaste
+visual   amiga   palette  scripts/verify-amiga-palette.sh
+visual   amiga   a1200pal JOEY_AMIGA_MODEL=A1200 scripts/verify-amiga-palette.sh
+visual   amiga   a4000pal JOEY_AMIGA_MODEL=A4000 scripts/verify-amiga-palette.sh
 tilebank iigs    tilebank make -s -f make/iigs.mk iigs-verify-tilebank || exit 1
 tilebank x68000  tilebank scripts/verify-x68000-tilebank.sh
 tilebank atarist tilebank scripts/verify-atarist-tilebank.sh
 tilebank amiga   tilebank scripts/verify-amiga-tilebank.sh
 tilebank dos     tilebank scripts/verify-dos-tilebank.sh
 tilebank dvx     tilebank scripts/verify-dvx-tilebank.sh
+tilebank sdl3    tilebank scripts/verify-sdl3-tilebank.sh
+tilebank web     tilebank scripts/verify-web-tilebank.sh
+tilebank win32   tilebank scripts/verify-win32-tilebank.sh
+tilebank win16   tilebank scripts/verify-win16-tilebank.sh
+tilebank psp     tilebank scripts/verify-psp-tilebank.sh
 input    iigs    input    scripts/verify-iigs-input.sh
 input    x68000  input    scripts/verify-x68000-input.sh
 input    x68000  joy      scripts/verify-x68000-joy.sh
+input    iigs    joy      scripts/verify-iigs-joy.sh
 input    atarist input    scripts/verify-atarist-input.sh
+input    atarist joy      scripts/verify-atarist-joy.sh atarist
+input    ste     joy      scripts/verify-atarist-joy.sh ste
 input    amiga   input    scripts/verify-amiga-input.sh
+input    amiga   joy      scripts/verify-amiga-joy.sh
 input    dos     input    scripts/verify-dos-input.sh
+input    dos     joy      scripts/verify-dos-joy.sh
 input    dvx     input    scripts/verify-dvx-input.sh
 input    dvx     depths   scripts/verify-dvx-input.sh 640x480x8:1 800x600x15:2 1024x768x32:3
 input    dvx     joy      scripts/verify-dvx-joy.sh
+input    sdl3    input    scripts/verify-sdl3-input.sh
+input    sdl3    joy      scripts/verify-sdl3-joy.sh
+input    web     input    scripts/verify-web-input.sh
+input    web     joy      scripts/verify-web-joy.sh
+input    win32   input    scripts/verify-win32-input.sh
+input    win32   joy      scripts/verify-win32-joy.sh
+input    win16   input    scripts/verify-win16-input.sh
+input    win16   joy      scripts/verify-win16-joy.sh
+input    psp     input    scripts/verify-psp-input.sh
+input    haiku   input    scripts/verify-be-input.sh haiku
+input    haiku32 input    scripts/verify-be-input.sh haiku32
+input    beos    input    scripts/verify-be-input.sh beos
 audio    iigs    audio    make -s -f make/iigs.mk iigs-verify-audio || exit 1
 audio    x68000  audio    make -s -f make/x68000.mk x68000-verify-audio || exit 1
 audio    atarist audio    scripts/verify-atarist-audio.sh
+audio    ste     audio    scripts/verify-atarist-audio.sh --machine ste
 audio    amiga   audio    scripts/verify-amiga-audio.sh
 audio    dos     audio    scripts/verify-dos-audio.sh
 audio    dvx     audio    scripts/verify-dvx-audio.sh
+audio    sdl3    audio    scripts/verify-sdl3-audio.sh
+audio    web     audio    scripts/verify-web-audio.sh
+audio    win32   audio    scripts/verify-win32-audio.sh
+audio    win16   audio    scripts/verify-win16-audio.sh
+audio    psp     audio    make -s -f make/psp.mk psp-verify-audio || exit 1
 serial   host    serial   scripts/check-serial.sh
 serial   iigs    serial   scripts/verify-iigs-serial.sh
 serial   x68000  serial   scripts/verify-x68000-serial.sh
@@ -149,9 +261,16 @@ serial   atarist serial   scripts/verify-atarist-serial.sh
 serial   amiga   serial   scripts/verify-amiga-serial.sh
 serial   dos     serial   scripts/verify-dos-serial.sh
 serial   dvx     serial   scripts/verify-dvx-serial.sh
+serial   win32   serial   scripts/verify-win32-serial.sh
+serial   win16   serial   scripts/verify-win16-serial.sh
 mouse    x68000  mouse    scripts/verify-x68000-mouse.sh
 mouse    x68000  sermouse scripts/verify-x68000-serial-mouse.sh
 save     iigs    save     make -s -f make/iigs.mk iigs-verify-save || exit 1
+save     sdl3    save     scripts/verify-sdl3-save.sh
+save     web     save     scripts/verify-web-save.sh
+save     win32   save     scripts/verify-win32-save.sh
+save     win16   save     scripts/verify-win16-save.sh
+save     psp     save     scripts/verify-psp-save.sh
 loss     iigs    loss     make -s -f make/iigs.mk iigs-verify-serial-loss || exit 1
 loss     x68000  scb      scripts/verify-x68000-serial-scb.sh
 disk     iigs    cffa2    make -s -f make/iigs.mk iigs-verify-game-disk GAME=DRAW GAME_DISK_SIZE=2048KB || exit 1
diff --git a/scripts/verify-amiga-audio.sh b/scripts/verify-amiga-audio.sh
index 83e3873..d3cec2a 100755
--- a/scripts/verify-amiga-audio.sh
+++ b/scripts/verify-amiga-audio.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-amiga-audio.sh - Amiga audio gate, graded on recorded sound.
 #
 # Paula cannot be tapped register-by-register here: fs-uae has no trace
@@ -47,15 +51,14 @@ set -uo pipefail
 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 joeyNeed FSUAE fsuae
-joeyNeed KICKSTART_ROM kickstart
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
 prog=${1:-Audio}
 binDir=$repo/build/amiga/bin
-kickstart=$KICKSTART_ROM
 grader=$repo/tools/gradeAudioPhases.py
 pulseLib=$repo/scripts/pulseRecordLib.sh
 label=verify-amiga-audio
 
-[ -f "$kickstart" ] || { echo "$label: missing $kickstart" >&2; exit 2; }
 [ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
 [ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; }
 # shellcheck source=scripts/pulseRecordLib.sh
@@ -118,10 +121,9 @@ sleep 1
 # --fast_memory=0 --slow_memory=0 keeps this a bare A500, the port's floor.
 DISPLAY="$display" PULSE_SINK="$sinkName" ALSOFT_DRIVERS=pulse SDL_AUDIODRIVER=pulse \
     "$FSUAE" \
-    --amiga_model=A500 \
+    "${AMIGA_MACHINE_ARGS[@]}" \
     --hard_drive_0="$work/hd" \
     --hard_drive_0_label=JOEYLIB \
-    --kickstart_file="$kickstart" \
     --fullscreen=1 \
     --initial_input_grab=1 \
     --fade_out_duration=0 \
diff --git a/scripts/verify-amiga-input.sh b/scripts/verify-amiga-input.sh
index 254010c..e06290f 100755
--- a/scripts/verify-amiga-input.sh
+++ b/scripts/verify-amiga-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-amiga-input.sh - Typed-character acceptance gate for the Amiga
 # port. Boots the Keys example off a directory hard drive in FS-UAE
 # under Xvfb, types the RetroNet definition-of-done string with xdotool
@@ -16,14 +20,13 @@ set -euo pipefail
 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 joeyNeed FSUAE fsuae
-joeyNeed KICKSTART_ROM kickstart
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
 bin_dir=$repo/build/amiga/bin
-kickstart=$KICKSTART_ROM
 
 target="192.168.1.10:6510"
 
 [ -f "$bin_dir/Keys" ] || { echo "verify-amiga-input: build Keys first (make amiga)" >&2; exit 2; }
-[ -f "$kickstart" ] || { echo "verify-amiga-input: missing $kickstart" >&2; exit 2; }
 
 work=$(mktemp -d -t joeylib-amiga-input.XXXXXX)
 display_num=$((RANDOM % 500 + 300))
@@ -49,9 +52,8 @@ sleep 1
 # focus itself, after which plain XTEST typing (xdotool without
 # --window) lands in the emulated Amiga.
 DISPLAY=":$display_num" "$FSUAE" \
-    --amiga_model=A500 \
+    "${AMIGA_MACHINE_ARGS[@]}" \
     --fast_memory=2048 \
-    --kickstart_file="$kickstart" \
     --hard_drive_0="$work/hd" \
     --hard_drive_0_label=JOEYLIB \
     --fullscreen=1 \
diff --git a/scripts/verify-amiga-joy.sh b/scripts/verify-amiga-joy.sh
new file mode 100755
index 0000000..5f99c6e
--- /dev/null
+++ b/scripts/verify-amiga-joy.sh
@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-amiga-joy.sh - Joystick gate for the Amiga: both ports, every
+# direction, both buttons and every release, driven through FS-UAE's
+# joystick ports and read back from what examples/joy draws.
+#
+# The port (src/amiga/input.c) reads both ports through lowlevel.library's
+# ReadJoyPort: port 1, the joystick port, is JOYSTICK_0 (stick A) and port 0,
+# the mouse port, JOYSTICK_1 (stick B), each a stick while lowlevel.library
+# takes it for one; the red button is button 0 and the blue (a two-button
+# stick's second) button 1. lowlevel.library cannot tell an idle stick from
+# an empty port, so it reports a stick only once it has moved: both must
+# read unplugged until a tap on each, connected after. Kickstart 3.1 has no lowlevel.library in ROM, so
+# the boot drive carries Workbench's (component amigaLowlevel). FS-UAE's
+# ports are both in joystick mode, every line of each on a host key of its
+# own (keyboard_key_*), and xdotool holds the keys; FS-UAE's screenshots
+# (its screenshot action, on F11) go through scripts/lib/joyScreen.py
+# (scripts/lib/joyGate.sh). Escape must then end JOY.
+#
+# Usage:   scripts/verify-amiga-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: FS-UAE, the Kickstart, lowlevel.library (install emulator:amiga),
+#           Xvfb, xdotool, python3 + PIL; build/amiga/bin/Joy (make amiga).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+joeyNeed FSUAE fsuae
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
+joeyNeed AMIGA_LOWLEVEL_LIBRARY amigaLowlevel
+
+JOY_LABEL=verify-amiga-joy
+program="$repo/build/amiga/bin/Joy"
+wall=300            # hard cap on FS-UAE, whatever happens to this script
+readySeconds=90     # AmigaDOS booting and JOY drawing
+focusSeconds=30     # the full-screen window taking the keyboard
+shotSeconds=5       # the screenshot key until FS-UAE has written the file
+exitSeconds=10      # Escape until JOY's screen is gone
+shotKey=F11
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build Joy first (make amiga)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-amiga-joy.XXXXXX)
+guard=""
+fsuae=""
+xvfb=""
+display=""
+cleanup() {
+    joeyStop "$fsuae" "$guard" "$xvfb" 2> /dev/null
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+# Control, its host key (xdotool's name, and FS-UAE's in lower case) and
+# the FS-UAE action it drives. Stick A is Amiga port 1, stick B port 0.
+keys=(
+    "A-left j action_joy_1_left"         "A-right l action_joy_1_right"
+    "A-up i action_joy_1_up"             "A-down k action_joy_1_down"
+    "A-button0 a action_joy_1_fire_button" "A-button1 s action_joy_1_2nd_button"
+    "B-left f action_joy_0_left"         "B-right h action_joy_0_right"
+    "B-up t action_joy_0_up"             "B-down g action_joy_0_down"
+    "B-button0 q action_joy_0_fire_button" "B-button1 w action_joy_0_2nd_button"
+    "shot $shotKey action_screenshot"
+)
+
+
+# key CONTROL -- the host key for a control.
+key() {
+    local entry
+    local control
+    local host
+    for entry in "${keys[@]}"; do
+        read -r control host _ <<< "$entry"
+        if [ "$control" = "$1" ]; then
+            printf '%s\n' "$host"
+            return 0
+        fi
+    done
+    return 1
+}
+
+
+# hold keydown|keyup CONTROL...
+hold() {
+    local action="$1"
+    local control
+    local host
+    shift
+    for control in "$@"; do
+        host=$(key "$control") || { joyFail "no key for $control"; continue; }
+        DISPLAY="$display" xdotool "$action" "$host"
+    done
+}
+
+
+joyPress() {
+    hold keydown "$@"
+}
+
+
+joyRelease() {
+    hold keyup "$@"
+}
+
+
+# FS-UAE writes more than one picture of each screenshot; the one cropped
+# to the Amiga's display is the stage's. It makes the file before it writes
+# it: it is done when it has bytes and one more poll adds none.
+joyShot() {
+    local before
+    local found
+    local size=""
+    local deadline
+    before=$(find "$JOY_WORK/shots" -name '*crop*.png' | wc -l)
+    hold keydown shot
+    sleep 0.3
+    hold keyup shot
+    deadline=$((SECONDS + shotSeconds))
+    while [ "$SECONDS" -lt "$deadline" ]; do
+        sleep 0.2
+        [ "$(find "$JOY_WORK/shots" -name '*crop*.png' | wc -l)" -gt "$before" ] || continue
+        found=$(ls -t "$JOY_WORK"/shots/*crop*.png | head -1)
+        [ -s "$found" ] || continue
+        if [ "$(stat -c %s "$found")" = "$size" ]; then
+            mv "$found" "$1"
+            return 0
+        fi
+        size=$(stat -c %s "$found")
+    done
+    return 1
+}
+
+
+# joyStage -- whether a screenshot now shows JOY's stage.
+joyStage() {
+    joyShot "$JOY_WORK/probe.png" && python3 "$repo/scripts/lib/joyScreen.py" "$JOY_WORK/probe.png" > /dev/null 2>&1
+}
+
+
+mkdir -p "$JOY_WORK/hd/s" "$JOY_WORK/hd/Libs" "$JOY_WORK/shots"
+cp "$program" "$JOY_WORK/hd/"
+cp "$AMIGA_LOWLEVEL_LIBRARY" "$JOY_WORK/hd/Libs/lowlevel.library"
+printf ':%s\n' "$(basename "$program")" > "$JOY_WORK/hd/s/startup-sequence"
+mapping=()
+for entry in "${keys[@]}"; do
+    read -r _ host action <<< "$entry"
+    mapping+=("--keyboard_key_${host,,}=$action")
+done
+
+display=":$((RANDOM % 400 + 5500))"
+Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp > /dev/null 2>&1 &
+xvfb=$!
+sleep 1
+# --fullscreen=1: under a WM-less Xvfb SDL takes the keyboard only once its
+# window has the focus, which a full-screen window takes by itself.
+DISPLAY="$display" ALSOFT_DRIVERS=null timeout -s KILL "$wall" "$FSUAE" \
+    "${AMIGA_MACHINE_ARGS[@]}" --fast_memory=2048 \
+    --hard_drive_0="$JOY_WORK/hd" --hard_drive_0_label=JOEYLIB \
+    --fullscreen=1 --initial_input_grab=1 --floppy_drive_speed=800 \
+    --base_dir="$JOY_WORK/fs-uae" --screenshots_output_dir="$JOY_WORK/shots" \
+    --joystick_port_0=none --joystick_port_0_mode=joystick \
+    --joystick_port_1=none --joystick_port_1_mode=joystick \
+    "${mapping[@]}" > "$JOY_WORK/fs-uae.log" 2>&1 &
+guard=$!
+fsuae=$(joeyChild "$guard")
+
+# XTEST keys land in whatever window has the focus, which the full-screen
+# window latches on its own schedule.
+win=""
+for ((waited = 0; waited < focusSeconds; waited++)); do
+    [ -n "$win" ] || win=$(DISPLAY="$display" xdotool search --name -- "FS-UAE" 2> /dev/null | head -1)
+    if [ -n "$win" ] && [ "$(DISPLAY="$display" xdotool getwindowfocus 2> /dev/null)" = "$win" ]; then
+        break
+    fi
+    [ -n "$win" ] && DISPLAY="$display" xdotool windowfocus --sync "$win" 2> /dev/null
+    sleep 1
+done
+
+ready=0
+for ((waited = 0; waited < readySeconds; waited += 2)); do
+    if joyStage; then
+        ready=1
+        break
+    fi
+    joeyAlive "$guard" || break
+    sleep 2
+done
+if [ "$ready" != 1 ]; then
+    echo "$JOY_LABEL: FAIL - FS-UAE with JOY did not come up" >&2
+    if [ -n "${JOY_SHOTS:-}" ] && [ -f "$JOY_WORK/probe.png" ]; then
+        mkdir -p "$JOY_SHOTS"
+        cp "$JOY_WORK/probe.png" "$JOY_SHOTS/not-ready.png"
+    fi
+    exit 1
+fi
+
+joyCheck "before either stick moves" "A=$JOY_ABSENT B=$JOY_ABSENT"
+joyPress A-left B-left
+sleep "$JOY_PRESS_SECONDS"
+joyRelease A-left B-left
+sleep "$JOY_PRESS_SECONDS"
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+
+DISPLAY="$display" xdotool keydown Escape
+sleep 0.5
+DISPLAY="$display" xdotool keyup Escape
+gone=0
+for ((waited = 0; waited < exitSeconds; waited++)); do
+    sleep 1
+    if ! joyStage; then
+        gone=1
+        break
+    fi
+done
+[ "$gone" = 1 ] || joyFail "Escape did not end JOY"
+
+joyResult "$JOY_LABEL" "both ports unplugged until moved, then every direction, both buttons and every release; Escape ends JOY"
diff --git a/scripts/verify-amiga-palette.sh b/scripts/verify-amiga-palette.sh
new file mode 100755
index 0000000..a66f016
--- /dev/null
+++ b/scripts/verify-amiga-palette.sh
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-amiga-palette.sh - the Amiga's palette and SCB changes through the
+# copper: fast, and right on the screen.
+#
+# The Amiga shows each SCB band's palette with a user copper list, built and
+# merged by graphics.library (MrgCop, LoadView, then a WaitTOF) -- about three
+# frames on an A500. A change that leaves every band where it was (a palette
+# changing, or a band given another palette) patches the colour MOVEs of the
+# installed list in place instead (src/amiga/hal.c, patchBandColors). The
+# stage hashes cannot see any of this: they hash the surface, not the copper.
+#
+# PALPROBE (examples/palprobe; make -f make/amiga.mk build/amiga/test/Palprobe)
+# runs under FS-UAE on an A500. It logs how many palette changes, and SCB
+# changes, it presented a second; the palette rate must be at least
+# MIN_PALETTE_RATE (one rebuild a change gave 16). Then it holds a screen of
+# four bands after one palette change and one same-layout SCB change, and
+# the screenshot's bands must be, top to bottom, red, green, yellow, red.
+#
+# Usage:   scripts/verify-amiga-palette.sh
+# Env:     TIMEOUT  seconds to wait for the probe (default 120)
+# Exit:    0 PASS, 1 FAIL, 2 SKIP (FS-UAE or the Kickstart not installed)
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+joeyNeed FSUAE fsuae
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
+timeout_s=${TIMEOUT:-120}
+probe=$repo/build/amiga/test/Palprobe
+label=verify-amiga-palette
+# Palette changes presented a second: 16 when every change rebuilt the
+# copper list, ~990 patching it in place (A500, 2026-10-06).
+MIN_PALETTE_RATE=500
+
+make -s -f "$repo/make/amiga.mk" "$probe" || {
+    echo "$label: FAIL - building Palprobe failed" >&2
+    exit 1
+}
+
+work=$(mktemp -d -t joeylib-amiga-palette.XXXXXX)
+mkdir -p "$work/s"
+cp "$probe" "$work/"
+printf 'stack 32768\n:Palprobe\n' > "$work/s/startup-sequence"
+display=:$((RANDOM % 400 + 1300))
+Xvfb "$display" -screen 0 800x600x24 -nolisten tcp > /dev/null 2>&1 &
+xvfb_pid=$!
+fs_pid=
+cleanup() {
+    [[ -n $fs_pid ]] && joeyStop "$fs_pid"
+    kill "$xvfb_pid" 2>/dev/null || true
+    wait 2>/dev/null || true
+    rm -rf "$work"
+}
+trap cleanup EXIT
+sleep 0.5
+
+DISPLAY=$display "$FSUAE" "${AMIGA_MACHINE_ARGS[@]}" --fast_memory=2048 \
+    --hard_drive_0="$work" --hard_drive_0_label=JOEYLIB \
+    --automatic_input_grab=0 \
+    --fullscreen=0 --fade_out_duration=0 > "$work/fsuae.log" 2>&1 &
+fs_pid=$!
+
+log=$work/joeylog.txt
+for ((waited = 0; waited < timeout_s; waited++)); do
+    if grep -q "PALPROBE: ready" "$log" 2>/dev/null; then
+        break
+    fi
+    sleep 1
+done
+if ! grep -q "PALPROBE: ready" "$log" 2>/dev/null; then
+    echo "$label: FAIL - the probe never reached its held screen within ${timeout_s}s" >&2
+    exit 1
+fi
+# Two seconds into the hold: the copper has run the patched list.
+sleep 2
+DISPLAY=$display import -window root "$work/screen.png"
+grep "PALPROBE:" "$log"
+
+python3 - "$work/screen.png" "$log" "$MIN_PALETTE_RATE" "$label" <<'PY'
+import re
+import sys
+
+from PIL import Image
+
+png, log, minRate, label = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
+rate = None
+for line in open(log, encoding="ascii", errors="replace"):
+    m = re.search(r"PALPROBE: palette\+present: \d+ in \d+ ms = (\d+)/s", line)
+    if m:
+        rate = int(m.group(1))
+if rate is None:
+    print(f"{label}: FAIL - no palette rate in the log")
+    sys.exit(1)
+
+# The bands down the middle column, top to bottom, black (the border)
+# dropped: each run of one colour is a band.
+img = Image.open(png).convert("RGB")
+w, h = img.size
+named = {(255, 0, 0): "red", (0, 255, 0): "green", (255, 255, 0): "yellow"}
+bands = []
+for y in range(h):
+    rgb = img.getpixel((w // 2, y))
+    if rgb == (0, 0, 0):
+        continue
+    name = named.get(rgb, "other " + str(rgb))
+    if not bands or bands[-1] != name:
+        bands.append(name)
+want = ["red", "green", "yellow", "red"]
+fails = []
+if rate < minRate:
+    fails.append(f"palette changes presented {rate}/s, want at least {minRate}")
+if bands != want:
+    fails.append(f"bands {bands}, want {want}")
+if fails:
+    print(f"{label}: FAIL - " + "; ".join(fails))
+    sys.exit(1)
+print(f"{label}: PASS (palette changes {rate}/s; bands {', '.join(want)} after a palette change and a same-layout SCB change)")
+PY
diff --git a/scripts/verify-amiga-serial.sh b/scripts/verify-amiga-serial.sh
index 8494c8e..bc1268c 100755
--- a/scripts/verify-amiga-serial.sh
+++ b/scripts/verify-amiga-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-amiga-serial.sh - both-directions RS-232 gate for the Amiga port.
 #
 # Boots SerTest (examples/sertest: banner, then echo) off a directory hard
@@ -19,7 +23,8 @@ set -uo pipefail
 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 joeyNeed FSUAE fsuae
-joeyNeed KICKSTART_ROM kickstart
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
 joeyNeed AMIGA_SERIAL_DEVICE amigaSerial
 
 bin="$repo/build/amiga/bin/SerTest"
@@ -48,7 +53,7 @@ sleep 1
 # serial_port tcp://.../wait: FS-UAE listens and holds the emulation until
 # the peer connects, so the banner is never sent to nobody.
 HOME="$work/home" DISPLAY="$display" SDL_AUDIODRIVER=dummy ALSOFT_DRIVERS=null "$FSUAE" \
-    --amiga_model=A500 --kickstart_file="$KICKSTART_ROM" \
+    "${AMIGA_MACHINE_ARGS[@]}" \
     --hard_drive_0="$work/hd" --hard_drive_0_label=JOEYLIB \
     --fade_out_duration=0 --joystick_port_0=none --joystick_port_1=none \
     --amiga_enable_serial_port=1 --serial_port="tcp://127.0.0.1:$port/wait" \
diff --git a/scripts/verify-amiga-tilebank.sh b/scripts/verify-amiga-tilebank.sh
index 8255844..41a6a19 100755
--- a/scripts/verify-amiga-tilebank.sh
+++ b/scripts/verify-amiga-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-amiga-tilebank.sh - Tile-bank gate for the Amiga: proves that
 # assetbake's amiga encoder (plane-major: 8 rows of plane 0, then plane 1, ...)
 # writes exactly the layout the Amiga's native tile paste
@@ -25,7 +29,8 @@ repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 source "$repo/scripts/lib/tileBankGate.sh"
 joeyNeed FSUAE fsuae
-joeyNeed KICKSTART_ROM kickstart
+source "$repo/scripts/lib/amigaGate.sh"
+amigaMachine
 
 binDir=$repo/build/amiga/bin
 dataDir=$(tileBankData "$binDir")
@@ -58,9 +63,8 @@ sleep 1
 
 # 2 MB of fast memory: a bare 512 KB A500 fails jlInit.
 DISPLAY="$display" timeout -s KILL "$wall" "$FSUAE" \
-    --amiga_model=A500 \
+    "${AMIGA_MACHINE_ARGS[@]}" \
     --fast_memory=2048 \
-    --kickstart_file="$KICKSTART_ROM" \
     --hard_drive_0="$work/hd" \
     --hard_drive_0_label=JOEYLIB \
     --automatic_input_grab=0 \
diff --git a/scripts/verify-atarist-audio.sh b/scripts/verify-atarist-audio.sh
index 1785f03..1ece96f 100755
--- a/scripts/verify-atarist-audio.sh
+++ b/scripts/verify-atarist-audio.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-atarist-audio.sh - Atari ST audio gate, read off the YM2149.
 #
 # The ST has ONE sound chip and two software layers competing for it, so
@@ -43,22 +47,53 @@
 # for speech) is a poor subject here despite exercising both layers:
 # check 4 can never pass on it.
 #
-# Usage:   scripts/verify-atarist-audio.sh [example]
-#            example  PRG base name in build/atarist/bin, default AUDIO
+# --machine ste runs the program's STe build (make ste, build/ste) on an
+# STe, where src/atarist/audio.c plays digital sound through the 8-bit DMA
+# sound chip instead of the PSG. Hatari's dmasound trace shows the chip
+# being started and every sample it plays, so the STe checks are:
+#
+#   1. The tracker is alive (as above).
+#   2. The DMA chip ran, as the HAL programs it: mono at 12517 Hz, play
+#      + repeat.
+#   3. It played the SFX: each run pulls a stretch of non-silent samples
+#      from the mix buffer, near full scale. A chip looping over silence
+#      (a mix that never reached the buffer, or unsigned samples left
+#      unconverted, which play as a DC step and not a wave) fails here.
+#   4. No PWM pump: reg 8 is never written at the pump's rate. The YM is
+#      not a DAC on this machine.
+#   5. Channel A is the tracker's while the chip plays: voice 0's period
+#      and volume registers (0, 1, 8) are written from the application
+#      inside the DMA runs. In the ST build voice 0 stands down for the
+#      pump, so this is the collision gone, not merely avoided.
+#
+# Usage:   scripts/verify-atarist-audio.sh [--machine st|ste] [example]
+#            st (default): the ST build (build/atarist/bin) on a plain ST
+#            ste:          the STe build (build/ste/bin) on an STe
+#            example       PRG base name, default AUDIO
 # Env:     ST_AUDIO_TAPS    how many held SPACE presses (default 6)
 #          ST_AUDIO_KEEP    set to keep the work dir for inspection
 # Requires: ./install.sh install emulator:atarist (Hatari, EmuTOS),
-#           build/atarist/bin/.PRG
+#           .PRG in that build's bin (make atarist, make ste)
 set -uo pipefail
 
 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
 joeyNeed HATARI hatari
 joeyNeed EMUTOS_IMG emutos
+machine=st
+if [ "${1:-}" = "--machine" ]; then
+    machine=${2:-}
+    shift 2
+fi
+case $machine in
+    st)  build=atarist; traceFlags=psg_write;          traceKeep='^ym write data' ;;
+    ste) build=ste;     traceFlags=psg_write,dmasound; traceKeep='^ym write data|^DMA snd (control write|mode write|fifo pull)' ;;
+    *)   echo "usage: scripts/verify-atarist-audio.sh [--machine st|ste] [example]" >&2; exit 2 ;;
+esac
 prog=${1:-AUDIO}
 taps=${ST_AUDIO_TAPS:-6}
 tos=$EMUTOS_IMG
-binDir=$repo/build/atarist/bin
+binDir=$repo/build/$build/bin
 
 # A program that ships in a folder of its own (ID_FOLDER) is found there;
 # everything else is a bare PRG.
@@ -68,7 +103,7 @@ else
     driveDir=$binDir
 fi
 
-[ -f "$driveDir/$prog.PRG" ] || { echo "verify-atarist-audio: missing $driveDir/$prog.PRG (run 'make atarist')" >&2; exit 2; }
+[ -f "$driveDir/$prog.PRG" ] || { echo "verify-atarist-audio: missing $driveDir/$prog.PRG (run 'make $build')" >&2; exit 2; }
 [ -f "$tos" ] || { echo "verify-atarist-audio: missing $tos" >&2; exit 2; }
 
 work=$(mktemp -d -t joeylib-staudio.XXXXXX)
@@ -94,8 +129,11 @@ trap cleanup EXIT INT TERM
 # asset failure, and this gate is about sound, not about memory.
 #
 # The psg_write trace runs ~800 KB/s, so it is filtered as it is produced
-# -- keeping the raw stream would put hundreds of MB in the work dir.
+# -- keeping the raw stream would put hundreds of MB in the work dir. The
+# dmasound trace keeps only the chip's control and mode writes and the
+# samples it plays (its FIFO refills would double the volume for nothing).
 SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy "$HATARI" \
+    --machine "$machine" \
     --tos "$tos" \
     --harddrive "$driveDir" \
     --gemdos-drive C \
@@ -104,9 +142,9 @@ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy "$HATARI" \
     --memsize 4 \
     --fast-forward on \
     --cmd-fifo "$work/cmd.fifo" \
-    --trace psg_write \
+    --trace "$traceFlags" \
     --auto "C:\\$prog.PRG" \
-    > >(grep --line-buffered -E '^ym write data' > "$work/ym.log") 2>&1 &
+    > >(grep --line-buffered -E "$traceKeep" > "$work/ym.log") 2>&1 &
 hatariPid=$!
 
 # Hatari CREATES the fifo; a write to one with no reader blocks forever,
@@ -159,7 +197,7 @@ sleep 1
 
 [ -s "$work/ym.log" ] || { echo "verify-atarist-audio: FAIL - empty PSG trace" >&2; exit 1; }
 
-python3 - "$work/ym.log" "$work/baseline.log" "$prog" "$taps" <<'PY'
+python3 - "$work/ym.log" "$work/baseline.log" "$prog" "$taps" "$machine" <<'PY'
 import re
 import sys
 
@@ -175,6 +213,16 @@ STRAY_FRAME_PCT_MAX = 5.0    # start/stop transitions only; a fighting
 REG_VOL_A   = 8
 REG_MIXER   = 7
 TONE_B_REGS = (2, 3)         # channel B period == the tracker's own voice
+VOICE_A_REGS = (0, 1, 8)     # channel A period + volume == the tracker's voice 0
+
+# STe DMA sound: "DMA snd mode write: 0x81 mode=mono freq=12517 ... pc=22eec"
+DMA_WRITE = re.compile(r'^DMA snd (?:mode|control) write: 0x([0-9a-f]+) .*pc=([0-9a-f]+)')
+DMA_MODE_MONO_12K       = 0x81   # bit 7 mono, rate 1 = 12517 Hz
+DMA_CTRL_PLAY_LOOP      = 0x03   # play + repeat
+DMA_UNCONVERTED_SILENCE = -128   # $80, the mixer's unsigned silence, read as signed
+SFX_SAMPLES_MIN         = 100    # test.sfx is ~390 non-silent samples at 12517 Hz
+SFX_PEAK_MIN            = 32     # it peaks near full scale (122 measured)
+UNSIGNED_PCT_MAX        = 5.0
 
 
 def perFrameWrites(path):
@@ -206,6 +254,7 @@ full = perFrameWrites(sys.argv[1])
 base = perFrameWrites(sys.argv[2])
 prog = sys.argv[3]
 taps = int(sys.argv[4])
+machine = sys.argv[5]
 if not full:
     print("verify-atarist-audio: FAIL - no application PSG writes at all", file=sys.stderr)
     sys.exit(1)
@@ -221,58 +270,143 @@ if baseTone == 0 or baseMixer == 0:
     fails.append(f"the tracker never played: {baseTone} channel-B period writes and "
                  f"{baseMixer} mixer writes before the SFX. jlMusicPlay did not drive the PSG")
 
-# 2. The pump ran, at the right rate. Group pump frames into contiguous runs
-#    so each SFX can be told from the next.
-runs = []
-current = []
-for f in frames:
-    if len(full[f].get(REG_VOL_A, ())) >= PUMP_PER_FRAME_MIN:
-        current.append(f)
-    elif current:
-        runs.append(current)
-        current = []
-if current:
-    runs.append(current)
 
-peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
-if not runs:
-    fails.append(f"the PWM pump never ran: peak {peak} reg-8 writes in a frame, want "
-                 f">= {PUMP_PER_FRAME_MIN}. jlAudioPlaySfx did not start Timer A, so "
-                 f"every digital sound on this port is silent")
-elif not (PUMP_RATE_LO <= peak <= PUMP_RATE_HI):
-    fails.append(f"the pump ran at {peak} reg-8 writes/frame, want {PUMP_RATE_LO}-{PUMP_RATE_HI} "
-                 f"(12288 Hz / 50-60 fps). The MFP Timer A divider is wrong, so sampled "
-                 f"sound plays at the wrong pitch")
+def gradePump(summary):
+    """Plain ST: the PWM pump through channel A's volume, checks 2-4."""
+    # 2. The pump ran, at the right rate. Group pump frames into contiguous runs
+    #    so each SFX can be told from the next.
+    runs = []
+    current = []
+    for f in frames:
+        if len(full[f].get(REG_VOL_A, ())) >= PUMP_PER_FRAME_MIN:
+            current.append(f)
+        elif current:
+            runs.append(current)
+            current = []
+    if current:
+        runs.append(current)
+
+    peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
+    if not runs:
+        fails.append(f"the PWM pump never ran: peak {peak} reg-8 writes in a frame, want "
+                     f">= {PUMP_PER_FRAME_MIN}. jlAudioPlaySfx did not start Timer A, so "
+                     f"every digital sound on this port is silent")
+    elif not (PUMP_RATE_LO <= peak <= PUMP_RATE_HI):
+        fails.append(f"the pump ran at {peak} reg-8 writes/frame, want {PUMP_RATE_LO}-{PUMP_RATE_HI} "
+                     f"(12288 Hz / 50-60 fps). The MFP Timer A divider is wrong, so sampled "
+                     f"sound plays at the wrong pitch")
+
+    if runs:
+        # 3 + 4. Inside each run, minus its first and last frame.
+        interior = [f for r in runs for f in (r[1:-1] if len(r) > 2 else [])]
+        writers = {}
+        for f in interior:
+            for pc in full[f].get(REG_VOL_A, ()):
+                writers[pc] = writers.get(pc, 0) + 1
+        ranked = sorted(writers.items(), key=lambda kv: -kv[1])
+        isr = ranked[0][0] if ranked else None
+        strayFrames = sum(1 for f in interior
+                          if any(pc != isr for pc in full[f].get(REG_VOL_A, ())))
+        strayPct = (100.0 * strayFrames / len(interior)) if interior else 0.0
+        if strayPct > STRAY_FRAME_PCT_MAX:
+            others = ", ".join(f"pc={pc} x{n}" for pc, n in ranked[1:4])
+            fails.append(f"reg 8 (volume A) has {len(ranked)} writers in {strayPct:.1f}% of the "
+                         f"pump's frames: ISR pc={isr} plus {others}. The tracker is overwriting "
+                         f"the PWM sample - digital and chiptune are colliding on channel A")
+
+        toneDuring = countReg(interior, full, TONE_B_REGS)
+        if toneDuring == 0:
+            fails.append("the tracker stopped writing its own channel B while the pump ran, so "
+                         "the SFX silenced the music instead of mixing with it")
+
+        summary += (f"; pump {len(runs)} runs (expected {taps}) peak {peak} reg8/frame, "
+                    f"{len(writers)} reg8 writer(s), stray frames {strayPct:.1f}%, "
+                    f"tracker toneB during pump {toneDuring}")
+    else:
+        summary += f"; pump never ran (peak {peak} reg8/frame)"
+    return summary
+
+
+def gradeDma(summary):
+    """STe: the DMA chip plays the digital sound and the PSG keeps channel A."""
+    modes = []
+    controls = []
+    runs = []
+    run = None
+    for line in open(sys.argv[1], errors='ignore'):
+        if line.startswith('DMA snd mode write:'):
+            m = DMA_WRITE.match(line)
+            if m and not m.group(2).startswith('e'):
+                modes.append(int(m.group(1), 16))
+        elif line.startswith('DMA snd control write: starting'):
+            run = {'samples': [], 'ym': {}}
+            runs.append(run)
+        elif line.startswith('DMA snd control write: stopping'):
+            run = None
+        elif line.startswith('DMA snd control write:'):
+            m = DMA_WRITE.match(line)
+            if m and not m.group(2).startswith('e'):
+                controls.append(int(m.group(1), 16))
+        elif run is None:
+            continue
+        elif line.startswith('DMA snd fifo pull'):
+            v = int(line.split()[-1], 16)
+            run['samples'].append(v - 256 if v > 127 else v)
+        else:
+            m = LINE.match(line)
+            if m and not m.group(4).startswith('e'):
+                reg = int(m.group(1), 16)
+                run['ym'][reg] = run['ym'].get(reg, 0) + 1
+
+    # 2. The chip ran, programmed as the HAL programs it.
+    if not runs:
+        fails.append("the DMA sound chip never started: jlAudioPlaySfx did not reach it, so "
+                     "every digital sound on the STe is silent")
+    if not modes or any(m != DMA_MODE_MONO_12K for m in modes):
+        fails.append(f"DMA mode writes {[hex(m) for m in modes]}, want {hex(DMA_MODE_MONO_12K)} "
+                     f"(mono, 12517 Hz) -- the mix is made for that rate")
+    if DMA_CTRL_PLAY_LOOP not in controls:
+        fails.append(f"DMA control writes {[hex(c) for c in controls]} never set play + repeat "
+                     f"({hex(DMA_CTRL_PLAY_LOOP)}), so the chip cannot loop over the mix buffer")
+
+    # 3. Each run played the SFX.
+    played = [sum(1 for v in r['samples'] if v != 0) for r in runs]
+    peaks = [max((abs(v) for v in r['samples']), default=0) for r in runs]
+    pulled = sum(len(r['samples']) for r in runs)
+    unsignedSilence = sum(1 for r in runs for v in r['samples'] if v == DMA_UNCONVERTED_SILENCE)
+    if runs and (min(played) < SFX_SAMPLES_MIN or min(peaks) < SFX_PEAK_MIN):
+        fails.append(f"a DMA run played no SFX: non-silent samples per run {played} (want >= "
+                     f"{SFX_SAMPLES_MIN}), peaks {peaks} (want >= {SFX_PEAK_MIN}). The mix never "
+                     f"reached the buffer the chip plays")
+    if pulled and 100.0 * unsignedSilence / pulled > UNSIGNED_PCT_MAX:
+        fails.append(f"{unsignedSilence} of {pulled} samples are -128: unsigned silence the HAL "
+                     f"never converted to the chip's signed format, a DC step rather than a wave")
+
+    # 4. No PWM pump on this machine.
+    peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
+    if peak >= PUMP_PER_FRAME_MIN:
+        fails.append(f"reg 8 was written {peak} times in one frame: the PWM pump ran on a "
+                     f"machine with DMA sound, so channel A is still the DAC")
+
+    # 5. The tracker keeps channel A (and B) while the chip plays.
+    voiceA = sum(r['ym'].get(reg, 0) for r in runs for reg in VOICE_A_REGS)
+    toneB = sum(r['ym'].get(reg, 0) for r in runs for reg in TONE_B_REGS)
+    if runs and voiceA == 0:
+        fails.append("the tracker wrote none of voice 0's registers (0, 1, 8) while the DMA chip "
+                     "played: channel A is still being given up to digital sound")
+    if runs and toneB == 0:
+        fails.append("the tracker stopped writing its own channel B while the DMA chip played, "
+                     "so the SFX silenced the music instead of mixing with it")
+
+    return (summary + f"; DMA {len(runs)} runs (expected {taps}), mode "
+            f"{','.join(sorted(set(hex(m) for m in modes)))}, non-silent samples/run "
+            f"{min(played, default=0)}..{max(played, default=0)}, peak "
+            f"{max(peaks, default=0)}, peak reg8/frame {peak}, tracker voice A {voiceA} / "
+            f"toneB {toneB} writes during DMA")
+
 
 summary = f"{prog} tracker baseline {baseTone} toneB/{baseMixer} mixer"
-if runs:
-    # 3 + 4. Inside each run, minus its first and last frame.
-    interior = [f for r in runs for f in (r[1:-1] if len(r) > 2 else [])]
-    writers = {}
-    for f in interior:
-        for pc in full[f].get(REG_VOL_A, ()):
-            writers[pc] = writers.get(pc, 0) + 1
-    ranked = sorted(writers.items(), key=lambda kv: -kv[1])
-    isr = ranked[0][0] if ranked else None
-    strayFrames = sum(1 for f in interior
-                      if any(pc != isr for pc in full[f].get(REG_VOL_A, ())))
-    strayPct = (100.0 * strayFrames / len(interior)) if interior else 0.0
-    if strayPct > STRAY_FRAME_PCT_MAX:
-        others = ", ".join(f"pc={pc} x{n}" for pc, n in ranked[1:4])
-        fails.append(f"reg 8 (volume A) has {len(ranked)} writers in {strayPct:.1f}% of the "
-                     f"pump's frames: ISR pc={isr} plus {others}. The tracker is overwriting "
-                     f"the PWM sample - digital and chiptune are colliding on channel A")
-
-    toneDuring = countReg(interior, full, TONE_B_REGS)
-    if toneDuring == 0:
-        fails.append("the tracker stopped writing its own channel B while the pump ran, so "
-                     "the SFX silenced the music instead of mixing with it")
-
-    summary += (f"; pump {len(runs)} runs (expected {taps}) peak {peak} reg8/frame, "
-                f"{len(writers)} reg8 writer(s), stray frames {strayPct:.1f}%, "
-                f"tracker toneB during pump {toneDuring}")
-else:
-    summary += f"; pump never ran (peak {peak} reg8/frame)"
+summary = gradeDma(summary) if machine == 'ste' else gradePump(summary)
 
 print(f"verify-atarist-audio: {summary}")
 for f in fails:
diff --git a/scripts/verify-atarist-dirtymark.sh b/scripts/verify-atarist-dirtymark.sh
index 64688cb..82dc38d 100755
--- a/scripts/verify-atarist-dirtymark.sh
+++ b/scripts/verify-atarist-dirtymark.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-atarist-dirtymark.sh - the stage's dirty-band marker on a real 68000:
 # tests/host/dirtyMarkHost.c built for the Atari ST (make -f make/atarist.mk
 # atarist-dirtymark -> build/atarist/test/DIRTYMRK.PRG) and run under Hatari,
diff --git a/scripts/verify-atarist-input.sh b/scripts/verify-atarist-input.sh
index 4317566..6ddde53 100755
--- a/scripts/verify-atarist-input.sh
+++ b/scripts/verify-atarist-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-atarist-input.sh - Headless typed-character acceptance gate for
 # the Atari ST port. Autostarts KEYS.PRG in Hatari (EmuTOS, GEMDOS
 # drive), injects the RetroNet definition-of-done string through
diff --git a/scripts/verify-atarist-joy.sh b/scripts/verify-atarist-joy.sh
new file mode 100755
index 0000000..b1a7fba
--- /dev/null
+++ b/scripts/verify-atarist-joy.sh
@@ -0,0 +1,223 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-atarist-joy.sh - Joystick gate for the Atari ST and STe builds:
+# both ports, every direction, the fire button and every release, driven
+# through Hatari's keyboard joysticks and read back from what examples/joy
+# draws.
+#
+# The port (src/atarist/input.c) decodes the IKBD's joystick packets: port 1,
+# the dedicated stick port, is JOYSTICK_0 (stick A) and port 0, the mouse
+# port, JOYSTICK_1 (stick B); one fire button each, both always connected.
+# The port keeps TOS's IKBD mode, in which port 0 is the mouse's: the IKBD
+# reports no stick there, so B must stay at rest whatever is held on it, and
+# port 1's fire, the same line as the mouse's right button, arrives as that
+# button.
+# Hatari's [Joystick0] and [Joystick1] (a config file of the gate's own) are
+# keyboard joysticks on host keys, which xdotool holds on a private Xvfb
+# display; Hatari's own screenshots (its command FIFO's screenshot shortcut)
+# go through scripts/lib/joyScreen.py (scripts/lib/joyGate.sh). Escape must
+# then end JOY.
+#
+# Usage:   scripts/verify-atarist-joy.sh [atarist|ste] [MACHINE]
+#          (default atarist on an st; ste on an ste)
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: Hatari, EmuTOS, Xvfb, xdotool, python3 + PIL;
+#           build//bin/JOY.PRG (make atarist / make ste).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+joeyNeed HATARI hatari
+joeyNeed EMUTOS_IMG emutos
+
+build="${1:-atarist}"
+# The ST build writes 3-bit colours on any machine (joyScreen.py --st).
+case "$build" in
+    atarist) machine="${2:-st}"; JOY_SCREEN_ARGS=(--st) ;;
+    ste)     machine="${2:-ste}" ;;
+    *)       echo "verify-atarist-joy: build must be atarist or ste" >&2; exit 2 ;;
+esac
+JOY_LABEL="verify-$build-joy"
+program="$repo/build/$build/bin/JOY.PRG"
+wall=300            # hard cap on Hatari, whatever happens to this script
+readySeconds=90     # EmuTOS booting and JOY drawing
+shotSeconds=5       # the screenshot shortcut until Hatari has written the file
+exitSeconds=10      # Escape until JOY's screen is gone
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY.PRG first (make $build)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-st-joy.XXXXXX)
+guard=""
+hatari=""
+xvfb=""
+display=""
+win=""
+cleanup() {
+    joeyStop "$hatari" "$guard" "$xvfb" 2> /dev/null
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+# Control, its host key (xdotool's name) and Hatari's name for the key.
+# Hatari's Joystick1 is ST port 1 (stick A), Joystick0 port 0 (stick B).
+keys=(
+    "A-left j J" "A-right l L" "A-up i I" "A-down k K" "A-button0 a A"
+    "B-left f F" "B-right h H" "B-up t T" "B-down g G" "B-button0 q Q"
+)
+
+
+# key CONTROL -- the host key for a control.
+key() {
+    local entry
+    local control
+    local host
+    for entry in "${keys[@]}"; do
+        read -r control host _ <<< "$entry"
+        if [ "$control" = "$1" ]; then
+            printf '%s\n' "$host"
+            return 0
+        fi
+    done
+    return 1
+}
+
+
+# hatariKey CONTROL -- Hatari's name for the control's key.
+hatariKey() {
+    local entry
+    local control
+    local name
+    for entry in "${keys[@]}"; do
+        read -r control _ name <<< "$entry"
+        if [ "$control" = "$1" ]; then
+            printf '%s\n' "$name"
+            return 0
+        fi
+    done
+    return 1
+}
+
+
+# hold keydown|keyup CONTROL...
+hold() {
+    local action="$1"
+    local control
+    local host
+    shift
+    for control in "$@"; do
+        host=$(key "$control") || { joyFail "no key for $control"; continue; }
+        DISPLAY="$display" xdotool "$action" "$host"
+    done
+}
+
+
+joyPress() {
+    hold keydown "$@"
+}
+
+
+joyRelease() {
+    hold keyup "$@"
+}
+
+
+# Hatari makes the file before it writes it: it is done when it has bytes
+# and one more poll adds none.
+joyShot() {
+    local before
+    local found
+    local size=""
+    local deadline
+    before=$(find "$JOY_WORK/shots" -name '*.png' | wc -l)
+    timeout 5 bash -c 'echo "hatari-shortcut screenshot" > "$1"' _ "$JOY_WORK/cmd.fifo" || return 1
+    deadline=$((SECONDS + shotSeconds))
+    while [ "$SECONDS" -lt "$deadline" ]; do
+        sleep 0.2
+        [ "$(find "$JOY_WORK/shots" -name '*.png' | wc -l)" -gt "$before" ] || continue
+        found=$(ls -t "$JOY_WORK/shots"/*.png | head -1)
+        [ -s "$found" ] || continue
+        if [ "$(stat -c %s "$found")" = "$size" ]; then
+            mv "$found" "$1"
+            return 0
+        fi
+        size=$(stat -c %s "$found")
+    done
+    return 1
+}
+
+
+# joyStage -- whether a screenshot now shows JOY's stage.
+joyStage() {
+    joyShot "$JOY_WORK/probe.png" && python3 "$repo/scripts/lib/joyScreen.py" "$JOY_WORK/probe.png" "${JOY_SCREEN_ARGS[@]}" > /dev/null 2>&1
+}
+
+
+# The keyboard joysticks: Joystick1 (port 1) stick A, Joystick0 stick B.
+{
+    for port in 1 0; do
+        stick=$([ "$port" = 1 ] && echo A || echo B)
+        printf '[Joystick%s]\nnJoystickMode = 2\nkUp = %s\nkDown = %s\nkLeft = %s\nkRight = %s\nkFire = %s\n\n' "$port" \
+            "$(hatariKey "$stick-up")" "$(hatariKey "$stick-down")" "$(hatariKey "$stick-left")" \
+            "$(hatariKey "$stick-right")" "$(hatariKey "$stick-button0")"
+    done
+} > "$JOY_WORK/joy.cfg"
+
+display=":$((RANDOM % 400 + 5100))"
+Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp > /dev/null 2>&1 &
+xvfb=$!
+sleep 1
+mkdir -p "$JOY_WORK/shots" "$JOY_WORK/home"
+(cd "$JOY_WORK" && exec env HOME="$JOY_WORK/home" XDG_CONFIG_HOME="$JOY_WORK/home" DISPLAY="$display" SDL_AUDIODRIVER=dummy \
+    timeout -s KILL "$wall" "$HATARI" --configfile "$JOY_WORK/joy.cfg" --machine "$machine" --tos "$EMUTOS_IMG" \
+    --harddrive "$(dirname "$program")" --gemdos-drive C --auto "C:\\$(basename "$program")" \
+    --sound off --statusbar false --crop true --screenshot-dir "$JOY_WORK/shots" \
+    --cmd-fifo "$JOY_WORK/cmd.fifo") > "$JOY_WORK/hatari.log" 2>&1 &
+guard=$!
+hatari=$(joeyChild "$guard")
+
+ready=0
+for ((waited = 0; waited < readySeconds; waited += 2)); do
+    if [ -p "$JOY_WORK/cmd.fifo" ] && joyStage; then
+        ready=1
+        break
+    fi
+    joeyAlive "$guard" || break
+    sleep 2
+done
+if [ "$ready" != 1 ]; then
+    echo "$JOY_LABEL: FAIL - Hatari with JOY did not come up (log follows)" >&2
+    grep -v '^Screen dump saved' "$JOY_WORK/hatari.log" | tail -20 >&2
+    if [ -n "${JOY_SHOTS:-}" ] && [ -f "$JOY_WORK/probe.png" ]; then
+        mkdir -p "$JOY_SHOTS"
+        cp "$JOY_WORK/probe.png" "$JOY_SHOTS/not-ready.png"
+    fi
+    exit 1
+fi
+# Xvfb has no window manager: the keyboard goes to the focused window, and
+# xdotool's keys (XTEST, which SDL takes) to the one that has it.
+win=$(joeyFindWindow "$display" "$hatari")
+if [ -n "$win" ]; then
+    DISPLAY="$display" xdotool windowfocus --sync "$win" 2> /dev/null || true
+    DISPLAY="$display" xdotool mousemove --window "$win" 20 20
+fi
+
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+# Stick A's steps (the first seven), then B held: the mouse's port.
+joyRunSteps "${JOY_STEPS_ONE_BUTTON[@]:0:7}" "B held: port 0 is the mouse's|B-left B-up B-button0|REST|REST"
+
+DISPLAY="$display" xdotool key Escape
+gone=0
+for ((waited = 0; waited < exitSeconds; waited++)); do
+    sleep 1
+    if ! joyStage; then
+        gone=1
+        break
+    fi
+done
+[ "$gone" = 1 ] || joyFail "Escape did not end JOY"
+
+joyResult "$JOY_LABEL" "$machine: port 1 every direction, the fire button and every release; port 0 left to the mouse; Escape ends JOY"
diff --git a/scripts/verify-atarist-palette.sh b/scripts/verify-atarist-palette.sh
new file mode 100755
index 0000000..7e85cb2
--- /dev/null
+++ b/scripts/verify-atarist-palette.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-atarist-palette.sh - The ST family's palette, read off the shifter.
+#
+# JoeyLib colours are 12-bit $0RGB. A plain ST's shifter has 3 bits per
+# channel, so it keeps each channel's top three bits; an STe's has 4, with
+# the channel's LOW bit in bit 3 of the register nibble. The ST build (make
+# atarist) must write the 3-bit truncation -- on every machine, an STe
+# included, since it never uses STe hardware -- and the STe build (make
+# ste) the exact 4-bit colour.
+#
+# Subject: the PATTERN example -- eight bands, each its own palette, each
+# palette a gradient whose entry i has i in some of its channels. So every
+# register sees every channel value 0..15 across the bands, odd ones
+# included, and the Timer B raster ISR reloads all sixteen registers at
+# every band. Hatari's video_color trace logs each palette register write
+# with its pc; the gate collects the application's (TOS lives at $Exxxxx),
+# and for each register the set of values written must be EXACTLY the set
+# the machine's encoding of the eight gradients gives. A shifter fed the
+# wrong encoding fails on the odd values; a missing band or a wrong
+# palette fails on the set.
+#
+# Usage:   scripts/verify-atarist-palette.sh [st|megast|ste|megaste ...]
+#            machines to run, default "st ste megaste": a plain ST and a
+#            Mega ST run the ST build, an STe and a Mega STe the STe build
+# Requires: ./install.sh install emulator:atarist (Hatari, EmuTOS),
+#           PATTERN.PRG in build/atarist/bin and build/ste/bin (make
+#           atarist, make ste)
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+joeyNeed HATARI hatari
+joeyNeed EMUTOS_IMG emutos
+
+machines=${*:-st ste megaste}
+wall=120            # hard cap on Hatari, whatever happens to this script
+runVbls=1200        # EmuTOS boot + PATTERN up and banding: ~600 frames
+
+work=$(mktemp -d -t joeylib-st-palette.XXXXXX)
+trap 'rm -rf "$work"' EXIT
+
+fails=0
+for machine in $machines; do
+    case $machine in
+        st|megast)   build=atarist ;;
+        ste|megaste) build=ste ;;
+        *) echo "verify-atarist-palette: unknown machine '$machine' (st, megast, ste or megaste)" >&2; exit 2 ;;
+    esac
+    prog=$repo/build/$build/bin/PATTERN.PRG
+    [ -f "$prog" ] || { echo "verify-atarist-palette: missing $prog (run 'make $build')" >&2; exit 2; }
+    rm -rf "$work/c"
+    mkdir -p "$work/c"
+    cp "$prog" "$work/c/"
+    # The trace is filtered as it is produced: only palette register writes
+    # from the application, not TOS's ($Exxxxx) or Hatari's other colour
+    # bookkeeping. --run-vbls ends the run; timeout is the backstop.
+    SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy timeout -s KILL "$wall" "$HATARI" \
+        --machine      "$machine" \
+        --tos          "$EMUTOS_IMG" \
+        --harddrive    "$work/c" \
+        --gemdos-drive C \
+        --drive-a off --drive-b off \
+        --sound        off \
+        --fast-forward on \
+        --run-vbls     "$runVbls" \
+        --trace        video_color \
+        --auto         'C:\PATTERN.PRG' 2>&1 \
+        | grep -E '^write col addr=ff82[45]' | grep -v ' pc=e' > "$work/$machine.log"
+
+    python3 - "$work/$machine.log" "$machine" "$build" <<'PY' || fails=$((fails + 1))
+import re
+import sys
+
+# write col addr=ff825e col=70 video_cyc_w=55892 ... pc=1f00e instr_cyc=12
+LINE = re.compile(r'^write col addr=ff82([45][0-9a-f]) col=([0-9a-f]+) ')
+PALETTE_BASE = 0x40
+COLORS = 16
+# PATTERN's eight palettes: which of (red, green, blue) carry the gradient.
+GRADIENTS = [(1, 1, 1), (1, 0, 0), (1, 1, 0), (0, 1, 0),
+             (0, 1, 1), (0, 0, 1), (1, 0, 1), (1, 1, 1)]
+
+
+def channel(build, v):
+    """One 4-bit channel as that build writes the shifter nibble."""
+    if build == 'atarist':
+        return v >> 1
+    return (v >> 1) | ((v & 1) << 3)
+
+
+path, machine, build = sys.argv[1], sys.argv[2], sys.argv[3]
+seen = [set() for _ in range(COLORS)]
+writes = 0
+for line in open(path, errors='ignore'):
+    m = LINE.match(line)
+    if m:
+        seen[(int(m.group(1), 16) - PALETTE_BASE) // 2].add(int(m.group(2), 16))
+        writes += 1
+
+bad = []
+for i in range(COLORS):
+    want = set()
+    for r, g, b in GRADIENTS:
+        v = channel(build, i) if i else 0
+        want.add(((v if r else 0) << 8) | ((v if g else 0) << 4) | (v if b else 0))
+    if seen[i] != want:
+        bad.append(f"register {i}: wrote {sorted(hex(v) for v in seen[i])}, "
+                   f"want {sorted(hex(v) for v in want)}")
+
+bits = '3-bit' if build == 'atarist' else '4-bit'
+if writes == 0:
+    print(f"verify-atarist-palette: {build} build on {machine} FAIL - PATTERN never wrote the palette", file=sys.stderr)
+    sys.exit(1)
+if bad:
+    print(f"verify-atarist-palette: {build} build on {machine} FAIL - not the {bits} encoding of PATTERN's palettes:",
+          file=sys.stderr)
+    for b in bad[:4]:
+        print(f"verify-atarist-palette:   {b}", file=sys.stderr)
+    sys.exit(1)
+print(f"verify-atarist-palette: {build} build on {machine} OK - {writes} palette writes, all 16 "
+      f"registers exactly the {bits} encoding of the 8 band palettes")
+PY
+done
+
+if [ "$fails" -gt 0 ]; then
+    echo "verify-atarist-palette: FAIL" >&2
+    exit 1
+fi
+echo "verify-atarist-palette: PASS"
diff --git a/scripts/verify-atarist-serial.sh b/scripts/verify-atarist-serial.sh
index 890b27f..9605be1 100755
--- a/scripts/verify-atarist-serial.sh
+++ b/scripts/verify-atarist-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-atarist-serial.sh - both-directions RS-232 gate for the Atari ST port.
 #
 # Runs SERTEST.PRG (examples/sertest: banner, then echo) under Hatari and
diff --git a/scripts/verify-atarist-tilebank.sh b/scripts/verify-atarist-tilebank.sh
index 4ab2fea..b2033ee 100755
--- a/scripts/verify-atarist-tilebank.sh
+++ b/scripts/verify-atarist-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-atarist-tilebank.sh - Tile-bank gate for the Atari ST: proves that
 # assetbake's atarist encoder (interleaved planes, 4 plane bytes per row)
 # writes exactly the layout the ST's native tile paste (src/atarist/stTile.h)
diff --git a/scripts/verify-be-input.sh b/scripts/verify-be-input.sh
new file mode 100755
index 0000000..1cad859
--- /dev/null
+++ b/scripts/verify-be-input.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-be-input.sh - Typed-character acceptance gate for a Be API port
+# (haiku, haiku32, beos). Runs KEYS in the port's VM under QEMU
+# (scripts/lib/beGate.sh) at one window pixel per stage pixel
+# (JOEYLIB_SCALE=1, JOEYLIB_ASPECT=square), types the RetroNet
+# definition-of-done string through QEMU's PS/2 keyboard (QMP send-key,
+# shift and all: the keys go through the system's input server and keymap
+# as a person's would), takes QEMU's screen and decodes the example's
+# verification scanline (row 198) with scripts/lib/keysRow.py, wherever the
+# desktop put the window. Typing starts once the screen shows KEYS's empty
+# row (the window is up); the screen is read until the row is whole or time
+# runs out. Escape then ends KEYS, and the VM.
+#
+# Usage:   scripts/verify-be-input.sh [haiku|haiku32|beos]   (default haiku)
+# Env:     BE_INPUT_KEEP  keep the last screen as build/PORT/be-input.png
+#                         when the gate fails
+# Requires: QEMU and the port's VM (./install.sh install emulator:PORT),
+#           python3 + PIL; build/PORT/bin/KEYS (make PORT).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/beGate.sh"
+
+port=${1:-haiku}
+label="verify-be-input ($port)"
+target="192.168.1.10:6510"
+beNeed "$port"
+program=$(joeyExampleProgram keys "$port")
+[ -x "$program" ] || { echo "$label: build KEYS first (make $port)" >&2; exit 2; }
+bootSeconds=120
+runSeconds=120
+rowSeconds=30
+escapeHoldMs=400
+
+work=$(mktemp -d -t joeylib-be-input.XXXXXX)
+cleanup() {
+    beStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+
+# rowShown TEXT -- the screen shows KEYS's row holding TEXT, read once a
+# second for up to rowSeconds; the last read's verdict is printed.
+rowShown() {
+    local tries
+    for ((tries = 0; tries < rowSeconds; tries++)); do
+        beQmp "$work" screendump "$work/keys.png"
+        if python3 "$repo/scripts/lib/keysRow.py" "$work/keys.png" "$1" --scale 1 > "$work/row.txt"; then
+            cat "$work/row.txt"
+            return 0
+        fi
+        sleep 1
+    done
+    cat "$work/row.txt"
+    return 1
+}
+
+
+mkdir -p "$work/job/KEYS"
+cp "$program" "$work/job/KEYS/KEYS"
+beJobScript KEYS KEYS "" "$runSeconds" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square > "$work/job/job.sh"
+beJobDisk "$work/job.img" "$work/job"
+
+beStart "$work" "$work/job.img"
+if ! beWaitJob "$work" "$bootSeconds"; then
+    echo "$label: FAIL - the VM did not start the job within ${bootSeconds} s" >&2
+    exit 1
+fi
+if ! rowShown "" > /dev/null; then
+    echo "$label: FAIL - KEYS's window did not come up within ${rowSeconds} s" >&2
+    exit 1
+fi
+beQmp "$work" type "$target"
+# The stage at one pixel a pixel, wherever the window is.
+if rowShown "$target"; then
+    passed=1
+else
+    passed=0
+fi
+beQmp "$work" hold esc "$escapeHoldMs"
+beWaitExit "$work" "$runSeconds" || true
+if [ "$passed" = 1 ]; then
+    echo "$label: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)"
+else
+    echo "$label: FAIL" >&2
+    if [ -n "${BE_INPUT_KEEP:-}" ]; then
+        cp "$work/keys.png" "$repo/build/$port/be-input.png"
+    fi
+    exit 1
+fi
diff --git a/scripts/verify-dos-audio.sh b/scripts/verify-dos-audio.sh
index 92fa9b6..51c1b3f 100755
--- a/scripts/verify-dos-audio.sh
+++ b/scripts/verify-dos-audio.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dos-audio.sh - DOS audio gate, graded on recorded sound.
 #
 # The DOS HAL drives a Sound Blaster Pro/SB16 with 8237 auto-init DMA fed
diff --git a/scripts/verify-dos-input.sh b/scripts/verify-dos-input.sh
index 7797c23..8c2a41f 100755
--- a/scripts/verify-dos-input.sh
+++ b/scripts/verify-dos-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dos-input.sh - Headless typed-character acceptance gate for the
 # DOS port. Runs KEYS.EXE in DOSBox Staging under Xvfb, types the
 # RetroNet definition-of-done string with xdotool (real X key events,
diff --git a/scripts/verify-dos-joy.sh b/scripts/verify-dos-joy.sh
new file mode 100755
index 0000000..2580854
--- /dev/null
+++ b/scripts/verify-dos-joy.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-dos-joy.sh - Joystick gate for DOS: both sticks, every direction,
+# both buttons and every release, driven through DOSBox Staging's emulated
+# game port and read back from what examples/joy draws; and no joystick at
+# all when the game port is empty.
+#
+# The port (src/dos/input.c) reads the game port through the BIOS, INT 15h
+# function 84h: the switches, then the four positions, both sticks connected
+# while the BIOS answers. DOSBox's joysticktype 4axis gives it two sticks on
+# one port, moved by host keys through a mapper file
+# (scripts/lib/dosboxJoy.sh); the raw screenshots go through
+# scripts/lib/joyScreen.py (scripts/lib/joyGate.sh). With joysticktype none
+# the BIOS says there is no game port and JOY must show both sticks
+# disconnected. Each run ends with Escape, which must end JOY (DOSBox exits
+# after it).
+#
+# Usage:   scripts/verify-dos-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL;
+#           build/dos/bin/JOY.EXE (make dos).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+source "$repo/scripts/lib/dosboxJoy.sh"
+joeyNeed DOSBOX dosbox
+
+JOY_LABEL=verify-dos-joy
+program="$repo/build/dos/bin/JOY.EXE"
+wall=240            # hard cap on each DOSBox run, whatever happens to this script
+bootSeconds=12      # DOS booting and JOY drawing on the emulated 386SX-16
+exitSeconds=10      # Escape until DOSBox, which exits after JOY, has gone
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY.EXE first (make dos)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-dos-joy.XXXXXX)
+guard=""
+dosbox=""
+xvfb=""
+cleanup() {
+    joeyStop "$dosbox" "$guard" "$xvfb" 2> /dev/null
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+
+# boot RUN JOYTYPE -- JOY in DOSBox with joysticktype JOYTYPE; sets guard,
+# dosbox and DOSBOX_JOY_WINDOW.
+boot() {
+    local dir="$JOY_WORK/$1"
+    mkdir -p "$dir/xdg" "$dir/shots"
+    dosboxJoyMapper "$dir/mapper.txt"
+    DOSBOX_JOY_SHOTS="$dir/shots"
+    XDG_CONFIG_HOME="$dir/xdg" DISPLAY="$DOSBOX_JOY_DISPLAY" SDL_AUDIODRIVER=dummy \
+        timeout -s KILL "$wall" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" \
+        --conf "$repo/scripts/dosbox-386sx16.conf" --set "joysticktype=$2" \
+        --set "mapperfile=$dir/mapper.txt" --set "capture_dir=$dir/shots" \
+        -c "MOUNT C \"$(dirname "$program")\"" -c "C:" -c "$(basename "$program")" -c "EXIT" \
+        > "$dir/dosbox.log" 2>&1 &
+    guard=$!
+    dosbox=$(joeyChild "$guard")
+    sleep "$bootSeconds"
+    DOSBOX_JOY_WINDOW=$(joeyFindWindow "$DOSBOX_JOY_DISPLAY" "$dosbox")
+    [ -n "$DOSBOX_JOY_WINDOW" ] || return 1
+    DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool windowactivate --sync "$DOSBOX_JOY_WINDOW" 2> /dev/null || true
+}
+
+
+# quit -- Escape, which must end JOY and with it DOSBox.
+quit() {
+    local waited
+    dosboxJoyTap escape
+    for ((waited = 0; waited < exitSeconds; waited++)); do
+        joeyAlive "$guard" || break
+        sleep 1
+    done
+    joeyAlive "$guard" && joyFail "Escape did not end JOY"
+    joeyStop "$dosbox" "$guard" 2> /dev/null
+    dosbox=""
+    guard=""
+}
+
+
+DOSBOX_JOY_DISPLAY=":$((RANDOM % 400 + 4700))"
+Xvfb "$DOSBOX_JOY_DISPLAY" -screen 0 1024x768x24 -nolisten tcp > /dev/null 2>&1 &
+xvfb=$!
+sleep 1
+
+# 1. Both sticks on the game port.
+boot sticks 4axis || { echo "$JOY_LABEL: FAIL - DOSBox with JOY did not come up" >&2; exit 1; }
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+quit
+
+# 2. No game port.
+boot none none || { echo "$JOY_LABEL: FAIL - DOSBox with JOY and no joystick did not come up" >&2; exit 1; }
+joyCheck "no joystick" "A=$JOY_ABSENT B=$JOY_ABSENT"
+quit
+
+joyResult "$JOY_LABEL" "both sticks, every direction, both buttons and every release; no joystick without a game port; Escape ends JOY"
diff --git a/scripts/verify-dos-serial.sh b/scripts/verify-dos-serial.sh
index 4b67921..892236d 100755
--- a/scripts/verify-dos-serial.sh
+++ b/scripts/verify-dos-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dos-serial.sh - both-directions RS-232 gate for the DOS port.
 #
 # Runs SERTEST.EXE (examples/sertest: banner, then echo) in DOSBox Staging
diff --git a/scripts/verify-dos-tilebank.sh b/scripts/verify-dos-tilebank.sh
index 806899a..033ab3a 100755
--- a/scripts/verify-dos-tilebank.sh
+++ b/scripts/verify-dos-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dos-tilebank.sh - Tile-bank gate for DOS: proves that assetbake's
 # dos encoder (chunky 4bpp, high nibble = left pixel) writes exactly the
 # layout the DOS tile paste reads, and that the DOS build refuses a bank baked
diff --git a/scripts/verify-dvx-audio.sh b/scripts/verify-dvx-audio.sh
index 9b24ec2..1d66c41 100755
--- a/scripts/verify-dvx-audio.sh
+++ b/scripts/verify-dvx-audio.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-audio.sh - DVX audio gate, graded on recorded sound.
 #
 # The DVX port plays through DVX's own sound library (audio.lib, with its
diff --git a/scripts/verify-dvx-display.sh b/scripts/verify-dvx-display.sh
index 88ad4e7..d42a138 100755
--- a/scripts/verify-dvx-display.sh
+++ b/scripts/verify-dvx-display.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-display.sh - Display parity gate for DVX: what the present puts
 # where the stage is shown must be the stage, on every desktop depth, at
 # every window scale and on the full screen.
diff --git a/scripts/verify-dvx-fullscreen.sh b/scripts/verify-dvx-fullscreen.sh
index 5d4a5d7..ad93ffb 100755
--- a/scripts/verify-dvx-fullscreen.sh
+++ b/scripts/verify-dvx-fullscreen.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-fullscreen.sh - Full-screen gate for DVX: a program on the whole
 # screen in DVX's exclusive mode (src/dvx/hal.c), started there by the port's
 # own setting as Game > Full Screen leaves it (dvxFullScreen), and read back
diff --git a/scripts/verify-dvx-input.sh b/scripts/verify-dvx-input.sh
index 3912cbb..a502473 100755
--- a/scripts/verify-dvx-input.sh
+++ b/scripts/verify-dvx-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-input.sh - Typed-character and present gate for DVX. Runs KEYS
 # (examples/keys) as the desktop of a scratch DVX in DOSBox Staging under
 # Xvfb and types the RetroNet definition-of-done string with xdotool (real X
diff --git a/scripts/verify-dvx-joy.sh b/scripts/verify-dvx-joy.sh
index aedd23f..5f2e5e3 100755
--- a/scripts/verify-dvx-joy.sh
+++ b/scripts/verify-dvx-joy.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-joy.sh - Joystick gate for DVX: both sticks, every direction,
 # both buttons and every release, driven through DOSBox Staging's emulated
 # game port and read back from what examples/joy draws; and no joystick at
@@ -11,116 +15,90 @@
 # buttons on the game port with no host joystick, which DVX's game port
 # driver (two-stick mode, its default) reads as two sticks, A and B, exactly
 # as a Y cable would give it. A mapper file binds host keys to DOSBox's
-# joystick events (as DVX's own tests do, src/test/dos/joyDrive.py), and
-# xdotool holds them; the raw screenshot is bound to a key of its own, so no
-# Ctrl reaches the program. With no host joystick nothing else moves the
-# stick: it rests in the middle, buttons up.
+# joystick events (scripts/lib/dosboxJoy.sh, as DVX's own tests do,
+# src/test/dos/joyDrive.py), and xdotool holds them; the raw screenshot is
+# bound to a key of its own, so no Ctrl reaches the program. With no host
+# joystick nothing else moves the stick: it rests in the middle, buttons up.
 #
 # JOY (examples/joy) draws, for each stick, a dot (in the middle at rest,
 # grey; green when moved), two button squares and a patch that says whether
 # the port reports a stick; scripts/lib/joyScreen.py reads the WHOLE state of
-# BOTH sticks off each screenshot: each direction of each stick alone, two
-# diagonals each, each button alone and both together, with a release after
-# every press so a latched line shows up, and a step holding different
-# lines on both sticks at once so crosstalk shows up too. Axes are judged
-# as DVX's own tests judge them, beyond half way: an uncalibrated DVX axis
-# ranges itself as it moves, so a full push reads a little short of the
-# end. Escape then ends JOY, which must leave DVX cleanly ("reaped").
+# BOTH sticks off each screenshot (scripts/lib/joyGate.sh's steps: each
+# direction of each stick alone, two diagonals each, each button alone and
+# both together, with a release after every press so a latched line shows
+# up, and a step holding different lines on both sticks at once so
+# crosstalk shows up too). Axes are judged as DVX's own tests judge them,
+# beyond half way: an uncalibrated DVX axis ranges itself as it moves, so a
+# full push reads a little short of the end. Escape then ends JOY, which
+# must leave DVX cleanly ("reaped").
 #
 # Second run, joysticktype disabled: DVX finds no game port stick and falls
 # back to its keyboard joystick (the arrows, LCtrl, Space...), which the
 # port leaves out, as every port reports no joystick then: JOY must show
 # both sticks disconnected at rest while the keyboard joystick's Left and
-# Space are held.
+# Space are held (the mapper binds them too in that run).
 #
 # Usage:   scripts/verify-dvx-joy.sh
-# Env:     DVX_JOY_SHOTS  also copy the screenshots into this folder
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
 # Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL, the dvx component;
 #           the joy program (make dvx).
 set -uo pipefail
 
 repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
 source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+source "$repo/scripts/lib/dosboxJoy.sh"
 source "$repo/scripts/lib/dvxGate.sh"
 joeyNeed DOSBOX dosbox
 joeyNeed DVX_ROOT dvx
 
+JOY_LABEL=verify-dvx-joy
 program=joy
 wall=240             # hard cap on each DOSBox run, whatever happens to this script
 readySeconds=60      # DVX boot until its shell reports ready
 settleSeconds=3      # then JOY opening its window and taking the focus
-pressSeconds=1       # keys held (or let go), then DVX's poll, JOY and the compositor catching up
-shotSeconds=5        # the screenshot key until DOSBox has written the file
 exitSeconds=10       # Escape until DVX reaps JOY
-rest="10000"         # a stick connected, in the middle, buttons up
-absent="00000"       # no stick
 # DVX.LOG when the game port gives two sticks, and when it has none and the
 # keyboard joystick stands in.
 twoSticksLog="Joystick: 2 device(s) found"
 keyboardLog="Joystick: keyboard0"
 reapedLog="Shell: reaped app 'Joy'"
-
-# Host key (xdotool name), its SDL scancode, and the DOSBox mapper event it
-# drives. Stick A = the game port's first two axes and buttons, stick B =
-# its last two (DOSBox 4axis: the first joystick's axes 2-3, buttons 2-3).
-mapKeys=(
-    "j 13 jaxis_0_0-"  "l 15 jaxis_0_0+"  "i 12 jaxis_0_1-"  "k 14 jaxis_0_1+"
-    "a 4 jbutton_0_0"  "s 22 jbutton_0_1"
-    "f 9 jaxis_0_2-"   "h 11 jaxis_0_2+"  "t 23 jaxis_0_3-"  "g 10 jaxis_0_3+"
-    "q 20 jbutton_0_2" "w 26 jbutton_0_3"
-    "F11 68 hand_rawshot"
-    "Escape 41 key_esc"
-)
-shotKey=F11
-
-# name, keys held, stick A's state, stick B's state (joyScreen.py's
-# characters: connected, X, Y, button 0, button 1).
-steps=(
-    "A left|j|1-000|$rest"          "A right|l|1+000|$rest"
-    "A up|i|10-00|$rest"            "A down|k|10+00|$rest"
-    "A up-left|j i|1--00|$rest"     "A down-right|l k|1++00|$rest"
-    "A button 0|a|10010|$rest"      "A button 1|s|10001|$rest"
-    "A both buttons|a s|10011|$rest"
-    "B left|f|$rest|1-000"          "B right|h|$rest|1+000"
-    "B up|t|$rest|10-00"            "B down|g|$rest|10+00"
-    "B up-right|h t|$rest|1+-00"    "B down-left|f g|$rest|1-+00"
-    "B button 0|q|$rest|10010"      "B button 1|w|$rest|10001"
-    "B both buttons|q w|$rest|10011"
-    "A left+button 1, B right+button 0|j s h q|1-001|1+010"
-)
+# DVX's keyboard joystick keys the second run holds (xdotool's names), and
+# their mapper bindings: Left and Space (SDL scancodes 80 and 44).
+keyboardKeys=(Left space)
+keyboardBinds='key_left "key 80"
+key_space "key 44"'
 
 dvxNeedBuild "$program"
 
-work=$(mktemp -d -t joeylib-dvx-joy.XXXXXX)
+JOY_WORK=$(mktemp -d -t joeylib-dvx-joy.XXXXXX)
 guard=""
 dosbox=""
 xvfb=""
-win=""
-fails=""
-checked=0
 # 2>/dev/null: bash reports the SIGKILLed timeout wrapper as "Killed".
 cleanup() {
     joeyStop "$dosbox" "$guard" "$xvfb" 2> /dev/null
-    rm -rf "$work"
+    rm -rf "$JOY_WORK"
 }
 trap cleanup EXIT
 
 
-# boot RUN JOYTYPE [MAPPER] -- DVX staged in $work/RUN with JOY its desktop,
-# DOSBox's joysticktype JOYTYPE and, if given, its mapper file; sets guard,
-# dosbox and win.
+# boot RUN JOYTYPE [BINDS] -- DVX staged in $JOY_WORK/RUN with JOY its
+# desktop, DOSBox's joysticktype JOYTYPE and the joystick mapper (with BINDS
+# appended); sets guard, dosbox and DOSBOX_JOY_WINDOW.
 boot() {
-    local dir="$work/$1"
+    local dir="$JOY_WORK/$1"
     local waited
     dvxStage "$dir/c" "$(dvxAppDir "$program")"
     dvxDosboxConf "$dir/dosbox.conf" "$dir/c"
-    printf '\n[capture]\ncapture_dir = %s\ndefault_image_capture_formats = raw\n' "$dir/shots" >> "$dir/dosbox.conf"
-    printf '\n[joystick]\njoysticktype = %s\n' "$2" >> "$dir/dosbox.conf"
-    if [ -n "${3:-}" ]; then
-        printf '\n[sdl]\nmapperfile = %s\n' "$3" >> "$dir/dosbox.conf"
-    fi
     mkdir -p "$dir/xdg" "$dir/shots"
-    XDG_CONFIG_HOME="$dir/xdg" DISPLAY="$display" SDL_AUDIODRIVER=dummy \
+    dosboxJoyMapper "$dir/mapper.txt"
+    [ -z "${3:-}" ] || printf '%s\n' "$3" >> "$dir/mapper.txt"
+    DOSBOX_JOY_SHOTS="$dir/shots"
+    printf '\n[capture]\ncapture_dir = %s\n' "$dir/shots" >> "$dir/dosbox.conf"
+    printf '\n[joystick]\njoysticktype = %s\n' "$2" >> "$dir/dosbox.conf"
+    printf '\n[sdl]\nmapperfile = %s\n' "$dir/mapper.txt" >> "$dir/dosbox.conf"
+    XDG_CONFIG_HOME="$dir/xdg" DISPLAY="$DOSBOX_JOY_DISPLAY" SDL_AUDIODRIVER=dummy \
         timeout -s KILL "$wall" "$DOSBOX" "${JOEY_DOSBOX_ISOLATE[@]}" \
         --conf "$dir/dosbox.conf" > "$dir/dosbox.log" 2>&1 &
     guard=$!
@@ -128,9 +106,9 @@ boot() {
     for ((waited = 0; waited < readySeconds; waited++)); do
         if dvxDvxLog "$dir/c" 200 | grep -q "DVX Shell ready"; then
             sleep "$settleSeconds"
-            win=$(joeyFindWindow "$display" "$dosbox")
-            [ -n "$win" ] || return 1
-            DISPLAY="$display" xdotool windowactivate --sync "$win" 2> /dev/null || true
+            DOSBOX_JOY_WINDOW=$(joeyFindWindow "$DOSBOX_JOY_DISPLAY" "$dosbox")
+            [ -n "$DOSBOX_JOY_WINDOW" ] || return 1
+            DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool windowactivate --sync "$DOSBOX_JOY_WINDOW" 2> /dev/null || true
             return 0
         fi
         joeyAlive "$guard" || return 1
@@ -140,50 +118,12 @@ boot() {
 }
 
 
-# check RUN NAME HOTKEY WANT -- a screenshot (HOTKEY, e.g. F11 or ctrl+F5)
-# as $work/RUN/NAME.png, whose two sticks must read WANT ("A=... B=...").
-check() {
-    local dir="$work/$1"
-    local before
-    local png=""
-    local got
-    local waited
-    before=$(ls "$dir/shots" | wc -l)
-    DISPLAY="$display" xdotool key --window "$win" "$3"
-    for ((waited = 0; waited < shotSeconds * 10; waited++)); do
-        if [ "$(ls "$dir/shots" | wc -l)" -gt "$before" ]; then
-            sleep 0.5
-            png=$(ls -t "$dir"/shots/*.png | head -1)
-            break
-        fi
-        sleep 0.1
-    done
-    checked=$((checked + 1))
-    if [ -z "$png" ]; then
-        fail "$2: no screenshot"
-        return
-    fi
-    mv "$png" "$dir/$(printf '%02d' "$checked") ${2}.png"
-    got=$(python3 "$repo/scripts/lib/joyScreen.py" "$dir/$(printf '%02d' "$checked") ${2}.png" --scale 1)
-    echo "verify-dvx-joy: $2: $got"
-    [ "${got% scale=*}" = "$4" ] || fail "$2: read $got, want $4"
-}
-
-
-# fail WHAT -- note a failed check; the gate goes on to report the rest.
-fail() {
-    echo "verify-dvx-joy: FAIL - $1"
-    fails="${fails:+$fails; }$1"
-}
-
-
-# keys DOWN|UP KEY... -- press or let go of the keys.
-keys() {
-    local action="key$1"
+# keyboardJoystick keydown|keyup -- hold or let go of DVX's keyboard
+# joystick keys.
+keyboardJoystick() {
     local k
-    shift
-    for k in "$@"; do
-        DISPLAY="$display" xdotool "$action" --window "$win" "$k"
+    for k in "${keyboardKeys[@]}"; do
+        DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool "$1" --window "$DOSBOX_JOY_WINDOW" "$k"
     done
 }
 
@@ -196,66 +136,41 @@ stop() {
 }
 
 
-display=":$((RANDOM % 400 + 4300))"
-Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp > /dev/null 2>&1 &
+DOSBOX_JOY_DISPLAY=":$((RANDOM % 400 + 4300))"
+Xvfb "$DOSBOX_JOY_DISPLAY" -screen 0 1024x768x24 -nolisten tcp > /dev/null 2>&1 &
 xvfb=$!
 sleep 1
 
 # 1. Both sticks on the game port, moved through the mapper.
-mapper="$work/mapper.txt"
-for entry in "${mapKeys[@]}"; do
-    read -r name code event <<< "$entry"
-    printf '%s "key %s"\n' "$event" "$code"
-done > "$mapper"
-if ! boot sticks 4axis "$mapper"; then
-    echo "verify-dvx-joy: FAIL - DVX with JOY did not come up (DVX.LOG follows)" >&2
-    dvxDvxLog "$work/sticks/c" >&2
+if ! boot sticks 4axis; then
+    echo "$JOY_LABEL: FAIL - DVX with JOY did not come up (DVX.LOG follows)" >&2
+    dvxDvxLog "$JOY_WORK/sticks/c" >&2
     exit 1
 fi
-dvxDvxLog "$work/sticks/c" 400 | grep -q "$twoSticksLog" || fail "DVX.LOG has no \"$twoSticksLog\": $(dvxDvxLog "$work/sticks/c" 400 | grep 'Joystick:' | tr '\n' ' ')"
-check sticks "rest" "$shotKey" "A=$rest B=$rest"
-for step in "${steps[@]}"; do
-    IFS='|' read -r name held wantA wantB <<< "$step"
-    read -r -a heldKeys <<< "$held"
-    keys down "${heldKeys[@]}"
-    sleep "$pressSeconds"
-    check sticks "$name" "$shotKey" "A=$wantA B=$wantB"
-    keys up "${heldKeys[@]}"
-    sleep "$pressSeconds"
-    check sticks "$name released" "$shotKey" "A=$rest B=$rest"
-done
-keys down Escape
-sleep 0.3
-keys up Escape
+dvxDvxLog "$JOY_WORK/sticks/c" 400 | grep -q "$twoSticksLog" || joyFail "DVX.LOG has no \"$twoSticksLog\": $(dvxDvxLog "$JOY_WORK/sticks/c" 400 | grep 'Joystick:' | tr '\n' ' ')"
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+dosboxJoyTap escape
 for ((waited = 0; waited < exitSeconds; waited++)); do
-    dvxDvxLog "$work/sticks/c" 400 | grep -qF "$reapedLog" && break
+    dvxDvxLog "$JOY_WORK/sticks/c" 400 | grep -qF "$reapedLog" && break
     sleep 1
 done
-dvxDvxLog "$work/sticks/c" 400 | grep -qF "$reapedLog" || fail "Escape did not end JOY (no \"$reapedLog\" in DVX.LOG)"
+dvxDvxLog "$JOY_WORK/sticks/c" 400 | grep -qF "$reapedLog" || joyFail "Escape did not end JOY (no \"$reapedLog\" in DVX.LOG)"
 stop
 
 # 2. No stick on the game port: DVX's keyboard joystick stands in, and the
-# port must leave it out. Plain DOSBox keys (no mapper), the default
-# screenshot hotkey.
-if ! boot none disabled; then
-    echo "verify-dvx-joy: FAIL - DVX with JOY and no joystick did not come up (DVX.LOG follows)" >&2
-    dvxDvxLog "$work/none/c" >&2
+# port must leave it out.
+if ! boot none disabled "$keyboardBinds"; then
+    echo "$JOY_LABEL: FAIL - DVX with JOY and no joystick did not come up (DVX.LOG follows)" >&2
+    dvxDvxLog "$JOY_WORK/none/c" >&2
     exit 1
 fi
-dvxDvxLog "$work/none/c" 400 | grep -q "$keyboardLog" || fail "DVX.LOG has no \"$keyboardLog\" (the keyboard joystick): $(dvxDvxLog "$work/none/c" 400 | grep 'Joystick:' | tr '\n' ' ')"
-check none "no joystick" ctrl+F5 "A=$absent B=$absent"
-keys down Left space
-sleep "$pressSeconds"
-check none "no joystick, keyboard joystick held" ctrl+F5 "A=$absent B=$absent"
-keys up Left space
+dvxDvxLog "$JOY_WORK/none/c" 400 | grep -q "$keyboardLog" || joyFail "DVX.LOG has no \"$keyboardLog\" (the keyboard joystick): $(dvxDvxLog "$JOY_WORK/none/c" 400 | grep 'Joystick:' | tr '\n' ' ')"
+joyCheck "no joystick" "A=$JOY_ABSENT B=$JOY_ABSENT"
+keyboardJoystick keydown
+sleep "$JOY_PRESS_SECONDS"
+joyCheck "no joystick, keyboard joystick held" "A=$JOY_ABSENT B=$JOY_ABSENT"
+keyboardJoystick keyup
 stop
 
-if [ -n "${DVX_JOY_SHOTS:-}" ]; then
-    mkdir -p "$DVX_JOY_SHOTS"
-    cp "$work"/sticks/*.png "$work"/none/*.png "$DVX_JOY_SHOTS/"
-fi
-if [ -n "$fails" ]; then
-    echo "verify-dvx-joy: FAIL - $fails"
-    exit 1
-fi
-echo "verify-dvx-joy: PASS ($checked screenshots: both sticks, every direction, both buttons and every release; no joystick without one, the keyboard joystick left out)"
+joyResult "$JOY_LABEL" "both sticks, every direction, both buttons and every release; no joystick without one, the keyboard joystick left out"
diff --git a/scripts/verify-dvx-serial.sh b/scripts/verify-dvx-serial.sh
index d53abc4..d9dde52 100755
--- a/scripts/verify-dvx-serial.sh
+++ b/scripts/verify-dvx-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-serial.sh - both-directions RS-232 gate for the DVX port.
 #
 # Runs SERTEST (examples/sertest: banner, then echo) as the desktop of a
diff --git a/scripts/verify-dvx-tilebank.sh b/scripts/verify-dvx-tilebank.sh
index 662ad58..f3e508a 100755
--- a/scripts/verify-dvx-tilebank.sh
+++ b/scripts/verify-dvx-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-dvx-tilebank.sh - Tile-bank gate for DVX: proves the DVX port loads
 # the DOS bake (chunky 4bpp, high nibble = left pixel -- DVX shows the DOS
 # stage and takes the DOS target byte) through the run-time DATA root beside
diff --git a/scripts/verify-iigs-all.sh b/scripts/verify-iigs-all.sh
index 3510fda..45f6f47 100755
--- a/scripts/verify-iigs-all.sh
+++ b/scripts/verify-iigs-all.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-all.sh - Boot-verify EVERY launchable example on the built
 # joey.2mg (the S16 apps cadius packed onto the volume), each via verify-iigs.sh
 # with its per-example distinct-color threshold. Reports one line per example
diff --git a/scripts/verify-iigs-audio.sh b/scripts/verify-iigs-audio.sh
index b4508f6..b028e7a 100755
--- a/scripts/verify-iigs-audio.sh
+++ b/scripts/verify-iigs-audio.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-audio.sh - Headless gate proving NTP is actually running on
 # the IIgs, modelled on verify-iigs.sh (same Finder launch timeline, same
 # "read emulated memory, not a snapshot" approach).
diff --git a/scripts/verify-iigs-game-disk.sh b/scripts/verify-iigs-game-disk.sh
index 499d092..391a29a 100755
--- a/scripts/verify-iigs-game-disk.sh
+++ b/scripts/verify-iigs-game-disk.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-game-disk.sh - Boot a disk made by make-iigs-game-disk.sh in
 # headless MAME and assert it reaches the program and draws.
 #
diff --git a/scripts/verify-iigs-input.sh b/scripts/verify-iigs-input.sh
index 4c5496b..c81152f 100755
--- a/scripts/verify-iigs-input.sh
+++ b/scripts/verify-iigs-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-input.sh - Headless typed-character acceptance gate for the
 # IIgs port. Boots GS/OS under MAME, launches the KEYS example off
 # joey.2mg, types the RetroNet definition-of-done string on the emulated
diff --git a/scripts/verify-iigs-joy.sh b/scripts/verify-iigs-joy.sh
new file mode 100755
index 0000000..cce4571
--- /dev/null
+++ b/scripts/verify-iigs-joy.sh
@@ -0,0 +1,237 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-iigs-joy.sh - Joystick gate for the IIgs: the stick on the game
+# port, every direction, both buttons and every release, driven through
+# MAME's joystick and read back from what examples/joy draws.
+#
+# The port (src/iigs/input.c) reads paddles 0 and 1 (the asm in
+# joeyDraw.s) and the buttons at $C061/$C062 as JOYSTICK_0 (stick A); until
+# a program calls jlJoystickReset a paddle reads as a digital direction.
+# The IIgs exposes one stick: B must read unplugged throughout. MAME's
+# joystick (-gameio joy) is held from Lua at each step's paddle positions
+# (0 and 255 full, 128 at rest) and buttons, and a snapshot taken once JOY
+# has drawn it; the snapshots, lines doubled (MAME's IIgs frame has
+# double-width pixels), then go through scripts/lib/joyScreen.py
+# (scripts/lib/joyGate.sh) in order. Escape must then end JOY.
+#
+# The program boots from a GS/OS floppy whose launcher starts it, with its
+# data disk beside it (scripts/make-iigs-boot-disk.sh, make-iigs-disk.sh),
+# as joey-run boots it.
+#
+# Usage:   scripts/verify-iigs-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: MAME, the IIgs firmware and GS/OS, the IIgs toolchain (the disk
+#           scripts), ImageMagick, python3 + PIL; build/iigs/bin/JOY
+#           (make iigs-disk).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+joeyNeed MAME mame
+joeyNeed IIGS_ROMPATH iigsFirmware
+joeyNeed GSOS_SYSTEM_DISK iigsFirmware
+joeyNeed CADIUS cadius
+
+JOY_LABEL=verify-iigs-joy
+JOY_REST_B="$JOY_ABSENT"
+# The checks read snapshots MAME has already taken: no waiting, and none
+# taken again.
+JOY_PRESS_SECONDS=0
+JOY_SHOT_TRIES=1
+program="$repo/build/iigs/bin/JOY"
+wall=300            # hard cap on MAME, whatever happens to this script
+readySeconds=80     # emulated: GS/OS booting and JOY drawing
+settleSeconds=0.5   # emulated: a change until JOY has drawn it
+exitSeconds=3       # emulated: Escape until JOY's screen is gone
+paddleLow=0
+paddleRest=128
+paddleHigh=255
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY first (make iigs-disk)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-iigs-joy.XXXXXX)
+mame=""
+cleanup() {
+    joeyStop "$mame" 2> /dev/null
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+# The steps checked: stick A's, B left alone.
+steps=("${JOY_STEPS_TWO_STICKS[@]:0:9}")
+
+
+# state CONTROL... -- the Lua table of paddle X, paddle Y, button 0 and
+# button 1 with the controls held.
+state() {
+    local x=$paddleRest
+    local y=$paddleRest
+    local b0=0
+    local b1=0
+    local control
+    for control in "$@"; do
+        case "$control" in
+            A-left)    x=$paddleLow ;;
+            A-right)   x=$paddleHigh ;;
+            A-up)      y=$paddleLow ;;
+            A-down)    y=$paddleHigh ;;
+            A-button0) b0=1 ;;
+            A-button1) b1=1 ;;
+            *)         joyFail "no control $control on the IIgs" ;;
+        esac
+    done
+    printf '{%s, %s, %s, %s}' "$x" "$y" "$b0" "$b1"
+}
+
+
+# The snapshots, in the order the checks take them: rest, then for each
+# step held and released.
+shotIndex=0
+joyPress() {
+    :
+}
+
+
+joyRelease() {
+    :
+}
+
+
+joyShot() {
+    local shot
+    shot=$(ls "$JOY_WORK"/snap/*.png 2> /dev/null | sort | sed -n "$((shotIndex + 1))p")
+    shotIndex=$((shotIndex + 1))
+    [ -n "$shot" ] || return 1
+    convert "$shot" -sample 100%x200% "$1"
+}
+
+
+states="$(state)"
+for step in "${steps[@]}"; do
+    IFS='|' read -r _ held _ _ <<< "$step"
+    read -r -a controls <<< "$held"
+    states="$states, $(state "${controls[@]}"), $(state)"
+done
+
+mkdir -p "$JOY_WORK/cfg" "$JOY_WORK/nvram" "$JOY_WORK/snap"
+BINDIR="$(dirname "$program")" JOEY_DISK_EXAMPLES="$(basename "$program")" \
+    "$repo/scripts/make-iigs-disk.sh" "$JOY_WORK/data.2mg" > "$JOY_WORK/disk.log" 2>&1 ||
+    { echo "$JOY_LABEL: FAIL - could not build the data disk" >&2; tail -5 "$JOY_WORK/disk.log" >&2; exit 1; }
+"$repo/scripts/make-iigs-boot-disk.sh" "$JOY_WORK/boot.po" "$(basename "$program")" > "$JOY_WORK/boot.log" 2>&1 ||
+    { echo "$JOY_LABEL: FAIL - could not build the boot disk" >&2; tail -5 "$JOY_WORK/boot.log" >&2; exit 1; }
+
+cat > "$JOY_WORK/joy.lua" << LUA
+local states = { $states }
+local readyAt = $readySeconds
+local settle = $settleSeconds
+local exitAfter = $exitSeconds
+
+-- A field of an ioport whose name contains pattern, or nil.
+local function field(tag, pattern)
+    local p = manager.machine.ioport.ports[tag]
+    if not p then return nil end
+    for name, f in pairs(p.fields) do
+        if name:find(pattern, 1, true) then return f end
+    end
+    return nil
+end
+
+-- The field of an ioport that drives mask, or nil.
+local function maskField(tag, mask)
+    local p = manager.machine.ioport.ports[tag]
+    if not p then return nil end
+    for _, f in pairs(p.fields) do
+        if f.mask == mask then return f end
+    end
+    return nil
+end
+
+-- A field named exactly name on any port whose tag starts with prefix.
+local function key(prefix, name)
+    for tag, p in pairs(manager.machine.ioport.ports) do
+        if tag:sub(1, #prefix) == prefix and p.fields[name] then return p.fields[name] end
+    end
+    return nil
+end
+
+local fx = field(":gameio:joy:joystick_1_x", "")
+local fy = field(":gameio:joy:joystick_1_y", "")
+-- The game port's switches SW0 and SW1: bits 4 and 5 of MAME's button
+-- port (its player numbers do not follow the switches).
+local fb0 = maskField(":gameio:joy:joystick_buttons", 0x10)
+local fb1 = maskField(":gameio:joy:joystick_buttons", 0x20)
+local esc = key(":macadb:", "Esc")
+if not (fx and fy and fb0 and fb1 and esc) then
+    for tag, p in pairs(manager.machine.ioport.ports) do
+        for name, f in pairs(p.fields) do
+            if tag:find(":gameio:", 1, true) or name:lower():find("esc", 1, true) then
+                print(string.format("JOYGATE field %s %s mask %x", tag, name, f.mask))
+            end
+        end
+    end
+    print("JOYGATE: MAME's joystick or Escape key not found")
+    manager.machine:exit()
+    return
+end
+
+local i = 0
+local nextAt = readyAt
+local phase = "apply"
+local escAt = nil
+emu.register_frame_done(function()
+    local t = manager.machine.time:as_double()
+    if t < nextAt then return end
+    if phase == "apply" then
+        i = i + 1
+        if i > #states then
+            esc:set_value(1)
+            escAt = t
+            phase = "escape"
+            nextAt = t + 0.5
+            return
+        end
+        local s = states[i]
+        fx:set_value(s[1]); fy:set_value(s[2]); fb0:set_value(s[3]); fb1:set_value(s[4])
+        phase = "shoot"
+        nextAt = t + settle
+    elseif phase == "shoot" then
+        manager.machine.video:snapshot()
+        phase = "apply"
+        nextAt = t
+    elseif phase == "escape" then
+        esc:set_value(0)
+        phase = "after"
+        nextAt = escAt + exitAfter
+    elseif phase == "after" then
+        manager.machine.video:snapshot()
+        manager.machine:exit()
+    end
+end)
+LUA
+
+(cd "$JOY_WORK" && exec env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy timeout -s KILL "$wall" "$MAME" apple2gs \
+    -rompath "$IIGS_ROMPATH" -flop3 "$JOY_WORK/boot.po" -flop4 "$JOY_WORK/data.2mg" -gameio joy \
+    -skip_gameinfo -cfg_directory "$JOY_WORK/cfg" -nvram_directory "$JOY_WORK/nvram" \
+    -snapshot_directory "$JOY_WORK/snap" -snapname "shot%i" \
+    -video none -sound none -nothrottle -autoboot_script "$JOY_WORK/joy.lua") > "$JOY_WORK/mame.log" 2>&1 &
+mame=$!
+wait "$mame"
+mame=""
+if grep -q '^JOYGATE' "$JOY_WORK/mame.log"; then
+    echo "$JOY_LABEL: FAIL - $(grep '^JOYGATE:' "$JOY_WORK/mame.log")" >&2
+    grep '^JOYGATE field' "$JOY_WORK/mame.log" >&2
+    exit 1
+fi
+
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST_B"
+joyRunSteps "${steps[@]}"
+# The last snapshot, after Escape: JOY gone.
+if joyShot "$JOY_WORK/after.png" && python3 "$repo/scripts/lib/joyScreen.py" "$JOY_WORK/after.png" > /dev/null 2>&1; then
+    joyFail "Escape did not end JOY"
+fi
+
+joyResult "$JOY_LABEL" "the game port's stick, every direction, both buttons and every release, B unplugged; Escape ends JOY"
diff --git a/scripts/verify-iigs-save.sh b/scripts/verify-iigs-save.sh
index ac62f01..89196f8 100755
--- a/scripts/verify-iigs-save.sh
+++ b/scripts/verify-iigs-save.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-save.sh - Runtime gate for the IIgs save-file HAL's GS/OS calls
 # (Create $2001 for the SAVES/ directory, Volume $2008 for jlDiskFree). Boots
 # GS/OS under MAME, launches the SAVE example off a freshly built JOEYLIB disk,
diff --git a/scripts/verify-iigs-serial-loss.sh b/scripts/verify-iigs-serial-loss.sh
index 069c29b..b03edd8 100755
--- a/scripts/verify-iigs-serial-loss.sh
+++ b/scripts/verify-iigs-serial-loss.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-serial-loss.sh - Receive-loss gate for the Apple IIgs serial add-on.
 #
 # SERLOSS (examples/serloss/serloss.c) fills and presents the whole screen in a
diff --git a/scripts/verify-iigs-serial.sh b/scripts/verify-iigs-serial.sh
index 945e412..ad86f6b 100755
--- a/scripts/verify-iigs-serial.sh
+++ b/scripts/verify-iigs-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-serial.sh - Cross-channel SCC gate for the Apple IIgs port.
 #
 # The 8530 carries both built-in ports on one chip: channel A is the printer
diff --git a/scripts/verify-iigs-tilebank.sh b/scripts/verify-iigs-tilebank.sh
index 001a0a2..f105992 100755
--- a/scripts/verify-iigs-tilebank.sh
+++ b/scripts/verify-iigs-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs-tilebank.sh - Tile-bank gate for the Apple IIgs: proves that
 # assetbake's iigs encoder (chunky 4bpp, high nibble = left pixel) writes
 # exactly the layout the IIgs tile paste reads, and that the IIgs build
diff --git a/scripts/verify-iigs.sh b/scripts/verify-iigs.sh
index 5a77c93..8510f83 100755
--- a/scripts/verify-iigs.sh
+++ b/scripts/verify-iigs.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-iigs.sh - Headless, deterministic visual-verification gate for the
 # IIgs port. Boots GS/OS under MAME, drives the Finder to launch one example
 # off the JOEYLIB data disk, then reads the Super Hi-Res framebuffer directly
diff --git a/scripts/verify-psp-audio.sh b/scripts/verify-psp-audio.sh
new file mode 100755
index 0000000..a3d9aa0
--- /dev/null
+++ b/scripts/verify-psp-audio.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-psp-audio.sh - PSP audio gate, graded on the sound itself.
+#
+# The PSP port mixes everything in software (src/hosted/audio.c, the sdl3
+# port's mixer) and plays the mix through SDL3's PSP audio driver, which
+# hands it to the PSP's audio channels. PPSSPPHeadless (scripts/lib/pspGate.sh)
+# records what reaches them (--dump-audio: PPSSPP's own recording, a 44.1 kHz
+# stereo WAV on the emulated clock) while AUDIOPRB runs from its folder on a
+# private memory stick, with every memory access checked as a PSP checks it.
+#
+# AUDIOPRB (make/examples.mk) is examples/audio pressing its own keys: the
+# PSP has none to type with. An --input script presses Select at
+# QUIT_MS, which the port gives as Escape: AUDIOPRB must quit on it, which
+# also ends the run and the recording. scripts/lib/pspAudioGrade.py grades
+# the recording: the tracker song alone at the start, three streamed takes
+# loud and each at its own pitch (so the PSP plays the mix at the rate it
+# was made for), and the end at Select.
+#
+# Usage:   scripts/verify-psp-audio.sh [program folder]
+#            Default build/psp/probe/AUDIOPRB (make -f make/psp.mk
+#            psp-verify-audio builds it and runs this).
+# Requires: PPSSPPHeadless, python3; AUDIOPRB built for the PSP.
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/pspGate.sh"
+pspNeed
+label=verify-psp-audio
+
+dir=${1:-$repo/build/psp/probe/AUDIOPRB}
+[ -f "$dir/EBOOT.PBP" ] || { echo "$label: build AUDIOPRB first (make -f make/psp.mk psp-verify-audio)" >&2; exit 2; }
+# Real seconds the run may take, and when (emulated milliseconds since
+# boot, the recording's start) Select is pressed: past the third take.
+runSeconds=60
+quitMs=6000
+msPerSecond=1000
+
+work=$(mktemp -d -t joeylib-psp-audio.XXXXXX)
+cleanup() {
+    pspStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+printf '%d buttons select\n' "$quitMs" > "$work/input.txt"
+folder=$(pspStage "$work" "$dir")
+pspRun "$work" "$folder" "$runSeconds" --dump-audio --input="$work/input.txt"
+pspWait
+pspStrictCheck "$work" "$dir" || { echo "$label: FAIL - a bad memory access" >&2; exit 1; }
+if grep -q '^TIMEOUT' "$work/ppsspp.log"; then
+    echo "$label: FAIL - AUDIOPRB did not quit on Select (Escape)" >&2
+    exit 1
+fi
+wav=$(find "$work/.ppsspp/PSP/AUDIO" -name '*.wav' -print -quit 2> /dev/null)
+[ -n "$wav" ] || { echo "$label: FAIL - PPSSPP recorded nothing" >&2; exit 1; }
+python3 "$repo/scripts/lib/pspAudioGrade.py" "$label" "$wav" "$(awk -v ms="$quitMs" -v k="$msPerSecond" 'BEGIN { print ms / k }')"
diff --git a/scripts/verify-psp-input.sh b/scripts/verify-psp-input.sh
new file mode 100755
index 0000000..5cc753b
--- /dev/null
+++ b/scripts/verify-psp-input.sh
@@ -0,0 +1,174 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-psp-input.sh - Input gate for the PSP: the PSP's own controls reach
+# JoeyLib, pressed through PPSSPPHeadless's input script and read back from
+# what the examples draw.
+#
+# The PSP has no keyboard and one set of controls: SDL3's PSP joystick, which
+# the SDL3 backend opens as gamepad 0 (JOYSTICK_0: the d-pad or the analog
+# nub, cross as button 0, circle as button 1), with Start as Return and
+# Select as Escape. Two runs under PPSSPPHeadless (scripts/lib/pspGate.sh),
+# each from its folder on a private memory stick with every memory access
+# checked as a PSP checks it, each driven by an --input script on PPSSPP's
+# emulated clock (installer/patches/ppsspp):
+#
+#   JOY   (examples/joy) each d-pad direction, two diagonals, each button
+#         and both together, the nub left and up, with a release after
+#         every press so a latched line shows up; after each, a snapshot of
+#         the PSP's screen, whose stage scripts/lib/joyScreen.py reads both
+#         sticks off (stick B must stay unplugged). Then Select: JOY must
+#         end by itself, as it does on Escape.
+#   KEYS  (examples/keys) Start: the Return key's cell lights, and goes out
+#         again on release, and Start has typed one Return, read off KEYS's
+#         row of the characters it received (scripts/lib/keysRow.py). Then
+#         Select: KEYS must end by itself.
+#
+# Usage:   scripts/verify-psp-input.sh
+# Requires: PPSSPPHeadless, python3 with PIL; build/psp/bin/JOY and
+#           build/psp/bin/KEYS (make psp).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/pspGate.sh"
+pspNeed
+label=verify-psp-input
+
+joyDir=$repo/build/psp/bin/JOY
+keysDir=$repo/build/psp/bin/KEYS
+for dir in "$joyDir" "$keysDir"; do
+    [ -f "$dir/EBOOT.PBP" ] || { echo "$label: build $(basename "$dir") first (make psp)" >&2; exit 2; }
+done
+python3 -c "import PIL" 2> /dev/null || { echo "$label: python3 has no PIL" >&2; exit 2; }
+
+# Real seconds a run may take: the script is emulated time, which passes
+# much faster than real time while the program waits.
+runSeconds=60
+# Emulated milliseconds: the first step (the program is up by then), and
+# from each press to its snapshot and on to the next press.
+startMs=1500
+holdMs=400
+stepMs=600
+# KEYS's Return cell (examples/keys: column 2, row 4 of 28x26 cells on a
+# 32x30 pitch from (2, 2)), at its centre, on the PSP's screen, where the
+# backend centres the 320x200 stage at 1:1 in 480x272.
+screenSize=480x272
+stageX=80
+stageY=36
+returnX=$((stageX + 2 + 2 * 32 + 14))
+returnY=$((stageY + 2 + 4 * 30 + 13))
+returnLit="0 255 0"
+returnUnlit="51 51 51"
+# KEYS's row of every character it received (stage row 198), which must
+# hold the one Return that Start typed.
+typedRow=$((stageY + 198))
+typedWant=$'\r'
+
+rest=10000
+unplugged=00000
+# name, input script action, stick A's state (joyScreen.py's characters:
+# connected, X, Y, button 0, button 1).
+joySteps=(
+    "rest|buttons none|$rest"
+    "left|buttons left|1-000"       "release|buttons none|$rest"
+    "right|buttons right|1+000"     "release|buttons none|$rest"
+    "up|buttons up|10-00"           "release|buttons none|$rest"
+    "down|buttons down|10+00"       "release|buttons none|$rest"
+    "up-left|buttons up left|1--00" "release|buttons none|$rest"
+    "down-right|buttons down right|1++00" "release|buttons none|$rest"
+    "cross|buttons cross|10010"     "release|buttons none|$rest"
+    "circle|buttons circle|10001"   "release|buttons none|$rest"
+    "cross+circle|buttons cross circle|10011" "release|buttons none|$rest"
+    "nub left|analog -1 0|1-000"    "nub centred|analog 0 0|$rest"
+    "nub up|analog 0 1|10-00"       "nub centred|analog 0 0|$rest"
+)
+
+work=$(mktemp -d -t joeylib-psp-input.XXXXXX)
+fails=""
+cleanup() {
+    pspStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+
+# fail WHAT -- note a failed check; the gate goes on to report the rest.
+fail() {
+    echo "$label: FAIL - $1"
+    fails="${fails:+$fails; }$1"
+}
+
+
+# runScript DIR NAME -- run the program folder DIR from $work/NAME with
+# $work/NAME/input.txt; fails unless it ended by itself, cleanly.
+runScript() {
+    local run="$work/$2"
+    local folder
+    folder=$(pspStage "$run" "$1")
+    pspRun "$run" "$folder" "$runSeconds" --input="$run/input.txt"
+    pspWait
+    pspStrictCheck "$run" "$1" || fail "$2: a bad memory access"
+    if grep -q '^TIMEOUT' "$run/ppsspp.log"; then
+        fail "$2: did not end on Select (Escape)"
+    fi
+}
+
+
+# JOY: each step's action, then its snapshot holdMs later; Select last.
+mkdir -p "$work/joy/shots"
+at=$startMs
+i=0
+for step in "${joySteps[@]}"; do
+    IFS='|' read -r name action want <<< "$step"
+    printf '%d %s\n%d snapshot %s/joy/shots/%02d.png\n' "$at" "$action" "$((at + holdMs))" "$work" "$i" >> "$work/joy/input.txt"
+    at=$((at + stepMs))
+    i=$((i + 1))
+done
+printf '%d buttons select\n' "$at" >> "$work/joy/input.txt"
+runScript "$joyDir" joy
+i=0
+for step in "${joySteps[@]}"; do
+    IFS='|' read -r name action want <<< "$step"
+    png=$(printf '%s/joy/shots/%02d.png' "$work" "$i")
+    i=$((i + 1))
+    if [ ! -f "$png" ]; then
+        fail "JOY $name: no snapshot"
+        continue
+    fi
+    got=$(python3 "$repo/scripts/lib/joyScreen.py" "$png" --scale 1)
+    echo "$label: JOY $name: $got"
+    [ "${got% scale=*}" = "A=$want B=$unplugged" ] || fail "JOY $name: read ${got% scale=*}, want A=$want B=$unplugged"
+done
+
+# KEYS: Start held, then let go, then Select.
+mkdir -p "$work/keys/shots"
+{
+    printf '%d buttons start\n' "$startMs"
+    printf '%d snapshot %s/keys/shots/start.png\n' "$((startMs + holdMs))" "$work"
+    printf '%d buttons none\n' "$((startMs + stepMs))"
+    printf '%d snapshot %s/keys/shots/release.png\n' "$((startMs + stepMs + holdMs))" "$work"
+    printf '%d buttons select\n' "$((startMs + 2 * stepMs))"
+} > "$work/keys/input.txt"
+runScript "$keysDir" keys
+for shot in "start|$returnLit" "release|$returnUnlit"; do
+    IFS='|' read -r name want <<< "$shot"
+    png=$work/keys/shots/$name.png
+    if [ ! -f "$png" ]; then
+        fail "KEYS $name: no snapshot"
+        continue
+    fi
+    got=$(python3 -c "from PIL import Image; import sys; print(*Image.open(sys.argv[1]).convert('RGB').getpixel((int(sys.argv[2]), int(sys.argv[3]))))" "$png" "$returnX" "$returnY")
+    echo "$label: KEYS $name: Return cell $got"
+    [ "$got" = "$want" ] || fail "KEYS $name: the Return cell is $got, want $want"
+    typed=$(python3 "$repo/scripts/lib/keysRow.py" "$png" "$typedWant" --size "$screenSize" --at "$stageX,$typedRow" --scale 1) || fail "KEYS $name: Start did not type one Return"
+    echo "$label: KEYS $name: ${typed//$'\n'/; }"
+done
+
+if [ -n "$fails" ]; then
+    echo "$label: FAIL - $fails" >&2
+    exit 1
+fi
+echo "$label: PASS (JOY: ${#joySteps[@]} d-pad, button and nub states read back from the screen, stick B unplugged; Start held KEYS's Return and typed one; Select ended both)"
diff --git a/scripts/verify-psp-save.sh b/scripts/verify-psp-save.sh
new file mode 100755
index 0000000..70f33c6
--- /dev/null
+++ b/scripts/verify-psp-save.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-psp-save.sh - Save gate for the PSP port: a save outlives the
+# program, beside it on the memory stick.
+#
+# SAVE (examples/save) counts its launches in SAVES/runs.dat. It is run twice
+# under PPSSPPHeadless (scripts/lib/pspGate.sh) from the same folder on a
+# private memory stick, PSP/GAME/SAVE, with every memory access checked as a
+# PSP checks it; the PSP starts a program with its own folder as the current
+# directory, so SAVES/ must appear there, and after the second run the count
+# must be 2.
+#
+# Usage:   scripts/verify-psp-save.sh
+# Requires: PPSSPPHeadless; build/psp/bin/SAVE (make psp).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/pspGate.sh"
+pspNeed
+
+program=$(joeyExampleProgram save psp)
+[ -f "$program" ] || { echo "verify-psp-save: build SAVE first (make psp)" >&2; exit 2; }
+dir=$(dirname "$program")
+# Real seconds a run is given: SAVE saves at once, then waits for Escape.
+runSeconds=3
+
+work=$(mktemp -d -t joeylib-psp-save.XXXXXX)
+cleanup() {
+    pspStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+folder=$(pspStage "$work" "$dir")
+for run in 1 2; do
+    pspRun "$work" "$folder" "$runSeconds"
+    pspWait
+    pspStrictCheck "$work" "$dir" || { echo "verify-psp-save: FAIL - a bad memory access in run $run" >&2; exit 1; }
+done
+[ -f "$folder/SAVES/runs.dat" ] || { echo "verify-psp-save: FAIL - no SAVES/runs.dat beside the program" >&2; exit 1; }
+count=$(python3 -c "import struct, sys; print(struct.unpack('&2
+    exit 1
+fi
diff --git a/scripts/verify-psp-tilebank.sh b/scripts/verify-psp-tilebank.sh
new file mode 100755
index 0000000..3c58f8d
--- /dev/null
+++ b/scripts/verify-psp-tilebank.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-psp-tilebank.sh - Tile-bank gate for the PSP: proves the PSP build
+# loads the DOS bake (its stage is the same chunky 4bpp) exactly and refuses
+# a bank baked for any other target.
+#
+# Runs TILEBANK (examples/tilebank) under PPSSPPHeadless
+# (scripts/lib/pspGate.sh) from its folder on a private memory stick, with
+# the DATA/tilebank make/psp.mk staged from the host gate's fixture beside it
+# and every memory access checked as a PSP checks it. Its TILEBANK line in
+# joeylog.txt must say PASS with both hashes equal to the host gate's and
+# every other target refused (scripts/lib/tileBankGate.sh).
+#
+# Usage:   scripts/verify-psp-tilebank.sh
+# Env:     TILEBANK_DATA_DIR  stage this DATA/tilebank instead (negative controls)
+# Requires: a host cc, PPSSPPHeadless; build/psp/bin/TILEBANK (make psp).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/tileBankGate.sh"
+source "$repo/scripts/lib/pspGate.sh"
+pspNeed
+
+dir=$repo/build/psp/bin/TILEBANK
+dataDir=$(tileBankData "$dir")
+lineSeconds=60
+
+tileBankNeedBuild "$dir/EBOOT.PBP" "$dataDir" "make psp"
+tileBankReference
+
+work=$(mktemp -d -t joeylib-psp-tilebank.XXXXXX)
+cleanup() {
+    pspStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/program/DATA"
+cp "$dir/EBOOT.PBP" "$work/program/"
+cp -r "$dataDir" "$work/program/DATA/tilebank"
+folder=$(pspStage "$work" "$work/program")
+pspRun "$work" "$folder" "$lineSeconds"
+
+log=$(tileBankAwaitLine "$folder" "$lineSeconds" "$PSP_PID")
+pspStop
+pspStrictCheck "$work" "$dir" || { echo "verify-psp-tilebank: FAIL - a bad memory access" >&2; exit 1; }
+if [ -z "$log" ]; then
+    echo "verify-psp-tilebank: FAIL - no TILEBANK line within ${lineSeconds}s" >&2
+    tail -5 "$work/ppsspp.log" >&2
+    exit 1
+fi
+tileBankJudge "$log"
diff --git a/scripts/verify-sdl3-audio.sh b/scripts/verify-sdl3-audio.sh
new file mode 100755
index 0000000..ef213fe
--- /dev/null
+++ b/scripts/verify-sdl3-audio.sh
@@ -0,0 +1,127 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-sdl3-audio.sh - SDL3 audio gate, graded on the sound itself.
+#
+# The SDL3 port mixes everything in software (src/hosted/audio.c), so the
+# gate records the mix exactly as it leaves the program: SDL's disk audio
+# driver writes it to a file at the real-time rate (signed 16-bit stereo at
+# 44.1 kHz, pinned by SDL_AUDIO_FORMAT/CHANNELS/FREQUENCY), with no sound
+# server involved. tools/gradeAudioPhases.py grades it, as the Amiga and DOS
+# gates grade their recordings.
+#
+# The AUDIO example runs on a private Xvfb display (keys through XTEST,
+# HELD for 0.4 s: the demo tests jlKeyPressed, an edge predicate, once a
+# frame) through the DOS gate's four phases and two more:
+#
+#   music     the baked tracker song on entry (the square-wave voices), loud
+#   paused    P: near silence
+#   sfx       SPACE during the pause, so any sound is the digital SFX
+#   resumed   P again: loud again
+#   noise     P, then N: the noise voice swept through its 32 pitches alone
+#   mod       M: the .MOD through libxmp-lite
+#
+# The file starts when the program opens its audio, a fraction of a second
+# after launch, so each phase is graded inside its window with margin.
+#
+# Usage:   scripts/verify-sdl3-audio.sh
+# Env:     SDL3_AUDIO_KEEP  set to keep the recording as build/sdl3/headless-audio.wav
+# Requires: Xvfb, xdotool, python3; build/sdl3/bin/AUDIO (make sdl3).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+grader=$repo/tools/gradeAudioPhases.py
+label=verify-sdl3-audio
+
+program=$(joeyExampleProgram audio sdl3)
+[ -x "$program" ] || { echo "$label: build AUDIO first (make sdl3)" >&2; exit 2; }
+for tool in Xvfb xdotool python3; do
+    command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
+done
+
+work=$(mktemp -d -t joeylib-sdl3-audio.XXXXXX)
+displayNum=$((RANDOM % 500 + 300))
+display=":$displayNum"
+cleanup() {
+    joeyStop "${audioPid:-}"
+    [ -n "${xvfbPid:-}" ] && kill "$xvfbPid" 2>/dev/null
+    rm -rf "$work"
+    return 0
+}
+trap cleanup EXIT
+
+Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 &
+xvfbPid=$!
+sleep 1
+
+DISPLAY="$display" SDL_VIDEO_DRIVER=x11 SDL_AUDIO_DRIVER=disk SDL_AUDIO_DISK_OUTPUT_FILE="$work/out.raw" \
+    SDL_AUDIO_FORMAT=S16LE SDL_AUDIO_CHANNELS=2 SDL_AUDIO_FREQUENCY=44100 \
+    "$program" > "$work/audio.log" 2>&1 &
+audioPid=$!
+sleep 1
+
+win=$(joeyFindWindow "$display" "$audioPid") || true
+if [ -z "$win" ]; then
+    echo "$label: FAIL - no AUDIO window to send keys to" >&2
+    exit 1
+fi
+DISPLAY="$display" xdotool windowfocus --sync "$win"
+
+hold() {                                        # $1 = key name
+    DISPLAY="$display" xdotool keydown "$1"
+    sleep 0.4
+    DISPLAY="$display" xdotool keyup "$1"
+}
+
+# The phase windows below are graded against these sleeps (seconds since
+# launch); keep them in step.
+sleep 5                                         # 0-6s   music
+hold p                                          # ->     paused
+sleep 8                                         # 6-14s  paused
+for _ in 1 2 3; do                              # 14-24s sfx into silence
+    hold space
+    sleep 1.5
+done
+sleep 4
+hold p                                          # ->     resumed
+sleep 5.6                                       # 24-30s resumed
+hold p                                          # ->     paused again
+sleep 0.6
+hold n                                          # 31-35s noise sweep alone
+sleep 4.6
+hold m                                          # 36-44s the MOD
+sleep 7.6
+hold Escape
+sleep 1
+
+[ -s "$work/out.raw" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; }
+python3 - "$work/out.raw" "$work/out.wav" <<'PY'
+import sys
+import wave
+
+raw = open(sys.argv[1], 'rb').read()
+with wave.open(sys.argv[2], 'wb') as w:
+    w.setnchannels(2)
+    w.setsampwidth(2)
+    w.setframerate(44100)
+    w.writeframes(raw)
+PY
+
+# Thresholds sit at about half the measured levels (music ~3070, paused 0,
+# sfx ~1240, noise ~1630, mod ~7980; the grader averages the two channels)
+# so the verdict has room either way.
+python3 "$grader" "$label" "$work/out.wav" \
+    "music:1:5:1500:" \
+    "paused:8:13::100" \
+    "sfx:15:22:600:" \
+    "resumed:25:29:1500:" \
+    "noise:31.5:34:800:" \
+    "mod:37:43:4000:"
+status=$?
+if [ -n "${SDL3_AUDIO_KEEP:-}" ]; then
+    cp "$work/out.wav" "$repo/build/sdl3/headless-audio.wav"
+fi
+exit $status
diff --git a/scripts/verify-sdl3-input.sh b/scripts/verify-sdl3-input.sh
new file mode 100755
index 0000000..286626e
--- /dev/null
+++ b/scripts/verify-sdl3-input.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-sdl3-input.sh - Headless typed-character acceptance gate for the
+# SDL3 port. Runs KEYS on a private Xvfb display through SDL's X11 driver at
+# one window pixel per stage pixel (JOEYLIB_SCALE=1, JOEYLIB_ASPECT=square),
+# types the RetroNet definition-of-done string with xdotool (real X key
+# events through XTEST, shift and all -- SDL ignores the XSendEvent ones
+# `xdotool type --window` sends), grabs the window with ImageMagick, and
+# decodes the example's verification scanline (row 198) with
+# scripts/lib/keysRow.py, as the DVX gates do.
+#
+# Usage:   scripts/verify-sdl3-input.sh
+# Requires: Xvfb, xdotool, ImageMagick, python3 + PIL; build/sdl3/bin/KEYS
+#           (make sdl3).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+
+target="192.168.1.10:6510"
+program=$(joeyExampleProgram keys sdl3)
+[ -x "$program" ] || { echo "verify-sdl3-input: build KEYS first (make sdl3)" >&2; exit 2; }
+
+work=$(mktemp -d -t joeylib-sdl3-input.XXXXXX)
+displayNum=$((RANDOM % 500 + 300))
+cleanup() {
+    joeyStop "${keysPid:-}"
+    [ -n "${xvfbPid:-}" ] && kill "$xvfbPid" 2>/dev/null
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+Xvfb ":$displayNum" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 &
+xvfbPid=$!
+sleep 1
+
+DISPLAY=":$displayNum" SDL_VIDEO_DRIVER=x11 SDL_AUDIO_DRIVER=dummy JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square \
+    "$program" >"$work/keys.log" 2>&1 &
+keysPid=$!
+sleep 3
+
+win=$(joeyFindWindow ":$displayNum" "$keysPid") || true
+if [ -z "$win" ]; then
+    echo "verify-sdl3-input: FAIL - KEYS window not found" >&2
+    exit 1
+fi
+# No window manager: give the window the keyboard directly.
+DISPLAY=":$displayNum" xdotool windowfocus --sync "$win"
+DISPLAY=":$displayNum" xdotool type --delay 150 -- "$target"
+sleep 2
+DISPLAY=":$displayNum" import -window "$win" "$work/keys.png"
+
+# The stage at one pixel a pixel: the row must be exactly at (0,198).
+if python3 "$repo/scripts/lib/keysRow.py" "$work/keys.png" "$target" --size 320x200 --at 0,198 --scale 1; then
+    echo "verify-sdl3-input: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)"
+else
+    echo "verify-sdl3-input: FAIL" >&2
+    exit 1
+fi
diff --git a/scripts/verify-sdl3-joy.sh b/scripts/verify-sdl3-joy.sh
new file mode 100755
index 0000000..a70cab3
--- /dev/null
+++ b/scripts/verify-sdl3-joy.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-sdl3-joy.sh - Joystick gate for the SDL3 port: both sticks, every
+# direction, both buttons and every release, driven through two virtual
+# gamepads and read back from what examples/joy draws; then the pads
+# unplugged while JOY runs.
+#
+# The port (src/hosted/sdl3/sdl3Backend.c) opens the first two gamepads SDL
+# offers as JOYSTICK_0 and JOYSTICK_1: the left stick, the south button as
+# button 0 and the east as button 1, plugged and unplugged as they come and
+# go. The pads are Linux uinput devices laid out as Xbox 360 pads
+# (scripts/lib/padGate.sh), the only ones SDL is let see; JOY runs on a
+# private Xvfb display through SDL's X11 driver at one window pixel per
+# stage pixel, and window grabs go through scripts/lib/joyScreen.py
+# (scripts/lib/joyGate.sh). Removing the pads must leave both sticks
+# disconnected, and Escape must then end JOY.
+#
+# Usage:   scripts/verify-sdl3-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: write access to /dev/uinput, Xvfb, xdotool, ImageMagick,
+#           python3 + PIL; build/sdl3/bin/JOY (make sdl3).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+source "$repo/scripts/lib/padGate.sh"
+
+JOY_LABEL=verify-sdl3-joy
+JOY_SCALE=1
+program=$(joeyExampleProgram joy sdl3)
+wall=120            # hard cap on JOY, whatever happens to this script
+readySeconds=10     # JOY's window up and drawn, with both pads open
+exitSeconds=5       # Escape until JOY has gone
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY first (make sdl3)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-sdl3-joy.XXXXXX)
+guard=""
+xvfb=""
+display=""
+win=""
+cleanup() {
+    padStop
+    joeyStop "$guard" "$xvfb" 2> /dev/null
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+
+joyShot() {
+    DISPLAY="$display" import -window "$win" "$1" 2> /dev/null
+}
+
+
+display=":$((RANDOM % 400 + 5900))"
+Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp > /dev/null 2>&1 &
+xvfb=$!
+sleep 1
+padStart "$JOY_WORK"
+(cd "$(dirname "$program")" && exec env DISPLAY="$display" SDL_VIDEO_DRIVER=x11 SDL_AUDIO_DRIVER=dummy \
+    JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square "${PAD_ONLY_ENV[@]}" timeout -s KILL "$wall" "$program") \
+    > "$JOY_WORK/joy.log" 2>&1 &
+guard=$!
+for ((waited = 0; waited < readySeconds; waited++)); do
+    sleep 1
+    win=$(joeyFindWindow "$display" "$(joeyChild "$guard")") && break
+done
+[ -n "$win" ] || { echo "$JOY_LABEL: FAIL - JOY's window did not come up" >&2; cat "$JOY_WORK/joy.log" >&2; exit 1; }
+DISPLAY="$display" xdotool windowfocus --sync "$win" 2> /dev/null || true
+sleep 1
+
+padOrder
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+
+padStop
+sleep "$JOY_PRESS_SECONDS"
+joyCheck "pads unplugged" "A=$JOY_ABSENT B=$JOY_ABSENT"
+
+DISPLAY="$display" xdotool key Escape
+for ((waited = 0; waited < exitSeconds; waited++)); do
+    joeyAlive "$guard" || break
+    sleep 1
+done
+joeyAlive "$guard" && joyFail "Escape did not end JOY"
+
+joyResult "$JOY_LABEL" "both pads, every direction, both buttons and every release; unplugged, both sticks gone; Escape ends JOY"
diff --git a/scripts/verify-sdl3-save.sh b/scripts/verify-sdl3-save.sh
new file mode 100755
index 0000000..c7e3fed
--- /dev/null
+++ b/scripts/verify-sdl3-save.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-sdl3-save.sh - Save gate for the SDL3 port: a save outlives the
+# program, beside it.
+#
+# SAVE (examples/save) counts its launches in SAVES/runs.dat. It is run twice
+# headless (SDL's dummy drivers) from a scratch folder, started from another
+# directory each time so the port's own move into the program's folder is
+# what puts SAVES/ there; after the second run the count must be 2.
+#
+# Usage:   scripts/verify-sdl3-save.sh
+# Requires: build/sdl3/bin/SAVE (make sdl3).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+
+program=$(joeyExampleProgram save sdl3)
+[ -x "$program" ] || { echo "verify-sdl3-save: build SAVE first (make sdl3)" >&2; exit 2; }
+runSeconds=3
+
+work=$(mktemp -d -t joeylib-sdl3-save.XXXXXX)
+trap 'rm -rf "$work"' EXIT
+mkdir -p "$work/program" "$work/elsewhere"
+cp "$program" "$work/program/SAVE"
+
+for run in 1 2; do
+    # SAVE waits for ESC; the timeout's SIGTERM, which SDL turns into a quit,
+    # ends each run once it has saved.
+    (cd "$work/elsewhere" && SDL_VIDEO_DRIVER=dummy SDL_AUDIO_DRIVER=dummy timeout "$runSeconds" "$work/program/SAVE" > /dev/null 2>&1) || true
+done
+[ -f "$work/program/SAVES/runs.dat" ] || { echo "verify-sdl3-save: FAIL - no SAVES/runs.dat beside the program" >&2; exit 1; }
+count=$(python3 -c "import struct, sys; print(struct.unpack('&2
+    exit 1
+fi
diff --git a/scripts/verify-sdl3-tilebank.sh b/scripts/verify-sdl3-tilebank.sh
new file mode 100755
index 0000000..59bdd98
--- /dev/null
+++ b/scripts/verify-sdl3-tilebank.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-sdl3-tilebank.sh - Tile-bank gate for SDL3: proves the SDL3 build
+# loads the DOS bake (its stage is the same chunky 4bpp) exactly and refuses
+# a bank baked for any other target.
+#
+# Runs TILEBANK (examples/tilebank) headless -- SDL's dummy video and audio
+# drivers -- from a scratch folder holding it and the DATA/tilebank
+# make/sdl3.mk staged from the host gate's fixture (the port makes the
+# program's own folder the current directory). Its TILEBANK line in
+# joeylog.txt must say PASS with both hashes equal to the host gate's and
+# every other target refused (scripts/lib/tileBankGate.sh).
+#
+# Usage:   scripts/verify-sdl3-tilebank.sh
+# Env:     TILEBANK_DATA_DIR  stage this DATA/tilebank instead (negative controls)
+# Requires: a host cc; build/sdl3/bin/TILEBANK and its DATA/tilebank (make sdl3).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/tileBankGate.sh"
+
+binDir=$repo/build/sdl3/bin
+dataDir=$(tileBankData "$binDir")
+wall=60             # hard cap on the program, whatever happens to this script
+lineSeconds=30
+
+tileBankNeedBuild "$binDir/TILEBANK" "$dataDir" "make sdl3"
+tileBankReference
+
+work=$(mktemp -d -t joeylib-sdl3-tilebank.XXXXXX)
+guard=""
+# 2>/dev/null: bash reports the SIGKILLed timeout wrapper as "Killed".
+cleanup() {
+    joeyStop "$guard" 2> /dev/null
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/DATA"
+cp "$binDir/TILEBANK" "$work/"
+cp -r "$dataDir" "$work/DATA/tilebank"
+
+SDL_VIDEO_DRIVER=dummy SDL_AUDIO_DRIVER=dummy timeout -s KILL "$wall" "$work/TILEBANK" > "$work/run.log" 2>&1 &
+guard=$!
+
+log=$(tileBankAwaitLine "$work" "$lineSeconds" "$guard")
+joeyStop "$guard" 2> /dev/null
+guard=""
+if [ -z "$log" ]; then
+    echo "verify-sdl3-tilebank: FAIL - no TILEBANK line within ${lineSeconds}s" >&2
+    tail -5 "$work/run.log" >&2
+    exit 1
+fi
+tileBankJudge "$log"
diff --git a/scripts/verify-ste-machine.sh b/scripts/verify-ste-machine.sh
new file mode 100755
index 0000000..510c0a8
--- /dev/null
+++ b/scripts/verify-ste-machine.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-ste-machine.sh - Which Atari build runs where.
+#
+# The ST port builds twice. The ST build (make atarist) is pure 68000 and
+# must run on every ST, an STe included. The STe build (make ste) uses the
+# BLiTTER, DMA sound and the 4-bit palette without asking, so its jlInit
+# must refuse every other machine -- with a reason in jlLastError, not a
+# crash -- and run on an STe and a Mega STe.
+#
+# Subject: HELLO, which prints jlPlatformName after jlInit, or "jlInit
+# failed: " and jlLastError when it fails. Hatari's --conout 2 copies the
+# TOS console to its own output, so the gate reads what the program said:
+#
+#   STe build on a plain ST   refused, with the STe build's reason
+#   STe build on a Mega ST    refused (a BLiTTER, but no DMA sound)
+#   STe build on an STe       "Platform: Atari STe"
+#   STe build on a Mega STe   "Platform: Atari STe"
+#   ST build on an STe        "Platform: Atari ST" (the ST build runs anywhere)
+#
+# Usage:   scripts/verify-ste-machine.sh
+# Requires: ./install.sh install emulator:atarist (Hatari, EmuTOS),
+#           HELLO.PRG in build/atarist/bin and build/ste/bin (make atarist,
+#           make ste)
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+joeyNeed HATARI hatari
+joeyNeed EMUTOS_IMG emutos
+
+wall=60             # hard cap on Hatari, whatever happens to this script
+runVbls=900         # EmuTOS boot + HELLO's lines: ~500 frames
+refused='jlInit failed: this is the STe build'
+
+work=$(mktemp -d -t joeylib-ste-machine.XXXXXX)
+trap 'rm -rf "$work"' EXIT
+
+fails=0
+# build machine expected-line
+while read -r build machine expect; do
+    prog=$repo/build/$build/bin/HELLO.PRG
+    [ -f "$prog" ] || { echo "verify-ste-machine: missing $prog (run 'make $build')" >&2; exit 2; }
+    rm -rf "$work/c"
+    mkdir -p "$work/c"
+    cp "$prog" "$work/c/"
+    SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy timeout -s KILL "$wall" "$HATARI" \
+        --machine      "$machine" \
+        --tos          "$EMUTOS_IMG" \
+        --harddrive    "$work/c" \
+        --gemdos-drive C \
+        --drive-a off --drive-b off \
+        --sound        off \
+        --fast-forward on \
+        --run-vbls     "$runVbls" \
+        --conout       2 \
+        --auto         'C:\HELLO.PRG' > "$work/out.txt" 2>&1
+    case $expect in
+        refused) want=$refused ;;
+        ste)     want='Platform: Atari STe' ;;
+        st)      want='Platform: Atari ST$' ;;
+    esac
+    said=$(grep -a -E 'jlInit failed|Platform:' "$work/out.txt" | tr -d '\r' | head -1)
+    if printf '%s\n' "$said" | grep -q -E "^$want"; then
+        echo "verify-ste-machine: $build build on $machine: $said"
+    else
+        echo "verify-ste-machine: FAIL - $build build on $machine said '${said:-nothing}', want '$want'" >&2
+        fails=$((fails + 1))
+    fi
+done <&2
+    exit 1
+fi
+echo "verify-ste-machine: PASS"
diff --git a/scripts/verify-web-audio.sh b/scripts/verify-web-audio.sh
new file mode 100755
index 0000000..390233c
--- /dev/null
+++ b/scripts/verify-web-audio.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-web-audio.sh - Web audio gate, graded on the sound the page plays.
+#
+# Runs AUDIO's page in headless Chromium (scripts/lib/webDriver.mjs, which
+# lets it play without a click) and records what it plays: SDL3 hands the
+# browser its sound through a ScriptProcessorNode, and the driver keeps a
+# copy of every buffer the node fills, as a WAV. tools/gradeAudioPhases.py
+# grades it through the same six phases as the SDL3 gate -- music, paused,
+# SFX into the pause, resumed, the noise sweep alone, the MOD -- so the
+# browser's audio path (the page's audio context, SDL's resampling to its
+# rate) is checked as well as the mix, which is the SDL3 port's own.
+#
+# The recording, and the driver's key script, start with the first buffer
+# the page plays, so the phases fall where the SDL3 gate's do.
+#
+# Usage:   scripts/verify-web-audio.sh
+# Env:     WEB_AUDIO_KEEP  set to keep the recording as build/web/headless-audio.wav
+# Requires: Chromium, emsdk's node, python3; build/web/bin/AUDIO (make web).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/webGate.sh"
+webNeed
+grader=$repo/tools/gradeAudioPhases.py
+label=verify-web-audio
+
+dir=$repo/build/web/bin/AUDIO
+[ -f "$dir/AUDIO.html" ] || { echo "$label: build AUDIO first (make web)" >&2; exit 2; }
+
+work=$(mktemp -d -t joeylib-web-audio.XXXXXX)
+cleanup() {
+    webStop
+    rm -rf "$work"
+    return 0
+}
+trap cleanup EXIT
+
+webServe "$dir"
+# The same timeline as verify-sdl3-audio.sh (seconds since the first buffer).
+if ! webDrive "AUDIO.html" --seconds 120 --audio "$work/out.wav" \
+    --step wait:5 --step hold:p:400 \
+    --step wait:8 \
+    --step "hold: :400" --step wait:1.5 --step "hold: :400" --step wait:1.5 --step "hold: :400" --step wait:1.5 \
+    --step wait:4 --step hold:p:400 \
+    --step wait:5.6 --step hold:p:400 \
+    --step wait:0.6 --step hold:n:400 \
+    --step wait:4.6 --step hold:m:400 \
+    --step wait:7.6; then
+    echo "$label: FAIL - the page could not be driven" >&2
+    exit 1
+fi
+[ -s "$work/out.wav" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; }
+
+# Thresholds sit at about half the measured levels (music ~3090, paused 0,
+# sfx ~1240, noise ~1500, mod ~7980, the grader averaging the two channels).
+python3 "$grader" "$label" "$work/out.wav" \
+    "music:1:5:1500:" \
+    "paused:8:13::100" \
+    "sfx:15:22:600:" \
+    "resumed:25:29:1500:" \
+    "noise:31.5:34:700:" \
+    "mod:37:43:4000:"
+status=$?
+if [ -n "${WEB_AUDIO_KEEP:-}" ]; then
+    cp "$work/out.wav" "$repo/build/web/headless-audio.wav"
+fi
+exit $status
diff --git a/scripts/verify-web-input.sh b/scripts/verify-web-input.sh
new file mode 100755
index 0000000..8c14cce
--- /dev/null
+++ b/scripts/verify-web-input.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-web-input.sh - Typed-character acceptance gate for the web port.
+# Runs KEYS's page in headless Chromium at one canvas pixel per stage pixel
+# (KEYS.html?scale=1&aspect=square), types the RetroNet definition-of-done
+# string as browser key events carrying their text (scripts/lib/webDriver.mjs,
+# over the DevTools protocol), takes the canvas, and decodes the example's
+# verification scanline (row 198) with scripts/lib/keysRow.py, as the SDL3
+# and DVX gates do.
+#
+# Usage:   scripts/verify-web-input.sh
+# Requires: Chromium, emsdk's node, python3 + PIL; build/web/bin/KEYS (make web).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/webGate.sh"
+webNeed
+
+target="192.168.1.10:6510"
+dir=$repo/build/web/bin/KEYS
+[ -f "$dir/KEYS.html" ] || { echo "verify-web-input: build KEYS first (make web)" >&2; exit 2; }
+
+work=$(mktemp -d -t joeylib-web-input.XXXXXX)
+cleanup() {
+    webStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+webServe "$dir"
+webDrive "KEYS.html?scale=1&aspect=square" --step wait:3 --step "type:$target" --step wait:2 --screenshot "$work/keys.png"
+# The stage at one pixel a pixel: the row must be exactly at (0,198).
+if python3 "$repo/scripts/lib/keysRow.py" "$work/keys.png" "$target" --size 320x200 --at 0,198 --scale 1; then
+    echo "verify-web-input: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)"
+else
+    echo "verify-web-input: FAIL" >&2
+    exit 1
+fi
diff --git a/scripts/verify-web-joy.sh b/scripts/verify-web-joy.sh
new file mode 100755
index 0000000..df113c6
--- /dev/null
+++ b/scripts/verify-web-joy.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-web-joy.sh - Joystick gate for the web port: both sticks, every
+# direction, both buttons and every release, driven through the page's
+# Gamepad API and read back from what examples/joy draws; then the pads
+# unplugged while JOY runs.
+#
+# The port is the SDL3 backend built with Emscripten: SDL reads the
+# browser's gamepads (navigator.getGamepads) and the backend opens the
+# first two as JOYSTICK_0 and JOYSTICK_1, the left stick, the south button
+# as button 0 and the east as button 1. The gate's pads stand in for the
+# browser's (webDriver.mjs --gamepads: standard-mapping pads, connected from
+# the start, driven by pad: steps), so nothing plugged into this machine
+# is seen. JOY runs in headless Chromium at one canvas pixel per stage
+# pixel; the step script takes a canvas shot after every change, and the
+# shots then go through scripts/lib/joyScreen.py (scripts/lib/joyGate.sh)
+# in order. Unplugging must leave both sticks disconnected.
+#
+# Usage:   scripts/verify-web-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: Chromium and Node (install emulator:web), python3 + PIL;
+#           build/web/bin/JOY (make web).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+source "$repo/scripts/lib/webGate.sh"
+webNeed
+
+JOY_LABEL=verify-web-joy
+JOY_SCALE=1
+# The checks read shots the driver has already taken: no waiting, and none
+# taken again.
+JOY_PRESS_SECONDS=0
+JOY_SHOT_TRIES=1
+program=$(joeyExampleProgram joy web)
+startSeconds=3      # the page loaded and JOY drawing
+changeSeconds=0.3   # a change until JOY has drawn it
+wall=120            # the driver's own cap
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY first (make web)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-web-joy.XXXXXX)
+cleanup() {
+    webStop
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+shotIndex=0
+shotCount=0
+script=()
+
+
+joyPress() {
+    :
+}
+
+
+joyRelease() {
+    :
+}
+
+
+joyShot() {
+    shotIndex=$((shotIndex + 1))
+    [ -s "$JOY_WORK/shots/$(printf '%03d' "$shotIndex").png" ] || return 1
+    cp "$JOY_WORK/shots/$(printf '%03d' "$shotIndex").png" "$1"
+}
+
+
+# shoot -- a shot step, numbered in the order the checks read them.
+shoot() {
+    shotCount=$((shotCount + 1))
+    script+=(--step "wait:$changeSeconds" --step "shot:$JOY_WORK/shots/$(printf '%03d' "$shotCount").png")
+}
+
+
+mkdir -p "$JOY_WORK/shots"
+script+=(--step "wait:$startSeconds")
+shoot
+for step in "${JOY_STEPS_TWO_STICKS[@]}"; do
+    IFS='|' read -r _ held _ _ <<< "$step"
+    controls="${held// /,}"
+    script+=(--step "pad:press:$controls")
+    shoot
+    script+=(--step "pad:release:$controls")
+    shoot
+done
+script+=(--step "pad:unplug")
+shoot
+
+webServe "$(dirname "$program")"
+webDrive "$(basename "$program")?scale=1&aspect=square" --gamepads --seconds "$wall" "${script[@]}" 2> "$JOY_WORK/driver.log" ||
+    { echo "$JOY_LABEL: FAIL - the page could not be driven" >&2; tail -10 "$JOY_WORK/driver.log" >&2; exit 1; }
+
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+joyCheck "pads unplugged" "A=$JOY_ABSENT B=$JOY_ABSENT"
+
+joyResult "$JOY_LABEL" "both pads, every direction, both buttons and every release; unplugged, both sticks gone"
diff --git a/scripts/verify-web-save.sh b/scripts/verify-web-save.sh
new file mode 100755
index 0000000..946115e
--- /dev/null
+++ b/scripts/verify-web-save.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-web-save.sh - Save gate for the web port: a save outlives the page.
+#
+# A web program's SAVES/ is kept in the browser's IndexedDB (the SDL3
+# backend mounts IDBFS there and writes it back every two seconds). SAVE
+# (examples/save) counts its launches in SAVES/runs.dat, so its page is
+# opened in headless Chromium, given time to write, opened again
+# (reloaded), and given time again: the count it then holds must be 2 --
+# the first launch's 1 came back from IndexedDB and the second launch added
+# one.
+#
+# Usage:   scripts/verify-web-save.sh
+# Requires: Chromium, emsdk's node; build/web/bin/SAVE (make web).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/webGate.sh"
+webNeed
+
+dir=$repo/build/web/bin/SAVE
+[ -f "$dir/SAVE.html" ] || { echo "verify-web-save: build SAVE first (make web)" >&2; exit 2; }
+
+work=$(mktemp -d -t joeylib-web-save.XXXXXX)
+cleanup() {
+    webStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+webServe "$dir"
+webDrive "SAVE.html" --step wait:4 --step reload --step wait:4 --file "/SAVES/runs.dat:$work/runs.dat"
+count=$(python3 -c "import struct, sys; print(struct.unpack('&2
+    exit 1
+fi
diff --git a/scripts/verify-web-tilebank.sh b/scripts/verify-web-tilebank.sh
new file mode 100755
index 0000000..30cf411
--- /dev/null
+++ b/scripts/verify-web-tilebank.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-web-tilebank.sh - Tile-bank gate for the web port: proves the web
+# build loads the DOS bake (its stage is the same chunky 4bpp) exactly and
+# refuses a bank baked for any other target.
+#
+# Runs TILEBANK's page (examples/tilebank, its DATA/tilebank packed into
+# TILEBANK.data by make/web.mk) in headless Chromium through
+# scripts/lib/webDriver.mjs. Its TILEBANK line in joeylog.txt must say PASS
+# with both hashes equal to the host gate's and every other target refused
+# (scripts/lib/tileBankGate.sh).
+#
+# Usage:   scripts/verify-web-tilebank.sh
+# Requires: a host cc, Chromium, emsdk's node; build/web/bin/TILEBANK (make web).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/tileBankGate.sh"
+source "$repo/scripts/lib/webGate.sh"
+webNeed
+
+dir=$repo/build/web/bin/TILEBANK
+lineSeconds=60
+
+tileBankNeedBuild "$dir/TILEBANK.html" "$dir/DATA/tilebank" "make web"
+tileBankReference
+
+work=$(mktemp -d -t joeylib-web-tilebank.XXXXXX)
+cleanup() {
+    webStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+webServe "$dir"
+if ! webDrive "TILEBANK.html" --seconds "$lineSeconds" --until "TILEBANK: " --log "$work/joeylog.txt"; then
+    echo "verify-web-tilebank: FAIL - no TILEBANK line within ${lineSeconds}s" >&2
+    exit 1
+fi
+tileBankJudge "$work/joeylog.txt"
diff --git a/scripts/verify-win16-audio.sh b/scripts/verify-win16-audio.sh
new file mode 100755
index 0000000..9a01a21
--- /dev/null
+++ b/scripts/verify-win16-audio.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win16-audio.sh - Win16 audio gate, graded on recorded sound.
+#
+# The port mixes in software (src/hosted/audio.c, 22.05 kHz mono, 8-bit out)
+# and plays through waveOut, which on Windows 3.11 under DOSBox
+# (scripts/lib/win16Gate.sh) is Windows' own Sound Blaster 1.5 driver on
+# DOSBox's emulated card. DOSBox plays through SDL into PulseAudio, and
+# PULSE_SINK sends this one run to a null sink made for it
+# (scripts/pulseRecordLib.sh), so the user's own sound is never touched;
+# parec records the sink's monitor and tools/gradeAudioPhases.py grades it
+# through the same six phases as the SDL3 and Win32 gates: music, paused,
+# SFX into the pause, resumed, the noise sweep alone, the MOD. Windows boots
+# before AUDIO plays anything, so the clock starts when the song is first
+# heard: the gate records from the start, waits for the first sound, runs
+# the keys (held 0.4 s: AUDIO reads jlKeyPressed once a frame) from there,
+# and grades the recording from that first sound on.
+#
+# Usage:   scripts/verify-win16-audio.sh
+# Env:     WIN16_AUDIO_KEEP  set to keep the recording as
+#                            build/win16/headless-audio.wav
+# Requires: Windows on DOSBox (install.sh install emulator:win16), Xvfb,
+#           xdotool, parec/pactl, python3; build/win16/bin/AUDIO.EXE
+#           (make win16).
+
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+source "$repo/scripts/pulseRecordLib.sh"
+
+label=verify-win16-audio
+win16Need
+grader=$repo/tools/gradeAudioPhases.py
+program=$(joeyExampleProgram audio win16)
+[ -f "$program" ] || { echo "$label: build AUDIO.EXE first (make win16)" >&2; exit 2; }
+for tool in Xvfb xdotool parec pactl python3; do
+    command -v "$tool" > /dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
+done
+pulseEnsureServer || { echo "$label: no PulseAudio/PipeWire server to record from" >&2; exit 2; }
+
+work=$(mktemp -d -t joeylib-win16-audio.XXXXXX)
+sinkModule=""
+parecPid=""
+cleanup() {
+    [ -n "$parecPid" ] && kill "$parecPid" 2> /dev/null
+    win16Stop
+    pulseUnloadSink "$sinkModule"
+    rm -rf "$work"
+    return 0
+}
+trap cleanup EXIT
+
+sinkName="joeyWin16Gate$$"
+# A short record latency, so the first sound reaches the file when it is
+# played: the clock starts there.
+RECORD_LATENCY_MS=250
+SOUND_THRESHOLD=256
+SOUND_WAIT_SECONDS=60
+sinkModule=$(pulseCreateSink "$sinkName") || sinkModule=""
+[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
+
+# In place: the SFX and the MOD are in the DATA folder beside it.
+win16Stage "$work" "$(dirname "$program")" "$(basename "$program")"
+parec --latency-msec="$RECORD_LATENCY_MS" --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \
+    > "$work/out.raw" 2> "$work/parec.log" &
+parecPid=$!
+WIN16_SOUND=1 PULSE_SINK="$sinkName" SDL_AUDIODRIVER=pulse win16Start "$work"
+
+# The byte offset of the first sound in the recording so far, or nothing.
+firstSound() {
+    python3 - "$work/out.raw" "$SOUND_THRESHOLD" <<'PY'
+import array
+import sys
+data = open(sys.argv[1], "rb").read()
+samples = array.array("h", data[: len(data) - len(data) % 4])
+for i, v in enumerate(samples):
+    if abs(v) > int(sys.argv[2]):
+        print((i // 2) * 4)
+        break
+PY
+}
+
+start=""
+for ((waited = 0; waited < SOUND_WAIT_SECONDS * 4; waited++)); do
+    start=$(firstSound)
+    [ -n "$start" ] && break
+    sleep 0.25
+done
+[ -n "$start" ] || { echo "$label: FAIL - no sound within ${SOUND_WAIT_SECONDS}s" >&2; tail -5 "$work/dosbox.log" >&2; exit 1; }
+win=$(win16Window) || { echo "$label: FAIL - DOSBox window not found" >&2; exit 1; }
+DISPLAY="$WIN16_DISPLAY" xdotool windowactivate --sync "$win" 2> /dev/null || true
+
+hold() {                                        # $1 = key name
+    DISPLAY="$WIN16_DISPLAY" xdotool keydown --window "$win" "$1"
+    sleep 0.4
+    DISPLAY="$WIN16_DISPLAY" xdotool keyup --window "$win" "$1"
+}
+
+# The same timeline as verify-sdl3-audio.sh (seconds since the first sound).
+sleep 5                                         # 0-6s   music
+hold p                                          # ->     paused
+sleep 8                                         # 6-14s  paused
+for _ in 1 2 3; do                              # 14-24s sfx into silence
+    hold space
+    sleep 1.5
+done
+sleep 4
+hold p                                          # ->     resumed
+sleep 5.6                                       # 24-30s resumed
+hold p                                          # ->     paused again
+sleep 0.6
+hold n                                          # 31-35s noise sweep alone
+sleep 4.6
+hold m                                          # 36-44s the MOD
+sleep 7.6
+kill "$parecPid" 2> /dev/null
+wait "$parecPid" 2> /dev/null
+parecPid=""
+
+# From the first sound on, as a WAV.
+python3 - "$work/out.raw" "$start" "$work/out.wav" <<'PY'
+import sys
+import wave
+data = open(sys.argv[1], "rb").read()[int(sys.argv[2]):]
+with wave.open(sys.argv[3], "wb") as w:
+    w.setnchannels(2)
+    w.setsampwidth(2)
+    w.setframerate(44100)
+    w.writeframes(data)
+PY
+
+# Thresholds sit at about half the measured levels (music ~1520, paused 0,
+# sfx ~620, resumed ~1540, noise ~770, mod ~3960; DOSBox's Sound Blaster
+# plays about half as loud as Wine's output).
+python3 "$grader" "$label" "$work/out.wav" \
+    "music:1:5:750:" \
+    "paused:8:13::100" \
+    "sfx:15:22:300:" \
+    "resumed:25:29:750:" \
+    "noise:31.5:34:380:" \
+    "mod:37:43:2000:"
+status=$?
+if [ -n "${WIN16_AUDIO_KEEP:-}" ]; then
+    cp "$work/out.wav" "$repo/build/win16/headless-audio.wav"
+fi
+exit $status
diff --git a/scripts/verify-win16-input.sh b/scripts/verify-win16-input.sh
new file mode 100755
index 0000000..db20155
--- /dev/null
+++ b/scripts/verify-win16-input.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win16-input.sh - Typed-character acceptance gate for the Win16
+# port. Runs KEYS.EXE on Windows 3.11 under DOSBox
+# (scripts/lib/win16Gate.sh) at one window pixel per stage pixel
+# (JOEYLIB_SCALE=1, JOEYLIB_ASPECT=square), types the RetroNet
+# definition-of-done string with xdotool (X key events DOSBox turns into
+# the PC keyboard's, which Windows turns into WM_KEYDOWN/WM_CHAR), takes
+# DOSBox's own screenshot of the 800x600 screen, and decodes the example's
+# verification scanline (row 198) with scripts/lib/keysRow.py, wherever
+# Windows put the window.
+#
+# Usage:   scripts/verify-win16-input.sh
+# Requires: Windows on DOSBox (install.sh install emulator:win16), Xvfb,
+#           xdotool, python3 + PIL; build/win16/bin/KEYS.EXE (make win16).
+
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+
+label=verify-win16-input
+win16Need
+target="192.168.1.10:6510"
+program=$(joeyExampleProgram keys win16)
+[ -f "$program" ] || { echo "$label: build KEYS.EXE first (make win16)" >&2; exit 2; }
+# Windows' boot and KEYS reaching its poll loop.
+bootSeconds=10
+
+work=$(mktemp -d -t joeylib-win16-input.XXXXXX)
+cleanup() {
+    win16Stop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/program" "$work/shots"
+cp "$program" "$work/program/KEYS.EXE"
+win16Stage "$work" "$work/program" KEYS.EXE
+WIN16_DOSBOX_ARGS=(--set "capture_dir=$work/shots" --set "default_image_capture_formats=raw")
+win16Start "$work" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square
+sleep "$bootSeconds"
+win=$(win16Window) || { echo "$label: FAIL - DOSBox window not found" >&2; exit 1; }
+DISPLAY="$WIN16_DISPLAY" xdotool windowactivate --sync "$win" 2> /dev/null || true
+DISPLAY="$WIN16_DISPLAY" xdotool type --window "$win" --delay 150 -- "$target"
+sleep 2
+# Ctrl+F5: DOSBox's screenshot hotkey.
+DISPLAY="$WIN16_DISPLAY" xdotool key --window "$win" ctrl+F5
+sleep 2
+png=$(find "$work/shots" -name '*.png' -print -quit)
+[ -n "$png" ] || { echo "$label: FAIL - DOSBox took no screenshot" >&2; exit 1; }
+if python3 "$repo/scripts/lib/keysRow.py" "$png" "$target" --scale 1; then
+    echo "$label: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)"
+else
+    echo "$label: FAIL" >&2
+    exit 1
+fi
diff --git a/scripts/verify-win16-joy.sh b/scripts/verify-win16-joy.sh
new file mode 100755
index 0000000..88acc67
--- /dev/null
+++ b/scripts/verify-win16-joy.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win16-joy.sh - Joystick gate for the Win16 port: both sticks,
+# every direction, both buttons and every release, driven through DOSBox
+# Staging's emulated game port, read by Windows through KPJOY.DRV (the
+# joystick driver JoeyLib's Windows runs with, component kpjoy) and read
+# back from what examples/joy draws; and no joystick at all when the game
+# port is empty.
+#
+# The port (src/hosted/win16) reads the first two sticks through MMSYSTEM's
+# joyGetPos, scaled by joyGetDevCaps's ranges. DOSBox Staging's joysticktype
+# 4axis puts four axes and four buttons on the game port with no host
+# joystick, which KPJOY reads as two sticks, A and B, as a Y cable gives
+# them; it takes each axis's rest from its first readings and reports
+# readings near the rest as the rest, so the dots sit exactly in the middle
+# through the jitter interrupts give the counts. KPJOY counts reads of the
+# port, so the emulated CPU runs at a fixed speed, as a real PC's does: at
+# DOSBox's max cycles (how joey-run runs Windows) the speed follows this
+# machine's load, and the counts with it. A mapper file binds host keys to
+# DOSBox's joystick events (scripts/lib/dosboxJoy.sh) and xdotool holds
+# them; the raw screenshot is on a key of its own, so no Ctrl reaches
+# Windows. The mapper also leaves out any joystick plugged into this
+# machine, so nothing else moves the sticks: they rest in the middle,
+# buttons up.
+#
+# JOY (examples/joy) is Windows' shell, at one window pixel per stage pixel;
+# scripts/lib/joyScreen.py reads both sticks' whole state off each
+# screenshot of the 800x600 screen (scripts/lib/joyGate.sh's steps). Escape
+# then ends JOY, and with it Windows and DOSBox.
+#
+# Second run, joysticktype disabled: no game port, so JOY must show both
+# sticks disconnected.
+#
+# Usage:   scripts/verify-win16-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL, the win311 and
+#           kpjoy components; JOY.EXE (make win16).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+source "$repo/scripts/lib/dosboxJoy.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+win16Need
+
+JOY_LABEL=verify-win16-joy
+JOY_SCALE=1
+program=$(joeyExampleProgram joy win16)
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY.EXE first (make win16)" >&2; exit 2; }
+upSeconds=60         # Windows booting until JOY's screen is there
+exitSeconds=20       # Escape until Windows and DOSBox have ended
+fixedCycles=60000    # the emulated CPU's speed (DOSBox cycles per ms)
+
+JOY_WORK=$(mktemp -d -t joeylib-win16-joy.XXXXXX)
+cleanup() {
+    win16Stop
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+
+# boot RUN JOYTYPE -- Windows staged in $JOY_WORK/RUN with JOY its shell,
+# DOSBox's joysticktype JOYTYPE and the joystick mapper; waits for JOY's
+# screen.
+boot() {
+    local dir="$JOY_WORK/$1"
+    local waited
+    mkdir -p "$dir/program" "$dir/shots"
+    cp "$program" "$dir/program/JOY.EXE"
+    win16Stage "$dir" "$dir/program" JOY.EXE
+    dosboxJoyMapper "$dir/mapper.txt"
+    DOSBOX_JOY_SHOTS="$dir/shots"
+    WIN16_DOSBOX_ARGS=(--set "capture_dir=$dir/shots" --set "default_image_capture_formats=raw"
+        --set "joysticktype=$2" --set "mapperfile=$dir/mapper.txt"
+        --set "cpu_cycles=$fixedCycles" --set "cpu_cycles_protected=$fixedCycles")
+    win16Start "$dir" JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square
+    DOSBOX_JOY_DISPLAY="$WIN16_DISPLAY"
+    for ((waited = 0; waited < upSeconds; waited++)); do
+        joeyAlive "$WIN16_PID" || return 1
+        DOSBOX_JOY_WINDOW=$(win16Window) && break
+        sleep 1
+    done
+    [ -n "$DOSBOX_JOY_WINDOW" ] || return 1
+    DISPLAY="$DOSBOX_JOY_DISPLAY" xdotool windowactivate --sync "$DOSBOX_JOY_WINDOW" 2> /dev/null || true
+    for ((; waited < upSeconds; waited++)); do
+        joyShot "$dir/up.png" && python3 "$repo/scripts/lib/joyScreen.py" "$dir/up.png" --scale "$JOY_SCALE" > /dev/null 2>&1 && return 0
+        joeyAlive "$WIN16_PID" || return 1
+        sleep 1
+    done
+    return 1
+}
+
+
+# 1. Both sticks on the game port, moved through the mapper.
+boot sticks 4axis || { echo "$JOY_LABEL: FAIL - Windows with JOY did not come up within ${upSeconds} s" >&2; exit 1; }
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+dosboxJoyTap escape
+for ((waited = 0; waited < exitSeconds; waited++)); do
+    joeyAlive "$WIN16_PID" || break
+    sleep 1
+done
+joeyAlive "$WIN16_PID" && joyFail "Escape did not end JOY, and Windows with it, within ${exitSeconds} s"
+win16Stop
+
+# 2. No game port: no stick.
+boot none disabled || { echo "$JOY_LABEL: FAIL - Windows with JOY and no game port did not come up within ${upSeconds} s" >&2; exit 1; }
+joyCheck "no joystick" "A=$JOY_ABSENT B=$JOY_ABSENT"
+win16Stop
+
+joyResult "$JOY_LABEL" "both sticks through KPJOY.DRV, every direction, both buttons and every release; no stick without a game port"
diff --git a/scripts/verify-win16-save.sh b/scripts/verify-win16-save.sh
new file mode 100755
index 0000000..f76f8b4
--- /dev/null
+++ b/scripts/verify-win16-save.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win16-save.sh - Save gate for the Win16 port: a save outlives the
+# program, beside it.
+#
+# SAVE.EXE (examples/save) counts its launches in SAVES\RUNS.DAT. Windows
+# 3.11 under DOSBox (scripts/lib/win16Gate.sh) is booted twice with it as
+# the shell, its scratch folder as drive D:. Windows starts it from
+# C:\WINDOWS, so the port's own move into the program's folder is what puts
+# SAVES there. Each run is stopped once it has counted itself (SAVE waits
+# for ESC); the first must count 1 and the second 2.
+#
+# Usage:   scripts/verify-win16-save.sh
+# Requires: Windows on DOSBox (install.sh install emulator:win16);
+#           build/win16/bin/SAVE.EXE (make win16).
+
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+
+label=verify-win16-save
+win16Need
+program=$(joeyExampleProgram save win16)
+[ -f "$program" ] || { echo "$label: build SAVE.EXE first (make win16)" >&2; exit 2; }
+runSeconds=60
+
+work=$(mktemp -d -t joeylib-win16-save.XXXXXX)
+cleanup() {
+    win16Stop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+# savedCount -- the launch count in SAVES\RUNS.DAT, or nothing yet.
+savedCount() {
+    local saved
+    saved=$(find "$work/program" -maxdepth 2 -ipath "*/saves/runs.dat" -print -quit)
+    [ -s "$saved" ] || return 0
+    python3 -c "import struct, sys; print(struct.unpack('&2
+        exit 1
+    fi
+done
+echo "$label: PASS (the second run counted 2, in SAVES beside the program)"
diff --git a/scripts/verify-win16-serial.sh b/scripts/verify-win16-serial.sh
new file mode 100755
index 0000000..2542d5e
--- /dev/null
+++ b/scripts/verify-win16-serial.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win16-serial.sh - both-directions RS-232 gate for the Win16 port.
+#
+# Runs SERTEST.EXE (examples/sertest: banner, then echo) on Windows 3.11
+# under DOSBox (scripts/lib/win16Gate.sh) with COM1 as a nullmodem
+# connecting to the host peer (scripts/lib/serialLoopPeer.py), which waits
+# for the banner, sends a probe and expects it echoed. That exercises the
+# chain: JoeyLib's Win16 serial HAL -> OpenComm/WriteComm/ReadComm ->
+# KPCOMM.DRV (the communications driver JoeyLib's Windows runs with in place
+# of COMM.DRV) and Windows' virtual COM device -> DOSBox's UART -> host.
+#
+# Usage:   scripts/verify-win16-serial.sh
+# Env:     WIN16_SERIAL_PORT  TCP port the peer listens on (default 6804)
+# Requires: Windows on DOSBox (install.sh install emulator:win16), python3;
+#           build/win16/bin/SERTEST.EXE (make win16).
+
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+
+label=verify-win16-serial
+win16Need
+program=$(joeyExampleProgram sertest win16)
+[ -f "$program" ] || { echo "$label: build SERTEST.EXE first (make win16)" >&2; exit 2; }
+port="${WIN16_SERIAL_PORT:-6804}"
+# The peer's budget: Windows' boot, then the banner and the echo.
+wall=60
+
+work=$(mktemp -d -t joeylib-win16-serial.XXXXXX)
+peerPid=""
+cleanup() {
+    joeyStop "$peerPid"
+    win16Stop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$port" "$wall" > "$work/peer.log" 2>&1 &
+peerPid=$!
+sleep 1
+mkdir -p "$work/program"
+cp "$program" "$work/program/SERTEST.EXE"
+win16Stage "$work" "$work/program" SERTEST.EXE
+# transparent:1 -- data bytes only: DOSBox's in-band escapes for the control
+# lines would reach the plain TCP peer as data.
+WIN16_DOSBOX_ARGS=(--set "serial1=nullmodem server:127.0.0.1 port:$port transparent:1")
+win16Start "$work"
+wait "$peerPid"
+rc=$?
+peerPid=""
+cat "$work/peer.log"
+if [ "$rc" -eq 0 ]; then
+    echo "$label: PASS (banner out, probe echoed through COM1)"
+else
+    echo "$label: FAIL" >&2
+fi
+exit "$rc"
diff --git a/scripts/verify-win16-tilebank.sh b/scripts/verify-win16-tilebank.sh
new file mode 100755
index 0000000..8e7b363
--- /dev/null
+++ b/scripts/verify-win16-tilebank.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win16-tilebank.sh - Tile-bank gate for the Win16 port: proves the
+# build loads the DOS bake (its stage is the same chunky 4bpp) exactly and
+# refuses a bank baked for any other target.
+#
+# Runs TILEBANK.EXE on Windows 3.11 under DOSBox (scripts/lib/win16Gate.sh)
+# from a scratch folder holding it and the DATA/tilebank its make staged
+# from the host gate's fixture (the port makes the program's own folder the
+# current directory). Its TILEBANK line in JOEYLOG.TXT must say PASS with
+# both hashes equal to the host gate's and every other target refused
+# (scripts/lib/tileBankGate.sh).
+#
+# Usage:   scripts/verify-win16-tilebank.sh
+# Requires: a host cc, Windows on DOSBox (install.sh install emulator:win16);
+#           build/win16/bin/TILEBANK.EXE and its DATA/tilebank (make win16).
+
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/tileBankGate.sh"
+source "$repo/scripts/lib/win16Gate.sh"
+
+label=verify-win16-tilebank
+TILEBANK_GATE=$label
+win16Need
+binDir=$repo/build/win16/bin
+dataDir=$(tileBankData "$binDir")
+lineSeconds=90
+
+tileBankNeedBuild "$binDir/TILEBANK.EXE" "$dataDir" "make win16"
+tileBankReference
+
+work=$(mktemp -d -t joeylib-win16-tilebank.XXXXXX)
+cleanup() {
+    win16Stop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/run/DATA"
+cp "$binDir/TILEBANK.EXE" "$work/run/"
+cp -r "$dataDir" "$work/run/DATA/tilebank"
+win16Stage "$work" "$work/run" TILEBANK.EXE
+win16Start "$work"
+log=$(tileBankAwaitLine "$work/run" "$lineSeconds" "$WIN16_GUARD")
+if [ -z "$log" ]; then
+    echo "$label: FAIL - no TILEBANK line within ${lineSeconds}s" >&2
+    tail -5 "$work/dosbox.log" >&2
+    exit 1
+fi
+tileBankJudge "$log"
diff --git a/scripts/verify-win32-audio.sh b/scripts/verify-win32-audio.sh
new file mode 100755
index 0000000..600cb1a
--- /dev/null
+++ b/scripts/verify-win32-audio.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win32-audio.sh - Win32 audio gate, graded on recorded sound.
+#
+# The port mixes in software (src/hosted/audio.c) and plays through waveOut.
+# Under Wine that reaches PulseAudio, and PULSE_SINK sends this one run to a
+# null sink made for it (scripts/pulseRecordLib.sh), so the user's
+# own sound is never touched; parec records the sink's monitor and
+# tools/gradeAudioPhases.py grades it through the same six phases as the
+# SDL3 gate: music, paused, SFX into the pause, resumed, the noise sweep
+# alone, the MOD. Wine's sound starts some seconds after the program does
+# (about 8 here, in its driver's start-up, before the first waveOut buffer
+# plays), so the clock starts when the song is first heard: the gate records
+# from the moment AUDIO's window is up, waits for the first sound, runs the
+# keys (held 0.4 s: AUDIO reads jlKeyPressed once a frame) from there, and
+# grades the recording from that first sound on.
+#
+# Usage:   scripts/verify-win32-audio.sh
+# Env:     WIN32_AUDIO_KEEP  set to keep the recording as
+#                            build/win32/headless-audio.wav
+# Requires: Wine, Xvfb, xdotool, parec/pactl, python3;
+#           build/win32/bin/AUDIO.EXE (make win32).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/wineGate.sh"
+source "$repo/scripts/pulseRecordLib.sh"
+wineNeed
+grader=$repo/tools/gradeAudioPhases.py
+label=verify-win32-audio
+
+program=$(joeyExampleProgram audio win32)
+[ -f "$program" ] || { echo "$label: build AUDIO.EXE first (make win32)" >&2; exit 2; }
+for tool in Xvfb xdotool parec pactl python3; do
+    command -v "$tool" > /dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
+done
+pulseEnsureServer || { echo "$label: no PulseAudio/PipeWire server to record from" >&2; exit 2; }
+
+work=$(mktemp -d -t "joeylib-win32-audio.XXXXXX")
+sinkModule=""
+parecPid=""
+cleanup() {
+    [ -n "$parecPid" ] && kill "$parecPid" 2> /dev/null
+    wineStop
+    pulseUnloadSink "$sinkModule"
+    rm -rf "$work"
+    return 0
+}
+trap cleanup EXIT
+
+sinkName="joeyWin32Gate$$"
+RECORD_LATENCY_MS=250
+sinkModule=$(pulseCreateSink "$sinkName") || sinkModule=""
+[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
+
+wineStart "$work"
+PULSE_SINK="$sinkName" wineRun "$program"
+win=$(wineWindow AUDIO) || { echo "$label: FAIL - no AUDIO window" >&2; exit 1; }
+xdotool windowfocus --sync "$win"
+# A short record latency: the first sound must reach the file when it is
+# played, as the clock starts there. Left to choose, the server can hand the
+# recording over in seconds-long fragments, and every key lands seconds late
+# on the timeline.
+parec --latency-msec="$RECORD_LATENCY_MS" --device="$sinkName.monitor" --format=s16le --rate=44100 --channels=2 \
+    > "$work/out.raw" 2> "$work/parec.log" &
+parecPid=$!
+
+# The byte offset of the first sound in the recording so far, or nothing.
+firstSound() {
+    python3 - "$work/out.raw" "$SOUND_THRESHOLD" <<'PY'
+import array
+import sys
+
+data = open(sys.argv[1], "rb").read()
+samples = array.array("h", data[: len(data) - len(data) % 4])
+for i, v in enumerate(samples):
+    if abs(v) > int(sys.argv[2]):
+        print((i // 2) * 4)
+        break
+PY
+}
+
+SOUND_THRESHOLD=256
+SOUND_WAIT_SECONDS=30
+start=""
+for ((waited = 0; waited < SOUND_WAIT_SECONDS * 4; waited++)); do
+    start=$(firstSound)
+    [ -n "$start" ] && break
+    sleep 0.25
+done
+[ -n "$start" ] || { echo "$label: FAIL - no sound within ${SOUND_WAIT_SECONDS}s" >&2; exit 1; }
+
+hold() {                                        # $1 = key name
+    xdotool keydown "$1"
+    sleep 0.4
+    xdotool keyup "$1"
+}
+
+# The same timeline as verify-sdl3-audio.sh (seconds since the first sound).
+sleep 5                                         # 0-6s   music
+hold p                                          # ->     paused
+sleep 8                                         # 6-14s  paused
+for _ in 1 2 3; do                              # 14-24s sfx into silence
+    hold space
+    sleep 1.5
+done
+sleep 4
+hold p                                          # ->     resumed
+sleep 5.6                                       # 24-30s resumed
+hold p                                          # ->     paused again
+sleep 0.6
+hold n                                          # 31-35s noise sweep alone
+sleep 4.6
+hold m                                          # 36-44s the MOD
+sleep 7.6
+
+kill "$parecPid" 2> /dev/null
+wait "$parecPid" 2> /dev/null
+parecPid=""
+# From the first sound on, as a WAV.
+python3 - "$work/out.raw" "$start" "$work/out.wav" <<'PY'
+import sys
+import wave
+
+data = open(sys.argv[1], "rb").read()[int(sys.argv[2]):]
+with wave.open(sys.argv[3], "wb") as w:
+    w.setnchannels(2)
+    w.setsampwidth(2)
+    w.setframerate(44100)
+    w.writeframes(data)
+PY
+
+# Thresholds sit at about half the measured levels (music ~3100, paused 0,
+# sfx ~1240-1520, resumed ~2770-3060, noise ~1220-1620, mod ~7390-7980, the
+# grader averaging the two channels).
+python3 "$grader" "$label" "$work/out.wav" \
+    "music:1:5:1500:" \
+    "paused:8:13::100" \
+    "sfx:15:22:700:" \
+    "resumed:25:29:1400:" \
+    "noise:31.5:34:650:" \
+    "mod:37:43:3700:"
+status=$?
+if [ -n "${WIN32_AUDIO_KEEP:-}" ]; then
+    cp "$work/out.wav" "$repo/build/win32/headless-audio.wav"
+fi
+exit $status
diff --git a/scripts/verify-win32-input.sh b/scripts/verify-win32-input.sh
new file mode 100755
index 0000000..eb9be61
--- /dev/null
+++ b/scripts/verify-win32-input.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win32-input.sh - Typed-character acceptance gate for the Win32 port.
+# Runs KEYS.EXE under Wine (scripts/lib/wineGate.sh) at one window pixel per
+# stage pixel (JOEYLIB_SCALE=1, JOEYLIB_ASPECT=square), types the RetroNet
+# definition-of-done string with xdotool (real X key events through XTEST,
+# which Wine turns into WM_KEYDOWN/WM_CHAR), grabs the window, and decodes
+# the example's verification scanline (row 198) with scripts/lib/keysRow.py.
+#
+# Usage:   scripts/verify-win32-input.sh
+# Requires: Wine (install.sh install emulator:win32), Xvfb, xdotool,
+#           ImageMagick, python3 + PIL; build/win32/bin/KEYS.EXE
+#           (make win32).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/wineGate.sh"
+label=verify-win32-input
+wineNeed
+
+target="192.168.1.10:6510"
+program=$(joeyExampleProgram keys win32)
+[ -f "$program" ] || { echo "$label: build KEYS.EXE first (make win32)" >&2; exit 2; }
+
+work=$(mktemp -d -t "joeylib-win32-input.XXXXXX")
+cleanup() {
+    wineStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+wineStart "$work"
+JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square wineRun "$program"
+win=$(wineWindow KEYS) || { echo "$label: FAIL - KEYS window not found" >&2; exit 1; }
+sleep 2
+xdotool windowfocus --sync "$win"
+xdotool type --delay 150 -- "$target"
+sleep 2
+import -window "$win" "$work/keys.png"
+# The window's client area at one pixel a pixel: the row must be exactly at
+# (0,198) of the stage, wherever Wine put the client area in the grab.
+if python3 "$repo/scripts/lib/keysRow.py" "$work/keys.png" "$target" --scale 1; then
+    echo "$label: PASS (typed \"$target\", all ${#target} chars arrived via jlInputGetChar)"
+else
+    echo "$label: FAIL" >&2
+    exit 1
+fi
diff --git a/scripts/verify-win32-joy.sh b/scripts/verify-win32-joy.sh
new file mode 100755
index 0000000..4f2a261
--- /dev/null
+++ b/scripts/verify-win32-joy.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win32-joy.sh - Joystick gate for the Win32 port: both sticks, every
+# direction, both buttons and every release, driven through two virtual
+# gamepads under Wine and read back from what examples/joy draws; then the
+# pads unplugged while JOY runs.
+#
+# The port (src/hosted/win32/win32Backend.c) reads JOYSTICKID1 and
+# JOYSTICKID2 through joyGetPosEx once a virtual frame, their X and Y scaled
+# from the driver's range and buttons 1 and 2. Under Wine those come from its
+# bus driver (winebus, through the host's SDL); the pads are Linux uinput
+# devices laid out as Xbox 360 pads (scripts/lib/padGate.sh), the only ones
+# SDL is let see. JOY runs headless under Wine (scripts/lib/wineGate.sh) at
+# one window pixel per stage pixel, and window grabs go through
+# scripts/lib/joyScreen.py (scripts/lib/joyGate.sh). Removing the pads must
+# leave both sticks disconnected, and Escape must then end JOY.
+#
+# Usage:   scripts/verify-win32-joy.sh
+# Env:     JOY_SHOTS  also keep the screenshots in this folder
+# Requires: Wine, write access to /dev/uinput, Xvfb, xdotool, ImageMagick,
+#           python3 + PIL; build/win32/bin/JOY.EXE (make win32).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/joyGate.sh"
+source "$repo/scripts/lib/padGate.sh"
+source "$repo/scripts/lib/wineGate.sh"
+wineNeed
+
+JOY_LABEL=verify-win32-joy
+JOY_SCALE=1
+program=$(joeyExampleProgram joy win32)
+unplugSeconds=3     # the pads gone until Wine has dropped them
+exitSeconds=5       # Escape until JOY has gone
+
+[ -f "$program" ] || { echo "$JOY_LABEL: build JOY.EXE first (make win32)" >&2; exit 2; }
+
+JOY_WORK=$(mktemp -d -t joeylib-win32-joy.XXXXXX)
+win=""
+cleanup() {
+    padStop
+    wineStop
+    rm -rf "$JOY_WORK"
+}
+trap cleanup EXIT
+
+
+joyShot() {
+    import -window "$win" "$1" 2> /dev/null
+}
+
+
+wineStart "$JOY_WORK"
+padStart "$JOY_WORK"
+# Wine's services (its bus driver among them) start with the first program
+# and take its environment.
+for setting in "${PAD_ONLY_ENV[@]}"; do
+    export "${setting?}"
+done
+JOEYLIB_SCALE=1 JOEYLIB_ASPECT=square wineRun "$program"
+win=$(wineWindow JOY) || { echo "$JOY_LABEL: FAIL - JOY's window did not come up" >&2; cat "$JOY_WORK/wine.log" >&2; exit 1; }
+xdotool windowfocus --sync "$win" 2> /dev/null || true
+sleep 2
+
+padOrder
+joyCheck "rest" "A=$JOY_REST B=$JOY_REST"
+joyRunSteps "${JOY_STEPS_TWO_STICKS[@]}"
+
+padStop
+sleep "$unplugSeconds"
+joyCheck "pads unplugged" "A=$JOY_ABSENT B=$JOY_ABSENT"
+
+xdotool key Escape
+for ((waited = 0; waited < exitSeconds; waited++)); do
+    joeyAlive "$WINE_PID" || break
+    sleep 1
+done
+joeyAlive "$WINE_PID" && joyFail "Escape did not end JOY"
+
+joyResult "$JOY_LABEL" "both pads, every direction, both buttons and every release; unplugged, both sticks gone; Escape ends JOY"
diff --git a/scripts/verify-win32-save.sh b/scripts/verify-win32-save.sh
new file mode 100755
index 0000000..fa8f7f7
--- /dev/null
+++ b/scripts/verify-win32-save.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win32-save.sh - Save gate for the Win32 port: a save outlives the
+# program, beside it.
+#
+# SAVE.EXE (examples/save) counts its launches in SAVES\RUNS.DAT. It is run
+# twice under Wine from a scratch folder, started from another directory so
+# the port's own move into the program's folder is what puts SAVES there;
+# after the second run the count must be 2.
+#
+# Usage:   scripts/verify-win32-save.sh
+# Requires: Wine; build/win32/bin/SAVE.EXE (make win32).
+set -euo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/wineGate.sh"
+label=verify-win32-save
+wineNeed
+
+program=$(joeyExampleProgram save win32)
+[ -f "$program" ] || { echo "$label: build SAVE.EXE first (make win32)" >&2; exit 2; }
+runSeconds=6
+
+work=$(mktemp -d -t "joeylib-win32-save.XXXXXX")
+cleanup() {
+    wineStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/program" "$work/elsewhere"
+cp "$program" "$work/program/SAVE.EXE"
+wineStart "$work"
+for run in 1 2; do
+    # SAVE waits for ESC: each run is stopped once it has saved.
+    (cd "$work/elsewhere" && wineRun "$work/program/SAVE.EXE" && sleep "$runSeconds" && joeyStop "$WINE_PID")
+    "$(dirname "$WINE")/wineserver" -w 2> /dev/null || true
+done
+saved=$(find "$work/program" -maxdepth 2 -ipath "*/saves/runs.dat" -print -quit)
+[ -n "$saved" ] || { echo "$label: FAIL - no SAVES/runs.dat beside the program" >&2; exit 1; }
+count=$(python3 -c "import struct, sys; print(struct.unpack('&2
+    exit 1
+fi
diff --git a/scripts/verify-win32-serial.sh b/scripts/verify-win32-serial.sh
new file mode 100755
index 0000000..ec41d52
--- /dev/null
+++ b/scripts/verify-win32-serial.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win32-serial.sh - both-directions RS-232 gate for the Win32 port.
+#
+# Runs SERTEST.EXE (examples/sertest: banner, then echo) under Wine with
+# COM1 joined to the host peer (scripts/lib/serialLoopPeer.py) through a
+# pseudo-terminal: the peer makes the pty pair, and the Wine prefix's COM1 is
+# mapped to the guest end (wineComPort), so the program's CreateFile("COM1")
+# opens it. The peer waits for the banner, sends a probe and expects it
+# echoed: the chain is JoeyLib's Win32 serial HAL -> the Win32 comms calls
+# -> Wine -> the pty -> the host.
+#
+# Usage:   scripts/verify-win32-serial.sh
+# Requires: Wine, python3; build/win32/bin/SERTEST.EXE (make win32).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/wineGate.sh"
+wineNeed
+
+program=$(joeyExampleProgram sertest win32)
+[ -f "$program" ] || { echo "verify-win32-serial: build SERTEST.EXE first (make win32)" >&2; exit 2; }
+wall=40
+ptyWaitTries=40
+
+work=$(mktemp -d -t joeylib-win32-serial.XXXXXX)
+peerPid=""
+cleanup() {
+    joeyStop "$peerPid"
+    wineStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+wineStart "$work"
+python3 "$repo/scripts/lib/serialLoopPeer.py" pty "$work/pty.path" "$wall" > "$work/peer.log" 2>&1 &
+peerPid=$!
+for ((tries = 0; tries < ptyWaitTries; tries++)); do
+    [ -s "$work/pty.path" ] && break
+    sleep 0.25
+done
+[ -s "$work/pty.path" ] || { echo "verify-win32-serial: FAIL - the peer made no pty" >&2; exit 1; }
+wineComPort 1 "$(cat "$work/pty.path")"
+
+wineRun "$program"
+wait "$peerPid"
+peerStatus=$?
+peerPid=""
+cat "$work/peer.log"
+if [ "$peerStatus" -eq 0 ]; then
+    echo "verify-win32-serial: PASS (banner out, probe echoed through COM1)"
+else
+    echo "verify-win32-serial: FAIL" >&2
+    exit 1
+fi
diff --git a/scripts/verify-win32-tilebank.sh b/scripts/verify-win32-tilebank.sh
new file mode 100755
index 0000000..f0a9186
--- /dev/null
+++ b/scripts/verify-win32-tilebank.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
+# verify-win32-tilebank.sh - Tile-bank gate for the Win32 port: proves the
+# build loads the DOS bake (its stage is the same chunky 4bpp) exactly and
+# refuses a bank baked for any other target.
+#
+# Runs TILEBANK.EXE under Wine from a scratch folder holding it and the
+# DATA/tilebank its make staged from the host gate's fixture (the port makes
+# the program's own folder the current directory). Its TILEBANK line in
+# joeylog.txt must say PASS with both hashes equal to the host gate's and
+# every other target refused (scripts/lib/tileBankGate.sh).
+#
+# Usage:   scripts/verify-win32-tilebank.sh
+# Requires: a host cc, Wine; build/win32/bin/TILEBANK.EXE and its
+#           DATA/tilebank (make win32).
+set -uo pipefail
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+source "$repo/scripts/lib/joeyEnv.sh"
+source "$repo/scripts/lib/tileBankGate.sh"
+source "$repo/scripts/lib/wineGate.sh"
+label=verify-win32-tilebank
+TILEBANK_GATE=$label
+wineNeed
+
+binDir=$repo/build/win32/bin
+dataDir=$(tileBankData "$binDir")
+lineSeconds=90
+
+tileBankNeedBuild "$binDir/TILEBANK.EXE" "$dataDir" "make win32"
+tileBankReference
+
+work=$(mktemp -d -t "joeylib-win32-tilebank.XXXXXX")
+cleanup() {
+    wineStop
+    rm -rf "$work"
+}
+trap cleanup EXIT
+
+mkdir -p "$work/run/DATA"
+cp "$binDir/TILEBANK.EXE" "$work/run/"
+cp -r "$dataDir" "$work/run/DATA/tilebank"
+wineStart "$work"
+wineRun "$work/run/TILEBANK.EXE"
+log=$(tileBankAwaitLine "$work/run" "$lineSeconds" "$WINE_PID")
+if [ -z "$log" ]; then
+    echo "$label: FAIL - no TILEBANK line within ${lineSeconds}s" >&2
+    exit 1
+fi
+tileBankJudge "$log"
diff --git a/scripts/verify-x68000-audio.sh b/scripts/verify-x68000-audio.sh
index 48994be..64b7c00 100755
--- a/scripts/verify-x68000-audio.sh
+++ b/scripts/verify-x68000-audio.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-audio.sh - X68000 audio gate, read off the chips.
 #
 # Boots a built example on a Human68k floppy pair under headless MAME and
diff --git a/scripts/verify-x68000-golden.sh b/scripts/verify-x68000-golden.sh
index 570e364..da9e29a 100755
--- a/scripts/verify-x68000-golden.sh
+++ b/scripts/verify-x68000-golden.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-golden.sh - X68000 golden-hash gate.
 #
 # Builds UBER with a 1-frame measurement window (the timings are meaningless at
diff --git a/scripts/verify-x68000-input.sh b/scripts/verify-x68000-input.sh
index c926006..2a93788 100755
--- a/scripts/verify-x68000-input.sh
+++ b/scripts/verify-x68000-input.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-input.sh - Headless typed-character acceptance gate for
 # the X68000 port. Boots Human68k under the patched MAME with KEYS.X in
 # AUTOEXEC.BAT, waits for the example's A5A5 sentinel to appear on the
diff --git a/scripts/verify-x68000-joy.sh b/scripts/verify-x68000-joy.sh
index 002feac..ef37242 100755
--- a/scripts/verify-x68000-joy.sh
+++ b/scripts/verify-x68000-joy.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-joy.sh - Joystick gate for the X68000: both ports, every
 # direction, both triggers, and every release, driven through MAME's joystick
 # inputs and read back from what examples/joy draws.
diff --git a/scripts/verify-x68000-mouse.sh b/scripts/verify-x68000-mouse.sh
index 24e69eb..2615ca1 100755
--- a/scripts/verify-x68000-mouse.sh
+++ b/scripts/verify-x68000-mouse.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-mouse.sh - Headless mouse acceptance gate for the X68000
 # port. Boots Human68k under the patched MAME with KEYS.X in
 # AUTOEXEC.BAT, waits for the mouse verification scanline's 5A5A
diff --git a/scripts/verify-x68000-scb.sh b/scripts/verify-x68000-scb.sh
index 35c6926..ccf3be0 100755
--- a/scripts/verify-x68000-scb.sh
+++ b/scripts/verify-x68000-scb.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-scb.sh - SCB gate for the X68000: proves that the per-band
 # palette reloads the text-plane present depends on land where they must, on
 # a screen that changes palette 147 times a frame.
diff --git a/scripts/verify-x68000-serial-mouse.sh b/scripts/verify-x68000-serial-mouse.sh
index 9f26194..3818b8d 100755
--- a/scripts/verify-x68000-serial-mouse.sh
+++ b/scripts/verify-x68000-serial-mouse.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-serial-mouse.sh - the X68000's RS-232C port and mouse at once.
 #
 # Both live on the one Z8530 SCC: RS-232C on channel A, where JoeyLib's serial
diff --git a/scripts/verify-x68000-serial-scb.sh b/scripts/verify-x68000-serial-scb.sh
index f2f6eda..161aa49 100755
--- a/scripts/verify-x68000-serial-scb.sh
+++ b/scripts/verify-x68000-serial-scb.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-serial-scb.sh - X68000 RS-232C receive (and the mouse) under a
 # per-line palette change: the SCB raster interrupt and the SCC together.
 #
diff --git a/scripts/verify-x68000-serial.sh b/scripts/verify-x68000-serial.sh
index 666c55b..94ad59f 100755
--- a/scripts/verify-x68000-serial.sh
+++ b/scripts/verify-x68000-serial.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-serial.sh - both-directions RS-232C gate for the X68000 port.
 #
 # Boots SERIAL.X under the patched MAME with SCC channel A wired to a null_modem
diff --git a/scripts/verify-x68000-tilebank.sh b/scripts/verify-x68000-tilebank.sh
index 9239f8f..d477150 100755
--- a/scripts/verify-x68000-tilebank.sh
+++ b/scripts/verify-x68000-tilebank.sh
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # verify-x68000-tilebank.sh - Tile-bank gate for the X68000: proves that
 # assetbake's x68000 encoder (plane-major, like the Amiga's, under its own
 # target byte) writes exactly the layout the X68000's native tile paste
diff --git a/scripts/x68kSerialPeer.py b/scripts/x68kSerialPeer.py
index b39f74f..f2aaabe 100755
--- a/scripts/x68kSerialPeer.py
+++ b/scripts/x68kSerialPeer.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """Host end of the X68000 serial gate.
 
 MAME's null_modem socket form CONNECTS outward, so this side listens. Once the
diff --git a/src/amiga/SDI_compiler.h b/src/amiga/SDI_compiler.h
index 052db17..8f9fe77 100644
--- a/src/amiga/SDI_compiler.h
+++ b/src/amiga/SDI_compiler.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Minimal SDI_compiler.h shim. The full SDI suite is a multi-compiler
 // macro layer; PTPlayer.h only needs ASM + REG, both of which collapse
 // to GCC m68k-amigaos register-binding syntax under Bebbo's compiler.
diff --git a/src/amiga/amigaPlanar.h b/src/amiga/amigaPlanar.h
index 068a252..433a50e 100644
--- a/src/amiga/amigaPlanar.h
+++ b/src/amiga/amigaPlanar.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Amiga per-surface planar storage, shared between the HAL
 // (src/amiga/hal.c) and the cross-platform sprite dispatchers
 // (src/codegen/spriteCompile.c) so plane-pointer access inlines
diff --git a/src/amiga/amigaTile.h b/src/amiga/amigaTile.h
index 14a5607..83c2566 100644
--- a/src/amiga/amigaTile.h
+++ b/src/amiga/amigaTile.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Amiga tile ops: the shared planar bodies in src/m68k/planarTile.h, bound to
 // the Amiga's planar surface data. The X68000 uses the same bodies
 // (src/x68000/x68kTile.h). Do not include this header directly; it comes with
diff --git a/src/amiga/audio.c b/src/amiga/audio.c
index 0230d9b..ac5c34b 100644
--- a/src/amiga/audio.c
+++ b/src/amiga/audio.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Amiga audio HAL: PTPlayer (Frank Wille, public domain) drives Paula
 // for both module playback and one-shot SFX overlay.
 //
diff --git a/src/amiga/hal.c b/src/amiga/hal.c
index ee3ba42..7ae84e9 100644
--- a/src/amiga/hal.c
+++ b/src/amiga/hal.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Commodore Amiga HAL for M2 + M2.5.
 //
 // M2 scope:
@@ -27,6 +31,7 @@
 #include "joey/tile.h"
 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -77,9 +82,12 @@ static void                amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widt
 static void                amigaPlanarSetPixel(AmigaPlanarT *pd, int16_t x, int16_t y, uint8_t color);
 static void                amigaPresentFlushRun(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t runFirst, int16_t runLast, uint16_t byteStart, uint16_t byteLen, bool *blitOwned);
 static void                amigaPresentRectInner(AmigaPlanarT *pd, UBYTE *destPlanes[AMIGA_BITPLANES], int16_t firstRow, int16_t lastRow, uint16_t byteStart, uint16_t bytesPerRow, bool *blitOwned);
+static uint16_t            bandsFromScb(const jlSurfaceT *src, uint8_t *lines, uint8_t *palettes);
 static void                buildCopperList(const jlSurfaceT *src);
 static void                installCopperList(void);
+static void                patchBandColors(const jlSurfaceT *src, uint16_t palettes);
 static void                patchBplCopperPokes(UBYTE *planes[AMIGA_BITPLANES]);
+static void                scanBandColorPokes(void);
 static void                scanBplCopperPokes(void);
 static void                uploadFirstBandPalette(const jlSurfaceT *src);
 static void                updateCopperIfNeeded(const jlSurfaceT *src);
@@ -105,6 +113,31 @@ static struct UCopList  *gNewUCL   = NULL;  // built but not yet installed
 // installed (i.e. before the first jlpPresent).
 static volatile uint16_t *gBplPokes[AMIGA_BITPLANES][2];  // [plane][0=PTH,1=PTL]
 
+// The SCB bands of the installed user copper list, top to bottom: the line
+// each one starts on and the palette it loads (buildCopperList records them
+// for the list it builds, installCopperList adopts them), and, found in the
+// merged hardware list by scanBandColorPokes, the data word of its COLOR00
+// MOVE -- the 16 colour MOVEs follow it COPPER_INSTR_WORDS apart. A palette
+// change, or an SCB change that leaves every band where it was and only
+// gives some another palette, patches those words in place
+// (patchBandColors) instead of rebuilding and merging the whole copper
+// list, which costs about three frames on an A500 (MrgCop, LoadView and a
+// WaitTOF). gBandPokesValid is false until the pokes of the installed list
+// are all found; then the full rebuild is the fallback.
+#define COPPER_INSTR_WORDS   2
+#define COPPER_WAIT_BIT      0x0001u
+#define COPPER_REG_MASK      0x01FEu
+#define COPPER_COLOR00       0x0180u
+#define COPPER_COLOR_STRIDE  2u
+static uint8_t            gBandLine      [SURFACE_HEIGHT];
+static uint8_t            gBandPalette   [SURFACE_HEIGHT];
+static uint8_t            gNewBandLine   [SURFACE_HEIGHT];
+static uint8_t            gNewBandPalette[SURFACE_HEIGHT];
+static uint16_t           gBandCount      = 0;
+static uint16_t           gNewBandCount   = 0;
+static volatile uint16_t *gBandColorWords[SURFACE_HEIGHT];
+static bool               gBandPokesValid = false;
+
 // Snapshot of the previous frame's dirty bands. With N=2 buffers,
 // the back buffer holds content from 2 frames ago, so per-present we
 // must refresh the union(prev, current) of dirty rows -- not just
@@ -340,18 +373,42 @@ static void amigaBlitterMaskedFillOne(uint8_t *dst, uint16_t widthWords, uint16_
 }
 
 
+// The SCB as bands: lines[b] is where band b starts and palettes[b] the
+// palette it loads (an out-of-range SCB entry counts as palette 0); a band
+// ends where the next starts. Returns the number of bands.
+static uint16_t bandsFromScb(const jlSurfaceT *src, uint8_t *lines, uint8_t *palettes) {
+    uint16_t count;
+    uint16_t line;
+    uint8_t  palIdx;
+
+    count = 0;
+    for (line = 0; line < SURFACE_HEIGHT; line++) {
+        palIdx = src->scb[line];
+        if (palIdx >= SURFACE_PALETTE_COUNT) {
+            palIdx = 0;
+        }
+        if (count != 0 && palettes[count - 1u] == palIdx) {
+            continue;
+        }
+        lines[count]    = (uint8_t)line;
+        palettes[count] = palIdx;
+        count++;
+    }
+    return count;
+}
+
+
 // Build a user copper list for per-scanline palette (SCB emulation).
-// One WAIT + 16 MOVEs per displayed scanline + one CEND. The list is
+// One WAIT + 16 MOVEs per band (bandsFromScb) + one CEND. The list is
 // stored in gNewUCL until installCopperList swaps it onto the screen.
 // DyOffset tells us where display line 0 sits in hardware coordinates
 // so the WAITs line up with the real visible region regardless of
 // PAL/NTSC or any overscan the user may have configured.
 static void buildCopperList(const jlSurfaceT *src) {
     struct UCopList *ucl;
-    UWORD            line;
+    UWORD            band;
     UWORD            col;
     UBYTE            palIdx;
-    UWORD            prevPalIdx;
     UWORD            vpos;
     UWORD            topBorder;
 
@@ -381,24 +438,16 @@ static void buildCopperList(const jlSurfaceT *src) {
     // (175 + 44 = 219 < 256), avoiding MrgCop's destructive wrap-
     // handling path that would otherwise disable bitplane DMA at
     // the viewport end.
-    topBorder  = 0;
-    prevPalIdx = 0xFFFF;
+    topBorder     = 0;
+    gNewBandCount = bandsFromScb(src, gNewBandLine, gNewBandPalette);
 
-    for (line = 0; line < SURFACE_HEIGHT; line++) {
-        palIdx = src->scb[line];
-        if (palIdx >= SURFACE_PALETTE_COUNT) {
-            palIdx = 0;
-        }
-        if ((UWORD)palIdx == prevPalIdx) {
-            continue;
-        }
-
-        vpos = (UWORD)(line + topBorder);
+    for (band = 0; band < gNewBandCount; band++) {
+        palIdx = gNewBandPalette[band];
+        vpos   = (UWORD)(gNewBandLine[band] + topBorder);
         CWAIT(ucl, vpos, 0);
         for (col = 0; col < SURFACE_COLORS_PER_PALETTE; col++) {
             CMOVE(ucl, custom.color[col], src->palette[palIdx][col]);
         }
-        prevPalIdx = (UWORD)palIdx;
     }
     CEND(ucl);
 
@@ -428,6 +477,9 @@ static void installCopperList(void) {
     gScreen->ViewPort.UCopIns = gNewUCL;
     gNewUCL = NULL;
     Permit();
+    memcpy(gBandLine, gNewBandLine, gNewBandCount);
+    memcpy(gBandPalette, gNewBandPalette, gNewBandCount);
+    gBandCount = gNewBandCount;
 
     MakeScreen(gScreen);
 
@@ -441,8 +493,88 @@ static void installCopperList(void) {
     // MrgCop rebuilt the hardware copper list from scratch, including
     // fresh BPL pointer pokes derived from gBitMap->Planes[]. Re-cache
     // pointers to those pokes so the per-frame flip in jlpPresent can
-    // patch them in place without another MrgCop pass.
+    // patch them in place without another MrgCop pass; and the bands'
+    // colour MOVEs, for palette changes.
     scanBplCopperPokes();
+    scanBandColorPokes();
+}
+
+
+// Write the colours of the changed palettes (bit N of palettes = palette N)
+// into the colour MOVEs of every band that loads one. The copper reads each
+// word whole, so a band the beam is in the middle of shows its new colours
+// from the next frame; the bands below it change this frame.
+static void patchBandColors(const jlSurfaceT *src, uint16_t palettes) {
+    volatile uint16_t *words;
+    uint16_t           band;
+    uint16_t           col;
+    uint8_t            palIdx;
+
+    for (band = 0; band < gBandCount; band++) {
+        palIdx = gBandPalette[band];
+        if ((palettes & (1u << palIdx)) == 0) {
+            continue;
+        }
+        words = gBandColorWords[band];
+        for (col = 0; col < SURFACE_COLORS_PER_PALETTE; col++) {
+            words[col * COPPER_INSTR_WORDS] = src->palette[palIdx][col];
+        }
+    }
+}
+
+
+// Find each band's 16 colour MOVEs in the merged hardware copper list: a run
+// of MOVEs to COLOR00..COLOR15 in order, not followed by COLOR16 (Intuition's
+// own colour load for the screen can be such a run too, or a longer one, but
+// it comes first: the bands are the LAST gBandCount runs). Leaves
+// gBandPokesValid false unless every band's run is found.
+static void scanBandColorPokes(void) {
+    struct View       *view;
+    volatile uint16_t *cop;
+    volatile uint16_t *runs[SURFACE_HEIGHT];
+    uint16_t           runCount;
+    uint16_t           maxPairs;
+    uint16_t           i;
+    uint16_t           col;
+    uint16_t           band;
+
+    gBandPokesValid = false;
+    view = ViewAddress();
+    if (view == NULL || view->LOFCprList == NULL || view->LOFCprList->start == NULL || gBandCount == 0) {
+        return;
+    }
+    cop      = (volatile uint16_t *)view->LOFCprList->start;
+    maxPairs = (uint16_t)view->LOFCprList->MaxCount;
+    runCount = 0;
+    i        = 0;
+    while (i + SURFACE_COLORS_PER_PALETTE <= maxPairs) {
+        for (col = 0; col < SURFACE_COLORS_PER_PALETTE; col++) {
+            if ((cop[(i + col) * COPPER_INSTR_WORDS] & (COPPER_REG_MASK | COPPER_WAIT_BIT)) != COPPER_COLOR00 + col * COPPER_COLOR_STRIDE) {
+                break;
+            }
+        }
+        if (col < SURFACE_COLORS_PER_PALETTE) {
+            i++;
+            continue;
+        }
+        // A 16-colour run; one that goes on into COLOR16 is not a band's.
+        if (i + SURFACE_COLORS_PER_PALETTE < maxPairs && (cop[(i + SURFACE_COLORS_PER_PALETTE) * COPPER_INSTR_WORDS] & (COPPER_REG_MASK | COPPER_WAIT_BIT)) == COPPER_COLOR00 + SURFACE_COLORS_PER_PALETTE * COPPER_COLOR_STRIDE) {
+            i += SURFACE_COLORS_PER_PALETTE;
+            continue;
+        }
+        if (runCount == SURFACE_HEIGHT) {
+            return;
+        }
+        runs[runCount++] = &cop[i * COPPER_INSTR_WORDS + 1];
+        i += SURFACE_COLORS_PER_PALETTE;
+    }
+    if (runCount < gBandCount) {
+        return;
+    }
+    for (band = 0; band < gBandCount; band++) {
+        gBandColorWords[band] = runs[runCount - gBandCount + band];
+    }
+    gBandPokesValid = true;
 }
 
 
@@ -540,15 +672,38 @@ static uint32_t gCopperRebuildCount = 0;
 static uint32_t gPresentCallCount   = 0;
 
 static void updateCopperIfNeeded(const jlSurfaceT *src) {
+    uint16_t palettes;
+    uint16_t band;
+
     if (!gStageScbDirty && !gStagePaletteDirty) {
         return;
     }
+    palettes = gStagePaletteDirty ? gStagePaletteDirtyMask : 0u;
+    // An SCB change that leaves every band where it was only gives some
+    // bands another palette: patch those bands' colours too.
+    if (gStageScbDirty && gBandPokesValid && bandsFromScb(src, gNewBandLine, gNewBandPalette) == gBandCount && memcmp(gNewBandLine, gBandLine, gBandCount) == 0) {
+        for (band = 0; band < gBandCount; band++) {
+            if (gNewBandPalette[band] != gBandPalette[band]) {
+                gBandPalette[band] = gNewBandPalette[band];
+                palettes          |= (uint16_t)(1u << gBandPalette[band]);
+            }
+        }
+        gStageScbDirty = false;
+    }
+    // Only colours changed, on bands whose MOVEs are known: patch them.
+    if (!gStageScbDirty && gBandPokesValid) {
+        patchBandColors(src, palettes);
+        gStagePaletteDirty     = false;
+        gStagePaletteDirtyMask = 0;
+        return;
+    }
     gCopperRebuildCount++;
     uploadFirstBandPalette(src);
     buildCopperList(src);
     installCopperList();
-    gStageScbDirty     = false;
-    gStagePaletteDirty = false;
+    gStageScbDirty         = false;
+    gStagePaletteDirty     = false;
+    gStagePaletteDirtyMask = 0;
 }
 
 
@@ -1090,6 +1245,15 @@ static void removeVblServer(void) {
 }
 
 
+// A 68020 or later caches instructions (and from the 68030 data, copyback
+// on the 68040 and 68060): code just written must leave the data cache and
+// stale copies the instruction cache before it runs. exec does both, and
+// nothing on a 68000.
+void jlpCodeWritten(void *addr, uint32_t bytes) {
+    CacheClearE(addr, bytes, CACRF_ClearI | CACRF_ClearD);
+}
+
+
 uint16_t jlpFrameCount(void) {
     return gFrameCount;
 }
diff --git a/src/amiga/input.c b/src/amiga/input.c
index f59b8de..d7223c5 100644
--- a/src/amiga/input.c
+++ b/src/amiga/input.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Amiga keyboard + mouse input: opens a backdrop borderless window on
 // the JoeyLib CUSTOMSCREEN to receive IDCMP_RAWKEY, IDCMP_MOUSEMOVE,
 // and IDCMP_MOUSEBUTTONS events, then drains those messages each
@@ -65,6 +69,11 @@
 // of failing at startup.
 struct Library *KeymapBase = NULL;
 
+// lowlevel.library base referenced by the ReadJoyPort and SetJoyPortAttrs
+// inline stubs, defined and opened here for the same reason: a boot drive
+// without Workbench's Libs/lowlevel.library degrades to "no joysticks".
+struct Library *LowLevelBase = NULL;
+
 // ----- External from hal.c -----
 
 extern struct Screen *gScreen;
@@ -88,6 +97,7 @@ static UWORD *gEmptyPointer = NULL;
 // ----- Prototypes -----
 
 static void drainMessages(void);
+static void joystickUnlock(int stick);
 static void pollJoysticks(void);
 
 // ----- Module state -----
@@ -157,14 +167,19 @@ static const uint8_t gRawKeyToKey[AMIGA_KEY_TABLE_SIZE] = {
 };
 
 static struct Window  *gWindow      = NULL;
-static struct Library *gLowLevelBase = NULL;
 
 // AmigaOS exposes joystick port 1 (the dedicated stick) via lowlevel
 // ReadJoyPort(1); port 0 shares the mouse port. We map our public
 // JOYSTICK_0 to Amiga port 1 so the primary stick is what retro games
 // expect.
-#define AMIGA_PORT_FOR_JS0  1
-#define AMIGA_PORT_FOR_JS1  0
+static const ULONG gAmigaPortForStick[JOYSTICK_COUNT] = { 1, 0 };
+
+// lowlevel.library senses a port's controller again now and then, and a
+// two-button stick held with both buttons down reads as no controller
+// then, until it moves (measured under FS-UAE). So a port that has shown
+// a stick is set to one (SetJoyPortAttrs) and stays one until
+// jlJoystickReset or shutdown hands it back to autosensing.
+static bool gJoyLocked[JOYSTICK_COUNT];
 
 // ----- Internal helpers -----
 
@@ -254,25 +269,35 @@ static void drainMessages(void) {
 }
 
 
+// Hand a locked port back to lowlevel.library's autosensing (which also
+// frees the potgo bits it took for the stick).
+static void joystickUnlock(int stick) {
+    if (!gJoyLocked[stick]) {
+        return;
+    }
+    SetJoyPortAttrs(gAmigaPortForStick[stick], SJA_Reinitialize, TRUE, TAG_DONE);
+    gJoyLocked[stick] = false;
+}
+
+
 // Read both joystick ports via lowlevel.library. Devices that report
 // JP_TYPE_JOYSTK or JP_TYPE_GAMECTLR populate gJoy*; anything else
 // (mouse on port 0, no device, unknown) leaves the stick disconnected
 // with zeroed state.
 static void pollJoysticks(void) {
-    static const int amigaPortForStick[JOYSTICK_COUNT] = {
-        AMIGA_PORT_FOR_JS0,
-        AMIGA_PORT_FOR_JS1
-    };
     ULONG state;
     ULONG type;
     int   stick;
 
-    if (gLowLevelBase == NULL) {
+    if (LowLevelBase == NULL) {
         return;
     }
     for (stick = 0; stick < JOYSTICK_COUNT; stick++) {
-        state = ReadJoyPort((UWORD)amigaPortForStick[stick]);
+        state = ReadJoyPort(gAmigaPortForStick[stick]);
         type  = state & JP_TYPE_MASK;
+        if (type == JP_TYPE_JOYSTK && !gJoyLocked[stick]) {
+            gJoyLocked[stick] = SetJoyPortAttrs(gAmigaPortForStick[stick], SJA_Type, SJA_TYPE_JOYSTK, TAG_DONE) != FALSE;
+        }
         if (type != JP_TYPE_JOYSTK && type != JP_TYPE_GAMECTLR) {
             gJoyConnected[stick] = false;
             gJoyAxisX[stick]     = 0;
@@ -300,9 +325,10 @@ static void pollJoysticks(void) {
 
 // ----- HAL API (alphabetical) -----
 
+// Sticks are digital, so nothing to calibrate: the port goes back to
+// autosensing, as a stick swapped for a mouse or a game controller needs.
 void jlpJoystickReset(jlJoystickE js) {
-    // Amiga sticks are digital -- no calibration to do.
-    (void)js;
+    joystickUnlock((int)js);
 }
 
 
@@ -343,10 +369,11 @@ void jlpInputInit(void) {
         }
     }
 
-    // lowlevel.library shipped with AmigaOS 2.05+. If absent (very old
-    // Kickstart, or stripped AROS), the joystick API silently reports
-    // disconnected sticks rather than failing init.
-    gLowLevelBase = OpenLibrary((CONST_STRPTR)"lowlevel.library", 0);
+    // lowlevel.library (OS 3.1, V40) is a disk library: Kickstart has none
+    // in ROM. If the boot drive has none either, the joystick API silently
+    // reports disconnected sticks rather than failing init.
+    memset(gJoyLocked, 0, sizeof(gJoyLocked));
+    LowLevelBase = OpenLibrary((CONST_STRPTR)"lowlevel.library", 0);
 
     // keymap.library (OS 2.0+) drives the typed-character path. If
     // absent, jlInputGetChar just stays empty; the predicates still
@@ -366,9 +393,13 @@ void jlpInputShutdown(void) {
         CloseLibrary(KeymapBase);
         KeymapBase = NULL;
     }
-    if (gLowLevelBase != NULL) {
-        CloseLibrary(gLowLevelBase);
-        gLowLevelBase = NULL;
+    if (LowLevelBase != NULL) {
+        // The ports' types are lowlevel.library's, shared with every
+        // program: none may stay set to a stick after this one.
+        joystickUnlock(JOYSTICK_0);
+        joystickUnlock(JOYSTICK_1);
+        CloseLibrary(LowLevelBase);
+        LowLevelBase = NULL;
     }
     if (gWindow == NULL) {
         return;
diff --git a/src/amiga/libinit.c b/src/amiga/libinit.c
index 20ad16a..2806c0a 100644
--- a/src/amiga/libinit.c
+++ b/src/amiga/libinit.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Tolerant replacement for libnix's __initlibraries / __exitlibraries.
 //
 // libnix's version treats any OpenLibrary failure as fatal: it walks the
diff --git a/src/amiga/presentScan.s b/src/amiga/presentScan.s
index 81ac338..9066021 100644
--- a/src/amiga/presentScan.s
+++ b/src/amiga/presentScan.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Amiga present union-scan -- 68000 hand-rolled (NATIVE-PERF Phase 7).
 |
 | Replaces jlpPresent's C union-scan loop. The C version was register-
diff --git a/src/amiga/save.c b/src/amiga/save.c
index fa40262..870830b 100644
--- a/src/amiga/save.c
+++ b/src/amiga/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Amiga save-file + disk-space HAL.
 //
 // SAVES/ is a real directory next to the executable, made with dos.library
diff --git a/src/amiga/serial.c b/src/amiga/serial.c
index 8d9b54c..2cd68f8 100644
--- a/src/amiga/serial.c
+++ b/src/amiga/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Amiga serial HAL -- serial.device via exec I/O requests.
 //
 // The Amiga does not expose the Paula UART registers to apps; the OS owns them
diff --git a/src/atarist/audio.c b/src/atarist/audio.c
index 35dde40..925fd00 100644
--- a/src/atarist/audio.c
+++ b/src/atarist/audio.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST audio HAL: libxmp-lite Protracker decoder + 68k Timer A IRQ
 // running PWM through YM2149 channel-A volume register.
 //
@@ -18,8 +22,16 @@
 //
 // Quality budget: 4-bit unsigned PWM at 12.3 kHz on the YM is grim by
 // modern standards but period-correct for stock 520ST/1040ST without
-// the STe DMA chip. Library users that want the higher-fidelity STe
-// path can skip this HAL and build their own.
+// the STe DMA chip.
+//
+// The STe build (make ste, JOEYLIB_ATARI_STE) plays the same mix buffer
+// through the STe's 8-bit DMA sound chip instead, mono at 12517 Hz,
+// looping over the whole buffer with no interrupt at all.
+// jlpAudioFrameTick reads the chip's frame counter to see which half it
+// has finished, converts each refilled half to the chip's signed
+// samples, and channel A stays the PSG's: the chiptune keeps all three
+// voices while digital sound plays, with no collision to arbitrate. The
+// ST build never touches the DMA chip.
 
 #include 
 
@@ -31,15 +43,11 @@
 #include "audioSfxMixInternal.h"
 #include "joey/audio.h"
 
-// ----- Constants -----
+#ifdef JOEYLIB_ATARI_STE
+#include "steMachine.h"
+#endif
 
-// MFP Timer A registers and the autovector slot for its IRQ (vector
-// $134, Setexc(0x134/4, ...) = Setexc(13, ...)).
-#define ST_MFP_TACR     ((volatile uint8_t *)0xFFFFFA19L)
-#define ST_MFP_TADR     ((volatile uint8_t *)0xFFFFFA1FL)
-#define ST_MFP_IERA     ((volatile uint8_t *)0xFFFFFA07L)
-#define ST_MFP_IMRA     ((volatile uint8_t *)0xFFFFFA13L)
-#define ST_MFP_ISRA     ((volatile uint8_t *)0xFFFFFA0FL)
+// ----- Constants -----
 
 // YM2149 (sound chip) supervisor-only ports. Index reg 7 (mixer)
 // controls per-channel tone + noise enables; reg 8/9/A are volumes
@@ -69,6 +77,49 @@
 #define YM_MIX_NOISE_A      0x08u
 #define YM_MIX_NOISE_C      0x20u
 
+#define MIX_BUFFER      2048
+#define MIX_HALF        (MIX_BUFFER / 2)
+
+// libxmp and the SFX overlay mix unsigned samples centred on $80.
+#define MIX_SILENCE_UNSIGNED 0x80u
+
+#ifdef JOEYLIB_ATARI_STE
+// STe build: the output stage is the DMA chip, which plays signed
+// samples, so the buffer's silence is 0 and a refilled half flips each
+// sample's top bit (a long at a time) before the chip reaches it.
+#define MIX_RATE           12517        // Hz, the DMA chip's 12.5 kHz rate
+#define MIX_SILENCE        0x00u
+#define MIX_SIGN_FLIP_LONG 0x80808080ul
+
+// DMA sound (supervisor-only, all byte registers at odd addresses). The
+// frame start and end addresses latch when a frame begins, so with
+// repeat set the chip loops over [start, end) on its own; the counter
+// reads back the address it is playing.
+#define STE_DMA_CTRL      ((volatile uint8_t *)0xFFFF8901L)
+#define STE_DMA_START_HI  ((volatile uint8_t *)0xFFFF8903L)
+#define STE_DMA_START_MID ((volatile uint8_t *)0xFFFF8905L)
+#define STE_DMA_START_LO  ((volatile uint8_t *)0xFFFF8907L)
+#define STE_DMA_COUNT_HI  ((volatile uint8_t *)0xFFFF8909L)
+#define STE_DMA_COUNT_MID ((volatile uint8_t *)0xFFFF890BL)
+#define STE_DMA_COUNT_LO  ((volatile uint8_t *)0xFFFF890DL)
+#define STE_DMA_END_HI    ((volatile uint8_t *)0xFFFF890FL)
+#define STE_DMA_END_MID   ((volatile uint8_t *)0xFFFF8911L)
+#define STE_DMA_END_LO    ((volatile uint8_t *)0xFFFF8913L)
+#define STE_DMA_MODE      ((volatile uint8_t *)0xFFFF8921L)
+#define STE_DMA_STOP      0x00u
+#define STE_DMA_PLAY_LOOP 0x03u         // bit 0 play, bit 1 repeat
+#define STE_DMA_MONO_12K  0x81u         // bit 7 mono, rate 1 = 12517 Hz
+#else
+// ST build: the output stage is PWM through YM channel A's volume, one
+// sample per MFP Timer A interrupt.
+// MFP Timer A registers and the autovector slot for its IRQ (vector
+// $134, Setexc(0x134/4, ...) = Setexc(13, ...)).
+#define ST_MFP_TACR     ((volatile uint8_t *)0xFFFFFA19L)
+#define ST_MFP_TADR     ((volatile uint8_t *)0xFFFFFA1FL)
+#define ST_MFP_IERA     ((volatile uint8_t *)0xFFFFFA07L)
+#define ST_MFP_IMRA     ((volatile uint8_t *)0xFFFFFA13L)
+#define ST_MFP_ISRA     ((volatile uint8_t *)0xFFFFFA0FL)
+
 #define MFP_TA_BIT      0x20
 #define MFP_TACR_STOP   0x00
 #define MFP_TACR_DIV200 0x07
@@ -77,21 +128,39 @@
 #define INT_TIMER_A     13              // Jenabint / Jdisint id for Timer A
 
 #define MIX_RATE        12288           // Hz, matches MFP 2.4576 MHz / 200
-#define MIX_BUFFER      2048
-#define MIX_HALF        (MIX_BUFFER / 2)
+#define MIX_SILENCE     MIX_SILENCE_UNSIGNED
+#endif
 
 // ----- Module state -----
 
-// The ISR (audio_isr.s) reads/writes these. Names must match the
-// .extern declarations there.
-volatile uint8_t  gMixBuf[MIX_BUFFER];
+// The mix buffer and its refill flags, which the PWM ISR (audio_isr.s)
+// raises in the ST build and the frame tick's read of the DMA chip in the
+// STe build. Names must match the .extern declarations in audio_isr.s.
+// Even-aligned: the DMA chip can only start a frame on an even address
+// (2 is also mint a.out's alignment cap).
+volatile uint8_t  gMixBuf[MIX_BUFFER] __attribute__((aligned(2)));
+volatile uint8_t  gNeedRefill[2] = { 0, 0 };
+
+#ifdef JOEYLIB_ATARI_STE
+// The half the DMA chip was playing at the last frame tick.
+static uint8_t gDmaHalf = 0u;
+#else
+// The PWM ISR's play pointer and its half and end marks (audio_isr.s).
 volatile uint8_t *gMixPos     = NULL;
 volatile uint8_t *gMixMid     = NULL;
 volatile uint8_t *gMixEnd     = NULL;
-volatile uint8_t  gNeedRefill[2] = { 0, 0 };
 
 extern void mfpTimerAIsr(void);
 
+static void (*gOldTimerAVec)(void) = NULL;
+
+// Voice 0's last requested state, latched whether or not it could be applied.
+// While the PWM pump owns channel A the voice cannot sound, and this is what
+// gets replayed to bring it back when the pump stops. Starts silent.
+static volatile uint16_t gVoiceAFreqHz;
+static volatile uint8_t  gVoiceAAtten = 15u;
+#endif
+
 static xmp_context gXmpCtx        = NULL;
 static bool        gReady         = false;
 static bool        gXmpStarted    = false;
@@ -105,8 +174,6 @@ static int         gXmpLoopCount  = 0;
 static bool        gPumpRunning   = false;
 static bool        gHalfSilent[2] = { false, false };
 
-static void (*gOldTimerAVec)(void) = NULL;
-
 // SFX overlay shared with the DOS HAL (see src/core/audioSfxMix.c).
 // Mixed in over libxmp's MOD output during jlpAudioFrameTick.
 static AudioSfxSlotT gSfxSlots[JOEY_AUDIO_SFX_SLOTS];
@@ -127,12 +194,6 @@ static volatile uint8_t  gYmAtten;
 static volatile uint8_t  gYmNoisePitch;
 static volatile uint8_t  gYmNoiseAtten;
 
-// Voice 0's last requested state, latched whether or not it could be applied.
-// While the PWM pump owns channel A the voice cannot sound, and this is what
-// gets replayed to bring it back when the pump stops. Starts silent.
-static volatile uint16_t gVoiceAFreqHz;
-static volatile uint8_t  gVoiceAAtten = 15u;
-
 // ----- Internal helpers -----
 
 // Supexec workers defined further down (with the PSG voice cluster)
@@ -140,6 +201,18 @@ static volatile uint8_t  gVoiceAAtten = 15u;
 static long ymPsgAllOff(void);
 static long applyYmVoice(void);
 
+static bool     anySfxActive(void);
+static void     refillHalf(uint8_t half);
+static void     silenceMixBuffer(void);
+#ifdef JOEYLIB_ATARI_STE
+static uint16_t dmaPlayOffset(void);
+static void     dmaPollRefill(void);
+static void     dmaSetFrame(volatile uint8_t *hi, volatile uint8_t *mid, volatile uint8_t *lo, uint32_t addr);
+static void     dmaSignHalf(volatile uint8_t *half);
+static long     installDma(void);
+static long     uninstallDma(void);
+#endif
+
 // Writing a YM register is a SELECT store then a DATA store, and the PWM ISR
 // writes register 8 on every one of its 12288 ticks a second. If it lands
 // between the two, the foreground's data byte goes to register 8 -- a click in
@@ -170,14 +243,11 @@ static void ymWriteMixer(void) {
 }
 
 
+#ifndef JOEYLIB_ATARI_STE
 static long installTimerA(void) {
-    uint16_t i;
-
     // Park the buffer in known-silent state (unsigned 8-bit middle
     // = 128) before opening the IRQ gate.
-    for (i = 0; i < MIX_BUFFER; i++) {
-        gMixBuf[i] = 0x80;
-    }
+    silenceMixBuffer();
     gMixPos = (volatile uint8_t *)gMixBuf;
     gMixMid = (volatile uint8_t *)(gMixBuf + MIX_HALF);
     gMixEnd = (volatile uint8_t *)(gMixBuf + MIX_BUFFER);
@@ -240,16 +310,119 @@ static long uninstallTimerA(void) {
     gYmAtten  = gVoiceAAtten;
     return applyYmVoice();
 }
+#endif
 
 
+// Silence in the output stage's own sample format (MIX_SILENCE).
 static void silenceMixBuffer(void) {
-    uint16_t i;
-    for (i = 0; i < MIX_BUFFER; i++) {
-        gMixBuf[i] = 0x80;
+    memset((void *)gMixBuf, MIX_SILENCE, MIX_BUFFER);
+}
+
+
+#ifdef JOEYLIB_ATARI_STE
+// Which byte of the buffer the DMA chip is playing. The counter is three
+// byte registers read one at a time, so a carry between the reads can
+// tear the value: re-read when the high byte moved (a buffer straddling
+// a 64 KB boundary would otherwise read 64 KB away). A torn low byte is
+// at worst 255 bytes early, which only delays one refill by a tick.
+static uint16_t dmaPlayOffset(void) {
+    uint8_t hi;
+    uint8_t mid;
+    uint8_t lo;
+
+    do {
+        hi  = *STE_DMA_COUNT_HI;
+        mid = *STE_DMA_COUNT_MID;
+        lo  = *STE_DMA_COUNT_LO;
+    } while (hi != *STE_DMA_COUNT_HI);
+    return (uint16_t)((((uint32_t)hi << 16) | ((uint32_t)mid << 8) | lo) - (uint32_t)gMixBuf);
+}
+
+
+// The DMA path's stand-in for the PWM ISR's refill flags: when the chip
+// has moved on to the other half since the last tick, the half it left
+// is free to refill. The chip needs no help to loop, so a tick that
+// comes a whole buffer late replays a stale buffer once instead of
+// tearing it.
+static void dmaPollRefill(void) {
+    uint8_t half;
+
+    half = (uint8_t)(dmaPlayOffset() >= MIX_HALF);
+    if (half != gDmaHalf) {
+        gNeedRefill[gDmaHalf] = 1;
+        gDmaHalf              = half;
     }
 }
 
 
+// Write a frame address into one of the chip's three-byte registers.
+static void dmaSetFrame(volatile uint8_t *hi, volatile uint8_t *mid, volatile uint8_t *lo, uint32_t addr) {
+    *hi  = (uint8_t)(addr >> 16);
+    *mid = (uint8_t)(addr >> 8);
+    *lo  = (uint8_t)addr;
+}
+
+
+// Flip a mixed half's unsigned samples to the chip's signed ones.
+static void dmaSignHalf(volatile uint8_t *half) {
+    uint32_t *flip;
+    uint16_t  i;
+
+    flip = (uint32_t *)half;
+    for (i = 0; i < MIX_HALF / sizeof(uint32_t); i++) {
+        flip[i] ^= MIX_SIGN_FLIP_LONG;
+    }
+}
+
+
+// Supexec worker: start the DMA chip looping over the (silent) buffer.
+static long installDma(void) {
+    silenceMixBuffer();
+    gNeedRefill[0] = 0;
+    gNeedRefill[1] = 0;
+    gDmaHalf       = 0u;
+    dmaSetFrame(STE_DMA_START_HI, STE_DMA_START_MID, STE_DMA_START_LO, (uint32_t)gMixBuf);
+    dmaSetFrame(STE_DMA_END_HI, STE_DMA_END_MID, STE_DMA_END_LO, (uint32_t)(gMixBuf + MIX_BUFFER));
+    *STE_DMA_MODE = STE_DMA_MONO_12K;
+    *STE_DMA_CTRL = STE_DMA_PLAY_LOOP;
+    return 0;
+}
+
+
+// Supexec worker: stop the DMA chip. Channel A was never the DAC here,
+// so there is no voice to hand back.
+static long uninstallDma(void) {
+    *STE_DMA_CTRL = STE_DMA_STOP;
+    return 0;
+}
+#endif
+
+
+// Mix one half of the buffer: the MOD (or silence), then the SFX over
+// it, then -- for the STe's DMA chip -- into signed samples. silentFill is
+// sampled BEFORE the overlay: a slot that exhausts during this overlay
+// still wrote its tail into the half, so the half only counts as silent
+// when nothing was live going in.
+static void refillHalf(uint8_t half) {
+    volatile uint8_t *dst;
+    bool              silentFill;
+
+    dst        = gMixBuf + (uint16_t)half * MIX_HALF;
+    silentFill = !gXmpStarted && !anySfxActive();
+    if (gXmpStarted) {
+        xmp_play_buffer(gXmpCtx, (void *)dst, MIX_HALF, gXmpLoopCount);
+    } else {
+        memset((void *)dst, MIX_SILENCE_UNSIGNED, MIX_HALF);
+    }
+    audioSfxOverlayMix(dst, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS);
+#ifdef JOEYLIB_ATARI_STE
+    dmaSignHalf(dst);
+#endif
+    gHalfSilent[half] = silentFill;
+    gNeedRefill[half] = 0;
+}
+
+
 static bool anySfxActive(void) {
     uint8_t i;
     for (i = 0; i < JOEY_AUDIO_SFX_SLOTS; i++) {
@@ -265,7 +438,11 @@ static void pumpStart(void) {
     if (gPumpRunning) {
         return;
     }
+#ifdef JOEYLIB_ATARI_STE
+    Supexec(installDma);
+#else
     Supexec(installTimerA);
+#endif
     gPumpRunning   = true;
     gHalfSilent[0] = false;
     gHalfSilent[1] = false;
@@ -282,7 +459,11 @@ static void pumpStop(void) {
     // is the first thing uninstallTimerA does, so the ISR cannot fire in
     // between and the early clear costs nothing.
     gPumpRunning = false;
+#ifdef JOEYLIB_ATARI_STE
+    Supexec(uninstallDma);
+#else
     Supexec(uninstallTimerA);
+#endif
 }
 
 
@@ -292,6 +473,12 @@ bool jlpAudioInit(void) {
     if (gReady) {
         return true;
     }
+#ifdef JOEYLIB_ATARI_STE
+    // The STe build's output stage is the DMA chip: no chip, no audio.
+    if (!steMachineCheck()) {
+        return false;
+    }
+#endif
     gXmpCtx = xmp_create_context();
     if (gXmpCtx == NULL) {
         return false;
@@ -424,9 +611,13 @@ static long applyYmVoice(void) {
     // output with a square wave, and the DAC's 12 kHz writes trample the
     // voice's level. While the pump runs the DAC wins and the voice waits --
     // jlpAudioVoice has latched its state, and uninstallTimerA replays it.
+    // (The STe build's DMA chip is not the PSG at all, so there it never
+    // waits.)
+#ifndef JOEYLIB_ATARI_STE
     if (gPumpRunning && gYmVoice == 0u) {
         return 0;
     }
+#endif
 
     if (gYmFreqHz == 0u || gYmAtten >= 15u) {
         // Silence: disable tone on this channel (mixer bit 0/1/2 set),
@@ -498,8 +689,10 @@ static long ymPsgAllOff(void) {
     ymWrite(YM_REG_VOL_A, 0);
     ymWrite(YM_REG_VOL_B, 0);
     ymWrite(YM_REG_VOL_C, 0);
+#ifndef JOEYLIB_ATARI_STE
     // Drop the latched voice-0 note too, or a later pump stop would replay it.
     gVoiceAAtten = 15u;
+#endif
     return 0;
 }
 
@@ -545,11 +738,14 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
     // A (reg 8) as its DAC for as long as it runs, so voice 0 is latched here
     // and applied when the pump stops; voices 1 and 2 play throughout. Writes
     // are atomic against the ISR (see ymWrite), which is what makes the two
-    // safe to run together at all.
+    // safe to run together at all. (The STe build's DMA chip leaves channel
+    // A alone, so there is nothing to latch.)
+#ifndef JOEYLIB_ATARI_STE
     if (voice == 0u) {
         gVoiceAFreqHz = freqHz;
         gVoiceAAtten  = atten;
     }
+#endif
     gYmVoice  = voice;
     gYmFreqHz = freqHz;
     gYmAtten  = atten;
@@ -589,7 +785,8 @@ void jlpAudioStopSfx(uint8_t slot) {
 
 // Drains ISR-raised refill flags by re-running libxmp's mixer into
 // the consumed half then overlaying any active SFX. Called from the
-// game loop, never from IRQ.
+// game loop, never from IRQ. The STe build has no ISR: the flags come
+// from reading the DMA chip's play position here.
 //
 // Soft-real-time constraint: the producer (this function) must refill
 // a consumed half within MIX_HALF / MIX_RATE = 1024 / 12288 ~= 83 ms
@@ -598,11 +795,12 @@ void jlpAudioStopSfx(uint8_t slot) {
 // audioSfxOverlayMix can approach that budget under load, so callers
 // should run the game loop comfortably faster than ~12 Hz.
 void jlpAudioFrameTick(void) {
-    bool silentFill;
-
     if (!gReady || !gPumpRunning) {
         return;
     }
+#ifdef JOEYLIB_ATARI_STE
+    dmaPollRefill();
+#endif
     // Underrun guard: both flags set means the producer fell a full
     // buffer behind, so the ISR is now draining a half this tick is
     // about to rewrite. Silence first so the in-flight torn read plays
@@ -610,30 +808,11 @@ void jlpAudioFrameTick(void) {
     if (gNeedRefill[0] && gNeedRefill[1]) {
         silenceMixBuffer();
     }
-    // silentFill is sampled BEFORE the overlay: a slot that exhausts
-    // during this overlay still wrote its tail into the half, so the
-    // half only counts as silent when nothing was live going in.
     if (gNeedRefill[0]) {
-        silentFill = !gXmpStarted && !anySfxActive();
-        if (gXmpStarted) {
-            xmp_play_buffer(gXmpCtx, (void *)gMixBuf, MIX_HALF, gXmpLoopCount);
-        } else {
-            memset((void *)gMixBuf, 0x80, MIX_HALF);
-        }
-        audioSfxOverlayMix(gMixBuf, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS);
-        gHalfSilent[0] = silentFill;
-        gNeedRefill[0] = 0;
+        refillHalf(0u);
     }
     if (gNeedRefill[1]) {
-        silentFill = !gXmpStarted && !anySfxActive();
-        if (gXmpStarted) {
-            xmp_play_buffer(gXmpCtx, (void *)(gMixBuf + MIX_HALF), MIX_HALF, gXmpLoopCount);
-        } else {
-            memset((void *)(gMixBuf + MIX_HALF), 0x80, MIX_HALF);
-        }
-        audioSfxOverlayMix(gMixBuf + MIX_HALF, MIX_HALF, gSfxSlots, JOEY_AUDIO_SFX_SLOTS);
-        gHalfSilent[1] = silentFill;
-        gNeedRefill[1] = 0;
+        refillHalf(1u);
     }
     // Drain-to-silence stop: both halves hold pure silence and nothing
     // is playing, so the ISR would stream silence forever -- park it.
diff --git a/src/atarist/audio_isr.s b/src/atarist/audio_isr.s
index 068a1cc..2d8cd35 100644
--- a/src/atarist/audio_isr.s
+++ b/src/atarist/audio_isr.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Atari ST audio ISR -- MFP Timer A drives per-sample PWM output via
 | the YM2149 channel-A volume register.
 |
diff --git a/src/atarist/circle.s b/src/atarist/circle.s
index bb3161d..102c9ba 100644
--- a/src/atarist/circle.s
+++ b/src/atarist/circle.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Atari ST word-interleaved planar circle outline -- 68000 hand-rolled.
 |
 | Mirrors src/amiga/circle.s in spirit but for ST's single
diff --git a/src/atarist/copyRect.s b/src/atarist/copyRect.s
index 7c6aec4..1ee579b 100644
--- a/src/atarist/copyRect.s
+++ b/src/atarist/copyRect.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | ST interleaved-planar rect-copy spans -- 68000 hand-rolled (NATIVE-PERF
 | Phase 7, #3). Replaces the per-row memcpy CALL in jlpSurfaceCopyRect
 | (src/atarist/hal.c) for the small clean-buffer windows: gameFrameClean
diff --git a/src/atarist/fillCircle.s b/src/atarist/fillCircle.s
index d80ae6d..552ea80 100644
--- a/src/atarist/fillCircle.s
+++ b/src/atarist/fillCircle.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Atari ST word-interleaved planar jlFillCircle -- 68000 hand-rolled.
 |
 | Exact-disk span walk, bit-identical to the chunky reference span
diff --git a/src/atarist/hal.c b/src/atarist/hal.c
index dff64fb..d2ff219 100644
--- a/src/atarist/hal.c
+++ b/src/atarist/hal.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST HAL for M2 + M2.5.
 //
 // M2 scope:
@@ -19,13 +23,19 @@
 //     frame the ISR runs 8 times instead of 313 -- well under the
 //     ~147-HBL-fires-per-frame cap Hatari's MFP emulation imposes on
 //     event-count mode, and ~0.2% CPU overhead vs ~60% for per-HBL.
-//   * gLinePalettes is a flat pre-quantized (line, color)->$0RGB
-//     table built in jlpPresent by flattenScbPalettes; the ISR uses
-//     its first row per band as the source of 16 shifter writes.
+//   * gShifterPalettes holds each of the 16 palettes already in shifter
+//     format; jlpPresent requantizes only the palettes jlPaletteSet
+//     changed (gStagePaletteDirtyMask), and the ISRs copy a band's row
+//     straight into the 16 shifter registers.
+//
+// Two builds of this one port. The ST build (make atarist) is pure 68000
+// and runs on every ST: 3-bit palette channels, PWM sound, CPU fills. The
+// STe build (make ste, JOEYLIB_ATARI_STE) adds src/atarist/ste and uses
+// the STe's 4-bit palette, DMA sound (audio.c) and BLiTTER, and a Mega
+// STe's 16 MHz clock and cache; its jlInit refuses any other machine.
 //
 // Deferred:
 //   * Takeover mode (direct shifter programming without TOS).
-//   * STE's extended palette bits (we drop to STF 9-bit for now).
 
 #include 
 #include 
@@ -39,6 +49,11 @@
 #include "spriteInternal.h"
 #include "joey/tile.h"
 
+#ifdef JOEYLIB_ATARI_STE
+#include "steBlitter.h"
+#include "steMachine.h"
+#endif
+
 // ----- Constants -----
 
 // Word-interleaved ST planar uses the same 160 bytes/scanline as our
@@ -56,6 +71,21 @@
 // the back page; bit 16 = "current frame marked at least one row".
 #define ST_SLAM_CUR_DIRTY_BIT 0x00010000ul
 
+#ifdef JOEYLIB_ATARI_STE
+// STe build: where the BLiTTER beats the CPU paths, measured on an STe in
+// cycles per jlFillRect / jlSurfaceCopyRect call (2026-10-06). Its four
+// per-plane blits cost ~550 cycles to set up, so a fill spanning two or
+// more 16-pixel groups wins from 8 rows (even at 320x8, 1.3x at 80x16,
+// 1.7x at 80x80); a one-group fill, where the CPU's column RMW is cheap,
+// only from about 24 rows (1.4x at 1x200); a copy from 4 rows (1.9x at
+// 320x200). Shorter work stays on the ST's CPU paths. Decided by the
+// rect's size alone: an STe always has the chip.
+#define ST_GROUP_PIXELS                16
+#define ST_BLT_FILL_MIN_ROWS           8
+#define ST_BLT_FILL_MIN_ROWS_ONE_GROUP 24
+#define ST_BLT_COPY_MIN_ROWS           4
+#endif
+
 
 // ----- Per-surface planar storage (project_planar_68k_plan, ST Phase 2) -----
 //
@@ -70,8 +100,20 @@
 // geometry change must fail here, not silently corrupt the display.
 typedef char stPresentSlamGeometryFits[(SURFACE_HEIGHT == 200 && ST_BYTES_PER_ROW == 160 && SURFACE_WORDS_PER_ROW == 80 && ST_BYTES_PER_GROUP == 8) ? 1 : -1];
 
-// Shifter palette registers: 16 words at $FFFF8240..$FFFF825F.
-#define ST_PALETTE_REGS  ((volatile uint16_t *)0xFFFF8240L)
+// Shifter palette registers: 16 words at $FFFF8240..$FFFF825F. Each
+// channel nibble holds the channel's top three bits in bits 0-2; the STe
+// shifter adds the channel's low bit as bit 3, which a plain ST does not
+// have. So $0RGB >> 1 masked to the 3-bit fields is the ST value, and the
+// STe build ORs each channel's low bit back in at bit 3: an exact 4-bit
+// match. (In the ST build the low bits are 0 and the OR folds away.)
+#define ST_PALETTE_REGS      ((volatile uint16_t *)0xFFFF8240L)
+#define ST_PAL_HIGH_BITS     0x0777u
+#define ST_PAL_LOW_TO_BIT3   3
+#ifdef JOEYLIB_ATARI_STE
+#define ST_PAL_LOW_BITS      0x0111u
+#else
+#define ST_PAL_LOW_BITS      0x0000u
+#endif
 
 // Shifter resolution ($FF8260, low 2 bits: 0=low 1=med 2=high/mono) and
 // sync-mode ($FF820A, bit 1: set=50 Hz PAL, clear=60 Hz NTSC) registers,
@@ -186,7 +228,7 @@ static inline __attribute__((always_inline)) uint16_t stFloodStopBits(const uint
     return (uint16_t)(stGroupEqBits(gp, matchColor) | stGroupEqBits(gp, newColor));
 }
 static uint16_t quantizeColorToSt(uint16_t orgb);
-static void     flattenScbPalettes(const jlSurfaceT *src);
+static void     quantizeDirtyPalettes(const jlSurfaceT *src);
 static void     setShifterVideoBase(const uint8_t *page);
 extern void     stLeaveSuper(void *savedSsp);
 
@@ -259,11 +301,13 @@ uint8_t gPrevStageMaxWord[SURFACE_HEIGHT] __attribute__((aligned(2)));
 // gPrevStage* above so the first two presents still run.
 static bool    gPrevAnyDirty = true;
 
-// Per-scanline pre-quantized palette table. Indexed by display line;
-// each row is a 16-word palette ready to be copied straight into the
-// shifter registers. Written at present() time, read by the Timer B
-// ISR with no CPU-side math beyond a counter subtract.
-static uint16_t gLinePalettes[SURFACE_HEIGHT][SURFACE_COLORS_PER_PALETTE];
+// The stage's 16 palettes in shifter format, ready to be copied straight
+// into the shifter registers by the VBL and Timer B ISRs. One row per
+// PALETTE, not per scanline: a palette change requantizes 16 entries,
+// where the old per-line table requantized 3200 on any change -- about
+// 38 ms of 8 MHz 68000 per jlPaletteSet, which is what made Space Taxi's
+// palette-cycled warp drop frames on the ST.
+static uint16_t gShifterPalettes[SURFACE_PALETTE_COUNT][SURFACE_COLORS_PER_PALETTE];
 
 // Band-transition table. Each entry is one palette change: at
 // display line gBandStart[i], load palette indexed by gBandPalIdx[i].
@@ -291,10 +335,9 @@ static void (*gOldTimerBVec)(void) = NULL;
 // gStagePaletteDirty flags (surfaceInternal.h) -- the mutation APIs
 // set them and refreshPaletteStateIfNeeded clears them after
 // rebuilding, replacing a 712-byte cached-mirror memcmp per present
-// (NATIVE-PERF Phase 2 R9; the IIgs consumed the flags first).
-// flattenScbPalettes runs 200 * 16 quantize conversions and
-// buildTransitions rescans the full SCB; neither is cheap on a 7 MHz
-// 68000, and in the typical game loop SCB and palette never change.
+// (NATIVE-PERF Phase 2 R9; the IIgs consumed the flags first). Each
+// flag rebuilds only its own table: a palette change requantizes the
+// palettes in gStagePaletteDirtyMask, an SCB change rescans the bands.
 
 // ----- Internal helpers (alphabetical) -----
 
@@ -328,57 +371,47 @@ static void buildTransitions(const jlSurfaceT *src) {
 }
 
 
-// Pre-quantize every palette row indexed by scanline through the SCB
-// into gLinePalettes, so the Timer B ISR can do a flat indexed copy
-// without any surface-level lookups. Called once per jlpPresent.
-static void flattenScbPalettes(const jlSurfaceT *src) {
-    uint16_t line;
-    uint16_t col;
-    uint8_t  idx;
+// 12-bit $0RGB to the shifter's palette word (see ST_PALETTE_REGS).
+static uint16_t quantizeColorToSt(uint16_t orgb) {
+    return (uint16_t)(((orgb >> 1) & ST_PAL_HIGH_BITS) | ((orgb & ST_PAL_LOW_BITS) << ST_PAL_LOW_TO_BIT3));
+}
 
-    for (line = 0; line < SURFACE_HEIGHT; line++) {
-        idx = src->scb[line];
-        if (idx >= SURFACE_PALETTE_COUNT) {
-            idx = 0;
+
+// Requantize the palettes jlPaletteSet changed since the last present
+// (gStagePaletteDirtyMask; any other stage change sets every bit).
+static void quantizeDirtyPalettes(const jlSurfaceT *src) {
+    uint16_t mask;
+    uint16_t pal;
+    uint16_t col;
+
+    mask = gStagePaletteDirtyMask;
+    for (pal = 0; mask != 0u; pal++, mask = (uint16_t)(mask >> 1)) {
+        if ((mask & 1u) == 0u) {
+            continue;
         }
         for (col = 0; col < SURFACE_COLORS_PER_PALETTE; col++) {
-            gLinePalettes[line][col] = quantizeColorToSt(src->palette[idx][col]);
+            gShifterPalettes[pal][col] = quantizeColorToSt(src->palette[pal][col]);
         }
     }
 }
 
 
-// Rebuild the per-line palette table and band-transition table only
-// when the SCB/palette state has actually changed. Both are hot -- the
-// flatten pass runs 3200 palette entries through quantization -- so
-// skipping them on clean frames dominates rect-present timing. `src`
-// is always gStage (jlpPresent's contract), so the core dirty flags
-// are the change signal; clear them only after rebuilding.
+// Rebuild the shifter palettes and the band-transition table only when
+// their source actually changed, and each only for its own flag: in the
+// typical game loop neither changes, and a palette-cycling effect
+// changes one palette, not the SCB. `src` is always gStage (jlpPresent's
+// contract), so the core dirty flags are the change signal; clear them
+// only after rebuilding.
 static void refreshPaletteStateIfNeeded(const jlSurfaceT *src) {
-    if (!gStageScbDirty && !gStagePaletteDirty) {
-        return;
+    if (gStagePaletteDirty) {
+        quantizeDirtyPalettes(src);
+        gStagePaletteDirty     = false;
+        gStagePaletteDirtyMask = 0u;
+    }
+    if (gStageScbDirty) {
+        buildTransitions(src);
+        gStageScbDirty = false;
     }
-    flattenScbPalettes(src);
-    buildTransitions(src);
-    gStageScbDirty     = false;
-    gStagePaletteDirty = false;
-}
-
-
-// 12-bit $0RGB to STF 9-bit palette register (drops the low bit of
-// each 4-bit channel).
-static uint16_t quantizeColorToSt(uint16_t orgb) {
-    uint16_t r;
-    uint16_t g;
-    uint16_t b;
-
-    r = (orgb >> 8) & 0x0F;
-    g = (orgb >> 4) & 0x0F;
-    b = orgb        & 0x0F;
-    r = r >> 1;
-    g = g >> 1;
-    b = b >> 1;
-    return (uint16_t)((r << 8) | (g << 4) | b);
 }
 
 
@@ -401,7 +434,6 @@ static uint16_t quantizeColorToSt(uint16_t orgb) {
 // one-ahead value is identical and they stay perfectly aligned.
 static void timerBIsr(void) {
     uint16_t           band;
-    uint8_t            palIdx;
     const uint16_t    *src;
     volatile uint16_t *dst;
     uint16_t           nextDelta;
@@ -410,11 +442,8 @@ static void timerBIsr(void) {
     gCurrentBand = band;
 
     if (band < gBandCount) {
-        palIdx = gBandPalIdx[band];
-        if (palIdx >= SURFACE_PALETTE_COUNT) {
-            palIdx = 0;
-        }
-        src = &gLinePalettes[gBandStart[band]][0];
+        // buildTransitions already folded out-of-range SCB values to 0.
+        src = gShifterPalettes[gBandPalIdx[band]];
         dst = ST_PALETTE_REGS;
         dst[ 0] = src[ 0];
         dst[ 1] = src[ 1];
@@ -476,7 +505,7 @@ static void vblIsr(void) {
     }
 
     // Stage band 0's palette into the shifter registers.
-    src = &gLinePalettes[gBandStart[0]][0];
+    src = gShifterPalettes[gBandPalIdx[0]];
     dst = ST_PALETTE_REGS;
     dst[ 0] = src[ 0];
     dst[ 1] = src[ 1];
@@ -577,6 +606,15 @@ bool jlpInit(const jlConfigT *config) {
 
     (void)config;
 
+#ifdef JOEYLIB_ATARI_STE
+    // The STe build uses the BLiTTER, DMA sound and 4-bit palette without
+    // asking again: refuse anything but an STe or Mega STe before touching
+    // the machine (steMachineCheck leaves the reason for jlLastError).
+    if (!steMachineCheck()) {
+        return false;
+    }
+#endif
+
     // Enter supervisor mode for the program's lifetime. The shifter
     // video-base, palette, and MFP registers live in the supervisor-only
     // $FF8000..$FFFFFF I/O region; jlpPresent pokes the video-base bytes
@@ -591,6 +629,12 @@ bool jlpInit(const jlConfigT *config) {
         gSuperEntered = true;
     }
 
+#ifdef JOEYLIB_ATARI_STE
+    // A Mega STe runs at 16 MHz with its cache on until jlpShutdown hands
+    // the clock back; an STe has no such switch.
+    steMachineSetFullSpeed();
+#endif
+
     // Align page 0 to 256 bytes inside the static storage; page 1
     // follows exactly one 32000-byte (256-aligned) page later, so it
     // is 256-aligned too. The shifter only displays 256-byte-aligned
@@ -883,6 +927,9 @@ void jlpShutdown(void) {
 
     Setscreen((long)gPrevLogbase, (long)gPrevPhysbase, gPrevRez);
     Supexec(writePrevPaletteRegs);
+#ifdef JOEYLIB_ATARI_STE
+    steMachineRestoreSpeed();
+#endif
     writeDiagnostics();
     gModeSet = false;
 
@@ -922,8 +969,10 @@ extern void surface68kStSpriteRestoreByteAligned(uint8_t *base, uint16_t x, uint
 void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
     StPlanarT *pd;
     uint8_t    color;
+#ifndef JOEYLIB_ATARI_STE
     uint32_t   loLong;
     uint32_t   hiLong;
+#endif
 
     if (s->portData == NULL) {
         jlpGenericSurfaceClear(s, doubled);
@@ -931,6 +980,10 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
     }
     pd = (StPlanarT *)s->portData;
     color  = (uint8_t)(doubled & 0x0Fu);
+#ifdef JOEYLIB_ATARI_STE
+    // A whole-surface fill is far past the BLiTTER's crossover.
+    stBlitFillRect(pd->base, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT, color);
+#else
     /* Per-group: [p0_word][p1_word][p2_word][p3_word] = 8 bytes = 2 longs.
      * loLong = (p0_word << 16) | p1_word; hiLong = (p2_word << 16) | p3_word. */
     loLong = ((color & 1u) ? 0xFFFF0000ul : 0ul)
@@ -940,6 +993,7 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
     surface68kStLongFill(pd->base,
                          (uint16_t)(ST_PLANAR_SIZE / ST_BYTES_PER_GROUP),
                          loLong, hiLong);
+#endif
 }
 
 
@@ -1094,6 +1148,14 @@ void jlpFillRect(jlSurfaceT *s, int16_t x, int16_t y, int16_t w, int16_t h, uint
     if (pd == NULL) {
         return;
     }
+#ifdef JOEYLIB_ATARI_STE
+    // STe build: the BLiTTER, where it wins (ST_BLT_FILL_MIN_ROWS*): tall,
+    // or past a single 16-pixel group and at least a few rows.
+    if (h >= ST_BLT_FILL_MIN_ROWS_ONE_GROUP || (h >= ST_BLT_FILL_MIN_ROWS && (x & (ST_GROUP_PIXELS - 1)) + w > ST_GROUP_PIXELS)) {
+        stBlitFillRect(pd->base, x, y, w, h, colorIndex);
+        return;
+    }
+#endif
 
     /* Phase 10 fast path: x == 0 AND w == SURFACE_WIDTH means the rect
      * spans every group on every row with no edge masks. movem.l-based
@@ -1190,6 +1252,14 @@ void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint
     // call-overhead class the Amiga blitter copyRect deleted. Long rows
     // keep memcpy, whose movem path beats a move.l loop past the
     // crossover. rowBytes is a whole number of longs (group-snapped).
+#ifdef JOEYLIB_ATARI_STE
+    // STe build: the BLiTTER takes the whole rect in one blit from a few
+    // rows up (ST_BLT_COPY_MIN_ROWS), whatever the width.
+    if (h >= ST_BLT_COPY_MIN_ROWS) {
+        stBlitCopyRows(d, s, rowBytes, h);
+        return;
+    }
+#endif
     if (rowBytes <= ST_COPYRECT_MOVEL_MAX_BYTES) {
         stCopyRectSpans68k(d, s, (int)(rowBytes >> 2), (int)h);
     } else {
diff --git a/src/atarist/input.c b/src/atarist/input.c
index 312d334..c96894d 100644
--- a/src/atarist/input.c
+++ b/src/atarist/input.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST keyboard + mouse input: replaces the TOS ikbdsys vector in
 // the KBDVECS table so every byte from the IKBD ACIA comes through our
 // packet-aware handler.
@@ -484,6 +488,12 @@ void jlpInputPoll(void) {
         gJoyButtonState[i][JOY_BUTTON_0] = (joy & JOY_BIT_FIRE) != 0;
         gJoyButtonState[i][JOY_BUTTON_1] = false;  // ST has 1 fire button
     }
+    // Port 1's fire and the mouse's right button are one IKBD line: while
+    // the IKBD reports the mouse (TOS's mode, kept here) a press on it
+    // arrives as the right button, never in a joystick packet.
+    if ((btn & MOUSE_HDR_RIGHT_BTN) != 0) {
+        gJoyButtonState[ST_PORT1_AS_JS][JOY_BUTTON_0] = true;
+    }
 }
 
 
diff --git a/src/atarist/lineSpan.s b/src/atarist/lineSpan.s
index 0c87ecc..5975b28 100644
--- a/src/atarist/lineSpan.s
+++ b/src/atarist/lineSpan.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Atari ST word-interleaved planar jlDrawLine -- 68000 hand-rolled.
 |
 | Bresenham line walker with 16-way color dispatch. Per pixel:
diff --git a/src/atarist/presentSlam.s b/src/atarist/presentSlam.s
index cac3568..e2749b6 100644
--- a/src/atarist/presentSlam.s
+++ b/src/atarist/presentSlam.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Atari ST fused dirty present -- 68000 hand-rolled (NATIVE-PERF W2).
 |
 | ONE pass over the 200 band rows replaces jlpPresent's three C passes
diff --git a/src/atarist/save.c b/src/atarist/save.c
index 7cfc543..d07ad1d 100644
--- a/src/atarist/save.c
+++ b/src/atarist/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST save-file + disk-space HAL.
 //
 // SAVES/ is a real GEMDOS subdirectory next to the .PRG, made with POSIX
diff --git a/src/atarist/serial.c b/src/atarist/serial.c
index 8fcee94..b02713b 100644
--- a/src/atarist/serial.c
+++ b/src/atarist/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST serial HAL -- the RS-232 Modem port via TOS.
 //
 // The base ST's one RS-232 line is the Modem port, driven by the 68901 MFP
diff --git a/src/atarist/spriteAsm.s b/src/atarist/spriteAsm.s
index 97969c8..a321f74 100644
--- a/src/atarist/spriteAsm.s
+++ b/src/atarist/spriteAsm.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | ST byte-aligned sprite save / restore. Buffer holds plane-major
 | bytes: per row, plane0/1/2/3 per tile col, for w/8 tile cols. The
 | inner per-tile-col macro is 4 byte copies (no chunky <-> planar
diff --git a/src/atarist/spriteWalk.s b/src/atarist/spriteWalk.s
index 239e7a3..ff3d7db 100644
--- a/src/atarist/spriteWalk.s
+++ b/src/atarist/spriteWalk.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | ST shifted compiled-sprite walker (NATIVE-PERF Phase 1).
 |
 | One shared routine serves all 14 sub-byte sprite phases: the emitted
diff --git a/src/atarist/stPlanar.h b/src/atarist/stPlanar.h
index 4308851..5180808 100644
--- a/src/atarist/stPlanar.h
+++ b/src/atarist/stPlanar.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST per-surface planar storage, shared between the HAL
 // (src/atarist/hal.c) and the cross-platform sprite dispatchers
 // (src/codegen/spriteCompile.c) so buffer-base access inlines instead
diff --git a/src/atarist/stTile.h b/src/atarist/stTile.h
index 5af8e92..5d28223 100644
--- a/src/atarist/stTile.h
+++ b/src/atarist/stTile.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Atari ST tile ops as always-inline functions (NATIVE-PERF Phase 2
 // R4a). Bodies moved verbatim from src/atarist/hal.c so the public
 // wrappers in src/core/tile.c inline them, deleting the second full
diff --git a/src/atarist/ste/blitter.c b/src/atarist/ste/blitter.c
new file mode 100644
index 0000000..f0b5238
--- /dev/null
+++ b/src/atarist/ste/blitter.c
@@ -0,0 +1,118 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// BLiTTER fills and copies for the STe build (see steBlitter.h).
+//
+// The interleaved layout puts a 16-pixel group's four plane words side by
+// side, so one plane of a rect is every fourth word: a fill is four blits,
+// one per plane, each writing all ones or all zeros (OP 15 or 0, no source
+// read) through the left and right edge masks; a copy is one blit of every
+// word in the span.
+//
+// Blits run in shared (non-HOG) mode with the documented restart loop: the
+// chip gives the bus back every 64 accesses, and the bset that restarts it
+// also reports whether it was still busy. The CPU takes interrupts between
+// restarts, so the VBL and the Timer B palette raster wait at most one
+// burst, where a HOG-mode blit would hold them off for the whole fill.
+
+#include "stPlanar.h"
+#include "steBlitter.h"
+
+// BLiTTER registers. The address and count registers are consumed as a
+// blit runs, so each blit rewrites them; the increments and masks keep
+// their values.
+#define BLT_SRC_XINC   (*(volatile int16_t  *)0xFFFF8A20L)
+#define BLT_SRC_YINC   (*(volatile int16_t  *)0xFFFF8A22L)
+#define BLT_SRC_ADDR   (*(volatile uint32_t *)0xFFFF8A24L)
+#define BLT_ENDMASK1   (*(volatile uint16_t *)0xFFFF8A28L)
+#define BLT_ENDMASK2   (*(volatile uint16_t *)0xFFFF8A2AL)
+#define BLT_ENDMASK3   (*(volatile uint16_t *)0xFFFF8A2CL)
+#define BLT_DST_XINC   (*(volatile int16_t  *)0xFFFF8A2EL)
+#define BLT_DST_YINC   (*(volatile int16_t  *)0xFFFF8A30L)
+#define BLT_DST_ADDR   (*(volatile uint32_t *)0xFFFF8A32L)
+#define BLT_X_COUNT    (*(volatile uint16_t *)0xFFFF8A36L)
+#define BLT_Y_COUNT    (*(volatile uint16_t *)0xFFFF8A38L)
+#define BLT_HOP        (*(volatile uint8_t  *)0xFFFF8A3AL)
+#define BLT_OP         (*(volatile uint8_t  *)0xFFFF8A3BL)
+#define BLT_SKEW       (*(volatile uint8_t  *)0xFFFF8A3DL)
+
+#define BLT_HOP_ONES   0x00u      // halftone op: all ones (no source read)
+#define BLT_HOP_SOURCE 0x02u      // halftone op: the source
+#define BLT_OP_ZEROS   0x00u
+#define BLT_OP_SOURCE  0x03u
+#define BLT_OP_ONES    0x0Fu
+#define BLT_NO_SKEW    0x00u
+#define BLT_MASK_ALL   0xFFFFu
+#define BLT_PLANES     4
+#define BLT_WORD_BYTES 2
+
+static inline void bltRun(void);
+
+
+// Start the programmed blit (LINE_NUM $8A3C: BUSY, shared mode, halftone
+// line 0) and restart it until it reports done.
+static inline void bltRun(void) {
+    __asm__ volatile ("move.b  #0x80,0xffff8a3c\n"
+                      "1:\n\t"
+                      "bset    #7,0xffff8a3c\n\t"
+                      "nop\n\t"
+                      "bne.s   1b"
+                      : : : "cc", "memory");
+}
+
+
+void stBlitCopyRows(uint8_t *dst, const uint8_t *src, uint16_t rowBytes, uint16_t h) {
+    int16_t rowStep;
+
+    rowStep       = (int16_t)(ST_BYTES_PER_ROW - rowBytes + BLT_WORD_BYTES);
+    BLT_SRC_XINC  = BLT_WORD_BYTES;
+    BLT_SRC_YINC  = rowStep;
+    BLT_DST_XINC  = BLT_WORD_BYTES;
+    BLT_DST_YINC  = rowStep;
+    BLT_ENDMASK1  = BLT_MASK_ALL;
+    BLT_ENDMASK2  = BLT_MASK_ALL;
+    BLT_ENDMASK3  = BLT_MASK_ALL;
+    BLT_X_COUNT   = (uint16_t)(rowBytes / BLT_WORD_BYTES);
+    BLT_HOP       = BLT_HOP_SOURCE;
+    BLT_OP        = BLT_OP_SOURCE;
+    BLT_SKEW      = BLT_NO_SKEW;
+    BLT_SRC_ADDR  = (uint32_t)src;
+    BLT_DST_ADDR  = (uint32_t)dst;
+    BLT_Y_COUNT   = h;
+    bltRun();
+}
+
+
+void stBlitFillRect(uint8_t *base, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t color) {
+    uint16_t groupFirst;
+    uint16_t groups;
+    uint16_t leftMask;
+    uint16_t rightMask;
+    uint32_t dst;
+    uint8_t  plane;
+
+    groupFirst = (uint16_t)((uint16_t)x >> 4);
+    groups     = (uint16_t)((((uint16_t)x + (uint16_t)w - 1u) >> 4) - groupFirst + 1u);
+    leftMask   = (uint16_t)(BLT_MASK_ALL >> ((uint16_t)x & 15u));
+    rightMask  = (uint16_t)(BLT_MASK_ALL << (15u - (((uint16_t)x + (uint16_t)w - 1u) & 15u)));
+
+    // A one-word line takes ENDMASK1 alone, so it carries both edges.
+    BLT_ENDMASK1 = (groups == 1u) ? (uint16_t)(leftMask & rightMask) : leftMask;
+    BLT_ENDMASK2 = BLT_MASK_ALL;
+    BLT_ENDMASK3 = rightMask;
+    BLT_DST_XINC = ST_BYTES_PER_GROUP;
+    BLT_DST_YINC = (int16_t)(ST_BYTES_PER_ROW - (groups - 1u) * ST_BYTES_PER_GROUP);
+    BLT_X_COUNT  = groups;
+    BLT_HOP      = BLT_HOP_ONES;
+    BLT_SKEW     = BLT_NO_SKEW;
+
+    dst = (uint32_t)(base + (uint16_t)y * ST_BYTES_PER_ROW + groupFirst * ST_BYTES_PER_GROUP);
+    for (plane = 0; plane < BLT_PLANES; plane++) {
+        BLT_OP       = ((color >> plane) & 1u) ? BLT_OP_ONES : BLT_OP_ZEROS;
+        BLT_DST_ADDR = dst;
+        BLT_Y_COUNT  = (uint16_t)h;
+        bltRun();
+        dst += BLT_WORD_BYTES;
+    }
+}
diff --git a/src/atarist/ste/machine.c b/src/atarist/ste/machine.c
new file mode 100644
index 0000000..7710a1b
--- /dev/null
+++ b/src/atarist/ste/machine.c
@@ -0,0 +1,104 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// The STe build's machine check (see steMachine.h).
+//
+// The TOS cookie jar names the machine (_MCH) and its sound hardware
+// (_SND). The STe build needs the STe family (_MCH high word 1: STe, Mega
+// STe) with 8-bit DMA sound (_SND bit 1) -- the one combination that
+// guarantees the BLiTTER, the DMA chip and the 4-bit palette together.
+// TOS 1.00-1.04 have no cookie jar at all ($5A0 holds 0); those are plain
+// STs, so a missing jar is refused like any other non-STe.
+
+#include 
+#include 
+
+#include 
+
+#include "port.h"
+#include "steMachine.h"
+
+// _p_cookies: the system variable holding the cookie jar's address, or 0.
+// Each jar entry is two longs (id, value); an id of 0 ends the jar.
+#define COOKIE_JAR_PTR     ((const int32_t * const *)0x5A0L)
+#define COOKIE_ID_MCH      0x5F4D4348L      // '_MCH'
+#define COOKIE_ID_SND      0x5F534E44L      // '_SND'
+
+// _MCH: machine family in the high word, model in the low word.
+#define MCH_FAMILY_SHIFT   16
+#define MCH_FAMILY_STE     1L               // STe, Mega STe
+#define MCH_MEGA_STE       0x00010010L
+
+// _SND bit 1: 8-bit DMA sound.
+#define SND_DMA_8BIT       0x02L
+
+// Mega STe CPU clock and cache control register: bit 0 selects 16 MHz,
+// bit 1 enables the 16 KB cache. The machine resets to 8 MHz without it.
+#define MEGASTE_CPU_CTRL   ((volatile uint8_t *)0xFFFF8E21L)
+#define MEGASTE_FULL_SPEED 0x03u
+
+// Filled by readCookies, which runs under Supexec and so cannot return them.
+static int32_t     gCookieMch;
+static int32_t     gCookieSnd;
+static bool        gChecked;
+static bool        gIsSte;
+static bool        gMegaSte;
+static uint8_t     gSavedCpuCtrl;
+static const char *gError;
+
+static long readCookies(void);
+
+
+const char *jlpLastError(void) {
+    return gError;
+}
+
+
+// Supexec worker: the jar lives in supervisor-only low memory.
+static long readCookies(void) {
+    const int32_t *jar;
+
+    jar = *COOKIE_JAR_PTR;
+    if (jar == NULL) {
+        return 0;
+    }
+    for (; jar[0] != 0; jar += 2) {
+        if (jar[0] == COOKIE_ID_MCH) {
+            gCookieMch = jar[1];
+        } else if (jar[0] == COOKIE_ID_SND) {
+            gCookieSnd = jar[1];
+        }
+    }
+    return 0;
+}
+
+
+bool steMachineCheck(void) {
+    if (gChecked) {
+        return gIsSte;
+    }
+    (void)Supexec(readCookies);
+    gIsSte   = (gCookieMch >> MCH_FAMILY_SHIFT) == MCH_FAMILY_STE && (gCookieSnd & SND_DMA_8BIT) != 0;
+    gMegaSte = gCookieMch == MCH_MEGA_STE;
+    gChecked = true;
+    if (!gIsSte) {
+        gError = "this is the STe build of the program: it needs an Atari STe or Mega STe (run the ST build here)";
+    }
+    return gIsSte;
+}
+
+
+void steMachineRestoreSpeed(void) {
+    if (gMegaSte) {
+        *MEGASTE_CPU_CTRL = gSavedCpuCtrl;
+    }
+}
+
+
+void steMachineSetFullSpeed(void) {
+    if (gMegaSte) {
+        gSavedCpuCtrl     = *MEGASTE_CPU_CTRL;
+        *MEGASTE_CPU_CTRL = MEGASTE_FULL_SPEED;
+    }
+}
diff --git a/src/atarist/ste/steBlitter.h b/src/atarist/ste/steBlitter.h
new file mode 100644
index 0000000..06c3860
--- /dev/null
+++ b/src/atarist/ste/steBlitter.h
@@ -0,0 +1,21 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// BLiTTER fills and copies for the ST family's interleaved planar buffers
+// (src/atarist/ste/blitter.c). STe build only: the STe and Mega STe always
+// have the chip, and the plain ST build never compiles this folder.
+#ifndef JOEY_STE_BLITTER_H
+#define JOEY_STE_BLITTER_H
+
+#include 
+
+// Copy h rows of rowBytes bytes (a whole number of 16-pixel groups) from
+// src to dst; both rows are ST_BYTES_PER_ROW apart.
+void stBlitCopyRows(uint8_t *dst, const uint8_t *src, uint16_t rowBytes, uint16_t h);
+
+// Fill the on-surface rect (x, y, w, h), w and h >= 1, of an interleaved
+// planar buffer with colour index color.
+void stBlitFillRect(uint8_t *base, int16_t x, int16_t y, int16_t w, int16_t h, uint8_t color);
+
+#endif
diff --git a/src/atarist/ste/steMachine.h b/src/atarist/ste/steMachine.h
new file mode 100644
index 0000000..6dd5ef9
--- /dev/null
+++ b/src/atarist/ste/steMachine.h
@@ -0,0 +1,26 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// The STe build's machine check (src/atarist/ste/machine.c). The STe build
+// (make ste, JOEYLIB_ATARI_STE) uses the BLiTTER, DMA sound and the 4-bit
+// palette unconditionally, so it must not run on anything else: jlInit
+// refuses a machine without them, with a reason in jlLastError. The plain
+// ST build (make atarist) never compiles this folder.
+#ifndef JOEY_STE_MACHINE_H
+#define JOEY_STE_MACHINE_H
+
+#include 
+
+// True on an STe or Mega STe. Reads the TOS cookie jar once (via Supexec,
+// so user or supervisor mode); later calls return the same answer. Both
+// jlpInit and jlpAudioInit ask, in whichever order a program calls them.
+bool steMachineCheck(void);
+
+// Mega STe only (a no-op on an STe): run the CPU at 16 MHz with the cache
+// on for the program's lifetime, and put back what TOS had on exit. Both
+// need supervisor mode, which jlpInit holds between them.
+void steMachineRestoreSpeed(void);
+void steMachineSetFullSpeed(void);
+
+#endif
diff --git a/src/atarist/superExit.s b/src/atarist/superExit.s
index f7e2c2b..54a7fa3 100644
--- a/src/atarist/superExit.s
+++ b/src/atarist/superExit.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Atari ST -- leave supervisor mode without losing the caller's frame.
 |
 | jlpInit enters supervisor for the program's lifetime (the shifter and
diff --git a/src/atarist/tileMap.s b/src/atarist/tileMap.s
index 36a46e5..0070225 100644
--- a/src/atarist/tileMap.s
+++ b/src/atarist/tileMap.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | ST whole-map tile-paste engine, movep.l row body (NATIVE-PERF
 | Phase 4).
 |
diff --git a/src/blank/blank.c b/src/blank/blank.c
index ddf0622..883dd88 100644
--- a/src/blank/blank.c
+++ b/src/blank/blank.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // JoeyLib new-port template ("jBlank").
 //
 // Copy this directory to src//, set JOEYLIB_PLATFORM_ in
diff --git a/src/codegen/spriteCompile.c b/src/codegen/spriteCompile.c
index f9bc045..755c24a 100644
--- a/src/codegen/spriteCompile.c
+++ b/src/codegen/spriteCompile.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Cross-platform sprite codegen runtime: jlSpriteCompile runs the shared
 // staging pass (spriteStage.c) to emit every (shift, op) routine into a
 // scratch image, allocates a slot in the codegen arena, copies the emitted
@@ -125,6 +129,7 @@ bool jlSpriteCompile(jlSpriteT *sp) {
     // already slot-relative (offset within `staging` == offset within the
     // slot after this copy), so nothing else needs fixing up.
     memcpy(codegenArenaSlotBase(slot), staging, (size_t)total);
+    jlpCodeWritten(codegenArenaSlotBase(slot), total);
     sp->slot = slot;
     // Registry (#34): every creation path (including jlSpriteBankLoad,
     // #32) already registered sp via spriteInitFields; this idempotent
diff --git a/src/codegen/spriteEmitChunky.h b/src/codegen/spriteEmitChunky.h
index 76d850c..28d8521 100644
--- a/src/codegen/spriteEmitChunky.h
+++ b/src/codegen/spriteEmitChunky.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared emit-time source-pixel helpers for the chunky 4bpp sprite
 // emitters -- spriteEmitX86.c (DOS, DVX) and spriteEmitIigs.c. These two
 // functions ARE the transparency/shift contract that the emitted
diff --git a/src/codegen/spriteEmitter.h b/src/codegen/spriteEmitter.h
index 0fcd99c..9c47c89 100644
--- a/src/codegen/spriteEmitter.h
+++ b/src/codegen/spriteEmitter.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Internal interface for per-CPU sprite emitters.
 //
 // Each per-CPU emitter file (src/x86/spriteEmitX86.c,
diff --git a/src/codegen/spriteStage.c b/src/codegen/spriteStage.c
index f9d2f06..db850ef 100644
--- a/src/codegen/spriteStage.c
+++ b/src/codegen/spriteStage.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared sprite-codegen staging (see spriteStage.h). Emits every included
 // (shift, op) routine once into the staging image and records slot-relative
 // routineOffsets. Split out of spriteCompile.c so the offline bake tool can
@@ -134,6 +138,10 @@ uint32_t spriteStageAndPlan(uint8_t *staging, jlSpriteT *sp, bool alignedOnly) {
 
     memset(sp->routineOffsets, 0xFF, sizeof(sp->routineOffsets));
     total = 0;
+#if defined(JOEYLIB_PLATFORM_IIGS)
+    // The save/restore emitters cover these rows.
+    spriteContentRowsIigs(sp);
+#endif
 
     for (shift = 0; shift < JOEY_SPRITE_SHIFT_COUNT; shift++) {
         uint32_t written;
diff --git a/src/codegen/spriteStage.h b/src/codegen/spriteStage.h
index 57a98b0..c8804bf 100644
--- a/src/codegen/spriteStage.h
+++ b/src/codegen/spriteStage.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared sprite-codegen staging: emits every included (shift, op) blit
 // routine once, back to back, into a caller-provided image buffer and
 // records the slot-relative routineOffsets on the sprite. This is the SINGLE
diff --git a/src/core/assetLoad.c b/src/core/assetLoad.c
index 150d6b9..82668e7 100644
--- a/src/core/assetLoad.c
+++ b/src/core/assetLoad.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Native asset loaders for baked .spr and .tbk files produced by
 // tools/assetbake/assetbake.py. Both formats are documented in detail
 // in that tool; the short version:
@@ -98,7 +102,7 @@ typedef char AssetTileSizeCheckT[(sizeof(jlTileT) == TILE_BYTES) ? 1 : -1];
 #define EXPECTED_TILE_TARGET   3u  // DVX shows the DOS stage: same chunky banks
 #elif defined(JOEYLIB_PLATFORM_IIGS)
 #define EXPECTED_TILE_TARGET   4u
-#elif defined(JOEYLIB_PLATFORM_BLANK)
+#elif defined(JOEYLIB_PLATFORM_BLANK) || defined(JOEYLIB_PLATFORM_HOSTED)
 #define EXPECTED_TILE_TARGET   3u  // chunky 4bpp, same as DOS (uses the generic tile ops)
 #elif defined(JOEYLIB_PLATFORM_X68000)
 // Plane-major like the Amiga (src/m68k/planarTile.h reads jlTileT.pixels as
@@ -116,8 +120,10 @@ typedef char AssetTileSizeCheckT[(sizeof(jlTileT) == TILE_BYTES) ? 1 : -1];
 #define EXPECTED_SPC_TARGET    1u
 #elif defined(JOEYLIB_PLATFORM_ATARIST)
 #define EXPECTED_SPC_TARGET    2u
-#elif defined(JOEYLIB_CPU_X86) || defined(JOEYLIB_PLATFORM_BLANK)
+#elif defined(JOEYLIB_CPU_X86)
 #define EXPECTED_SPC_TARGET    3u  // x86 code: DOS and DVX run the same banks
+#elif defined(JOEYLIB_SPRITE_INTERPRETED)
+#define EXPECTED_SPC_TARGET    3u  // the DOS banks' chunky cels; their code is skipped
 #elif defined(JOEYLIB_PLATFORM_IIGS)
 #define EXPECTED_SPC_TARGET    4u
 #elif defined(JOEYLIB_PLATFORM_X68000)
@@ -313,7 +319,7 @@ uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels, uint
     if (path == NULL || outCels == NULL || maxCels == 0u) {
         return 0u;
     }
-    // "JSC1" + the per-machine target byte (pre-compiled code is machine-
+    // SPC_MAGIC + the per-machine target byte (pre-compiled code is machine-
     // specific, validated like a .tbk but against the .spc target table).
     if (!openReadValidateHeader(path, SPC_MAGIC, EXPECTED_SPC_TARGET, header, outPalette, &fp)) {
         return 0u;
@@ -414,8 +420,10 @@ uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels, uint
 
         // Install the pre-compiled image. On arena-full (compact-and-retry,
         // like jlSpriteCompile) leave the cel uncompiled -> interpreter, no
-        // error: the sprite still draws, just at the old speed.
+        // error: the sprite still draws, just at the old speed. A port that
+        // interprets every sprite never installs it.
         slot = NULL;
+#if !defined(JOEYLIB_SPRITE_INTERPRETED)
         if (imageSize > 0u) {
             slot = codegenArenaAlloc(imageSize);
             if (slot == NULL) {
@@ -423,6 +431,7 @@ uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels, uint
                 slot = codegenArenaAlloc(imageSize);
             }
         }
+#endif
         if (slot != NULL) {
             uint8_t *slotBase = codegenArenaSlotBase(slot);
 
@@ -447,6 +456,12 @@ uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels, uint
 #else
             (void)relocOffsets;
 #endif
+#if defined(JOEYLIB_PLATFORM_IIGS)
+            // The rows the baked save/restore cover (the baker's staging
+            // derived them from the same tile data).
+            spriteContentRowsIigs(sp);
+#endif
+            jlpCodeWritten(slotBase, imageSize);
             sp->slot = slot;
         }
 
diff --git a/src/core/audio.c b/src/core/audio.c
index c319d46..c1595f8 100644
--- a/src/core/audio.c
+++ b/src/core/audio.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Public audio API: thin pass-through to the per-port HAL plus a
 // gate that makes every call safe when audio init failed (or has not
 // been called). Game code can fire-and-forget calls regardless of
diff --git a/src/core/audioSfxMix.c b/src/core/audioSfxMix.c
index 095977e..3bfeefb 100644
--- a/src/core/audioSfxMix.c
+++ b/src/core/audioSfxMix.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared SFX overlay mixer. See audioSfxMixInternal.h for the contract.
 
 #include 
diff --git a/src/core/audioSfxMixInternal.h b/src/core/audioSfxMixInternal.h
index e4ff308..ae02452 100644
--- a/src/core/audioSfxMixInternal.h
+++ b/src/core/audioSfxMixInternal.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared SFX overlay mixer used by HALs whose engine outputs MOD-only
 // (libxmp-lite on DOS + ST). The mixer overlays one-shot signed 8-bit
 // PCM samples on top of the engine's already-rendered unsigned 8-bit
diff --git a/src/core/codegenArena.c b/src/core/codegenArena.c
index 89cccac..f7fda3c 100644
--- a/src/core/codegenArena.c
+++ b/src/core/codegenArena.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Codegen arena: free-list allocator with adjacent-hole coalescing
 // and manual compaction, over one bank (most ports) or several 64 KB
 // banks (IIgs). See codegenArenaInternal.h for the contract.
@@ -28,6 +32,7 @@
 #include 
 
 #include "codegenArenaInternal.h"
+#include "port.h"
 
 
 
@@ -122,6 +127,7 @@ static void bankCompact(uint8_t bank) {
             uint32_t at = slot->offset & CODEGEN_ARENA_BANK_MASK;
             if (at != cursor) {
                 memmove(base + cursor, base + at, slot->size);
+                jlpCodeWritten(base + cursor, slot->size);
                 slot->offset = ((uint32_t)bank << CODEGEN_ARENA_BANK_SHIFT) | cursor;
             }
             cursor += slot->size;
diff --git a/src/core/codegenArenaInternal.h b/src/core/codegenArenaInternal.h
index 6f33a39..dc3cce4 100644
--- a/src/core/codegenArenaInternal.h
+++ b/src/core/codegenArenaInternal.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Codegen arena: a pool of executable memory that holds compiled
 // sprite routines. Allocator is first-fit with adjacent-hole
 // coalescing on free; manual compaction (codegenArenaCompact)
diff --git a/src/core/debug.c b/src/core/debug.c
index 463bfa5..6dedb38 100644
--- a/src/core/debug.c
+++ b/src/core/debug.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Cross-platform "where did it hang?" logger.
 //
 // On DOS/Amiga/ST: joeylog.txt is held open across calls and FLUSHED
diff --git a/src/core/draw.c b/src/core/draw.c
index 6e527db..b3013e6 100644
--- a/src/core/draw.c
+++ b/src/core/draw.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Drawing primitives on chunky 4bpp packed surfaces.
 //
 // Byte layout: two pixels per byte, high nibble is the LEFT pixel.
diff --git a/src/core/fileRoot.h b/src/core/fileRoot.h
index ee415b5..d898ae8 100644
--- a/src/core/fileRoot.h
+++ b/src/core/fileRoot.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Run-time file roots (JL_HAS_FILE_ROOTS).
 //
 // Every machine port runs its program with the program's own folder as the
diff --git a/src/core/init.c b/src/core/init.c
index 090f090..3bdf295 100644
--- a/src/core/init.c
+++ b/src/core/init.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // JoeyLib lifecycle: init, shutdown, error reporting.
 //
 // jlInit stores configuration, allocates the library-owned screen
diff --git a/src/core/input.c b/src/core/input.c
index db81cad..bd77fed 100644
--- a/src/core/input.c
+++ b/src/core/input.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Public input API. Maintains current/previous state buffers for the
 // keyboard (gKeyState/gKeyPrev), mouse buttons (gMouseButtonState/
 // gMouseButtonPrev), and joystick buttons (gJoyButtonState/
diff --git a/src/core/inputInternal.h b/src/core/inputInternal.h
index ef40eef..2f1d38e 100644
--- a/src/core/inputInternal.h
+++ b/src/core/inputInternal.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Internal input state shared between core and per-port HAL.
 //
 // Per-port jlpInputPoll() writes directly into the public state
diff --git a/src/core/music.c b/src/core/music.c
index c5f6411..344a170 100644
--- a/src/core/music.c
+++ b/src/core/music.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Portable chip-tracker music sequencer. One authored song (baked to
 // the JYM1 event stream by tools/songbake) plays on every port through
 // the jlAudioVoice 3-voice tone layer plus the jlAudioNoise percussion
diff --git a/src/core/musicInternal.h b/src/core/musicInternal.h
index faf23a5..5746da6 100644
--- a/src/core/musicInternal.h
+++ b/src/core/musicInternal.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Core-internal music sequencer hooks. The public API is jlMusicPlay /
 // jlMusicStop / jlMusicIsPlaying in joey/audio.h; the pump is called
 // once per frame by jlAudioFrameTick (above its gAudioReady gate --
diff --git a/src/core/palette.c b/src/core/palette.c
index 24970b0..54dd1da 100644
--- a/src/core/palette.c
+++ b/src/core/palette.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Palette accessors.
 //
 // Library contract: color 0 of every palette is forced to black ($000).
diff --git a/src/core/port.h b/src/core/port.h
index 96e43ab..4f25298 100644
--- a/src/core/port.h
+++ b/src/core/port.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Internal platform-override registry (the new joeylib's joey.h equivalent).
 //
 // Every overridable operation is a jlp() resolved at COMPILE TIME: a
@@ -1114,6 +1118,20 @@ uint32_t jlpGenericDiskFree(void);
 #endif
 
 
+// --- Compiled code written (the codegen arena) ---
+// The library wrote or moved bytes it will run (a compiled sprite, a loaded
+// .spc image, compaction): a CPU with an instruction cache must not run
+// stale ones. Only the Amiga's (68020 and later) need clearing; on every
+// other port the call is deleted at preprocess time.
+#if !defined(jlpCodeWritten)
+  #if defined(JL_HAS_CODE_WRITTEN)
+    void jlpCodeWritten(void *addr, uint32_t bytes);
+  #else
+    #define jlpCodeWritten(_addr, _bytes) ((void)0)
+  #endif
+#endif
+
+
 // --- Large allocation (malloc/free generic default; IIgs Memory Manager override) ---
 void *jlpGenericBigAlloc(uint32_t bytes);
 void jlpGenericBigFree(void *p);
diff --git a/src/core/present.c b/src/core/present.c
index f1ae6f4..2415c0c 100644
--- a/src/core/present.c
+++ b/src/core/present.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Stage present dispatcher.
 //
 // jlStagePresent walks the per-row dirty bands set by drawing primitives
diff --git a/src/core/random.c b/src/core/random.c
index d824b22..8acbe46 100644
--- a/src/core/random.c
+++ b/src/core/random.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Deterministic, seedable PRNG (xorshift32 / Marsaglia).
 //
 // stdlib rand() is unsuitable: the per-platform C libraries ship
diff --git a/src/core/save.c b/src/core/save.c
index 79cd635..0515f7d 100644
--- a/src/core/save.c
+++ b/src/core/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Writable save files (SAVES/) and a disk-space query.
 //
 // Mirrors jlDataOpen's forced-prefix approach (see assetLoad.c) but rooted at
diff --git a/src/core/scb.c b/src/core/scb.c
index ec93285..33c0e41 100644
--- a/src/core/scb.c
+++ b/src/core/scb.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Scanline control byte (SCB) accessors.
 //
 // Each scanline holds one uint8_t SCB value in range 0..15 selecting
diff --git a/src/core/serial.c b/src/core/serial.c
index 85795c7..bbb7a8a 100644
--- a/src/core/serial.c
+++ b/src/core/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Public serial API: a thin pass-through to the per-port HAL plus a gate that
 // makes every call safe when no port is open (or the platform has no serial
 // support). Game code can fire-and-forget regardless of whether serial is
diff --git a/src/core/sprite.c b/src/core/sprite.c
index 8a3bfd8..14b0a7a 100644
--- a/src/core/sprite.c
+++ b/src/core/sprite.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sprite system: create, destroy, draw, save/restore-under.
 //
 // This file is the interpreted fallback path. It is correct on every
@@ -70,9 +74,9 @@
 // On the IIgs all three spellings expand to surfaceMarkDirtyRect,
 // token-identical to today's path (band derive inline, one JSL into
 // iigsMarkDirtyRowsInner; a C unroll of 16 long-abs RMW pairs would
-// REGRESS the 65816). The W1 fused IIgs sprite entries take ownership
-// DISPATCH-side, not here: an s == gStage arm in the compiled path
-// calls the fused asm entry (which widens the band in-section) and
+// REGRESS the 65816). The IIgs stage sprite entries (joeyDraw.s) take
+// ownership DISPATCH-side, not here: an s == gStage arm in the compiled
+// path calls the asm entry (which widens the band in-section) and
 // returns BEFORE these C marks, so the spellings stay live only for
 // the non-stage compiled paths, where surfaceMarkDirtyRect's stage
 // gate makes them no-ops. Slow / interpreted paths always mark
@@ -511,6 +515,8 @@ static void spriteResetCompiledState(jlSpriteT *sp) {
     memset(sp->cachedDstBank,   0xFF, sizeof(sp->cachedDstBank));
     memset(sp->cachedSrcBank,   0xFF, sizeof(sp->cachedSrcBank));
     memset(sp->cachedSizeBytes, 0,    sizeof(sp->cachedSizeBytes));
+    sp->contentRow0 = 0u;
+    sp->contentRows = sp->heightPx;
 #endif
 }
 
@@ -656,20 +662,12 @@ void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) {
     heightPx = sp->heightPx;
 
 #if defined(JOEYLIB_PLATFORM_IIGS)
-    // NATIVE-PERF Phase 6 W1: fused stage draw. ONE JSL runs the
-    // compiled body AND the dirty-band widen (iigsSpriteDrawMark),
-    // replacing the compiled-call + surfaceMarkDirtyRect ->
-    // iigsMarkDirtyRowsInner JSL pair. Gates mirror the shared arm
-    // below exactly; non-stage destinations and uncompiled sprites
-    // fall through to it (its C mark is a stage-gated no-op there).
-    if (s == gStage && sp->slot != NULL && isFullyOnSurface(x, y, widthPx, heightPx)) {
-        uint16_t fusedOffset;
-
-        fusedOffset = SPRITE_ROUTE_OFFSET(sp, SPRITE_SHIFT_INDEX(x), SPRITE_OP_DRAW);
-        if (fusedOffset != SPRITE_NOT_COMPILED) {
-            spriteCompiledDrawMark(sp, x, y, fusedOffset);
-            return;
-        }
+    // Stage draw: ONE JSL runs the compiled body AND the dirty-band
+    // widen (iigsSpriteDrawStage). Non-stage destinations and uncompiled
+    // sprites fall through to the shared arm below (its C mark is a
+    // stage-gated no-op there).
+    if (s == gStage && isFullyOnSurface(x, y, widthPx, heightPx) && iigsSpriteDrawStage(sp, x, y) == 0u) {
+        return;
     }
 #endif
 
@@ -747,17 +745,11 @@ void jlSpriteDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y) {
         }
     }
 #elif defined(JOEYLIB_PLATFORM_IIGS)
-    // Skip validation -> the fused stage draw+mark JSL (the same arm
-    // jlSpriteDraw gates behind isFullyOnSurface). s == gStage only
-    // (the fused draw derives the pinned stage base).
-    if (sp->slot != NULL && s == gStage) {
-        uint16_t fusedOffset;
-
-        fusedOffset = SPRITE_ROUTE_OFFSET(sp, SPRITE_SHIFT_INDEX(x), SPRITE_OP_DRAW);
-        if (fusedOffset != SPRITE_NOT_COMPILED) {
-            spriteCompiledDrawMark(sp, x, y, fusedOffset);
-            return;
-        }
+    // Skip validation -> the stage draw entry (the same arm jlSpriteDraw
+    // gates behind isFullyOnSurface). s == gStage only (it derives the
+    // pinned stage base).
+    if (s == gStage && iigsSpriteDrawStage(sp, x, y) == 0u) {
+        return;
     }
 #endif
     jlSpriteDraw(s, sp, x, y);
@@ -798,28 +790,15 @@ void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlS
     heightPx = sp->heightPx;
 
 #if defined(JOEYLIB_PLATFORM_IIGS)
-    // Fused pair (W1): compiled save + fused draw. iigsSpriteDrawMark
-    // widens the pair's ONE dirty rect (the same rect the shared arm
-    // marks at its tail), so there is no separate C mark here.
-    // Non-stage destinations and uncompiled sprites fall through to
-    // the public-API fallback below (non-stage SaveAndDraw then runs
-    // the compiled save + generic draw through those calls).
-    if (slot != NULL && backup->bytes != NULL && s == gStage
-            && isFullyOnSurface(x, y, widthPx, heightPx)) {
-        uint8_t  shift;
-        uint8_t  saveClass;
-        uint16_t saveOffset;
-        uint16_t drawOffset;
-
-        shift      = SPRITE_SHIFT_INDEX(x);
-        saveClass  = SPRITE_SAVE_CLASS(sp, x);
-        saveOffset = SPRITE_ROUTE_OFFSET(sp, saveClass, SPRITE_OP_SAVE);
-        drawOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_DRAW);
-        if (saveOffset != SPRITE_NOT_COMPILED && drawOffset != SPRITE_NOT_COMPILED) {
-            spriteCompiledSaveUnderStage(sp, x, y, backup, saveClass, saveOffset);
-            spriteCompiledDrawMark (sp, x, y, drawOffset);
-            return;
-        }
+    // Stage pair: compiled save + draw and the pair's ONE dirty rect (the
+    // same rect the shared arm marks at its tail) in one JSL
+    // (iigsSpriteSaveDrawStage). Non-stage destinations and uncompiled
+    // sprites fall through to the public-API fallback below (non-stage
+    // SaveAndDraw then runs the compiled save + generic draw through
+    // those calls).
+    if (slot != NULL && backup->bytes != NULL && s == gStage && isFullyOnSurface(x, y, widthPx, heightPx)
+            && iigsSpriteSaveDrawStage(sp, x, y, backup) == 0u) {
+        return;
     }
 #else
     // Fast path: compiled bytes available, fully on surface, backup
@@ -877,10 +856,10 @@ void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlS
 // gates that jlSpriteSaveAndDraw runs, going straight to the compiled
 // save + draw + mark. Same trust contract as jlSpriteRestoreUnderTrusted
 // (see joey/sprite.h). Optimized on Amiga/ST (compiled save + draw +
-// inline h==16 mark) and IIgs (compiled save + the fused draw+mark JSL,
-// mirroring the safe fused arm, s==gStage only); falls back to the
-// fully-validated jlSpriteSaveAndDraw for uncompiled sprites and on
-// DOS / generic.
+// inline h==16 mark); falls back to the fully-validated
+// jlSpriteSaveAndDraw for uncompiled sprites and on DOS / generic. The
+// IIgs body is asm (joeyDraw.s, beside the stage sprite entries).
+#if !defined(JOEYLIB_PLATFORM_IIGS)
 void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
 #if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_CPU_X86)
     ArenaSlotT *slot;
@@ -908,29 +887,10 @@ void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t
             return;
         }
     }
-#elif defined(JOEYLIB_PLATFORM_IIGS)
-    // Skip validation -> the safe fused stage save-and-draw arm
-    // (compiled save + spriteCompiledDrawMark's one fused draw+widen JSL).
-    // s == gStage only (the fused draw derives the pinned stage base).
-    if (sp->slot != NULL && s == gStage) {
-        uint8_t  shift;
-        uint8_t  saveClass;
-        uint16_t saveOffset;
-        uint16_t drawOffset;
-
-        shift      = SPRITE_SHIFT_INDEX(x);
-        saveClass  = SPRITE_SAVE_CLASS(sp, x);
-        saveOffset = SPRITE_ROUTE_OFFSET(sp, saveClass, SPRITE_OP_SAVE);
-        drawOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_DRAW);
-        if (saveOffset != SPRITE_NOT_COMPILED && drawOffset != SPRITE_NOT_COMPILED) {
-            spriteCompiledSaveUnderStage(sp, x, y, backup, saveClass, saveOffset);
-            spriteCompiledDrawMark (sp, x, y, drawOffset);
-            return;
-        }
-    }
 #endif
     jlSpriteSaveAndDraw(s, sp, x, y, backup);
 }
+#endif
 
 
 // Defragment the codegen arena (compiled sprite slots). The live .spr
@@ -983,7 +943,7 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) {
     }
 
     sp = backup->sprite;
-    if (sp != NULL && sp->slot != NULL && bh == sp->heightPx
+    if (sp != NULL && sp->slot != NULL && bh == SPRITE_SAVED_ROWS(sp)
             && SPRITE_BACKUP_PTR_OK(backup->bytes)) {
         uint16_t routeOffset;
         copyBytes = (int16_t)(bw >> 1);
@@ -1000,14 +960,10 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) {
         shift = SPRITE_RESTORE_CLASS(sp, bx, bw, (uint16_t)copyBytes);
         if (shift != 0xFFu) {
 #if defined(JOEYLIB_PLATFORM_IIGS)
-            // Fused stage restore (W1): compiled MVN body + dirty-band
-            // widen in one JSL; bank patch stays in C (rarely taken).
-            if (s == gStage) {
-                routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
-                if (routeOffset != SPRITE_NOT_COMPILED) {
-                    spriteCompiledRestoreMark(sp, backup, shift, routeOffset, bx, by, bw, bh);
-                    return;
-                }
+            // Stage restore: compiled MVN body + dirty-band widen in one
+            // JSL (iigsSpriteRestoreStage).
+            if (s == gStage && iigsSpriteRestoreStage(backup) == 0u) {
+                return;
             }
 #elif defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_CPU_X86)
             // Fused stage restore (P7-1 lever b): the compiled copy body
@@ -1082,9 +1038,9 @@ void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup) {
 // the gate both prevents jumping into garbage AND routes anything the
 // trusted fast path does not recognise to the fully-validated fallback,
 // so a conservative caller degrades safely. Optimized on Amiga/ST (the
-// inline h==16 mark) and on IIgs (skip validation -> the fused MVN
-// restore+mark JSL, mirroring the safe fused arm); on DOS / generic this
-// is just the validated call.
+// inline h==16 mark); on DOS / generic this is just the validated call.
+// The IIgs body is asm (joeyDraw.s, beside the stage sprite entries).
+#if !defined(JOEYLIB_PLATFORM_IIGS)
 void jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup) {
 #if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_CPU_X86)
     jlSpriteT *sp;
@@ -1110,36 +1066,10 @@ void jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup) {
             return;
         }
     }
-#elif defined(JOEYLIB_PLATFORM_IIGS)
-    // Skip the validation chain -> the safe fused stage-restore arm
-    // (spriteCompiledRestoreMark: compiled MVN body + dirty-band widen in
-    // one JSL). The class/route derive + NOT_COMPILED gate stay (route to
-    // the safe fallback for an uncompiled shift / non-stage window).
-    jlSpriteT *sp;
-    int16_t    bx;
-    int16_t    by;
-    uint16_t   bw;
-    uint16_t   bh;
-    uint8_t    shift;
-
-    sp    = backup->sprite;
-    bx    = backup->x;
-    by    = backup->y;
-    bw    = backup->width;
-    bh    = backup->height;
-    shift = SPRITE_RESTORE_CLASS(sp, bx, bw, (uint16_t)(bw >> 1));
-    if (shift != 0xFFu && s == gStage) {
-        uint16_t routeOffset;
-
-        routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_RESTORE);
-        if (routeOffset != SPRITE_NOT_COMPILED) {
-            spriteCompiledRestoreMark(sp, backup, shift, routeOffset, bx, by, bw, bh);
-            return;
-        }
-    }
 #endif
     jlSpriteRestoreUnder(s, backup);
 }
+#endif
 
 
 void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
@@ -1174,10 +1104,9 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
         shift       = SPRITE_SAVE_CLASS(sp, x);
         routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE);
 #if defined(JOEYLIB_PLATFORM_IIGS)
-        // Fused stage arm (rank 10): asm-side screen-offset derive +
-        // one JSL. Non-stage saves keep the generic dispatch below.
-        if (routeOffset != SPRITE_NOT_COMPILED && s == gStage) {
-            spriteCompiledSaveUnderStage(sp, x, y, backup, shift, routeOffset);
+        // Stage save in one JSL (iigsSpriteSaveStage). Non-stage saves
+        // keep the generic dispatch below.
+        if (s == gStage && iigsSpriteSaveStage(sp, x, y, backup) == 0u) {
             return;
         }
 #endif
@@ -1280,16 +1209,16 @@ void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
 
 // TRUSTED save-under (P7-1). Skips the NULL / on-surface / backup gates
 // jlSpriteSaveUnder runs, going straight to the compiled save. Same
-// trust contract as jlSpriteRestoreUnderTrusted (see joey/sprite.h);
-// keeps backup->sprite (the Amiga/ST compiled save does not set it, and
-// jlSpriteRestoreUnder* reads it; the IIgs compiled save sets it too, so
-// the write is redundant-but-harmless there). Optimized on Amiga/ST/IIgs
-// (identical: the compiled save is a read, so there is no fused entry --
-// just skip validation; the IIgs plane hook is a no-op); falls back to
-// the fully-validated jlSpriteSaveUnder for uncompiled sprites and on
-// DOS / generic.
+// trust contract as jlSpriteRestoreUnderTrusted (see joey/sprite.h).
+// Optimized on Amiga/ST (the compiled save is a read, so there is no
+// fused entry -- just skip validation, and set backup->sprite, which
+// their compiled save does not and jlSpriteRestoreUnder* reads) and on
+// the IIgs (the stage save entry, which fills all the backup's
+// metadata); falls back to the fully-validated jlSpriteSaveUnder for
+// uncompiled sprites, other destinations on the IIgs, and on DOS /
+// generic.
 void jlSpriteSaveUnderTrusted(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup) {
-#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST) || defined(JOEYLIB_PLATFORM_IIGS)
+#if defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_ATARIST)
     ArenaSlotT *slot;
 
     slot = sp->slot;
@@ -1300,24 +1229,15 @@ void jlSpriteSaveUnderTrusted(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int
         shift       = SPRITE_SAVE_CLASS(sp, x);
         routeOffset = SPRITE_ROUTE_OFFSET(sp, shift, SPRITE_OP_SAVE);
         if (routeOffset != SPRITE_NOT_COMPILED) {
-#if !defined(JOEYLIB_PLATFORM_IIGS)
-            // 68k compiled saves do not set backup->sprite; both IIgs
-            // arms do (asm fill / generic dispatch), so IIgs skips the
-            // redundant far store (P7-2 stage 2).
             backup->sprite = sp;
-#endif
-#if defined(JOEYLIB_PLATFORM_IIGS)
-            // Fused stage arm (rank 10); trusted callers target the
-            // stage, but keep the generic dispatch for any other dst.
-            if (s == gStage) {
-                spriteCompiledSaveUnderStage(sp, x, y, backup, shift, routeOffset);
-                return;
-            }
-#endif
             spriteCompiledSaveUnder(s, sp, x, y, backup, shift, routeOffset);
             return;
         }
     }
+#elif defined(JOEYLIB_PLATFORM_IIGS)
+    if (s == gStage && iigsSpriteSaveStage(sp, x, y, backup) == 0u) {
+        return;
+    }
 #endif
     jlSpriteSaveUnder(s, sp, x, y, backup);
 }
diff --git a/src/core/spriteDispatch.h b/src/core/spriteDispatch.h
index bffe0d0..44c6807 100644
--- a/src/core/spriteDispatch.h
+++ b/src/core/spriteDispatch.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Compiled-sprite runtime dispatchers as always-inline functions
 // (NATIVE-PERF Phase 2 R5). Bodies moved verbatim from
 // src/codegen/spriteCompile.c so the hot entries in src/core/sprite.c
@@ -74,10 +78,10 @@ static inline __attribute__((always_inline)) void spriteCompiledDraw(jlSurfaceT
 //   epilogue (2):  AB 6B
 // The 4-byte prologue shifts MVN dstbk to routine offset (12*R + 8), srcbk to
 // (12*R + 9).
-static inline void patchMvnBanks(uint8_t *routine, uint16_t heightPx, uint8_t dstBank, uint8_t srcBank) {
+static inline void patchMvnBanks(uint8_t *routine, uint16_t rows, uint8_t dstBank, uint8_t srcBank) {
     uint16_t r;
 
-    for (r = 0; r < heightPx; r++) {
+    for (r = 0; r < rows; r++) {
         routine[12u * r + 8u] = dstBank;
         routine[12u * r + 9u] = srcBank;
     }
@@ -106,39 +110,47 @@ static inline void patchMvnBanks(uint8_t *routine, uint16_t heightPx, uint8_t ds
 // hit -- so the cache was deleted rather than moved per-sprite.
 
 
-// Fused stage entries (src/iigs/joeyDraw.s, .text.iigsFusedRect).
-// Only sprite.c consumes this header, so the externs live here, not
-// in port.h (they are not jlp ops). Each runs the compiled arena
-// routine AND the dirty-band widen in one JSL; the caller owns the
-// s == gStage gate and validation.
-// NOTE (W1 measurement): an R12-style resolved-fnAddr cache
-// (per-(shift,op) address + arena-epoch invalidation) was built and
-// benched here 2026-07-07 and REMOVED: on llvm-mos the epoch-checked
-// hit path costs as much as the 3-far-load derive it replaces, and
-// the extra 32-bit value pressured the caller frames -- save (cache
-// only, no fusion) measured 602 -> 543 ops/s. The derive below is the
-// cheap form on this compiler.
-extern void iigsSpriteDrawMark(uint32_t fnAddr, uint16_t x, uint16_t y, uint16_t widthPx, uint16_t heightPx);
-extern void iigsSpriteRestoreMark(uint32_t fnAddr, uint16_t backupLo, uint16_t bx, uint16_t by, uint16_t bw, uint16_t bh);
-extern void iigsSpriteSaveStage(uint32_t fnAddr, uint16_t backupLo, uint16_t x, uint16_t y);
+// Stage entries (src/iigs/joeyDraw.s, .text.iigsFusedRect): a compiled
+// sprite's draw, save, save-and-draw or restore on gStage in ONE JSL,
+// reading the sprite and backup structs themselves -- the arena slot,
+// the routine for the shift, the MVN bank-patch cache, the backup's
+// metadata -- then deriving the stage offset, calling the routine(s)
+// and widening the dirty band. They return 0 when done and non-zero,
+// having done nothing, when the sprite or the op at that shift is not
+// compiled (or, for the restore, the backup is not a whole
+// compiled-format one); the caller falls back. Callers own s == gStage
+// and, for the checked entries, the geometry validation. Only sprite.c
+// consumes this header, so the externs live here, not in port.h.
+extern uint16_t iigsSpriteDrawStage(jlSpriteT *sp, int16_t x, int16_t y);
+extern uint16_t iigsSpriteSaveStage(jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup);
+extern uint16_t iigsSpriteSaveDrawStage(jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup);
+extern uint16_t iigsSpriteRestoreStage(const jlSpriteBackupT *backup);
 
-
-// Fused-draw C glue: derive the arena address (same three-term form
-// as spriteCompiledDraw) and make the ONE JSL that runs the compiled
-// body and the dirty-band widen. Caller owns s == gStage +
-// isFullyOnSurface + the NOT_COMPILED gate.
-static inline __attribute__((always_inline)) void spriteCompiledDrawMark(const jlSpriteT *sp, int16_t x, int16_t y, uint16_t routeOffset) {
-    uint32_t fnAddr;
-
-    fnAddr = codegenArenaSlotAddr(sp->slot)
-            + (uint32_t)routeOffset;
-    iigsSpriteDrawMark(fnAddr, (uint16_t)x, (uint16_t)y, sp->widthPx, sp->heightPx);
-}
+// The stage entries read these fields at fixed offsets (SPR_* and BK_*
+// in joeyDraw.s); a layout change must fail the build, not the sprites.
+typedef char iigsSpriteWidthAt8[(offsetof(jlSpriteT, widthPx) == 8u) ? 1 : -1];
+typedef char iigsSpriteHeightAt10[(offsetof(jlSpriteT, heightPx) == 10u) ? 1 : -1];
+typedef char iigsSpriteSlotAt12[(offsetof(jlSpriteT, slot) == 12u) ? 1 : -1];
+typedef char iigsSpriteRoutesAt16[(offsetof(jlSpriteT, routineOffsets) == 16u) ? 1 : -1];
+typedef char iigsSpriteDstBanksAt28[(offsetof(jlSpriteT, cachedDstBank) == 28u) ? 1 : -1];
+typedef char iigsSpriteSrcBanksAt34[(offsetof(jlSpriteT, cachedSrcBank) == 34u) ? 1 : -1];
+typedef char iigsSpriteSizesAt40[(offsetof(jlSpriteT, cachedSizeBytes) == 40u) ? 1 : -1];
+typedef char iigsBackupSpriteAt0[(offsetof(jlSpriteBackupT, sprite) == 0u) ? 1 : -1];
+typedef char iigsBackupXAt4[(offsetof(jlSpriteBackupT, x) == 4u) ? 1 : -1];
+typedef char iigsBackupYAt6[(offsetof(jlSpriteBackupT, y) == 6u) ? 1 : -1];
+typedef char iigsBackupWidthAt8[(offsetof(jlSpriteBackupT, width) == 8u) ? 1 : -1];
+typedef char iigsBackupHeightAt10[(offsetof(jlSpriteBackupT, height) == 10u) ? 1 : -1];
+typedef char iigsBackupBytesAt12[(offsetof(jlSpriteBackupT, bytes) == 12u) ? 1 : -1];
+typedef char iigsBackupSizeAt16[(offsetof(jlSpriteBackupT, sizeBytes) == 16u) ? 1 : -1];
+typedef char iigsSpriteRow0At44[(offsetof(jlSpriteT, contentRow0) == 44u) ? 1 : -1];
+typedef char iigsSpriteRowsAt46[(offsetof(jlSpriteT, contentRows) == 46u) ? 1 : -1];
+typedef char iigsSlotOffsetAt0[(offsetof(ArenaSlotT, offset) == 0u) ? 1 : -1];
 
 
 static inline __attribute__((always_inline)) void spriteCompiledSaveUnder(const jlSurfaceT *src, jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) {
     int16_t   clippedX;
-    uint16_t  heightPx;
+    int16_t   firstY;
+    uint16_t  rows;
     uint16_t  copyBytes;
     uint16_t  screenLo;
     uint16_t  backupLo;
@@ -152,21 +164,22 @@ static inline __attribute__((always_inline)) void spriteCompiledSaveUnder(const
     uint8_t  *cachedSrc;      /* &sp->cachedSrcBank[0][0] + cacheIdx */
 
     // shift and routeOffset arrive pre-derived from the dispatcher
-    // gate (#29); widthPx/heightPx are create-time constants cached in
-    // the sprite struct.
+    // gate (#29); widthPx and the content rows are constants cached in
+    // the sprite struct. The save covers the content rows only.
     clippedX  = (int16_t)(x & ~1);
-    heightPx  = sp->heightPx;
+    firstY    = (int16_t)(y + (int16_t)sp->contentRow0);
+    rows      = sp->contentRows;
     copyBytes = (uint16_t)((sp->widthPx >> 1) + shift);  /* shift is x & 1, exactly 0 or 1 */
 
-    screenPtr = (uint8_t *)&src->pixels[SURFACE_ROW_OFFSET(y) + ((uint16_t)clippedX >> 1)];
+    screenPtr = (uint8_t *)&src->pixels[SURFACE_ROW_OFFSET(firstY) + ((uint16_t)clippedX >> 1)];
     SPLIT_POINTER(screenPtr,     &screenLo, &screenBank);
     SPLIT_POINTER(backup->bytes, &backupLo, &backupBank);
 
     backup->sprite    = sp;
     backup->x         = clippedX;
-    backup->y         = y;
+    backup->y         = firstY;
     backup->width     = (uint16_t)(copyBytes << 1);
-    backup->height    = heightPx;
+    backup->height    = rows;
     /* sizeBytes is constant per (sprite, shift); cache to dodge the
      * per-call ~CUMUL2 (uint16_t * uint16_t) helper. The byte-pointer
      * arithmetic avoids reintroducing ~MUL4 for the uint16_t array
@@ -174,7 +187,7 @@ static inline __attribute__((always_inline)) void spriteCompiledSaveUnder(const
     {
         uint16_t *sizeCachePtr = (uint16_t *)((uint8_t *)sp->cachedSizeBytes + ((uint16_t)shift << 1));
         if (*sizeCachePtr == 0) {
-            *sizeCachePtr = (uint16_t)(copyBytes * heightPx);
+            *sizeCachePtr = (uint16_t)(copyBytes * rows);
         }
         backup->sizeBytes = *sizeCachePtr;
     }
@@ -194,7 +207,7 @@ static inline __attribute__((always_inline)) void spriteCompiledSaveUnder(const
     // backup: dst = backup bank, src = screen bank.
     if (*cachedDst != backupBank || *cachedSrc != screenBank) {
         routine = (uint8_t *)fnAddr;
-        patchMvnBanks(routine, heightPx, /*dst*/backupBank, /*src*/screenBank);
+        patchMvnBanks(routine, rows, /*dst*/backupBank, /*src*/screenBank);
         *cachedDst = backupBank;
         *cachedSrc = screenBank;
     }
@@ -209,65 +222,6 @@ static inline __attribute__((always_inline)) void spriteCompiledSaveUnder(const
 }
 
 
-// Fused stage-save C glue (perf-hunt-2 rank 10): keeps the backup-
-// metadata fills but replaces spriteCompiledSaveUnder's C-side
-// screen-pointer derive (SURFACE_ROW_OFFSET + pointer split) and
-// indirect fn-pointer call with ONE JSL into iigsSpriteSaveStage,
-// which derives the screen offset from the pinned stage base asm-side.
-// Caller owns s == gStage, the NOT_COMPILED gate, and (for the safe
-// entry) validation. Mirrors spriteCompiledRestoreMark's shape.
-// P7-2 STAGE 2 WAS BUILT AND REVERTED (2026-07-19): moving these
-// metadata fills into a 10-arg asm entry benched save 722 -> 602 --
-// llvm-mos's marshalling of the extra stack args + pointer splits cost
-// more than the far stores it deleted (the fnAddr-cache wall again).
-static inline __attribute__((always_inline)) void spriteCompiledSaveUnderStage(jlSpriteT *sp, int16_t x, int16_t y, jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) {
-    int16_t   clippedX;
-    uint16_t  heightPx;
-    uint16_t  copyBytes;
-    uint16_t  backupLo;
-    uint8_t   backupBank;
-    uint8_t   screenBank;
-    uint32_t  fnAddr;
-    uint16_t  cacheIdx;
-    uint8_t  *cachedDst;
-    uint8_t  *cachedSrc;
-
-    clippedX  = (int16_t)(x & ~1);
-    heightPx  = sp->heightPx;
-    copyBytes = (uint16_t)((sp->widthPx >> 1) + shift);  /* shift is x & 1 */
-
-    SPLIT_POINTER(backup->bytes, &backupLo, &backupBank);
-    screenBank = ((const uint8_t *)&gStage->pixels)[2];
-
-    backup->sprite = sp;
-    backup->x      = clippedX;
-    backup->y      = y;
-    backup->width  = (uint16_t)(copyBytes << 1);
-    backup->height = heightPx;
-    {
-        uint16_t *sizeCachePtr = (uint16_t *)((uint8_t *)sp->cachedSizeBytes + ((uint16_t)shift << 1));
-        if (*sizeCachePtr == 0) {
-            *sizeCachePtr = (uint16_t)(copyBytes * heightPx);
-        }
-        backup->sizeBytes = *sizeCachePtr;
-    }
-
-    cacheIdx  = SPRITE_ROUTINE_INDEX(shift, SPRITE_OP_SAVE);
-    cachedDst = (uint8_t *)sp->cachedDstBank + cacheIdx;
-    cachedSrc = (uint8_t *)sp->cachedSrcBank + cacheIdx;
-
-    fnAddr = codegenArenaSlotAddr(sp->slot)
-            + (uint32_t)routeOffset;
-
-    if (*cachedDst != backupBank || *cachedSrc != screenBank) {
-        patchMvnBanks((uint8_t *)fnAddr, heightPx, /*dst*/backupBank, /*src*/screenBank);
-        *cachedDst = backupBank;
-        *cachedSrc = screenBank;
-    }
-    iigsSpriteSaveStage(fnAddr, backupLo, (uint16_t)clippedX, (uint16_t)y);
-}
-
-
 static inline __attribute__((always_inline)) void spriteCompiledRestoreUnder(jlSurfaceT *dst, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset) {
     uint16_t  heightPx;
     uint16_t  screenLo;
@@ -319,41 +273,6 @@ static inline __attribute__((always_inline)) void spriteCompiledRestoreUnder(jlS
 }
 
 
-// Fused-restore C glue (NATIVE-PERF Phase 6 W1): MVN bank-patch check
-// (screen bank read from the stage's pinned pixels pointer -- byte 2
-// of the 32-bit field, no second $01 source of truth in C), then ONE
-// JSL into iigsSpriteRestoreMark, which derives the screen offset from
-// the pinned stage base, long-calls the compiled MVN routine, and
-// widens the dirty band in asm. Caller validated the backup geometry,
-// gated s == gStage, and gate-checked routeOffset; bh == sp->heightPx
-// (validated), which patchMvnBanks needs.
-static inline __attribute__((always_inline)) void spriteCompiledRestoreMark(jlSpriteT *sp, const jlSpriteBackupT *backup, uint8_t shift, uint16_t routeOffset, int16_t bx, int16_t by, uint16_t bw, uint16_t bh) {
-    uint16_t  backupLo;
-    uint8_t   backupBank;
-    uint8_t   screenBank;
-    uint32_t  fnAddr;
-    uint16_t  cacheIdx;
-    uint8_t  *cachedDst;
-    uint8_t  *cachedSrc;
-
-    SPLIT_POINTER(backup->bytes, &backupLo, &backupBank);
-    screenBank = ((const uint8_t *)&gStage->pixels)[2];
-
-    fnAddr = codegenArenaSlotAddr(sp->slot)
-            + (uint32_t)routeOffset;
-
-    cacheIdx  = SPRITE_ROUTINE_INDEX(shift, SPRITE_OP_RESTORE);
-    cachedDst = (uint8_t *)sp->cachedDstBank + cacheIdx;
-    cachedSrc = (uint8_t *)sp->cachedSrcBank + cacheIdx;
-    if (*cachedDst != screenBank || *cachedSrc != backupBank) {
-        patchMvnBanks((uint8_t *)fnAddr, bh, screenBank, backupBank);
-        *cachedDst = screenBank;
-        *cachedSrc = backupBank;
-    }
-    iigsSpriteRestoreMark(fnAddr, backupLo, (uint16_t)bx, (uint16_t)by, bw, bh);
-}
-
-
 #elif defined(JOEYLIB_PLATFORM_AMIGA) || defined(JOEYLIB_PLATFORM_X68000)
 
 // Amiga AND X68000 planar dispatchers. Both ports emit through
diff --git a/src/core/spriteInternal.h b/src/core/spriteInternal.h
index c72e218..2b2dd01 100644
--- a/src/core/spriteInternal.h
+++ b/src/core/spriteInternal.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Internal sprite definitions shared between sprite.c and the
 // per-platform codegen emitters. Public API users include
 // joey/sprite.h instead.
@@ -107,6 +111,14 @@
 #define SPRITE_BACKUP_PTR_OK(_p)    (1)
 #endif
 
+// Rows a compiled save covers, and so the height a compiled-format backup
+// records: the IIgs saves only a sprite's content rows (contentRows).
+#if defined(JOEYLIB_PLATFORM_IIGS)
+#define SPRITE_SAVED_ROWS(_sp)      ((_sp)->contentRows)
+#else
+#define SPRITE_SAVED_ROWS(_sp)      ((_sp)->heightPx)
+#endif
+
 // The 4bpp nibble value treated as transparent by the interpreters
 // (sprite.c) and every per-CPU emitter. Single definition so the
 // transparency contract cannot drift between the compiled and
@@ -171,14 +183,25 @@ struct jlSpriteT {
     uint8_t        cachedDstBank[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT];
     uint8_t        cachedSrcBank[JOEY_SPRITE_SHIFT_COUNT][SPRITE_OP_COUNT];
 
-    // Cached `copyBytes * heightPx` per shift for spriteCompiledSaveUnder's
+    // Cached `copyBytes * contentRows` per shift for the compiled save's
     // `backup->sizeBytes` field. uint16_t * uint16_t goes through a
     // software multiply helper (~30-50 cyc); cache hit dodges it. Filled
     // lazily on first call (0 sentinel = uncached).
     // (A W1 resolved-fnAddr cache with arena-epoch invalidation also
     // lived here briefly -- benched as a net LOSS on llvm-mos and
-    // removed; see the note in spriteDispatch.h.)
+    // removed; the IIgs stage sprite entries in joeyDraw.s now derive
+    // the address in asm.)
     uint16_t       cachedSizeBytes[JOEY_SPRITE_SHIFT_COUNT];
+
+    // The rows the compiled save and restore cover: the first row holding
+    // an opaque pixel and the count through the last one (the whole
+    // height when there is none). The draw never writes the transparent
+    // rows above and below, so nothing there needs saving, restoring or
+    // presenting -- a one-tile star cel's three rows instead of eight. Set
+    // with the routines (spriteContentRowsIigs) and read by the stage
+    // entries in joeyDraw.s.
+    uint16_t       contentRow0;
+    uint16_t       contentRows;
 #endif
 };
 
@@ -228,4 +251,12 @@ void spriteRegistryAdd(jlSpriteT *sp);
 // codegenArenaShutdown releases the slots right after.
 void spriteSystemShutdown(void);
 
+#if defined(JOEYLIB_PLATFORM_IIGS)
+// Set sp->contentRow0 / contentRows from the sprite's tile data. Called
+// wherever compiled routines are put in place (spriteStageAndPlan, the
+// .spc loader), so the rows always match the routines. Defined in
+// src/iigs/spriteEmitIigs.c, which the offline baker links too.
+void spriteContentRowsIigs(jlSpriteT *sp);
+#endif
+
 #endif
diff --git a/src/core/spritePrecompiled.h b/src/core/spritePrecompiled.h
index 322c9d0..1182a20 100644
--- a/src/core/spritePrecompiled.h
+++ b/src/core/spritePrecompiled.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // .spc "sprite pre-compiled" format -- shared spec for the offline baker
 // (tools/spritebake) and the runtime loader (src/core/assetLoad.c).
 //
@@ -19,8 +23,9 @@
 
 // 4-byte magic. Trailing digit is the format version; bump it if the emitted
 // machine-code ABI or this container layout changes (the loader validates it,
-// so a stale .spc is rejected rather than mis-executed).
-#define SPC_MAGIC              "JSC1"
+// so a stale .spc is rejected rather than mis-executed). 2: the IIgs save
+// and restore cover only a cel's content rows (contentRows, spriteInternal.h).
+#define SPC_MAGIC              "JSC2"
 
 // Upper bound on one cel's routine-image size (== the JIT staging cap): the
 // uint16 slot-relative offset budget keeps every image under 64 KB. The
diff --git a/src/core/surface.c b/src/core/surface.c
index b2411ba..7651f8f 100644
--- a/src/core/surface.c
+++ b/src/core/surface.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Surface allocation, destruction, persistence, and the library-owned
 // stage (the back-buffer surface).
 
diff --git a/src/core/surfaceInternal.h b/src/core/surfaceInternal.h
index 8e55d64..d360954 100644
--- a/src/core/surfaceInternal.h
+++ b/src/core/surfaceInternal.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Internal surface definition shared across core and port code.
 
 #ifndef JOEYLIB_SURFACE_INTERNAL_H
diff --git a/src/core/tile.c b/src/core/tile.c
index ce62781..273832b 100644
--- a/src/core/tile.c
+++ b/src/core/tile.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Tiles as 8x8 surface regions. The whole API is byte-shoveling
 // between jlSurfaceT regions (or between a jlSurfaceT region and a
 // stack jlTileT buffer); no separate tileset container, no allocator.
diff --git a/src/dos/audio.c b/src/dos/audio.c
index f88e861..e71bb18 100644
--- a/src/dos/audio.c
+++ b/src/dos/audio.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DOS audio HAL: Sound Blaster Pro / SB16 8-bit mono playback driven
 // by 8237 auto-init DMA, fed by libxmp-lite's mixer.
 //
diff --git a/src/dos/dosTimer.h b/src/dos/dosTimer.h
index f9370d3..e9307fb 100644
--- a/src/dos/dosTimer.h
+++ b/src/dos/dosTimer.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DOS timer hardware shared by the port's clock (hal.c) and its audio
 // dispatcher (audio.c): the 8253/8254 PIT counter 0 that drives IRQ0, the
 // master 8259 PIC, and the BIOS tick count IRQ0 advances.
diff --git a/src/dos/hal.c b/src/dos/hal.c
index f42e22c..43fcd0c 100644
--- a/src/dos/hal.c
+++ b/src/dos/hal.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DOS HAL: VGA mode 13h, 6-bit DAC, nibble-expand present.
 //
 // JoeyLib surfaces are 4bpp chunky with a 200-entry SCB table selecting
diff --git a/src/dos/input.c b/src/dos/input.c
index 7b1a704..fd6d1e8 100644
--- a/src/dos/input.c
+++ b/src/dos/input.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DOS keyboard + mouse input.
 //
 // Keyboard: hooks INT 9 to capture AT set-1 scan codes from port 0x60.
@@ -113,11 +117,11 @@
 // INT 15h game-port BIOS functions:
 //   AH=84h, DX=0: read switches  (AL bits 4-7 = buttons, 0=pressed)
 //   AH=84h, DX=1: read positions (AX/BX/CX/DX = stickA X/Y, stickB X/Y)
-// The position values are raw R-C timer counts; magnitude depends on
-// the joystick's potentiometers and the host BIOS calibration. We
-// threshold around a nominal center to derive digital direction; for
-// DOSBox-mapped sticks 128/384 covers the default emulated range and
-// gives clean L/R/U/D snaps.
+// The position values are raw R-C timer counts, whose size depends on the
+// stick's potentiometers and the CPU's speed (DOSBox's sit at 128 +-127),
+// so only a stick's resting position says where its middle is: the first
+// read after init or jlJoystickReset is taken as rest, and a position past
+// half the rest value either way is a full push (digital direction).
 #define BIOS_INT                  0x15
 #define BIOS_FN_GAMEPORT          0x84
 #define BIOS_GAMEPORT_DX_SWITCHES 0x0000
@@ -127,8 +131,8 @@
 #define BIOS_BTN_A2               0x20
 #define BIOS_BTN_B1               0x40
 #define BIOS_BTN_B2               0x80
-#define JOY_AXIS_LO_THRESHOLD     128
-#define JOY_AXIS_HI_THRESHOLD     384
+// A push is full once it is this fraction of the rest value from it.
+#define JOY_PUSH_DIVISOR          2u
 
 // ----- Prototypes -----
 
@@ -137,7 +141,7 @@ static void   joystickPoll(void);
 static void   keyboardIsr(void);
 static bool   mouseInit(void);
 static void   mousePoll(void);
-static int8_t scaleAxis(uint16_t v);
+static int8_t scaleAxis(uint16_t v, uint16_t rest);
 
 // ----- Module state -----
 
@@ -220,6 +224,12 @@ static volatile uint8_t   gIsrCharTail = 0;
 static bool gMousePresent    = false;
 static bool gJoystickPresent = false;
 
+// Each stick's resting position, and whether it has been taken since init
+// or the stick's jlJoystickReset.
+static uint16_t gJoyRestX     [JOYSTICK_COUNT];
+static uint16_t gJoyRestY     [JOYSTICK_COUNT];
+static bool     gJoyRestValid [JOYSTICK_COUNT];
+
 // ----- Internal helpers -----
 
 static void keyboardIsr(void) {
@@ -366,11 +376,17 @@ static bool mouseInit(void) {
 }
 
 
-static int8_t scaleAxis(uint16_t v) {
-    if (v < JOY_AXIS_LO_THRESHOLD) {
+// A position against its stick's rest: a full push past half the rest
+// value either way. An axis with nothing on it reads 0 at rest and stays
+// there, so it never reads as pushed.
+static int8_t scaleAxis(uint16_t v, uint16_t rest) {
+    uint16_t push;
+
+    push = (uint16_t)(rest / JOY_PUSH_DIVISOR);
+    if (v < rest - push) {
         return JOYSTICK_AXIS_MIN;
     }
-    if (v > JOY_AXIS_HI_THRESHOLD) {
+    if (v > rest + push) {
         return JOYSTICK_AXIS_MAX;
     }
     return 0;
@@ -437,10 +453,20 @@ static void joystickPoll(void) {
         gJoyAxisY[JOYSTICK_1] = 0;
         return;
     }
-    gJoyAxisX[JOYSTICK_0] = scaleAxis(r.x.ax);
-    gJoyAxisY[JOYSTICK_0] = scaleAxis(r.x.bx);
-    gJoyAxisX[JOYSTICK_1] = scaleAxis(r.x.cx);
-    gJoyAxisY[JOYSTICK_1] = scaleAxis(r.x.dx);
+    if (!gJoyRestValid[JOYSTICK_0]) {
+        gJoyRestX[JOYSTICK_0]     = r.x.ax;
+        gJoyRestY[JOYSTICK_0]     = r.x.bx;
+        gJoyRestValid[JOYSTICK_0] = true;
+    }
+    if (!gJoyRestValid[JOYSTICK_1]) {
+        gJoyRestX[JOYSTICK_1]     = r.x.cx;
+        gJoyRestY[JOYSTICK_1]     = r.x.dx;
+        gJoyRestValid[JOYSTICK_1] = true;
+    }
+    gJoyAxisX[JOYSTICK_0] = scaleAxis(r.x.ax, gJoyRestX[JOYSTICK_0]);
+    gJoyAxisY[JOYSTICK_0] = scaleAxis(r.x.bx, gJoyRestY[JOYSTICK_0]);
+    gJoyAxisX[JOYSTICK_1] = scaleAxis(r.x.cx, gJoyRestX[JOYSTICK_1]);
+    gJoyAxisY[JOYSTICK_1] = scaleAxis(r.x.dx, gJoyRestY[JOYSTICK_1]);
 }
 
 
@@ -471,9 +497,10 @@ static void mousePoll(void) {
 
 // ----- HAL API (alphabetical) -----
 
+// Probe the game port again and take the stick's next position as rest.
 void jlpJoystickReset(jlJoystickE js) {
-    // DOS sticks are digital -- no calibration to do.
-    (void)js;
+    gJoyRestValid[js] = false;
+    gJoystickPresent  = joystickInit();
 }
 
 
@@ -524,6 +551,7 @@ void jlpInputInit(void) {
 
     gMousePresent    = mouseInit();
     gMouseAttached   = gMousePresent;
+    memset(gJoyRestValid, 0, sizeof(gJoyRestValid));
     gJoystickPresent = joystickInit();
 }
 
diff --git a/src/dos/save.c b/src/dos/save.c
index 03f06ba..444d2ec 100644
--- a/src/dos/save.c
+++ b/src/dos/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DOS save-file + disk-space HAL.
 //
 // SAVES/ is a real subdirectory next to the .EXE, made with POSIX mkdir;
diff --git a/src/dos/serial.c b/src/dos/serial.c
index e6b70cc..1856562 100644
--- a/src/dos/serial.c
+++ b/src/dos/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DOS serial HAL -- 8250/16550 UART on a COM port, interrupt-driven RX ring.
 //
 // BIOS INT 14h is polled and unbuffered, so a byte arriving mid-frame is lost
diff --git a/src/dvx/audio.c b/src/dvx/audio.c
index d28db3b..14bc976 100644
--- a/src/dvx/audio.c
+++ b/src/dvx/audio.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DVX audio HAL: JoeyLib's sound through DVX's sound library (audio.lib)
 // and music library (music.lib), which own the sound card for every
 // program on the desktop and mix them all together.
diff --git a/src/dvx/dvxApp.c b/src/dvx/dvxApp.c
index 37b4fc3..5297bbe 100644
--- a/src/dvx/dvxApp.c
+++ b/src/dvx/dvxApp.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // The DVX entry of a JoeyLib program: the .app descriptor DVX reads when it
 // loads the module, and appMain, which DVX runs in the program's own task.
 //
diff --git a/src/dvx/dvxPort.h b/src/dvx/dvxPort.h
index 86ddfa2..d7baa77 100644
--- a/src/dvx/dvxPort.h
+++ b/src/dvx/dvxPort.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared by the files of the DVX port (src/dvx). Include after dvxSdk.h.
 //
 // A JoeyLib program on DVX is a DVX main-loop program (an .app module):
diff --git a/src/dvx/dvxSdk.h b/src/dvx/dvxSdk.h
index 31e7a65..912572c 100644
--- a/src/dvx/dvxSdk.h
+++ b/src/dvx/dvxSdk.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // The DVX SDK headers the DVX port uses, made safe to mix with JoeyLib's.
 // Include it FIRST in every src/dvx file, before any JoeyLib header.
 //
diff --git a/src/dvx/hal.c b/src/dvx/hal.c
index af5e4ba..06af4e4 100644
--- a/src/dvx/hal.c
+++ b/src/dvx/hal.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DVX HAL: the stage in a window or on the whole screen, the frame clock and
 // the present.
 //
diff --git a/src/dvx/input.c b/src/dvx/input.c
index b0155e2..8936c63 100644
--- a/src/dvx/input.c
+++ b/src/dvx/input.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DVX input: keyboard, typed characters, mouse and joysticks.
 //
 // Keyboard: DVX keeps a table of held keys by raw PC scan code (set 1, plus
diff --git a/src/dvx/program.c b/src/dvx/program.c
index e19968e..aa0891f 100644
--- a/src/dvx/program.c
+++ b/src/dvx/program.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Running and ending a JoeyLib program on DVX, and the folders its files
 // live in.
 //
diff --git a/src/dvx/save.c b/src/dvx/save.c
index 59f9e3d..30ab61a 100644
--- a/src/dvx/save.c
+++ b/src/dvx/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DVX save-file + disk-space HAL.
 //
 // SAVES/ lives in the program's settings folder (program.c's write root);
diff --git a/src/dvx/serial.c b/src/dvx/serial.c
index f51eb8c..64ceb09 100644
--- a/src/dvx/serial.c
+++ b/src/dvx/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // DVX serial HAL: a COM port through DVX's serial library (rs232), which
 // owns the UARTs for every program on the desktop -- one owner per port, so
 // a port another program has open is refused. Its interrupt fills a receive
diff --git a/src/generic/genericDraw.c b/src/generic/genericDraw.c
index 305bbd3..8e649bf 100644
--- a/src/generic/genericDraw.c
+++ b/src/generic/genericDraw.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic portable-C op defaults -- the new joeylib's "generic backend" (the
 // joey.c-equivalent reference). A machine that does not override an op (no
 // JL_HAS_ defined for it in include/joey/platform.h) links these. An
diff --git a/src/generic/genericPort.c b/src/generic/genericPort.c
index e5000dc..6bd7be4 100644
--- a/src/generic/genericPort.c
+++ b/src/generic/genericPort.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic portable-C defaults for Category B platform SERVICES (timing, and --
 // in later steps -- lifecycle/present/input/audio). Most are inherently
 // platform-specific, so the "generic" is a no-op/zero stub that lets a blank/
diff --git a/src/generic/genericSave.c b/src/generic/genericSave.c
index 728c898..fdac641 100644
--- a/src/generic/genericSave.c
+++ b/src/generic/genericSave.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic portable-C no-op defaults for the save-file + disk-space service.
 // A port with no writable filesystem (or the BLANK template) links these: the
 // SAVES/ directory cannot be made, nothing can be deleted, and free space is
diff --git a/src/generic/genericSerial.c b/src/generic/genericSerial.c
index 2429752..9ebd645 100644
--- a/src/generic/genericSerial.c
+++ b/src/generic/genericSerial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic portable-C no-op defaults for the serial add-on (Category B
 // platform service). Serial is inherently platform-specific hardware, so the
 // "generic" is a no-op/zero stub that lets a blank/template port link with the
diff --git a/src/generic/genericSprite.c b/src/generic/genericSprite.c
index 907ccfe..8ce57a4 100644
--- a/src/generic/genericSprite.c
+++ b/src/generic/genericSprite.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic sprite "planes" op defaults. The chunky software sprite blit lives in
 // core/sprite.c (spriteDrawInterpreted) and the per-platform sprite codegen does
 // the fast path; jlpSprite*Planes is the PLANAR mirror/interpreter, which has no
diff --git a/src/generic/genericSurface.c b/src/generic/genericSurface.c
index d06248e..072a4f6 100644
--- a/src/generic/genericSurface.c
+++ b/src/generic/genericSurface.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic portable-C surface-service defaults -- the joey.c-equivalent reference
 // for Category B structural ops (see genericDraw.c for the jlp dispatch
 // model). A machine that does not override an op (no JL_HAS_ in
diff --git a/src/generic/genericTile.c b/src/generic/genericTile.c
index 69d3b36..bcaa6f5 100644
--- a/src/generic/genericTile.c
+++ b/src/generic/genericTile.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Generic portable-C tile op defaults (chunky 4bpp). The compile-time jlp
 // dispatch in src/core/port.h routes to these when a machine declares no
 // JL_HAS_ for the tile op; machine overrides (IIgs asm macros, Amiga/ST
diff --git a/src/generic/spriteEmitStub.c b/src/generic/spriteEmitStub.c
index afda7af..99f06a2 100644
--- a/src/generic/spriteEmitStub.c
+++ b/src/generic/spriteEmitStub.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Stub sprite codegen. Used by ports that don't yet have a per-CPU
 // emitter so the link still resolves. jlSpriteCompile always returns
 // false (sprite stays interpreter-only); spriteCompiledDraw is
diff --git a/src/hosted/audio.c b/src/hosted/audio.c
new file mode 100644
index 0000000..9d7a0ec
--- /dev/null
+++ b/src/hosted/audio.c
@@ -0,0 +1,471 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Hosted audio: a software mixer on the program's thread.
+//
+// Everything the machine ports' sound chips do is done here in software and
+// mixed into the backend's format (hosted.h: signed 16-bit, stereo or mono,
+// at HOSTED_AUDIO_RATE):
+//   * MOD music through libxmp-lite (the DOS, ST and X68000 ports' player),
+//   * the SFX slots through the shared overlay mixer (audioSfxMix.c), each
+//     placed left, centre or right (jlAudioSetPan; a mono mix has no sides),
+//   * the three tone voices as square waves and the noise voice as the
+//     YM2149's 17-bit LFSR clocked at 125 kHz / pitch (the ST's chip, the
+//     reference for the noise pitch curve), each attenuated 2 dB a step,
+//   * jlAudioTone as voice 0 at full volume (one-voice tone generator on a
+//     machine with several oscillators, as joey/audio.h says).
+// The mix is made on the program's thread, as DOS makes its own: the
+// library's frequent calls (present, the frame wait, the input poll, the
+// frame tick) keep the backend's queue AUDIO_QUEUE_FRAMES deep
+// (hostedAudioService). So the stream fill callbacks run on the program's
+// thread as they do on DOS and the IIgs, and a tick callback
+// (jlAudioTickRegister) is called between the very samples it falls
+// between: notes start where they should in the sound, however the mixing is
+// batched. The critical sections stay the generic no-ops.
+
+#include 
+#include 
+#include 
+
+#include 
+
+#include "port.h"
+#include "audioSfxMixInternal.h"
+#include "hosted.h"
+#include "joey/audio.h"
+
+// ----- Constants -----
+
+// The queue the service keeps: four virtual frames of sound, so a program
+// may miss a few frames of library calls before it runs dry.
+#define AUDIO_QUEUE_VIRTUAL_FRAMES 4u
+#define AUDIO_QUEUE_FRAMES     (HOSTED_AUDIO_RATE * AUDIO_QUEUE_VIRTUAL_FRAMES / HOSTED_FRAME_HZ)
+// Frames mixed at a time.
+#define AUDIO_CHUNK_FRAMES     1024u
+
+// Square-wave voices: the loudest a voice gets, and 2 dB less a step of
+// attenuation down to ATTEN_SILENT.
+#define VOICE_PEAK             4096.0
+#define ATTEN_DB_PER_STEP      2.0
+#define DB_PER_DECADE          20.0
+#define ATTEN_SILENT           15u
+#define ATTEN_STEPS            16u
+
+// Phase accumulators are 32-bit fractions of a cycle; the top bit is the
+// square's half.
+#define PHASE_ONE              4294967296.0
+#define PHASE_HIGH_HALF        0x80000000u
+#define PHASE_SHIFT            32
+
+// The YM2149 noise generator: a 17-bit LFSR fed by bit 0 XOR bit 3, clocked
+// at the ST's 2 MHz master clock / 16 / period, where period is the pitch
+// (0 counts as 1).
+#define NOISE_CLOCK_HZ         125000u
+#define NOISE_LFSR_SEED        1u
+#define NOISE_LFSR_TOP_SHIFT   16
+#define NOISE_LFSR_TAP_SHIFT   3
+
+// An SFX sample is signed 8-bit; it is mixed at full 16-bit scale, as the DOS
+// mixer mixes it over the MOD.
+#define SFX_BIAS               128
+#define SFX_SHIFT              8
+
+#define SAMPLE_MAX             32767
+#define SAMPLE_MIN             (-32768)
+
+// libxmp-lite's output: signed 16-bit, in the mix's channels.
+#define MOD_FORMAT             (HOSTED_AUDIO_CHANNELS == 1u ? XMP_FORMAT_MONO : 0)
+
+// ----- Types -----
+
+typedef struct {
+    uint32_t phase;
+    uint32_t step;      // phase per output frame; 0 = silent
+    int16_t  amp;
+} SquareT;
+
+// ----- Prototypes -----
+
+static int16_t  clampSample(int32_t value);
+static void     mixEveryChannel(int32_t *frame, int16_t level);
+static void     mixFrames(int16_t *out, uint32_t frames);
+static void     mixModInto(uint32_t frames);
+static void     mixNoiseInto(uint32_t frames);
+static void     mixSfxInto(uint32_t frames);
+static void     mixSquareInto(SquareT *sq, uint32_t frames);
+static bool     panReaches(uint8_t pan, uint8_t channel);
+static uint32_t phaseStep(uint32_t hz);
+static void     squareSet(SquareT *sq, uint16_t freqHz, uint8_t atten);
+static void     xmpRelease(void);
+
+// ----- Module state -----
+
+static bool             gReady      = false;
+static xmp_context      gXmpCtx     = NULL;
+static bool             gXmpLoaded  = false;
+static bool             gXmpStarted = false;
+static int              gXmpLoop    = 0;
+
+static AudioSfxSlotT    gSfxSlots[JOEY_AUDIO_SFX_SLOTS];
+static uint8_t          gSfxPan  [JOEY_AUDIO_SFX_SLOTS];
+
+static int16_t          gAttenAmp[ATTEN_STEPS];
+static SquareT          gVoices  [JOEY_AUDIO_VOICES];
+static uint64_t         gNoisePhase = 0;
+static uint64_t         gNoiseStep  = 0;   // 32.32 LFSR clocks per output frame
+static uint32_t         gNoiseLfsr  = NOISE_LFSR_SEED;
+static int16_t          gNoiseAmp   = 0;
+
+static jlAudioTickFnT   gTickFn  = NULL;
+static uint32_t         gTickHz  = 0;
+static uint32_t         gTickAcc = 0;     // counts HOSTED_AUDIO_RATE per tick, gTickHz a frame
+
+// One chunk's mix, left and right interleaved, and its pieces.
+static int32_t          gMix   [AUDIO_CHUNK_FRAMES * HOSTED_AUDIO_CHANNELS];
+static int16_t          gModBuf[AUDIO_CHUNK_FRAMES * HOSTED_AUDIO_CHANNELS];
+static int16_t          gOut   [AUDIO_CHUNK_FRAMES * HOSTED_AUDIO_CHANNELS];
+static uint8_t          gSfxBuf[AUDIO_CHUNK_FRAMES];
+
+// ----- Internal helpers (alphabetical) -----
+
+static int16_t clampSample(int32_t value) {
+    if (value > SAMPLE_MAX) {
+        return SAMPLE_MAX;
+    }
+    if (value < SAMPLE_MIN) {
+        return SAMPLE_MIN;
+    }
+    return (int16_t)value;
+}
+
+
+// A centred voice: level into each channel of one frame of the mix.
+static void mixEveryChannel(int32_t *frame, int16_t level) {
+    uint8_t c;
+
+    for (c = 0; c < HOSTED_AUDIO_CHANNELS; c++) {
+        frame[c] += level;
+    }
+}
+
+
+// Mix frames of everything into out, calling the tick callback between the
+// samples it falls between.
+static void mixFrames(int16_t *out, uint32_t frames) {
+    uint32_t done;
+    uint32_t run;
+    uint32_t i;
+
+    done = 0;
+    while (done < frames) {
+        run = frames - done;
+        if (gTickFn != NULL) {
+            if (gTickAcc >= HOSTED_AUDIO_RATE) {
+                gTickAcc -= HOSTED_AUDIO_RATE;
+                gTickFn();
+                continue;
+            }
+            // Frames until the accumulator reaches the next tick.
+            run = (HOSTED_AUDIO_RATE - gTickAcc + gTickHz - 1u) / gTickHz;
+            if (run > frames - done) {
+                run = frames - done;
+            }
+            gTickAcc += run * gTickHz;
+        }
+        memset(gMix, 0, run * HOSTED_AUDIO_CHANNELS * sizeof(gMix[0]));
+        mixModInto(run);
+        for (i = 0; i < JOEY_AUDIO_VOICES; i++) {
+            mixSquareInto(&gVoices[i], run);
+        }
+        mixNoiseInto(run);
+        mixSfxInto(run);
+        for (i = 0; i < run * HOSTED_AUDIO_CHANNELS; i++) {
+            out[done * HOSTED_AUDIO_CHANNELS + i] = clampSample(gMix[i]);
+        }
+        done += run;
+    }
+}
+
+
+static void mixModInto(uint32_t frames) {
+    uint32_t i;
+
+    if (!gXmpStarted) {
+        return;
+    }
+    // A song that has ended (not looping) stops the player.
+    if (xmp_play_buffer(gXmpCtx, gModBuf, (int)(frames * HOSTED_AUDIO_CHANNELS * sizeof(gModBuf[0])), gXmpLoop) != 0) {
+        xmpRelease();
+        return;
+    }
+    for (i = 0; i < frames * HOSTED_AUDIO_CHANNELS; i++) {
+        gMix[i] += gModBuf[i];
+    }
+}
+
+
+static void mixNoiseInto(uint32_t frames) {
+    uint32_t i;
+    uint32_t clocks;
+    int16_t  level;
+
+    if (gNoiseAmp == 0) {
+        return;
+    }
+    for (i = 0; i < frames; i++) {
+        gNoisePhase += gNoiseStep;
+        for (clocks = (uint32_t)(gNoisePhase >> PHASE_SHIFT); clocks > 0; clocks--) {
+            gNoiseLfsr = (gNoiseLfsr >> 1) | (((gNoiseLfsr ^ (gNoiseLfsr >> NOISE_LFSR_TAP_SHIFT)) & 1u) << NOISE_LFSR_TOP_SHIFT);
+        }
+        gNoisePhase &= (uint64_t)UINT32_MAX;
+        level = (gNoiseLfsr & 1u) != 0 ? gNoiseAmp : (int16_t)-gNoiseAmp;
+        mixEveryChannel(&gMix[i * HOSTED_AUDIO_CHANNELS], level);
+    }
+}
+
+
+// Each slot alone through the shared overlay mixer (its unsigned 8-bit
+// output, centred at SFX_BIAS, is that slot's signal), then into the
+// channels its pan reaches.
+static void mixSfxInto(uint32_t frames) {
+    uint32_t i;
+    int32_t  sample;
+    uint8_t  s;
+    uint8_t  c;
+    bool     reaches[HOSTED_AUDIO_CHANNELS];
+
+    for (s = 0; s < JOEY_AUDIO_SFX_SLOTS; s++) {
+        if (!gSfxSlots[s].active) {
+            continue;
+        }
+        memset(gSfxBuf, SFX_BIAS, frames);
+        audioSfxOverlayMix(gSfxBuf, (uint16_t)frames, &gSfxSlots[s], 1u);
+        for (c = 0; c < HOSTED_AUDIO_CHANNELS; c++) {
+            reaches[c] = panReaches(gSfxPan[s], c);
+        }
+        for (i = 0; i < frames; i++) {
+            sample = ((int32_t)gSfxBuf[i] - SFX_BIAS) << SFX_SHIFT;
+            for (c = 0; c < HOSTED_AUDIO_CHANNELS; c++) {
+                if (reaches[c]) {
+                    gMix[i * HOSTED_AUDIO_CHANNELS + c] += sample;
+                }
+            }
+        }
+    }
+}
+
+
+static void mixSquareInto(SquareT *sq, uint32_t frames) {
+    uint32_t i;
+    int16_t  level;
+
+    if (sq->step == 0 || sq->amp == 0) {
+        return;
+    }
+    for (i = 0; i < frames; i++) {
+        sq->phase += sq->step;
+        level      = (sq->phase & PHASE_HIGH_HALF) != 0 ? sq->amp : (int16_t)-sq->amp;
+        mixEveryChannel(&gMix[i * HOSTED_AUDIO_CHANNELS], level);
+    }
+}
+
+
+// Left is the first channel, right the last, centre all of them; a mono mix
+// has one channel, which every pan reaches.
+static bool panReaches(uint8_t pan, uint8_t channel) {
+    return pan == AUDIO_PAN_CENTER || channel == (pan == AUDIO_PAN_LEFT ? 0u : HOSTED_AUDIO_CHANNELS - 1u);
+}
+
+
+// The phase step of a hz oscillator: hz cycles a second in 32-bit fractions
+// of a cycle per output frame.
+static uint32_t phaseStep(uint32_t hz) {
+    return (uint32_t)((double)hz * PHASE_ONE / (double)HOSTED_AUDIO_RATE);
+}
+
+
+static void squareSet(SquareT *sq, uint16_t freqHz, uint8_t atten) {
+    sq->step = phaseStep(freqHz);
+    sq->amp  = gAttenAmp[atten < ATTEN_SILENT ? atten : ATTEN_SILENT];
+}
+
+
+static void xmpRelease(void) {
+    if (gXmpStarted) {
+        xmp_end_player(gXmpCtx);
+        gXmpStarted = false;
+    }
+    if (gXmpLoaded) {
+        xmp_release_module(gXmpCtx);
+        gXmpLoaded = false;
+    }
+}
+
+// ----- Hosted core API (alphabetical) -----
+
+void hostedAudioService(void) {
+    uint32_t queued;
+    uint32_t frames;
+
+    if (!gReady) {
+        return;
+    }
+    queued = hostBackendAudioQueued();
+    while (queued < AUDIO_QUEUE_FRAMES) {
+        frames = AUDIO_QUEUE_FRAMES - queued;
+        if (frames > AUDIO_CHUNK_FRAMES) {
+            frames = AUDIO_CHUNK_FRAMES;
+        }
+        mixFrames(gOut, frames);
+        hostBackendAudioQueue(gOut, frames);
+        queued += frames;
+    }
+}
+
+// ----- HAL API (alphabetical) -----
+
+void jlpAudioFrameTick(void) {
+    hostedAudioService();
+}
+
+
+bool jlpAudioInit(void) {
+    uint8_t i;
+
+    if (gReady) {
+        return true;
+    }
+    if (!hostBackendAudioOpen()) {
+        return false;
+    }
+    gXmpCtx = xmp_create_context();
+    if (gXmpCtx == NULL) {
+        hostBackendAudioClose();
+        return false;
+    }
+    // Rounded with C89's floor (BeOS R5 has no lrint); the amplitudes are
+    // positive and never halfway, so it rounds as lrint would.
+    for (i = 0; i < ATTEN_SILENT; i++) {
+        gAttenAmp[i] = (int16_t)floor(VOICE_PEAK * pow(10.0, -(double)i * ATTEN_DB_PER_STEP / DB_PER_DECADE) + 0.5);
+    }
+    gAttenAmp[ATTEN_SILENT] = 0;
+    memset(gSfxSlots, 0, sizeof(gSfxSlots));
+    memset(gSfxPan,   AUDIO_PAN_CENTER, sizeof(gSfxPan));
+    memset(gVoices,   0, sizeof(gVoices));
+    gNoiseStep = 0;
+    gNoiseAmp  = 0;
+    gNoiseLfsr = NOISE_LFSR_SEED;
+    gReady     = true;
+    return true;
+}
+
+
+bool jlpAudioIsPlayingMod(void) {
+    return gXmpStarted;
+}
+
+
+void jlpAudioNoise(uint8_t pitch, uint8_t atten) {
+    uint32_t period;
+
+    period     = pitch == 0 ? 1u : pitch;
+    gNoiseStep = ((uint64_t)NOISE_CLOCK_HZ << PHASE_SHIFT) / period / HOSTED_AUDIO_RATE;
+    gNoiseAmp  = gAttenAmp[atten < ATTEN_SILENT ? atten : ATTEN_SILENT];
+}
+
+
+void jlpAudioPlayMod(const uint8_t *data, uint32_t length, bool loop) {
+    if (!gReady) {
+        return;
+    }
+    xmpRelease();
+    if (xmp_load_module_from_memory(gXmpCtx, (void *)data, (long)length) != 0) {
+        return;
+    }
+    gXmpLoaded = true;
+    if (xmp_start_player(gXmpCtx, (int)HOSTED_AUDIO_RATE, MOD_FORMAT) != 0) {
+        xmpRelease();
+        return;
+    }
+    // xmp_play_buffer's loop argument: 0 stops at the song's end, -1 never.
+    gXmpLoop    = loop ? -1 : 0;
+    gXmpStarted = true;
+}
+
+
+void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint16_t rateHz) {
+    if (!gReady || slot >= JOEY_AUDIO_SFX_SLOTS) {
+        return;
+    }
+    audioSfxSlotArm(&gSfxSlots[slot], sample, length, rateHz, HOSTED_AUDIO_RATE);
+}
+
+
+void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uint16_t rateHz) {
+    if (!gReady || slot >= JOEY_AUDIO_SFX_SLOTS) {
+        return;
+    }
+    audioSfxSlotArmStream(&gSfxSlots[slot], fill, ctx, rateHz, HOSTED_AUDIO_RATE);
+}
+
+
+void jlpAudioSetPan(uint8_t slot, uint8_t pan) {
+    if (slot >= JOEY_AUDIO_SFX_SLOTS || pan >= AUDIO_PAN_COUNT) {
+        return;
+    }
+    gSfxPan[slot] = pan;
+}
+
+
+void jlpAudioShutdown(void) {
+    if (!gReady) {
+        return;
+    }
+    xmpRelease();
+    xmp_free_context(gXmpCtx);
+    gXmpCtx = NULL;
+    gTickFn = NULL;
+    hostBackendAudioClose();
+    gReady = false;
+}
+
+
+void jlpAudioStopMod(void) {
+    if (!gReady) {
+        return;
+    }
+    xmpRelease();
+}
+
+
+void jlpAudioStopSfx(uint8_t slot) {
+    if (slot >= JOEY_AUDIO_SFX_SLOTS) {
+        return;
+    }
+    gSfxSlots[slot].active = false;
+}
+
+
+bool jlpAudioTickRegister(jlAudioTickFnT fn, uint16_t hz) {
+    if (fn != NULL && (hz == 0 || hz > HOSTED_AUDIO_RATE)) {
+        return false;
+    }
+    gTickFn  = fn;
+    gTickHz  = hz;
+    gTickAcc = 0;
+    return true;
+}
+
+
+void jlpAudioTone(uint16_t freqHz) {
+    squareSet(&gVoices[0], freqHz, 0);
+}
+
+
+void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) {
+    if (voice >= JOEY_AUDIO_VOICES) {
+        return;
+    }
+    squareSet(&gVoices[voice], freqHz, atten);
+}
diff --git a/src/hosted/be/beBackend.cpp b/src/hosted/be/beBackend.cpp
new file mode 100644
index 0000000..ecaa52d
--- /dev/null
+++ b/src/hosted/be/beBackend.cpp
@@ -0,0 +1,1090 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Be API backend for the hosted core (JOEYLIB_HOSTED_BE): Haiku, and the
+// BeOS it reimplements. One source for Haiku x86_64 (gcc 13) and Haiku x86
+// (gcc 2.95, the BeOS C++ ABI), kept to calls BeOS R5 already had.
+//
+// Threads: the program's main() owns the main thread, and every call into
+// the core -- hostedKeyEvent and the rest -- is made on it. The
+// BApplication runs on a thread of its own, started by the first
+// hostBackendOpen, and the window on its own, as every BWindow does. The
+// window's view turns key and mouse messages, and a request to close the
+// window, into events in a locked queue, which hostBackendPump drains on the
+// program's thread. Frames are drawn from the program's thread with the
+// window locked. Sound comes from a BSoundPlayer, whose thread takes it from
+// a locked ring that hostBackendAudioQueue fills.
+//
+// The window shows the 320x200 frame from a BBitmap scaled to fill it at the
+// stage's shape: 4:3 by default, as the machine ports' 320x200 fills a 4:3
+// monitor, or square pixels (16:10) with JOEYLIB_ASPECT=square; the rest is
+// black. The app_server scales bitmaps without filtering, so the pixels stay
+// sharp. JOEYLIB_SCALE=N opens it N times 320 wide (default 3), and
+// JOEYLIB_FULLSCREEN=1 opens it borderless on the whole screen; Alt+Enter
+// switches between the two while the program runs. The pointer is hidden
+// over the window: JoeyLib programs draw their own.
+//
+// Input: keys by raw key code (where the key is, so a keymap does not move
+// the arrow keys or WASD), typed characters from the bytes the keymap gives
+// a key (7-bit ASCII only; Return is a linefeed to the Be API), the mouse in
+// stage pixels, and the first two joysticks (BJoystick) as JOYSTICK_0 and
+// JOYSTICK_1 -- their first two axes and buttons, read once a virtual frame.
+// Keys and buttons still held when the window loses the keyboard are let go.
+// Closing the window ends the program.
+//
+// Files: the folder the program's executable is in is the current directory
+// from the moment the window opens; SAVES/ is made with mkdir and free space
+// comes from fs_stat_dev.
+
+#include 
+// gcc 2.95's stdbool.h makes bool a macro for an unsigned char even in C++,
+// which would change the Be API's own signatures: C++ has a bool of its own.
+#if defined(bool)
+#undef bool
+#undef true
+#undef false
+#endif
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+extern "C" {
+#include "port.h"
+#include "inputInternal.h"
+#include "hosted.h"
+}
+
+// ----- Constants -----
+
+#define WINDOW_SCALE_DEFAULT   3
+#define WINDOW_SCALE_MAX       8
+
+// The displayed shape of the 320x200 stage: 4:3 like a CRT, or 16:10 when
+// its pixels are kept square.
+#define ASPECT_CRT_W           4
+#define ASPECT_CRT_H           3
+#define ASPECT_SQUARE_W        SURFACE_WIDTH
+#define ASPECT_SQUARE_H        SURFACE_HEIGHT
+
+// The program's MIME signature: this prefix and its file name, with
+// anything a signature may not hold made a '-'.
+#define SIGNATURE_PREFIX       "application/x-vnd.JoeyLib-"
+// The calling team, to the kernel's team calls (Haiku's B_CURRENT_TEAM,
+// which BeOS R5 does not name).
+#define CURRENT_TEAM           0
+#define SIGNATURE_FILLER       '-'
+
+#define DIR_MODE               0755
+
+#define US_PER_SECOND          1000000u
+
+// A cursor in the Be API's 16x16, 1-bit format (size, depth, hot spot y and
+// x, then the colour and mask bits), every pixel masked out: the pointer
+// over the frame.
+#define CURSOR_DATA_BYTES      68
+#define CURSOR_SIZE            16
+#define CURSOR_DEPTH           1
+
+// Events from the window thread to the program's thread. A power of two.
+#define EVENT_QUEUE_SIZE       256u
+#define EVENT_QUEUE_MASK       (EVENT_QUEUE_SIZE - 1u)
+
+// The sound ring, in frames: more than the core ever keeps queued
+// (src/hosted/audio.c). A power of two, so its samples wrap on a mask.
+#define AUDIO_RING_FRAMES      8192u
+#define AUDIO_RING_SAMPLE_MASK (AUDIO_RING_FRAMES * HOSTED_AUDIO_CHANNELS - 1u)
+// Frames the sound player asks for at a time.
+#define AUDIO_BUFFER_FRAMES    512u
+#define AUDIO_FRAME_BYTES      (HOSTED_AUDIO_CHANNELS * sizeof(int16_t))
+// A 16-bit sample as a float sample (-1.0..1.0).
+#define SAMPLE_FLOAT_SCALE     32768.0f
+
+// Joysticks: the most axes a stick may have (the joystick driver's own
+// limit), the two read, and the two buttons.
+#define STICK_AXES_MAX         12
+#define STICK_AXES_USED        2
+#define STICK_BUTTON_0_MASK    0x01u
+#define STICK_BUTTON_1_MASK    0x02u
+#define STICK_POLL_US          (US_PER_SECOND / HOSTED_FRAME_HZ)
+
+// Be raw key codes: the key's place on the keyboard, whatever the keymap.
+#define RAW_KEY_COUNT          256
+#define RAW_KEY_ESCAPE         0x01
+#define RAW_KEY_1              0x12
+#define RAW_KEY_9              0x1a
+#define RAW_KEY_0              0x1b
+#define RAW_KEY_BACKSPACE      0x1e
+#define RAW_KEY_TAB            0x26
+#define RAW_KEY_ROW_Q          0x27
+#define RAW_KEY_ROW_A          0x3c
+#define RAW_KEY_RETURN         0x47
+#define RAW_KEY_LSHIFT         0x4b
+#define RAW_KEY_ROW_Z          0x4c
+#define RAW_KEY_RSHIFT         0x56
+#define RAW_KEY_UP             0x57
+#define RAW_KEY_KP_ENTER       0x5b
+#define RAW_KEY_LCTRL          0x5c
+#define RAW_KEY_LALT           0x5d
+#define RAW_KEY_SPACE          0x5e
+#define RAW_KEY_RALT           0x5f
+#define RAW_KEY_RCTRL          0x60
+#define RAW_KEY_LEFT           0x61
+#define RAW_KEY_DOWN           0x62
+#define RAW_KEY_RIGHT          0x63
+
+// Key bytes from here up belong to wider UTF-8 characters.
+#define ASCII_LIMIT            0x80u
+
+// ----- Types -----
+
+typedef enum {
+    EVENT_KEY = 0,
+    EVENT_CHAR,
+    EVENT_MOUSE_MOVE,
+    EVENT_MOUSE_BUTTON
+} EventKindE;
+
+typedef struct {
+    EventKindE kind;
+    uint8_t    code;    // the jlKeyE, character or jlMouseButtonE
+    bool       down;
+    int16_t    x;
+    int16_t    y;
+} EventT;
+
+// A row of letter keys: the raw code of its first key and its letters.
+typedef struct {
+    int32       first;
+    const char *letters;
+} KeyRowT;
+
+// The view the frame is drawn in, which takes the window's input.
+class JoeyViewT : public BView {
+public:
+                 JoeyViewT(BRect frame);
+    virtual void Draw(BRect updateRect);
+    virtual void FrameResized(float width, float height);
+    virtual void KeyDown(const char *bytes, int32 numBytes);
+    virtual void KeyUp(const char *bytes, int32 numBytes);
+    virtual void MessageReceived(BMessage *message);
+    virtual void MouseDown(BPoint where);
+    virtual void MouseMoved(BPoint where, uint32 transit, const BMessage *dragMessage);
+    virtual void MouseUp(BPoint where);
+    virtual void WindowActivated(bool active);
+    void         drawFrame(void);
+};
+
+class JoeyWindowT : public BWindow {
+public:
+                 JoeyWindowT(BRect frame, const char *title, window_look look);
+    virtual bool QuitRequested(void);
+};
+
+// ----- Prototypes -----
+
+static int32       appThread(void *data);
+static void        audioPlay(void *cookie, void *buffer, size_t size, const media_raw_audio_format &format);
+static void        backendExit(void);
+static void        buttonsChanged(BMessage *message);
+static void        eventPush(EventKindE kind, uint8_t code, bool down, int16_t x, int16_t y);
+static uint32_t    eventsTake(EventT *out);
+static BRect       frameRect(BRect bounds);
+static void        fullScreenToggle(void);
+static jlKeyE      keyFromRaw(int32 raw);
+static void        keyMessage(BMessage *message, bool down);
+static void        keysRelease(void);
+static int         optionInt(const char *name, int fallback, int max);
+static void        pointerAt(BPoint where);
+static bool        programFind(void);
+static int8_t      stickAxis(int16 raw);
+static void        sticksClose(void);
+static void        sticksOpen(void);
+static void        sticksPoll(void);
+static BRect       windowFrame(void);
+
+// ----- Module state -----
+
+static const KeyRowT kLetterRows[] = {
+    { RAW_KEY_ROW_Q, "QWERTYUIOP" },
+    { RAW_KEY_ROW_A, "ASDFGHJKL" },
+    { RAW_KEY_ROW_Z, "ZXCVBNM" }
+};
+static const uint32         kMouseButtons[]   = { B_PRIMARY_MOUSE_BUTTON, B_SECONDARY_MOUSE_BUTTON, B_TERTIARY_MOUSE_BUTTON };
+static const jlMouseButtonE kMouseButtonIds[] = { MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_MIDDLE };
+static const uint8          kHiddenCursor[CURSOR_DATA_BYTES] = { CURSOR_SIZE, CURSOR_DEPTH };
+
+// The program, the application and the window. Set up by hostBackendOpen.
+static char          gProgramName[B_FILE_NAME_LENGTH];
+static char          gProgramFolder[B_PATH_NAME_LENGTH];
+static char          gSignature[B_MIME_TYPE_LENGTH];
+static thread_id     gAppThread  = -1;
+static sem_id        gAppReady   = -1;
+static status_t      gAppStatus  = B_NO_INIT;
+static JoeyWindowT  *gWindow     = NULL;
+static JoeyViewT    *gView       = NULL;
+static BBitmap      *gBitmap     = NULL;
+static BCursor      *gCursor     = NULL;
+static bool          gSquare     = false;
+static bool          gFullScreen = false;
+static BRect         gWindowedFrame;       // where the window was before the whole screen
+static bool          gAtExit     = false;
+
+// The window thread's own: which raw keys are down (a key message for a key
+// already down is a repeat) and which mouse buttons.
+static uint8_t       gRawDown[RAW_KEY_COUNT];
+static uint32        gButtons    = 0;
+
+// The event queue, filled on the window thread and drained on the program's.
+static BLocker       gEventLock("JoeyLib events");
+static EventT        gEvents[EVENT_QUEUE_SIZE];
+static uint32_t      gEventHead  = 0;      // next to write
+static uint32_t      gEventTail  = 0;      // next to read
+static bool          gQuitAsked  = false;
+
+// The sound ring, filled on the program's thread and played on the sound
+// player's. Head and tail count frames and wrap.
+static BLocker       gAudioLock("JoeyLib sound");
+static BSoundPlayer *gPlayer     = NULL;
+static int16_t       gRing[AUDIO_RING_FRAMES * HOSTED_AUDIO_CHANNELS];
+static uint32_t      gRingHead   = 0;
+static uint32_t      gRingTail   = 0;
+
+// The joysticks, read on the program's thread.
+static BJoystick    *gSticks[JOYSTICK_COUNT];
+static bigtime_t     gSticksPolled = 0;
+
+// ----- The view and the window -----
+
+// The view paints every pixel itself (drawFrame), so the app_server is not
+// asked to clear it first.
+JoeyViewT::JoeyViewT(BRect frame) : BView(frame, "JoeyLib", B_FOLLOW_ALL, B_WILL_DRAW | B_FRAME_EVENTS) {
+    SetViewColor(B_TRANSPARENT_COLOR);
+    SetViewCursor(gCursor);
+}
+
+
+void JoeyViewT::Draw(BRect updateRect) {
+    (void)updateRect;
+    drawFrame();
+}
+
+
+void JoeyViewT::FrameResized(float width, float height) {
+    (void)width;
+    (void)height;
+    Invalidate();
+}
+
+
+void JoeyViewT::KeyDown(const char *bytes, int32 numBytes) {
+    BMessage *message;
+    int32     raw;
+    int32     mods;
+    uint8_t   ch;
+
+    message = Window()->CurrentMessage();
+    if (message == NULL || message->FindInt32("key", &raw) != B_OK || message->FindInt32("modifiers", &mods) != B_OK) {
+        return;
+    }
+    if ((raw == RAW_KEY_RETURN || raw == RAW_KEY_KP_ENTER) && ((uint32)mods & B_COMMAND_KEY) != 0) {
+        if (!gRawDown[raw]) {
+            gRawDown[raw] = 1u;
+            fullScreenToggle();
+        }
+        return;
+    }
+    // Typed characters, repeats too.
+    if (numBytes == 1 && (uint8_t)bytes[0] < ASCII_LIMIT) {
+        ch = (uint8_t)bytes[0] == B_RETURN ? (uint8_t)JL_CHAR_RETURN : (uint8_t)bytes[0];
+        eventPush(EVENT_CHAR, ch, true, 0, 0);
+    }
+    keyMessage(message, true);
+}
+
+
+void JoeyViewT::KeyUp(const char *bytes, int32 numBytes) {
+    BMessage *message;
+
+    (void)bytes;
+    (void)numBytes;
+    message = Window()->CurrentMessage();
+    if (message != NULL) {
+        keyMessage(message, false);
+    }
+}
+
+
+// Keys that give no character (Shift, Ctrl, Alt) come as unmapped keys.
+void JoeyViewT::MessageReceived(BMessage *message) {
+    switch (message->what) {
+        case B_UNMAPPED_KEY_DOWN:
+            keyMessage(message, true);
+            break;
+
+        case B_UNMAPPED_KEY_UP:
+            keyMessage(message, false);
+            break;
+
+        default:
+            BView::MessageReceived(message);
+            break;
+    }
+}
+
+
+// The pointer's messages keep coming while a button is held, even off the
+// window.
+void JoeyViewT::MouseDown(BPoint where) {
+    SetMouseEventMask(B_POINTER_EVENTS, 0);
+    pointerAt(where);
+    buttonsChanged(Window()->CurrentMessage());
+}
+
+
+void JoeyViewT::MouseMoved(BPoint where, uint32 transit, const BMessage *dragMessage) {
+    (void)transit;
+    (void)dragMessage;
+    pointerAt(where);
+    buttonsChanged(Window()->CurrentMessage());
+}
+
+
+void JoeyViewT::MouseUp(BPoint where) {
+    pointerAt(where);
+    buttonsChanged(Window()->CurrentMessage());
+}
+
+
+void JoeyViewT::WindowActivated(bool active) {
+    if (!active) {
+        keysRelease();
+    }
+}
+
+
+// The bitmap into the frame's place in the view, black around it. The
+// window is locked.
+void JoeyViewT::drawFrame(void) {
+    BRect bounds;
+    BRect dst;
+
+    bounds = Bounds();
+    dst    = frameRect(bounds);
+    SetHighColor(0, 0, 0);
+    FillRect(BRect(bounds.left, bounds.top, bounds.right, dst.top - 1.0f));
+    FillRect(BRect(bounds.left, dst.bottom + 1.0f, bounds.right, bounds.bottom));
+    FillRect(BRect(bounds.left, dst.top, dst.left - 1.0f, dst.bottom));
+    FillRect(BRect(dst.right + 1.0f, dst.top, bounds.right, dst.bottom));
+    DrawBitmap(gBitmap, gBitmap->Bounds(), dst);
+}
+
+
+JoeyWindowT::JoeyWindowT(BRect frame, const char *title, window_look look) : BWindow(frame, title, look, B_NORMAL_WINDOW_FEEL, 0) {
+}
+
+
+// Closing the window asks the program to end; it does at its next event
+// pump, which takes the window down on the way out.
+bool JoeyWindowT::QuitRequested(void) {
+    if (gEventLock.Lock()) {
+        gQuitAsked = true;
+        gEventLock.Unlock();
+    }
+    return false;
+}
+
+// ----- Internal helpers (alphabetical) -----
+
+// The application's thread: the BApplication is made, and runs, here.
+static int32 appThread(void *data) {
+    BApplication *app;
+
+    (void)data;
+    app        = new BApplication(gSignature);
+    gAppStatus = app->InitCheck();
+    release_sem(gAppReady);
+    if (gAppStatus == B_OK) {
+        app->Run();
+    }
+    delete app;
+    return 0;
+}
+
+
+// The sound player's thread wants size bytes: as much of the ring as there
+// is, then silence.
+static void audioPlay(void *cookie, void *buffer, size_t size, const media_raw_audio_format &format) {
+    uint32_t frames;
+    uint32_t have;
+    uint32_t i;
+    int16_t  sample;
+
+    (void)cookie;
+    memset(buffer, 0, size);
+    if (format.channel_count != HOSTED_AUDIO_CHANNELS || (format.format != media_raw_audio_format::B_AUDIO_SHORT && format.format != media_raw_audio_format::B_AUDIO_FLOAT)) {
+        return;
+    }
+    frames = (uint32_t)(size / ((format.format & media_raw_audio_format::B_AUDIO_SIZE_MASK) * HOSTED_AUDIO_CHANNELS));
+    if (!gAudioLock.Lock()) {
+        return;
+    }
+    have = gRingHead - gRingTail;
+    if (frames > have) {
+        frames = have;
+    }
+    for (i = 0; i < frames * HOSTED_AUDIO_CHANNELS; i++) {
+        sample = gRing[(gRingTail * HOSTED_AUDIO_CHANNELS + i) & AUDIO_RING_SAMPLE_MASK];
+        if (format.format == media_raw_audio_format::B_AUDIO_SHORT) {
+            ((int16_t *)buffer)[i] = sample;
+        } else {
+            ((float *)buffer)[i] = (float)sample / SAMPLE_FLOAT_SCALE;
+        }
+    }
+    gRingTail += frames;
+    gAudioLock.Unlock();
+}
+
+
+// exit() from anywhere -- the program's own, or closing the window -- gives
+// the window, the sound and the application back.
+static void backendExit(void) {
+    status_t status;
+
+    hostBackendClose();
+    if (gAppThread >= 0) {
+        be_app->PostMessage(B_QUIT_REQUESTED);
+        wait_for_thread(gAppThread, &status);
+        gAppThread = -1;
+    }
+    delete gCursor;
+    gCursor = NULL;
+}
+
+
+// The mouse buttons after a mouse message, as button events.
+static void buttonsChanged(BMessage *message) {
+    int32  buttons;
+    uint32 changed;
+    uint32 i;
+
+    if (message == NULL || message->FindInt32("buttons", &buttons) != B_OK) {
+        return;
+    }
+    changed = gButtons ^ (uint32)buttons;
+    for (i = 0; i < sizeof(kMouseButtons) / sizeof(kMouseButtons[0]); i++) {
+        if ((changed & kMouseButtons[i]) != 0) {
+            eventPush(EVENT_MOUSE_BUTTON, (uint8_t)kMouseButtonIds[i], ((uint32)buttons & kMouseButtons[i]) != 0, 0, 0);
+        }
+    }
+    gButtons = (uint32)buttons;
+}
+
+
+// Queue an event for the program's thread. A move replaces a move still
+// queued; anything else is dropped when the queue is full.
+static void eventPush(EventKindE kind, uint8_t code, bool down, int16_t x, int16_t y) {
+    EventT *e;
+
+    if (!gEventLock.Lock()) {
+        return;
+    }
+    e = NULL;
+    if (kind == EVENT_MOUSE_MOVE && gEventHead != gEventTail && gEvents[(gEventHead - 1u) & EVENT_QUEUE_MASK].kind == EVENT_MOUSE_MOVE) {
+        e = &gEvents[(gEventHead - 1u) & EVENT_QUEUE_MASK];
+    } else if (gEventHead - gEventTail < EVENT_QUEUE_SIZE) {
+        e = &gEvents[gEventHead & EVENT_QUEUE_MASK];
+        gEventHead++;
+    }
+    if (e != NULL) {
+        e->kind = kind;
+        e->code = code;
+        e->down = down;
+        e->x    = x;
+        e->y    = y;
+    }
+    gEventLock.Unlock();
+}
+
+
+// Every queued event into out (room for EVENT_QUEUE_SIZE); how many.
+static uint32_t eventsTake(EventT *out) {
+    uint32_t count;
+
+    if (!gEventLock.Lock()) {
+        return 0;
+    }
+    count = 0;
+    while (gEventTail != gEventHead) {
+        out[count++] = gEvents[gEventTail & EVENT_QUEUE_MASK];
+        gEventTail++;
+    }
+    gEventLock.Unlock();
+    return count;
+}
+
+
+// Where the frame goes in a view of these bounds: as large as fits at the
+// stage's shape, centred.
+static BRect frameRect(BRect bounds) {
+    float outW;
+    float outH;
+    float shapeW;
+    float shapeH;
+    float w;
+    float h;
+    float left;
+    float top;
+
+    outW   = bounds.Width() + 1.0f;
+    outH   = bounds.Height() + 1.0f;
+    shapeW = (float)(gSquare ? ASPECT_SQUARE_W : ASPECT_CRT_W);
+    shapeH = (float)(gSquare ? ASPECT_SQUARE_H : ASPECT_CRT_H);
+    if (outW * shapeH > outH * shapeW) {
+        h = outH;
+        w = (float)(int32)(h * shapeW / shapeH);
+    } else {
+        w = outW;
+        h = (float)(int32)(w * shapeH / shapeW);
+    }
+    left = bounds.left + (float)(int32)((outW - w) / 2.0f);
+    top  = bounds.top + (float)(int32)((outH - h) / 2.0f);
+    return BRect(left, top, left + w - 1.0f, top + h - 1.0f);
+}
+
+
+// Between the window and the whole screen, borderless there. On the
+// window's thread.
+static void fullScreenToggle(void) {
+    BRect frame;
+
+    if (gFullScreen) {
+        frame = gWindowedFrame;
+        gWindow->SetLook(B_TITLED_WINDOW_LOOK);
+    } else {
+        gWindowedFrame = gWindow->Frame();
+        frame          = BScreen(gWindow).Frame();
+        gWindow->SetLook(B_NO_BORDER_WINDOW_LOOK);
+    }
+    gWindow->MoveTo(frame.LeftTop());
+    gWindow->ResizeTo(frame.Width(), frame.Height());
+    gFullScreen = !gFullScreen;
+}
+
+
+static jlKeyE keyFromRaw(int32 raw) {
+    uint32 row;
+    int32  i;
+
+    if (raw >= B_F1_KEY && raw <= B_F10_KEY) {
+        return (jlKeyE)(KEY_F1 + (raw - B_F1_KEY));
+    }
+    if (raw >= RAW_KEY_1 && raw <= RAW_KEY_9) {
+        return (jlKeyE)(KEY_1 + (raw - RAW_KEY_1));
+    }
+    for (row = 0; row < sizeof(kLetterRows) / sizeof(kLetterRows[0]); row++) {
+        i = raw - kLetterRows[row].first;
+        if (i >= 0 && i < (int32)strlen(kLetterRows[row].letters)) {
+            return (jlKeyE)(KEY_A + (kLetterRows[row].letters[i] - 'A'));
+        }
+    }
+    switch (raw) {
+        case RAW_KEY_0:
+            return KEY_0;
+
+        case RAW_KEY_SPACE:
+            return KEY_SPACE;
+
+        case RAW_KEY_ESCAPE:
+            return KEY_ESCAPE;
+
+        case RAW_KEY_RETURN:
+        case RAW_KEY_KP_ENTER:
+            return KEY_RETURN;
+
+        case RAW_KEY_TAB:
+            return KEY_TAB;
+
+        case RAW_KEY_BACKSPACE:
+            return KEY_BACKSPACE;
+
+        case RAW_KEY_UP:
+            return KEY_UP;
+
+        case RAW_KEY_DOWN:
+            return KEY_DOWN;
+
+        case RAW_KEY_LEFT:
+            return KEY_LEFT;
+
+        case RAW_KEY_RIGHT:
+            return KEY_RIGHT;
+
+        case RAW_KEY_LSHIFT:
+            return KEY_LSHIFT;
+
+        case RAW_KEY_RSHIFT:
+            return KEY_RSHIFT;
+
+        // JoeyLib has one Ctrl and one Alt: either key is it.
+        case RAW_KEY_LCTRL:
+        case RAW_KEY_RCTRL:
+            return KEY_LCTRL;
+
+        case RAW_KEY_LALT:
+        case RAW_KEY_RALT:
+            return KEY_LALT;
+
+        default:
+            return KEY_NONE;
+    }
+}
+
+
+// A key went down or up: an event when it changes, so repeats are left out.
+static void keyMessage(BMessage *message, bool down) {
+    int32 raw;
+
+    if (message->FindInt32("key", &raw) != B_OK || raw < 0 || raw >= RAW_KEY_COUNT) {
+        return;
+    }
+    if ((gRawDown[raw] != 0) == down) {
+        return;
+    }
+    gRawDown[raw] = down ? 1u : 0u;
+    eventPush(EVENT_KEY, (uint8_t)keyFromRaw(raw), down, 0, 0);
+}
+
+
+// The window lost the keyboard: what is held will never be seen let go. On
+// the window's thread.
+static void keysRelease(void) {
+    int32  raw;
+    uint32 i;
+
+    for (raw = 0; raw < RAW_KEY_COUNT; raw++) {
+        if (gRawDown[raw] != 0) {
+            gRawDown[raw] = 0u;
+            eventPush(EVENT_KEY, (uint8_t)keyFromRaw(raw), false, 0, 0);
+        }
+    }
+    for (i = 0; i < sizeof(kMouseButtons) / sizeof(kMouseButtons[0]); i++) {
+        if ((gButtons & kMouseButtons[i]) != 0) {
+            eventPush(EVENT_MOUSE_BUTTON, (uint8_t)kMouseButtonIds[i], false, 0, 0);
+        }
+    }
+    gButtons = 0;
+}
+
+
+// An environment variable as a number 1..max, or fallback.
+static int optionInt(const char *name, int fallback, int max) {
+    const char *text;
+    int         value;
+
+    text = getenv(name);
+    if (text == NULL) {
+        return fallback;
+    }
+    value = atoi(text);
+    return value >= 1 && value <= max ? value : fallback;
+}
+
+
+// The pointer at a view position, in stage pixels, clamped to the stage.
+static void pointerAt(BPoint where) {
+    BRect   dst;
+    int32_t stageX;
+    int32_t stageY;
+
+    dst    = frameRect(gView->Bounds());
+    stageX = (int32_t)((where.x - dst.left) * (float)SURFACE_WIDTH / (dst.Width() + 1.0f));
+    stageY = (int32_t)((where.y - dst.top) * (float)SURFACE_HEIGHT / (dst.Height() + 1.0f));
+    stageX = stageX < 0 ? 0 : (stageX >= SURFACE_WIDTH ? SURFACE_WIDTH - 1 : stageX);
+    stageY = stageY < 0 ? 0 : (stageY >= SURFACE_HEIGHT ? SURFACE_HEIGHT - 1 : stageY);
+    eventPush(EVENT_MOUSE_MOVE, 0, false, (int16_t)stageX, (int16_t)stageY);
+}
+
+
+// The program's executable: its folder, its file name, and the signature
+// made from that.
+static bool programFind(void) {
+    image_info info;
+    int32      cookie;
+    char      *slash;
+    size_t     prefix;
+    size_t     i;
+    char       c;
+
+    cookie = 0;
+    while (get_next_image_info(CURRENT_TEAM, &cookie, &info) == B_OK) {
+        if (info.type != B_APP_IMAGE) {
+            continue;
+        }
+        strncpy(gProgramFolder, info.name, sizeof(gProgramFolder) - 1);
+        gProgramFolder[sizeof(gProgramFolder) - 1] = '\0';
+        slash = strrchr(gProgramFolder, '/');
+        if (slash == NULL) {
+            return false;
+        }
+        *slash = '\0';
+        strncpy(gProgramName, slash + 1, sizeof(gProgramName) - 1);
+        gProgramName[sizeof(gProgramName) - 1] = '\0';
+        prefix = strlen(SIGNATURE_PREFIX);
+        strcpy(gSignature, SIGNATURE_PREFIX);
+        for (i = 0; gProgramName[i] != '\0' && prefix + i < sizeof(gSignature) - 1; i++) {
+            c = gProgramName[i];
+            gSignature[prefix + i] = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ? c : SIGNATURE_FILLER;
+        }
+        gSignature[prefix + i] = '\0';
+        return true;
+    }
+    return false;
+}
+
+
+// One stick axis (BJoystick's -32768..32767) in JoeyLib's range.
+static int8_t stickAxis(int16 raw) {
+    return hostedAxisScale(raw, INT16_MIN, INT16_MAX);
+}
+
+
+static void sticksClose(void) {
+    int slot;
+
+    for (slot = 0; slot < JOYSTICK_COUNT; slot++) {
+        if (gSticks[slot] != NULL) {
+            gSticks[slot]->Close();
+            delete gSticks[slot];
+            gSticks[slot] = NULL;
+            hostedJoystickConnect((jlJoystickE)slot, false);
+        }
+    }
+}
+
+
+// The first two joysticks, in enhanced mode (every axis, -32768..32767).
+static void sticksOpen(void) {
+    BJoystick probe;
+    char      name[B_OS_NAME_LENGTH];
+    int32     count;
+    int32     i;
+    int       slot;
+
+    count = probe.CountDevices();
+    slot  = 0;
+    for (i = 0; i < count && slot < JOYSTICK_COUNT; i++) {
+        if (probe.GetDeviceName(i, name, sizeof(name)) != B_OK) {
+            continue;
+        }
+        gSticks[slot] = new BJoystick();
+        if (gSticks[slot]->Open(name, true) < B_OK || gSticks[slot]->CountAxes() < STICK_AXES_USED || gSticks[slot]->CountAxes() > STICK_AXES_MAX) {
+            delete gSticks[slot];
+            gSticks[slot] = NULL;
+            continue;
+        }
+        hostedJoystickConnect((jlJoystickE)slot, true);
+        slot++;
+    }
+}
+
+
+// Once a virtual frame: each stick's position and its two buttons.
+static void sticksPoll(void) {
+    int16     axes[STICK_AXES_MAX];
+    uint32    buttons;
+    bigtime_t now;
+    int       slot;
+
+    now = system_time();
+    if (now - gSticksPolled < (bigtime_t)STICK_POLL_US) {
+        return;
+    }
+    gSticksPolled = now;
+    for (slot = 0; slot < JOYSTICK_COUNT; slot++) {
+        if (gSticks[slot] == NULL || gSticks[slot]->Update() != B_OK || gSticks[slot]->GetAxisValues(axes) != B_OK) {
+            continue;
+        }
+        buttons = gSticks[slot]->ButtonValues();
+        hostedJoystickMove((jlJoystickE)slot, stickAxis(axes[0]), stickAxis(axes[1]));
+        hostedJoyButtonEvent((jlJoystickE)slot, JOY_BUTTON_0, (buttons & STICK_BUTTON_0_MASK) != 0);
+        hostedJoyButtonEvent((jlJoystickE)slot, JOY_BUTTON_1, (buttons & STICK_BUTTON_1_MASK) != 0);
+    }
+}
+
+
+// The window's frame: the stage at JOEYLIB_SCALE and its shape, centred on
+// the screen, or the whole screen.
+static BRect windowFrame(void) {
+    BRect screen;
+    int   scale;
+    float w;
+    float h;
+    float left;
+    float top;
+
+    screen = BScreen(B_MAIN_SCREEN_ID).Frame();
+    if (gFullScreen) {
+        return screen;
+    }
+    scale = optionInt("JOEYLIB_SCALE", WINDOW_SCALE_DEFAULT, WINDOW_SCALE_MAX);
+    w     = (float)(SURFACE_WIDTH * scale);
+    h     = (float)(gSquare ? SURFACE_HEIGHT * scale : SURFACE_WIDTH * scale * ASPECT_CRT_H / ASPECT_CRT_W);
+    left  = screen.left + (float)(int32)((screen.Width() + 1.0f - w) / 2.0f);
+    top   = screen.top + (float)(int32)((screen.Height() + 1.0f - h) / 2.0f);
+    return BRect(left, top, left + w - 1.0f, top + h - 1.0f);
+}
+
+// ----- Backend API (alphabetical) -----
+
+void hostBackendAudioClose(void) {
+    if (gPlayer != NULL) {
+        gPlayer->Stop();
+        delete gPlayer;
+        gPlayer = NULL;
+    }
+    gRingHead = 0;
+    gRingTail = 0;
+}
+
+
+bool hostBackendAudioOpen(void) {
+    media_raw_audio_format format;
+
+    if (gPlayer != NULL) {
+        return true;
+    }
+    memset(&format, 0, sizeof(format));
+    format.frame_rate    = (float)HOSTED_AUDIO_RATE;
+    format.channel_count = HOSTED_AUDIO_CHANNELS;
+    format.format        = media_raw_audio_format::B_AUDIO_SHORT;
+    format.byte_order    = B_MEDIA_HOST_ENDIAN;
+    format.buffer_size   = AUDIO_BUFFER_FRAMES * AUDIO_FRAME_BYTES;
+    gPlayer = new BSoundPlayer(&format, gProgramName, audioPlay, NULL, NULL);
+    if (gPlayer->InitCheck() != B_OK || gPlayer->Start() != B_OK) {
+        delete gPlayer;
+        gPlayer = NULL;
+        return false;
+    }
+    gPlayer->SetHasData(true);
+    return true;
+}
+
+
+// Into the ring, as much as fits (the core never queues more than it holds).
+void hostBackendAudioQueue(const int16_t *frames, uint32_t count) {
+    uint32_t room;
+    uint32_t i;
+
+    if (!gAudioLock.Lock()) {
+        return;
+    }
+    room = AUDIO_RING_FRAMES - (gRingHead - gRingTail);
+    if (count > room) {
+        count = room;
+    }
+    for (i = 0; i < count * HOSTED_AUDIO_CHANNELS; i++) {
+        gRing[(gRingHead * HOSTED_AUDIO_CHANNELS + i) & AUDIO_RING_SAMPLE_MASK] = frames[i];
+    }
+    gRingHead += count;
+    gAudioLock.Unlock();
+}
+
+
+uint32_t hostBackendAudioQueued(void) {
+    uint32_t queued;
+
+    if (!gAudioLock.Lock()) {
+        return 0;
+    }
+    queued = gRingHead - gRingTail;
+    gAudioLock.Unlock();
+    return queued;
+}
+
+
+void hostBackendClose(void) {
+    hostBackendAudioClose();
+    sticksClose();
+    if (gWindow != NULL && gWindow->Lock()) {
+        gWindow->Quit();
+    }
+    gWindow = NULL;
+    gView   = NULL;
+    delete gBitmap;
+    gBitmap = NULL;
+}
+
+
+uint64_t hostBackendMicros(void) {
+    return (uint64_t)system_time();
+}
+
+
+const char *hostBackendOpen(void) {
+    const char *aspect;
+
+    // The program's own folder is the current directory, as on every
+    // machine port.
+    if (!programFind() || chdir(gProgramFolder) != 0) {
+        return "cannot make the program's folder the current directory";
+    }
+    if (gAppThread < 0) {
+        gAppReady = create_sem(0, "JoeyLib application");
+        if (gAppReady < B_OK) {
+            return "cannot make a semaphore";
+        }
+        gAppThread = spawn_thread(appThread, "JoeyLib application", B_NORMAL_PRIORITY, NULL);
+        if (gAppThread < B_OK || resume_thread(gAppThread) != B_OK) {
+            delete_sem(gAppReady);
+            gAppThread = -1;
+            return "cannot start the application's thread";
+        }
+        acquire_sem(gAppReady);
+        delete_sem(gAppReady);
+        if (gAppStatus != B_OK) {
+            wait_for_thread(gAppThread, &gAppStatus);
+            gAppThread = -1;
+            return "cannot start the application (is the app_server running?)";
+        }
+        gCursor = new BCursor(kHiddenCursor);
+    }
+    aspect      = getenv("JOEYLIB_ASPECT");
+    gSquare     = aspect != NULL && strcmp(aspect, "square") == 0;
+    gFullScreen = optionInt("JOEYLIB_FULLSCREEN", 0, 1) != 0;
+    gBitmap     = new BBitmap(BRect(0.0f, 0.0f, (float)(SURFACE_WIDTH - 1), (float)(SURFACE_HEIGHT - 1)), B_RGB32);
+    if (!gBitmap->IsValid()) {
+        delete gBitmap;
+        gBitmap = NULL;
+        return "cannot make the frame's bitmap";
+    }
+    memset(gBitmap->Bits(), 0, (size_t)gBitmap->BitsLength());
+    memset(gRawDown, 0, sizeof(gRawDown));
+    gButtons       = 0;
+    gQuitAsked     = false;
+    gEventHead     = 0;
+    gEventTail     = 0;
+    gWindow        = new JoeyWindowT(windowFrame(), gProgramName, gFullScreen ? B_NO_BORDER_WINDOW_LOOK : B_TITLED_WINDOW_LOOK);
+    gWindowedFrame = gWindow->Frame();
+    gView          = new JoeyViewT(gWindow->Bounds());
+    gWindow->Lock();
+    gWindow->AddChild(gView);
+    gView->MakeFocus(true);
+    gWindow->Unlock();
+    gWindow->Show();
+    sticksOpen();
+    if (!gAtExit) {
+        atexit(backendExit);
+        gAtExit = true;
+    }
+    return NULL;
+}
+
+
+void hostBackendPump(void) {
+    EventT   events[EVENT_QUEUE_SIZE];
+    uint32_t count;
+    uint32_t i;
+    bool     quit;
+
+    count = eventsTake(events);
+    for (i = 0; i < count; i++) {
+        switch (events[i].kind) {
+            case EVENT_KEY:
+                hostedKeyEvent((jlKeyE)events[i].code, events[i].down);
+                break;
+
+            case EVENT_CHAR:
+                jlInputCharPush(events[i].code);
+                break;
+
+            case EVENT_MOUSE_MOVE:
+                hostedMouseMove(events[i].x, events[i].y);
+                break;
+
+            case EVENT_MOUSE_BUTTON:
+                hostedMouseButtonEvent((jlMouseButtonE)events[i].code, events[i].down);
+                break;
+        }
+    }
+    sticksPoll();
+    quit = false;
+    if (gEventLock.Lock()) {
+        quit = gQuitAsked;
+        gEventLock.Unlock();
+    }
+    if (quit) {
+        exit(EXIT_SUCCESS);
+    }
+}
+
+
+void hostBackendShow(const uint32_t *pixels) {
+    uint8_t *bits;
+    int32    rowBytes;
+    int      y;
+
+    if (gWindow == NULL || !gWindow->Lock()) {
+        return;
+    }
+    bits     = (uint8_t *)gBitmap->Bits();
+    rowBytes = gBitmap->BytesPerRow();
+    for (y = 0; y < SURFACE_HEIGHT; y++) {
+        memcpy(bits + y * rowBytes, pixels + y * SURFACE_WIDTH, SURFACE_WIDTH * sizeof(uint32_t));
+    }
+    gView->drawFrame();
+    // The app_server reads the bitmap as it draws: done before it changes.
+    gView->Sync();
+    gWindow->Unlock();
+}
+
+
+void hostBackendSleepUntil(uint64_t micros) {
+    if ((bigtime_t)micros > system_time()) {
+        snooze_until((bigtime_t)micros, B_SYSTEM_TIMEBASE);
+    }
+}
+
+// ----- HAL API (alphabetical) -----
+
+// Free bytes where the program runs, saturated at what a uint32_t holds.
+uint32_t jlpDiskFree(void) {
+    fs_info  info;
+    uint64_t bytes;
+
+    if (fs_stat_dev(dev_for_path("."), &info) != 0) {
+        return 0u;
+    }
+    bytes = (uint64_t)info.free_blocks * (uint64_t)info.block_size;
+    return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes;
+}
+
+
+bool jlpSaveDelete(const char *path) {
+    return unlink(path) == 0;
+}
+
+
+bool jlpSaveDirEnsure(const char *dir) {
+    struct stat st;
+
+    if (mkdir(dir, DIR_MODE) == 0) {
+        return true;
+    }
+    return stat(dir, &st) == 0 && S_ISDIR(st.st_mode);
+}
diff --git a/src/hosted/hal.c b/src/hosted/hal.c
new file mode 100644
index 0000000..91d1450
--- /dev/null
+++ b/src/hosted/hal.c
@@ -0,0 +1,271 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Hosted HAL: the stage resolved into an RGB frame, the virtual frame clock,
+// and when frames reach the backend's window.
+//
+// Present: the stage's changed rows are expanded through one table per
+// palette -- the 0x00RRGGBB pixel for each of its 16 entries, the $0RGB
+// channels widened to 8 bits -- into the frame the backend shows. Each row
+// takes the table its SCB picks, so SCB palettes are exact. A palette change
+// rebuilds its table and expands every row that uses it again; an SCB change
+// expands every row. An indexed frame (HOSTED_FRAME_INDEXED, hosted.h) takes
+// each pixel as its palette and colour instead, so a palette change only
+// hands the backend its new colours and the rows stay as they are.
+//
+// Timing: a window has no vertical blank, so the frame rate is a virtual
+// one, HOSTED_FRAME_HZ, kept by the backend's microsecond clock from jlInit
+// on. jlWaitVBL sleeps to the clock's next frame boundary. The frame reaches
+// the window at most once a virtual frame: when a present lands in a frame
+// that has not been shown yet, else at the next frame wait or input poll.
+// A program that presents many times a frame (UBER's present rows) pays for
+// expanding its rows, not for the window.
+//
+// The backend's events are taken whenever a frame is shown and at every
+// frame wait (and every input poll, input.c), so the window answers the OS
+// and closing it ends the program even in a loop that never polls input.
+
+#include 
+
+#include "port.h"
+#include "surfaceInternal.h"
+#include "hosted.h"
+
+// ----- Constants -----
+
+#define US_PER_SECOND          1000000u
+#define US_PER_MS              1000u
+
+// $0RGB palette entries: 4 bits a channel, widened to 8 by * 17 ($F -> $FF).
+#define RGB12_RED_SHIFT        8
+#define RGB12_GREEN_SHIFT      4
+#define RGB12_BLUE_SHIFT       0
+#define RGB12_CHANNEL_MASK     0x0Fu
+#define RGB12_TO_RGB24         17u
+#define RGB24_RED_SHIFT        16
+#define RGB24_GREEN_SHIFT      8
+#define RGB24_BLUE_SHIFT       0
+
+// The stage: two pixels a byte, the left one in the high nibble; the dirty
+// bands count 4-pixel words.
+#define NIBBLE_BITS            4
+#define NIBBLE_MASK            0x0Fu
+#define STAGE_PIXELS_PER_BYTE  2u
+#define STAGE_PIXELS_PER_WORD  (SURFACE_WIDTH / SURFACE_WORDS_PER_ROW)
+
+#define PALETTES_NONE          0x0000u
+#define PALETTES_ALL           0xFFFFu
+
+// Where row y starts in the frame.
+#if defined(HOSTED_FRAME_BOTTOM_UP)
+  #define FRAME_ROW(y)         ((uint32_t)(SURFACE_HEIGHT - 1u - (y)) * SURFACE_WIDTH)
+#else
+  #define FRAME_ROW(y)         ((uint32_t)(y) * SURFACE_WIDTH)
+#endif
+
+// The frame's pixel for colour c of palette p: its colour table entry in an
+// indexed frame, else its colour.
+#if defined(HOSTED_FRAME_INDEXED)
+  #define FRAME_PIXEL(p, c)    ((HostedPixelT)(((p) << NIBBLE_BITS) | (c)))
+#else
+  #define FRAME_PIXEL(p, c)    (gLut[(p)][(c)])
+#endif
+
+// ----- Prototypes -----
+
+static void     expandRow(const jlSurfaceT *src, uint16_t y, uint16_t x0, uint16_t x1);
+static uint64_t frameNow(void);
+static void     lutBuild(const jlSurfaceT *src, uint16_t palettes);
+static uint32_t rgb12To24(uint16_t rgb);
+static void     show(void);
+
+// ----- Module state -----
+
+static HostedPixelT gFrame[SURFACE_HEIGHT * SURFACE_WIDTH];
+// Each palette's colours, 0x00RRGGBB; palette after palette, so entry
+// (p << 4) | c is an indexed frame's colour table.
+static uint32_t     gLut[SURFACE_PALETTE_COUNT][SURFACE_COLORS_PER_PALETTE];
+static const char  *gError       = NULL;
+static bool         gOpen        = false;
+static bool         gShowPending = false;
+static uint64_t     gShownFrame  = 0;
+static uint64_t     gStartUs     = 0;
+
+// ----- Internal helpers (alphabetical) -----
+
+// Pixels x0..x1-1 of row y (x0 and x1 even) in the row's palette.
+static void expandRow(const jlSurfaceT *src, uint16_t y, uint16_t x0, uint16_t x1) {
+    const uint8_t *in;
+    HostedPixelT  *out;
+    uint16_t       x;
+    uint8_t        palette;
+    uint8_t        pair;
+
+    palette = (uint8_t)(src->scb[y] & NIBBLE_MASK);
+    in      = &src->pixels[SURFACE_ROW_OFFSET(y) + x0 / STAGE_PIXELS_PER_BYTE];
+    out     = &gFrame[FRAME_ROW(y) + x0];
+    for (x = x0; x < x1; x += STAGE_PIXELS_PER_BYTE) {
+        pair   = *in++;
+        out[0] = FRAME_PIXEL(palette, pair >> NIBBLE_BITS);
+        out[1] = FRAME_PIXEL(palette, pair & NIBBLE_MASK);
+        out   += STAGE_PIXELS_PER_BYTE;
+    }
+}
+
+
+// The virtual frame the clock is in.
+static uint64_t frameNow(void) {
+    return (hostBackendMicros() - gStartUs) * HOSTED_FRAME_HZ / US_PER_SECOND;
+}
+
+
+static void lutBuild(const jlSurfaceT *src, uint16_t palettes) {
+    uint8_t p;
+    uint8_t c;
+
+    for (p = 0; p < SURFACE_PALETTE_COUNT; p++) {
+        if ((palettes & (1u << p)) == 0) {
+            continue;
+        }
+        for (c = 0; c < SURFACE_COLORS_PER_PALETTE; c++) {
+            gLut[p][c] = rgb12To24(src->palette[p][c]);
+        }
+    }
+#if defined(HOSTED_FRAME_INDEXED)
+    hostBackendColors(&gLut[0][0], palettes);
+#endif
+}
+
+
+static uint32_t rgb12To24(uint16_t rgb) {
+    uint32_t r;
+    uint32_t g;
+    uint32_t b;
+
+    r = ((rgb >> RGB12_RED_SHIFT) & RGB12_CHANNEL_MASK) * RGB12_TO_RGB24;
+    g = ((rgb >> RGB12_GREEN_SHIFT) & RGB12_CHANNEL_MASK) * RGB12_TO_RGB24;
+    b = ((rgb >> RGB12_BLUE_SHIFT) & RGB12_CHANNEL_MASK) * RGB12_TO_RGB24;
+    return (r << RGB24_RED_SHIFT) | (g << RGB24_GREEN_SHIFT) | (b << RGB24_BLUE_SHIFT);
+}
+
+
+static void show(void) {
+    hostBackendPump();
+    hostBackendShow(gFrame);
+    gShowPending = false;
+    gShownFrame  = frameNow();
+}
+
+// ----- Hosted core API (alphabetical) -----
+
+void hostedShowPending(void) {
+    if (gShowPending && frameNow() != gShownFrame) {
+        show();
+    }
+}
+
+// ----- HAL API (alphabetical) -----
+
+uint16_t jlpFrameCount(void) {
+    return (uint16_t)frameNow();
+}
+
+
+uint16_t jlpFrameHz(void) {
+    return HOSTED_FRAME_HZ;
+}
+
+
+bool jlpInit(const jlConfigT *config) {
+    (void)config;
+    gError = hostBackendOpen();
+    if (gError != NULL) {
+        return false;
+    }
+    gOpen        = true;
+    gShowPending = false;
+    gStartUs     = hostBackendMicros();
+    gShownFrame  = 0;
+    return true;
+}
+
+
+const char *jlpLastError(void) {
+    return gError;
+}
+
+
+uint32_t jlpMillisElapsed(void) {
+    return (uint32_t)((hostBackendMicros() - gStartUs) / US_PER_MS);
+}
+
+
+void jlpPresent(const jlSurfaceT *src) {
+    uint16_t palettes;
+    uint16_t y;
+    uint16_t x0;
+    uint16_t x1;
+    uint8_t  maxWord;
+
+    if (src == NULL || !gOpen) {
+        return;
+    }
+    palettes = PALETTES_NONE;
+    if (gStagePaletteDirty) {
+        lutBuild(src, gStagePaletteDirtyMask);
+#if !defined(HOSTED_FRAME_INDEXED)
+        // The rows hold colours: those in a changed palette are expanded again.
+        palettes = gStagePaletteDirtyMask;
+#endif
+    }
+    // An SCB change moves rows to other tables: expand them all again.
+    if (gStageScbDirty) {
+        palettes = PALETTES_ALL;
+    }
+    gStagePaletteDirty     = false;
+    gStagePaletteDirtyMask = PALETTES_NONE;
+    gStageScbDirty         = false;
+    for (y = 0; y < SURFACE_HEIGHT; y++) {
+        if ((palettes & (1u << (src->scb[y] & NIBBLE_MASK))) != 0) {
+            x0 = 0;
+            x1 = SURFACE_WIDTH;
+        } else if (STAGE_DIRTY_ROW_CLEAN(y)) {
+            continue;
+        } else {
+            // Clamp a band past the row end, as the DOS present does.
+            maxWord = gStageMaxWord[y] < SURFACE_WORDS_PER_ROW ? gStageMaxWord[y] : (uint8_t)(SURFACE_WORDS_PER_ROW - 1u);
+            x0      = (uint16_t)(gStageMinWord[y] * STAGE_PIXELS_PER_WORD);
+            x1      = (uint16_t)((maxWord + 1u) * STAGE_PIXELS_PER_WORD);
+        }
+        expandRow(src, y, x0, x1);
+        gPresentCopiedBytes += (uint32_t)(x1 - x0) / STAGE_PIXELS_PER_BYTE;
+    }
+    gPresentScannedRows += SURFACE_HEIGHT;
+    gShowPending         = true;
+    hostedShowPending();
+    hostedAudioService();
+}
+
+
+// The audio device goes with the backend, so the mixer stops first even when
+// the program never called jlAudioShutdown.
+void jlpShutdown(void) {
+    jlpAudioShutdown();
+    gOpen = false;
+    hostBackendClose();
+}
+
+
+// Sleep to the clock's next frame boundary, then show what the frame
+// before it presented.
+void jlpWaitVBL(void) {
+    uint64_t next;
+
+    next = (frameNow() + 1u) * US_PER_SECOND / HOSTED_FRAME_HZ;
+    // One microsecond past the rounded-down boundary is inside the new frame.
+    hostBackendSleepUntil(gStartUs + next + 1u);
+    hostBackendPump();
+    hostedShowPending();
+    hostedAudioService();
+}
diff --git a/src/hosted/hosted.h b/src/hosted/hosted.h
new file mode 100644
index 0000000..e93fe6a
--- /dev/null
+++ b/src/hosted/hosted.h
@@ -0,0 +1,116 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// The hosted core and its backends (JOEYLIB_PLATFORM_HOSTED).
+//
+// A hosted program runs under a desktop operating system in a window. The
+// core (src/hosted/*.c) is everything that does not depend on which OS that
+// is: it resolves the stage through its SCB palettes into an RGB frame,
+// keeps a virtual frame clock, turns the backend's input events into
+// JoeyLib's input state, and mixes the audio. A backend
+// (src/hosted//, picked by JOEYLIB_HOSTED_) is the OS: the
+// window and how a frame gets onto it, events, the clock, the audio device
+// and the file system. A new OS is a new backend; the core stays as it is.
+//
+// Everything here runs on the program's thread: the backend delivers events
+// only from hostBackendPump, and the audio is mixed by the core and handed
+// to the backend's queue (hostBackendAudioQueue), never pulled by an audio
+// thread.
+
+#ifndef JOEYLIB_HOSTED_H
+#define JOEYLIB_HOSTED_H
+
+#include 
+#include 
+
+#include "joey/input.h"
+#include "joey/surface.h"
+
+// The virtual frame rate (a window has no vertical blank; hal.c).
+#define HOSTED_FRAME_HZ        60u
+
+// The frame the core shows: SURFACE_WIDTH x SURFACE_HEIGHT pixels, row after
+// row, each 0x00RRGGBB. A backend whose build defines HOSTED_FRAME_INDEXED
+// (a system that shows 8-bit pictures best: Windows 3.x) takes one byte a
+// pixel instead, (palette << 4) | colour -- the row's SCB palette and the
+// stage's colour -- shown through a 256-entry colour table the core keeps
+// it up to date with (hostBackendColors). Its palette changes then only
+// touch that table. HOSTED_FRAME_BOTTOM_UP stores the rows last first, as a
+// Windows 3.x DIB is.
+#if defined(HOSTED_FRAME_INDEXED)
+typedef uint8_t  HostedPixelT;
+#else
+typedef uint32_t HostedPixelT;
+#endif
+
+// The audio the core mixes: signed 16-bit frames of HOSTED_AUDIO_CHANNELS
+// samples (left, right; or one, mono) at HOSTED_AUDIO_RATE. 44.1 kHz stereo
+// unless the backend's build asks for less (Windows 3.x: 22.05 kHz mono, what
+// its sound drivers and a 386 manage).
+#if !defined(HOSTED_AUDIO_RATE)
+  #define HOSTED_AUDIO_RATE    44100u
+#endif
+#if !defined(HOSTED_AUDIO_CHANNELS)
+  #define HOSTED_AUDIO_CHANNELS 2u
+#endif
+
+// ----- What a backend supplies -----
+
+// Open the window and get ready to show frames. Before it returns, the
+// current directory is the folder the program's executable is in, as on
+// every machine port (DATA/, SAVES/ and joeylog.txt are opened by those
+// relative names). NULL on success, else why the window could not be opened.
+const char *hostBackendOpen(void);
+// Close the window and the audio device; safe when either is not open.
+void        hostBackendClose(void);
+// Show one frame (HostedPixelT above).
+void        hostBackendShow(const HostedPixelT *pixels);
+#if defined(HOSTED_FRAME_INDEXED)
+// The colour table changed: colors holds all 256 entries, 0x00RRGGBB, entry
+// (palette << 4) | colour, and palettes has bit p set for each palette whose
+// sixteen are new. Called before the first frame with every bit set.
+void        hostBackendColors(const uint32_t *colors, uint16_t palettes);
+#endif
+// Deliver every pending event to the core (hostedKeyEvent and the rest). A
+// request to close the window ends the program (exit), as closing a machine
+// port's program would.
+void        hostBackendPump(void);
+// Microseconds since some fixed moment before the program started.
+uint64_t    hostBackendMicros(void);
+// Sleep until hostBackendMicros reaches micros; returns at once when it has.
+void        hostBackendSleepUntil(uint64_t micros);
+// Open the audio output for the core's frames (HOSTED_AUDIO_RATE above).
+// False when there is no audio device.
+bool        hostBackendAudioOpen(void);
+void        hostBackendAudioClose(void);
+// Frames queued and not yet played.
+uint32_t    hostBackendAudioQueued(void);
+// Queue count frames (count * HOSTED_AUDIO_CHANNELS samples).
+void        hostBackendAudioQueue(const int16_t *frames, uint32_t count);
+
+// ----- What the core gives the backend -----
+
+// Keyboard: a key went down or up. Repeats need not be reported.
+void hostedKeyEvent(jlKeyE key, bool down);
+// Mouse: the pointer's place on the stage (already clamped to it), and its
+// buttons.
+void hostedMouseMove(int16_t x, int16_t y);
+void hostedMouseButtonEvent(jlMouseButtonE button, bool down);
+// Joysticks: a driver's axis position on its min..max range as a JoeyLib
+// axis; a stick was attached or removed; its position (each axis
+// JOYSTICK_AXIS_MIN..JOYSTICK_AXIS_MAX); a button went down or up.
+int8_t hostedAxisScale(int32_t pos, int32_t min, int32_t max);
+void hostedJoystickConnect(jlJoystickE js, bool connected);
+void hostedJoystickMove(jlJoystickE js, int8_t x, int8_t y);
+void hostedJoyButtonEvent(jlJoystickE js, jlJoyButtonE button, bool down);
+
+// ----- Shared between the core's files -----
+
+// Keep the audio queue full; the core calls it from every frequent library
+// call (present, the frame wait, the input poll and the frame tick).
+void hostedAudioService(void);
+// Show a converted frame that has not been shown yet; the input poll calls it.
+void hostedShowPending(void);
+
+#endif
diff --git a/src/hosted/input.c b/src/hosted/input.c
new file mode 100644
index 0000000..f612126
--- /dev/null
+++ b/src/hosted/input.c
@@ -0,0 +1,166 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Hosted input: the backend's events turned into JoeyLib's input state.
+//
+// A machine port reads its keyboard, mouse and sticks as they are at poll
+// time. A backend reports changes as events instead, between polls, so a
+// key (or button) pressed and released again before the next jlInputPoll
+// would never be seen down. Each key therefore keeps two flags: held, as the
+// events leave it, and pressed, set by any press since the last poll. A poll
+// reports a key down when it is held or was pressed since the poll before,
+// so a tap shorter than a frame is down for exactly one poll.
+
+#include 
+
+#include "port.h"
+#include "inputInternal.h"
+#include "hosted.h"
+
+// ----- Module state -----
+
+static uint8_t gKeyHeld        [KEY_COUNT];
+static uint8_t gKeyTapped      [KEY_COUNT];
+static uint8_t gMouseHeld      [MOUSE_BUTTON_COUNT];
+static uint8_t gMouseTapped    [MOUSE_BUTTON_COUNT];
+static uint8_t gJoyHeld        [JOYSTICK_COUNT][JOY_BUTTON_COUNT];
+static uint8_t gJoyTapped      [JOYSTICK_COUNT][JOY_BUTTON_COUNT];
+static int8_t  gJoyRawX        [JOYSTICK_COUNT];
+static int8_t  gJoyRawY        [JOYSTICK_COUNT];
+
+// ----- Prototypes -----
+
+static int8_t deadZone(int8_t value, uint8_t zone);
+static void   latch(uint8_t *state, uint8_t *held, uint8_t *tapped, uint16_t count);
+
+// ----- Internal helpers (alphabetical) -----
+
+// A stick position inside its dead zone (jlJoystickReset) reads as centred.
+static int8_t deadZone(int8_t value, uint8_t zone) {
+    int16_t magnitude;
+
+    magnitude = value < 0 ? (int16_t)-value : (int16_t)value;
+    return magnitude < (int16_t)zone ? (int8_t)0 : value;
+}
+
+
+// state = held or pressed since the last poll; then forget the presses.
+static void latch(uint8_t *state, uint8_t *held, uint8_t *tapped, uint16_t count) {
+    uint16_t i;
+
+    for (i = 0; i < count; i++) {
+        state[i] = (uint8_t)(held[i] | tapped[i]);
+    }
+    memset(tapped, 0, count);
+}
+
+// ----- Hosted core API (alphabetical) -----
+
+// Rounded to the nearest step, so a centred stick reads 0 whichever middle
+// value its driver reports (0..65535 has two: 32767 and 32768), and the
+// ends read JOYSTICK_AXIS_MIN and MAX.
+int8_t hostedAxisScale(int32_t pos, int32_t min, int32_t max) {
+    int32_t span;
+
+    span = max > min ? max - min : 1;
+    if (pos < min) {
+        pos = min;
+    }
+    if (pos > max) {
+        pos = max;
+    }
+    return (int8_t)(((pos - min) * (JOYSTICK_AXIS_MAX - JOYSTICK_AXIS_MIN) + span / 2) / span + JOYSTICK_AXIS_MIN);
+}
+
+
+void hostedJoyButtonEvent(jlJoystickE js, jlJoyButtonE button, bool down) {
+    if ((uint16_t)js >= JOYSTICK_COUNT || (uint16_t)button >= JOY_BUTTON_COUNT) {
+        return;
+    }
+    gJoyHeld[js][button] = down;
+    if (down) {
+        gJoyTapped[js][button] = 1u;
+    }
+}
+
+
+void hostedJoystickConnect(jlJoystickE js, bool connected) {
+    if ((uint16_t)js >= JOYSTICK_COUNT) {
+        return;
+    }
+    gJoyConnected[js] = connected;
+    gJoyRawX[js]      = 0;
+    gJoyRawY[js]      = 0;
+    memset(gJoyHeld[js], 0, sizeof(gJoyHeld[js]));
+}
+
+
+void hostedJoystickMove(jlJoystickE js, int8_t x, int8_t y) {
+    if ((uint16_t)js >= JOYSTICK_COUNT) {
+        return;
+    }
+    gJoyRawX[js] = x;
+    gJoyRawY[js] = y;
+}
+
+
+void hostedKeyEvent(jlKeyE key, bool down) {
+    if (key <= KEY_NONE || key >= KEY_COUNT) {
+        return;
+    }
+    gKeyHeld[key] = down;
+    if (down) {
+        gKeyTapped[key] = 1u;
+    }
+}
+
+
+void hostedMouseButtonEvent(jlMouseButtonE button, bool down) {
+    if (button <= MOUSE_BUTTON_NONE || button >= MOUSE_BUTTON_COUNT) {
+        return;
+    }
+    gMouseHeld[button] = down;
+    if (down) {
+        gMouseTapped[button] = 1u;
+    }
+}
+
+
+void hostedMouseMove(int16_t x, int16_t y) {
+    gMouseX = x;
+    gMouseY = y;
+}
+
+// ----- HAL API (alphabetical) -----
+
+void jlpInputInit(void) {
+    memset(gKeyHeld,     0, sizeof(gKeyHeld));
+    memset(gKeyTapped,   0, sizeof(gKeyTapped));
+    memset(gMouseHeld,   0, sizeof(gMouseHeld));
+    memset(gMouseTapped, 0, sizeof(gMouseTapped));
+    memset(gJoyHeld,     0, sizeof(gJoyHeld));
+    memset(gJoyTapped,   0, sizeof(gJoyTapped));
+    gMouseAttached = true;
+    // The middle of the stage, as on the machine ports, until the backend
+    // reports the pointer (it reports nothing while the pointer is outside
+    // the window).
+    hostedMouseMove(SURFACE_WIDTH / 2, SURFACE_HEIGHT / 2);
+}
+
+
+void jlpInputPoll(void) {
+    uint8_t js;
+
+    hostBackendPump();
+    latch(gKeyState, gKeyHeld, gKeyTapped, KEY_COUNT);
+    latch(gMouseButtonState, gMouseHeld, gMouseTapped, MOUSE_BUTTON_COUNT);
+    latch(&gJoyButtonState[0][0], &gJoyHeld[0][0], &gJoyTapped[0][0], JOYSTICK_COUNT * JOY_BUTTON_COUNT);
+    for (js = 0; js < JOYSTICK_COUNT; js++) {
+        gJoyAxisX[js] = deadZone(gJoyRawX[js], gJoyDeadZone[js]);
+        gJoyAxisY[js] = deadZone(gJoyRawY[js], gJoyDeadZone[js]);
+    }
+    hostedShowPending();
+    hostedAudioService();
+}
+
diff --git a/src/hosted/sdl3/sdl3Backend.c b/src/hosted/sdl3/sdl3Backend.c
new file mode 100644
index 0000000..1eb4762
--- /dev/null
+++ b/src/hosted/sdl3/sdl3Backend.c
@@ -0,0 +1,896 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// SDL3 backend for the hosted core (JOEYLIB_HOSTED_SDL3).
+//
+// The window shows the 320x200 frame scaled to fill it at the stage's shape:
+// 4:3 by default, as the machine ports' 320x200 fills a 4:3 monitor (each
+// pixel 1.2 times as tall as wide), or square pixels (16:10) with
+// JOEYLIB_ASPECT=square. The rest of the window is black. SDL's pixel-art
+// scaling keeps the pixels sharp at any size; a whole multiple is exact.
+// JOEYLIB_SCALE=N opens the window N times 320 wide (default 3), and
+// JOEYLIB_FULLSCREEN=1 opens it on the whole screen; Alt+Enter switches
+// between the two while the program runs. The OS pointer is hidden over the
+// window: JoeyLib programs draw their own.
+//
+// Input: the keyboard by scancode (where the key is, so a layout does not
+// move the arrow keys or WASD), typed characters from SDL's text input
+// (layout and shift applied), the mouse in stage pixels, and the first two
+// gamepads as JOYSTICK_0 and JOYSTICK_1 -- the left stick or the d-pad, the
+// south button as button 0 and the east button as button 1. Closing the
+// window ends the program.
+//
+// Audio: one SDL audio stream on the default playback device, fed by the
+// core's mixer; SDL converts to whatever the device takes.
+//
+// Files: the program's folder is the current directory from the moment the
+// window opens; SAVES/ is made with SDL's file system calls and free space
+// comes from statvfs.
+//
+// In a browser (Emscripten, the web port) the program cannot block: it is
+// built with ASYNCIFY, and every wait here (emscripten_sleep) hands the
+// browser its turn and carries on where it was. The program gets its turn
+// back at least once a virtual frame even when it never waits (a loop on
+// jlInputPoll alone, as jlWaitForAnyKey is). Its folder is the in-memory file
+// system's root, where its DATA is unpacked; SAVES/ there is kept in the
+// browser's IndexedDB (IDBFS), read when the window opens and written back
+// every WEB_SAVES_SYNC_US, so saves outlive the page.
+//
+// On the PSP (the psp port) the window is the 480x272 screen and the stage
+// sits in its middle at 1:1. The PSP's own controls are gamepad 0 through
+// SDL's mapping (the d-pad or the analog nub, cross as button 0 and circle
+// as button 1); having no keyboard, Start is the Return key and Select the
+// Escape key, held and typed as those keys are, so "press any key", Return
+// and Escape work. SDL's text input, which would open the PSP's on-screen
+// keyboard, is never started, so nothing else is typed. The HOME
+// menu's Exit ends the program as closing a window does. The program's
+// folder (PSP/GAME/NAME on the memory stick) is the current directory, and
+// free space comes from the memory stick the program runs from.
+
+#include 
+
+#include 
+
+#if defined(__EMSCRIPTEN__)
+#include 
+#endif
+
+// POSIX: chdir for the program's folder, statvfs for the free space. The
+// PSP has chdir but no statvfs: its kernel says how much room is left.
+#if defined(__PSP__)
+#include 
+#include 
+#include 
+#elif defined(__unix__) || defined(__APPLE__) || defined(__EMSCRIPTEN__)
+#include 
+#include 
+#else
+#error "the SDL3 backend needs chdir and statvfs for this system"
+#endif
+
+#include "port.h"
+#include "inputInternal.h"
+#include "hosted.h"
+
+// ----- Constants -----
+
+#define NS_PER_US              1000u
+
+#define WINDOW_SCALE_DEFAULT   3
+#define WINDOW_SCALE_MAX       8
+#define WINDOW_TITLE_DEFAULT   "JoeyLib"
+
+// The displayed shape of the 320x200 stage: 4:3 like a CRT, or 16:10 when
+// its pixels are kept square.
+#define ASPECT_CRT_W           4
+#define ASPECT_CRT_H           3
+#define ASPECT_SQUARE_W        SURFACE_WIDTH
+#define ASPECT_SQUARE_H        SURFACE_HEIGHT
+
+// The frame the core gives (hosted.h), and the texture it goes into: the
+// same on every system but the PSP, whose renderer takes ABGR8888 (red and
+// blue the other way round) or 16-bit formats; given XRGB8888, SDL would
+// keep the frame as 16-bit BGR565, which cannot hold the $0RGB palette
+// exactly.
+#define FRAME_FORMAT           SDL_PIXELFORMAT_XRGB8888
+#define FRAME_PITCH            (SURFACE_WIDTH * (int)sizeof(uint32_t))
+#if defined(__PSP__)
+#define TEXTURE_FORMAT         SDL_PIXELFORMAT_ABGR8888
+#else
+#define TEXTURE_FORMAT         FRAME_FORMAT
+#endif
+
+// SDL audio: signed 16-bit frames of HOSTED_AUDIO_CHANNELS samples.
+#define AUDIO_FRAME_BYTES      ((int)(HOSTED_AUDIO_CHANNELS * sizeof(int16_t)))
+
+// Characters a key gives without SDL's text input (it sends only printable
+// text): the controls joey/input.h delivers.
+#define CHAR_NONE              0u
+// Text input bytes from here up belong to wider UTF-8 characters.
+#define ASCII_LIMIT            0x80u
+
+#define ERROR_TEXT_BYTES       256
+
+#if defined(__EMSCRIPTEN__)
+#define US_PER_MS              1000u
+// The browser's turn at least this often: one virtual frame.
+#define WEB_YIELD_US           (1000000u / HOSTED_FRAME_HZ)
+// SAVES/ goes back to IndexedDB this often.
+#define WEB_SAVES_SYNC_US      2000000u
+#endif
+
+#if defined(__PSP__)
+#define PSP_MODULE_NAME        "JoeyLib"
+#define PSP_MODULE_MAJOR       1
+#define PSP_MODULE_MINOR       0
+// The thread that waits for the HOME menu's Exit: the priority and stack the
+// PSPSDK's samples give it.
+#define PSP_EXIT_PRIORITY      0x11
+#define PSP_EXIT_STACK_BYTES   0x1000
+// sceIoDevctl's request for a memory stick's (or the PSP Go's internal
+// storage's) size and free space.
+#define PSP_DEVCTL_FREE_SPACE  0x02425818u
+// A device name such as "ms0:", its colon and a terminator.
+#define PSP_DEVICE_BYTES       16
+#define PSP_DEVICE_END         ':'
+#endif
+
+// ----- Types -----
+
+#if defined(__PSP__)
+// What PSP_DEVCTL_FREE_SPACE fills in: free bytes are freeClusters *
+// sectorCount * sectorSize.
+typedef struct {
+    uint32_t maxClusters;
+    uint32_t freeClusters;
+    uint32_t maxSectors;
+    uint32_t sectorSize;
+    uint32_t sectorCount;
+} PspDeviceSizeT;
+#endif
+
+// ----- Prototypes -----
+
+static void            charFromKey(SDL_Scancode code);
+static void            frameRect(SDL_FRect *dst);
+static void            fullScreenToggle(void);
+static void            keyEvent(SDL_Scancode code, bool down, bool repeat);
+static jlKeyE          keyFromScancode(SDL_Scancode code);
+static int             optionInt(const char *name, int fallback, int max);
+static void            padAdded(SDL_JoystickID id);
+static void            padRemoved(SDL_JoystickID id);
+static int             padSlot(SDL_JoystickID id);
+static void            padUpdate(SDL_JoystickID id);
+static void            pointerMove(float windowX, float windowY);
+#if defined(__PSP__)
+static void            pspButtonKey(uint8_t button, bool down);
+static int             pspExitCallback(int arg1, int arg2, void *common);
+static int             pspExitThread(SceSize args, void *argp);
+#endif
+static void            reshow(void);
+static int8_t          stickAxis(int16_t raw, bool negative, bool positive);
+static void            textureFill(const uint32_t *pixels);
+#if defined(__EMSCRIPTEN__)
+static void            webService(void);
+#endif
+static const char     *windowTitle(void);
+
+// ----- Module state -----
+
+static SDL_Window      *gWindow   = NULL;
+static SDL_Renderer    *gRenderer = NULL;
+static SDL_Texture     *gTexture  = NULL;
+static SDL_AudioStream *gAudio    = NULL;
+static SDL_Gamepad     *gPads[JOYSTICK_COUNT];
+static const uint32_t  *gLastFrame  = NULL;
+static bool             gSquare     = false;
+static bool             gAtExit     = false;
+static char             gErrorText[ERROR_TEXT_BYTES];
+#if defined(__EMSCRIPTEN__)
+static bool             gWebSavesMounted = false;
+static uint64_t         gWebYieldUs      = 0;    // when the browser last had its turn
+static uint64_t         gWebSyncUs       = 0;    // when SAVES/ last went back to IndexedDB
+#endif
+
+#if defined(__PSP__)
+// The PSP loads a program by its module information. Its main thread may
+// use the VFPU, as SDL's own PSP main gives it.
+PSP_MODULE_INFO(PSP_MODULE_NAME, PSP_MODULE_USER, PSP_MODULE_MAJOR, PSP_MODULE_MINOR);
+PSP_MAIN_THREAD_ATTR(THREAD_ATTR_USER | THREAD_ATTR_VFPU);
+#endif
+
+// ----- Internal helpers (alphabetical) -----
+
+// The controls joey/input.h delivers as characters, which SDL's text input
+// does not send. Repeats come through too, as a held key repeats its text.
+static void charFromKey(SDL_Scancode code) {
+    uint8_t ch;
+
+    switch (code) {
+        case SDL_SCANCODE_BACKSPACE:
+            ch = JL_CHAR_BACKSPACE;
+            break;
+
+        case SDL_SCANCODE_TAB:
+            ch = JL_CHAR_TAB;
+            break;
+
+        case SDL_SCANCODE_RETURN:
+        case SDL_SCANCODE_KP_ENTER:
+            ch = JL_CHAR_RETURN;
+            break;
+
+        case SDL_SCANCODE_ESCAPE:
+            ch = JL_CHAR_ESCAPE;
+            break;
+
+        case SDL_SCANCODE_DELETE:
+            ch = JL_CHAR_DELETE;
+            break;
+
+        default:
+            ch = CHAR_NONE;
+            break;
+    }
+    if (ch != CHAR_NONE) {
+        jlInputCharPush(ch);
+    }
+}
+
+
+// Where the frame goes in the window: as large as fits at the stage's shape,
+// centred (on the PSP's screen, at 1:1).
+static void frameRect(SDL_FRect *dst) {
+    int   outW;
+    int   outH;
+    float w;
+    float h;
+#if !defined(__PSP__)
+    int   shapeW;
+    int   shapeH;
+#endif
+
+    outW = 0;
+    outH = 0;
+    SDL_GetCurrentRenderOutputSize(gRenderer, &outW, &outH);
+#if defined(__PSP__)
+    w = (float)SURFACE_WIDTH;
+    h = (float)SURFACE_HEIGHT;
+#else
+    shapeW = gSquare ? ASPECT_SQUARE_W : ASPECT_CRT_W;
+    shapeH = gSquare ? ASPECT_SQUARE_H : ASPECT_CRT_H;
+    if ((int64_t)outW * shapeH > (int64_t)outH * shapeW) {
+        h = (float)outH;
+        w = h * (float)shapeW / (float)shapeH;
+    } else {
+        w = (float)outW;
+        h = w * (float)shapeH / (float)shapeW;
+    }
+#endif
+    dst->x = ((float)outW - w) / 2.0f;
+    dst->y = ((float)outH - h) / 2.0f;
+    dst->w = w;
+    dst->h = h;
+}
+
+
+static void fullScreenToggle(void) {
+    SDL_SetWindowFullscreen(gWindow, (SDL_GetWindowFlags(gWindow) & SDL_WINDOW_FULLSCREEN) == 0);
+}
+
+
+// A key went down (repeat: it is held and repeating) or up: what it types,
+// and the key itself.
+static void keyEvent(SDL_Scancode code, bool down, bool repeat) {
+    if (down) {
+        charFromKey(code);
+    }
+    if (!repeat) {
+        hostedKeyEvent(keyFromScancode(code), down);
+    }
+}
+
+
+static jlKeyE keyFromScancode(SDL_Scancode code) {
+    if (code >= SDL_SCANCODE_A && code <= SDL_SCANCODE_Z) {
+        return (jlKeyE)(KEY_A + (code - SDL_SCANCODE_A));
+    }
+    // SDL orders the digit row 1..9 then 0.
+    if (code >= SDL_SCANCODE_1 && code <= SDL_SCANCODE_9) {
+        return (jlKeyE)(KEY_1 + (code - SDL_SCANCODE_1));
+    }
+    if (code >= SDL_SCANCODE_F1 && code <= SDL_SCANCODE_F10) {
+        return (jlKeyE)(KEY_F1 + (code - SDL_SCANCODE_F1));
+    }
+    switch (code) {
+        case SDL_SCANCODE_0:
+            return KEY_0;
+
+        case SDL_SCANCODE_SPACE:
+            return KEY_SPACE;
+
+        case SDL_SCANCODE_ESCAPE:
+            return KEY_ESCAPE;
+
+        case SDL_SCANCODE_RETURN:
+        case SDL_SCANCODE_KP_ENTER:
+            return KEY_RETURN;
+
+        case SDL_SCANCODE_TAB:
+            return KEY_TAB;
+
+        case SDL_SCANCODE_BACKSPACE:
+            return KEY_BACKSPACE;
+
+        case SDL_SCANCODE_UP:
+            return KEY_UP;
+
+        case SDL_SCANCODE_DOWN:
+            return KEY_DOWN;
+
+        case SDL_SCANCODE_LEFT:
+            return KEY_LEFT;
+
+        case SDL_SCANCODE_RIGHT:
+            return KEY_RIGHT;
+
+        case SDL_SCANCODE_LSHIFT:
+            return KEY_LSHIFT;
+
+        case SDL_SCANCODE_RSHIFT:
+            return KEY_RSHIFT;
+
+        // JoeyLib has one Ctrl and one Alt: either key is it.
+        case SDL_SCANCODE_LCTRL:
+        case SDL_SCANCODE_RCTRL:
+            return KEY_LCTRL;
+
+        case SDL_SCANCODE_LALT:
+        case SDL_SCANCODE_RALT:
+            return KEY_LALT;
+
+        default:
+            return KEY_NONE;
+    }
+}
+
+
+// An environment variable as a number 1..max, or fallback.
+static int optionInt(const char *name, int fallback, int max) {
+    const char *text;
+    int         value;
+
+    text = SDL_getenv(name);
+    if (text == NULL) {
+        return fallback;
+    }
+    value = SDL_atoi(text);
+    return value >= 1 && value <= max ? value : fallback;
+}
+
+
+// A gamepad was plugged in: it becomes the first free stick.
+static void padAdded(SDL_JoystickID id) {
+    int slot;
+
+    if (padSlot(id) >= 0) {
+        return;
+    }
+    for (slot = 0; slot < JOYSTICK_COUNT; slot++) {
+        if (gPads[slot] == NULL) {
+            gPads[slot] = SDL_OpenGamepad(id);
+            if (gPads[slot] != NULL) {
+                hostedJoystickConnect((jlJoystickE)slot, true);
+            }
+            return;
+        }
+    }
+}
+
+
+static void padRemoved(SDL_JoystickID id) {
+    int slot;
+
+    slot = padSlot(id);
+    if (slot < 0) {
+        return;
+    }
+    SDL_CloseGamepad(gPads[slot]);
+    gPads[slot] = NULL;
+    hostedJoystickConnect((jlJoystickE)slot, false);
+}
+
+
+// The stick a gamepad is, or -1.
+static int padSlot(SDL_JoystickID id) {
+    int slot;
+
+    for (slot = 0; slot < JOYSTICK_COUNT; slot++) {
+        if (gPads[slot] != NULL && SDL_GetGamepadID(gPads[slot]) == id) {
+            return slot;
+        }
+    }
+    return -1;
+}
+
+
+// Read a gamepad whole after any of its events: the stick from the left
+// stick and the d-pad, and the two buttons.
+static void padUpdate(SDL_JoystickID id) {
+    SDL_Gamepad *pad;
+    int          slot;
+    int8_t       x;
+    int8_t       y;
+
+    slot = padSlot(id);
+    if (slot < 0) {
+        return;
+    }
+    pad = gPads[slot];
+    x   = stickAxis(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX), SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_LEFT), SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_RIGHT));
+    y   = stickAxis(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY), SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_UP), SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_DPAD_DOWN));
+    hostedJoystickMove((jlJoystickE)slot, x, y);
+    hostedJoyButtonEvent((jlJoystickE)slot, JOY_BUTTON_0, SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH));
+    hostedJoyButtonEvent((jlJoystickE)slot, JOY_BUTTON_1, SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST));
+}
+
+
+// The pointer at a window position, in stage pixels, clamped to the stage.
+static void pointerMove(float windowX, float windowY) {
+    SDL_FRect dst;
+    float     x;
+    float     y;
+    int32_t   stageX;
+    int32_t   stageY;
+
+    if (!SDL_RenderCoordinatesFromWindow(gRenderer, windowX, windowY, &x, &y)) {
+        return;
+    }
+    frameRect(&dst);
+    if (dst.w <= 0.0f || dst.h <= 0.0f) {
+        return;
+    }
+    stageX = (int32_t)((x - dst.x) * (float)SURFACE_WIDTH / dst.w);
+    stageY = (int32_t)((y - dst.y) * (float)SURFACE_HEIGHT / dst.h);
+    stageX = stageX < 0 ? 0 : (stageX >= SURFACE_WIDTH ? SURFACE_WIDTH - 1 : stageX);
+    stageY = stageY < 0 ? 0 : (stageY >= SURFACE_HEIGHT ? SURFACE_HEIGHT - 1 : stageY);
+    hostedMouseMove((int16_t)stageX, (int16_t)stageY);
+}
+
+
+#if defined(__PSP__)
+// No keyboard: Start is the Return key and Select the Escape key.
+static void pspButtonKey(uint8_t button, bool down) {
+    if (button == SDL_GAMEPAD_BUTTON_START) {
+        keyEvent(SDL_SCANCODE_RETURN, down, false);
+    } else if (button == SDL_GAMEPAD_BUTTON_BACK) {
+        keyEvent(SDL_SCANCODE_ESCAPE, down, false);
+    }
+}
+
+
+// The HOME menu's Exit, on pspExitThread: a quit event, as closing a window
+// gives (SDL_PushEvent may be called from any thread).
+static int pspExitCallback(int arg1, int arg2, void *common) {
+    SDL_Event e;
+
+    (void)arg1;
+    (void)arg2;
+    (void)common;
+    SDL_zero(e);
+    e.type = SDL_EVENT_QUIT;
+    SDL_PushEvent(&e);
+    return 0;
+}
+
+
+// Registers pspExitCallback and sleeps where the kernel can call it.
+static int pspExitThread(SceSize args, void *argp) {
+    (void)args;
+    (void)argp;
+    sceKernelRegisterExitCallback(sceKernelCreateCallback(PSP_MODULE_NAME, pspExitCallback, NULL));
+    sceKernelSleepThreadCB();
+    return 0;
+}
+#endif
+
+
+// Draw the last frame again (the window was resized or uncovered).
+static void reshow(void) {
+    if (gLastFrame != NULL) {
+        hostBackendShow(gLastFrame);
+    }
+}
+
+
+// One stick axis: the d-pad when it is held, else the analog stick (SDL's
+// -32768..32767).
+static int8_t stickAxis(int16_t raw, bool negative, bool positive) {
+    if (negative != positive) {
+        return negative ? (int8_t)JOYSTICK_AXIS_MIN : (int8_t)JOYSTICK_AXIS_MAX;
+    }
+    return hostedAxisScale(raw, INT16_MIN, INT16_MAX);
+}
+
+
+// The frame into the texture, converted on the way where the texture's
+// format is not the frame's (the PSP's).
+static void textureFill(const uint32_t *pixels) {
+#if defined(__PSP__)
+    void *texels;
+    int   pitch;
+
+    if (SDL_LockTexture(gTexture, NULL, &texels, &pitch)) {
+        SDL_ConvertPixels(SURFACE_WIDTH, SURFACE_HEIGHT, FRAME_FORMAT, pixels, FRAME_PITCH, TEXTURE_FORMAT, texels, pitch);
+        SDL_UnlockTexture(gTexture);
+    }
+#else
+    SDL_UpdateTexture(gTexture, NULL, pixels, FRAME_PITCH);
+#endif
+}
+
+
+#if defined(__EMSCRIPTEN__)
+// SAVES/ in the root of the in-memory file system, kept in IndexedDB: made
+// and filled from it once (the program waits here until it has been read).
+EM_ASYNC_JS(void, webSavesMount, (void), {
+    FS.mkdir("/SAVES");
+    FS.mount(IDBFS, {}, "/SAVES");
+    await new Promise(function (resolve) {
+        FS.syncfs(true, function () {
+            resolve();
+        });
+    });
+});
+
+
+// SAVES/ back to IndexedDB, in the background.
+EM_JS(void, webSavesSync, (void), {
+    FS.syncfs(false, function () {
+    });
+});
+
+
+// The browser's turn when it has gone a frame without one, and SAVES/ back
+// to IndexedDB when it is due.
+static void webService(void) {
+    uint64_t now;
+
+    now = hostBackendMicros();
+    if (now - gWebYieldUs >= WEB_YIELD_US) {
+        emscripten_sleep(0);
+        gWebYieldUs = hostBackendMicros();
+    }
+    if (gWebSavesMounted && now - gWebSyncUs >= WEB_SAVES_SYNC_US) {
+        webSavesSync();
+        gWebSyncUs = now;
+    }
+}
+#endif
+
+
+// The program's name: its executable's, where the C library knows it.
+static const char *windowTitle(void) {
+#if defined(__GLIBC__)
+    extern char *program_invocation_short_name;
+
+    return program_invocation_short_name;
+#else
+    return WINDOW_TITLE_DEFAULT;
+#endif
+}
+
+// ----- Backend API (alphabetical) -----
+
+void hostBackendAudioClose(void) {
+    if (gAudio != NULL) {
+        SDL_DestroyAudioStream(gAudio);
+        gAudio = NULL;
+        SDL_QuitSubSystem(SDL_INIT_AUDIO);
+    }
+}
+
+
+bool hostBackendAudioOpen(void) {
+    SDL_AudioSpec spec;
+
+    if (gAudio != NULL) {
+        return true;
+    }
+    if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) {
+        return false;
+    }
+    spec.format   = SDL_AUDIO_S16;
+    spec.channels = (int)HOSTED_AUDIO_CHANNELS;
+    spec.freq     = (int)HOSTED_AUDIO_RATE;
+    gAudio = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, NULL, NULL);
+    if (gAudio == NULL) {
+        SDL_QuitSubSystem(SDL_INIT_AUDIO);
+        return false;
+    }
+    SDL_ResumeAudioStreamDevice(gAudio);
+    return true;
+}
+
+
+void hostBackendAudioQueue(const int16_t *frames, uint32_t count) {
+    SDL_PutAudioStreamData(gAudio, frames, (int)count * AUDIO_FRAME_BYTES);
+}
+
+
+uint32_t hostBackendAudioQueued(void) {
+    int bytes;
+
+    bytes = SDL_GetAudioStreamQueued(gAudio);
+    return bytes > 0 ? (uint32_t)(bytes / AUDIO_FRAME_BYTES) : 0u;
+}
+
+
+void hostBackendClose(void) {
+    int slot;
+
+    hostBackendAudioClose();
+    for (slot = 0; slot < JOYSTICK_COUNT; slot++) {
+        if (gPads[slot] != NULL) {
+            SDL_CloseGamepad(gPads[slot]);
+            gPads[slot] = NULL;
+        }
+    }
+    if (gTexture != NULL) {
+        SDL_DestroyTexture(gTexture);
+        gTexture = NULL;
+    }
+    if (gRenderer != NULL) {
+        SDL_DestroyRenderer(gRenderer);
+        gRenderer = NULL;
+    }
+    if (gWindow != NULL) {
+        SDL_DestroyWindow(gWindow);
+        gWindow = NULL;
+        SDL_Quit();
+    }
+    gLastFrame = NULL;
+}
+
+
+uint64_t hostBackendMicros(void) {
+    return SDL_GetTicksNS() / NS_PER_US;
+}
+
+
+const char *hostBackendOpen(void) {
+    const char *base;
+    const char *aspect;
+    int         scale;
+    int         height;
+
+    if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) {
+        return SDL_GetError();
+    }
+    // The program's own folder is the current directory, as on every
+    // machine port.
+    base = SDL_GetBasePath();
+    if (base == NULL || chdir(base) != 0) {
+        SDL_Quit();
+        return "cannot make the program's folder the current directory";
+    }
+#if defined(__EMSCRIPTEN__)
+    if (!gWebSavesMounted) {
+        webSavesMount();
+        gWebSavesMounted = true;
+    }
+#endif
+    aspect  = SDL_getenv("JOEYLIB_ASPECT");
+    gSquare = aspect != NULL && SDL_strcmp(aspect, "square") == 0;
+    scale   = optionInt("JOEYLIB_SCALE", WINDOW_SCALE_DEFAULT, WINDOW_SCALE_MAX);
+    height  = gSquare ? SURFACE_HEIGHT * scale : SURFACE_WIDTH * scale * ASPECT_CRT_H / ASPECT_CRT_W;
+    gWindow = SDL_CreateWindow(windowTitle(), SURFACE_WIDTH * scale, height, SDL_WINDOW_RESIZABLE | (optionInt("JOEYLIB_FULLSCREEN", 0, 1) != 0 ? SDL_WINDOW_FULLSCREEN : 0));
+    if (gWindow == NULL) {
+        SDL_Quit();
+        return SDL_GetError();
+    }
+    gRenderer = SDL_CreateRenderer(gWindow, NULL);
+    if (gRenderer != NULL) {
+        gTexture = SDL_CreateTexture(gRenderer, TEXTURE_FORMAT, SDL_TEXTUREACCESS_STREAMING, SURFACE_WIDTH, SURFACE_HEIGHT);
+    }
+    if (gTexture == NULL) {
+        // Closing clears SDL's error: keep its text first.
+        SDL_strlcpy(gErrorText, SDL_GetError(), sizeof(gErrorText));
+        hostBackendClose();
+        return gErrorText;
+    }
+    if (!SDL_SetTextureScaleMode(gTexture, SDL_SCALEMODE_PIXELART)) {
+        SDL_SetTextureScaleMode(gTexture, SDL_SCALEMODE_NEAREST);
+    }
+#if !defined(__PSP__)
+    SDL_StartTextInput(gWindow);
+#endif
+    SDL_HideCursor();
+    // exit() from anywhere -- the program's own, or closing the window --
+    // must still give the screen and the audio device back. On the PSP the
+    // HOME menu's Exit closes the window.
+    if (!gAtExit) {
+        atexit(hostBackendClose);
+#if defined(__PSP__)
+        sceKernelStartThread(sceKernelCreateThread(PSP_MODULE_NAME, pspExitThread, PSP_EXIT_PRIORITY, PSP_EXIT_STACK_BYTES, 0, NULL), 0, NULL);
+#endif
+        gAtExit = true;
+    }
+    return NULL;
+}
+
+
+void hostBackendPump(void) {
+    SDL_Event e;
+
+#if defined(__EMSCRIPTEN__)
+    webService();
+#endif
+    while (SDL_PollEvent(&e)) {
+        switch (e.type) {
+            case SDL_EVENT_QUIT:
+                exit(EXIT_SUCCESS);
+                break;
+
+            case SDL_EVENT_KEY_DOWN:
+                if (e.key.scancode == SDL_SCANCODE_RETURN && (e.key.mod & SDL_KMOD_ALT) != 0) {
+                    if (!e.key.repeat) {
+                        fullScreenToggle();
+                    }
+                    break;
+                }
+                keyEvent(e.key.scancode, true, e.key.repeat);
+                break;
+
+            case SDL_EVENT_KEY_UP:
+                keyEvent(e.key.scancode, false, false);
+                break;
+
+            case SDL_EVENT_TEXT_INPUT:
+                {
+                    const char *text;
+
+                    // 7-bit ASCII only: the bytes of anything wider are
+                    // left out (jlInputCharPush filters the rest).
+                    for (text = e.text.text; *text != '\0'; text++) {
+                        if ((uint8_t)*text < ASCII_LIMIT) {
+                            jlInputCharPush((uint8_t)*text);
+                        }
+                    }
+                }
+                break;
+
+            case SDL_EVENT_MOUSE_MOTION:
+                pointerMove(e.motion.x, e.motion.y);
+                break;
+
+            case SDL_EVENT_MOUSE_BUTTON_DOWN:
+            case SDL_EVENT_MOUSE_BUTTON_UP:
+                pointerMove(e.button.x, e.button.y);
+                if (e.button.button == SDL_BUTTON_LEFT) {
+                    hostedMouseButtonEvent(MOUSE_BUTTON_LEFT, e.button.down);
+                } else if (e.button.button == SDL_BUTTON_RIGHT) {
+                    hostedMouseButtonEvent(MOUSE_BUTTON_RIGHT, e.button.down);
+                } else if (e.button.button == SDL_BUTTON_MIDDLE) {
+                    hostedMouseButtonEvent(MOUSE_BUTTON_MIDDLE, e.button.down);
+                }
+                break;
+
+            case SDL_EVENT_GAMEPAD_ADDED:
+                padAdded(e.gdevice.which);
+                break;
+
+            case SDL_EVENT_GAMEPAD_REMOVED:
+                padRemoved(e.gdevice.which);
+                break;
+
+            case SDL_EVENT_GAMEPAD_AXIS_MOTION:
+                padUpdate(e.gaxis.which);
+                break;
+
+            case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
+            case SDL_EVENT_GAMEPAD_BUTTON_UP:
+                padUpdate(e.gbutton.which);
+#if defined(__PSP__)
+                pspButtonKey(e.gbutton.button, e.gbutton.down);
+#endif
+                break;
+
+            case SDL_EVENT_WINDOW_EXPOSED:
+            case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
+                reshow();
+                break;
+
+            default:
+                break;
+        }
+    }
+}
+
+
+void hostBackendShow(const uint32_t *pixels) {
+    SDL_FRect dst;
+
+    gLastFrame = pixels;
+    textureFill(pixels);
+    SDL_SetRenderDrawColor(gRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE);
+    SDL_RenderClear(gRenderer);
+    frameRect(&dst);
+    SDL_RenderTexture(gRenderer, gTexture, NULL, &dst);
+    SDL_RenderPresent(gRenderer);
+}
+
+
+void hostBackendSleepUntil(uint64_t micros) {
+    uint64_t now;
+
+    now = hostBackendMicros();
+    if (micros > now) {
+#if defined(__EMSCRIPTEN__)
+        // The browser's turn, in whole milliseconds rounded up.
+        emscripten_sleep((unsigned int)((micros - now + US_PER_MS - 1u) / US_PER_MS));
+        gWebYieldUs = hostBackendMicros();
+#else
+        SDL_DelayPrecise((micros - now) * NS_PER_US);
+#endif
+    }
+}
+
+// ----- HAL API (alphabetical) -----
+
+// Free bytes where the program runs, saturated at what a uint32_t holds. On
+// the PSP: the device its folder is on ("ms0:"), asked for its free clusters.
+uint32_t jlpDiskFree(void) {
+#if defined(__PSP__)
+    PspDeviceSizeT  size;
+    PspDeviceSizeT *sizeArg;
+    const char     *base;
+    const char     *end;
+    size_t          length;
+    char            device[PSP_DEVICE_BYTES];
+#else
+    struct statvfs  fs;
+#endif
+    uint64_t        bytes;
+
+#if defined(__PSP__)
+    base = SDL_GetBasePath();
+    end  = base == NULL ? NULL : SDL_strchr(base, PSP_DEVICE_END);
+    if (end == NULL) {
+        return 0u;
+    }
+    // The device's name with its colon; strlcpy's size adds the terminator.
+    length = (size_t)(end - base) + 1u;
+    if (length >= sizeof(device)) {
+        return 0u;
+    }
+    SDL_strlcpy(device, base, length + 1u);
+    sizeArg = &size;
+    if (sceIoDevctl(device, PSP_DEVCTL_FREE_SPACE, &sizeArg, (int)sizeof(sizeArg), NULL, 0) < 0) {
+        return 0u;
+    }
+    bytes = (uint64_t)size.freeClusters * size.sectorCount * size.sectorSize;
+#else
+    if (statvfs(".", &fs) != 0) {
+        return 0u;
+    }
+    bytes = (uint64_t)fs.f_bavail * (uint64_t)fs.f_frsize;
+#endif
+    return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes;
+}
+
+
+bool jlpSaveDelete(const char *path) {
+    return SDL_RemovePath(path);
+}
+
+
+bool jlpSaveDirEnsure(const char *dir) {
+    SDL_PathInfo info;
+
+    if (SDL_CreateDirectory(dir)) {
+        return true;
+    }
+    return SDL_GetPathInfo(dir, &info) && info.type == SDL_PATHTYPE_DIRECTORY;
+}
diff --git a/src/hosted/win16/win16Backend.c b/src/hosted/win16/win16Backend.c
new file mode 100644
index 0000000..6acce12
--- /dev/null
+++ b/src/hosted/win16/win16Backend.c
@@ -0,0 +1,832 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Win16 backend for the hosted core (JOEYLIB_HOSTED_WIN16).
+//
+// For Windows 3.1 on a 386 or better: only what Windows 3.1 has -- USER, GDI,
+// KERNEL and MMSYSTEM -- called from 32-bit flat-model code through Watcom's
+// Win386 supervisor (make/win16.mk), which turns each pointer argument into
+// the 16:16 pointer Windows takes. Two kinds of pointer it cannot turn are
+// made here: the frame and its colour table, given to GDI as 16:16 aliases
+// made once (the supervisor's own conversion of a DIB's bits on every show
+// left Wine faulting at exit), and the waveOut buffers, which the sound
+// driver keeps using after the call returns -- each is a GlobalAlloc block,
+// header and samples together, as Windows 3.1 asks, written through a far
+// pointer.
+//
+// Everything runs on the program's thread, as on Windows 3.x it must: the
+// window's messages are dispatched by hostBackendPump (and while a frame
+// wait sleeps), and the sound goes out through waveOut buffers handed over
+// there, with no callback. A frame wait yields to the other programs through
+// the message loop.
+//
+// The frame is 8-bit (HOSTED_FRAME_INDEXED): each pixel (palette << 4) |
+// colour, a bottom-up DIB as Windows 3.1 takes it (HOSTED_FRAME_BOTTOM_UP),
+// whose 256-entry colour table holds all 16 palettes, so SCB palettes are
+// exact and a palette change only touches the table. On a 256-colour
+// display the table is a logical palette too, realized whenever a frame is
+// shown, so the colours are exact as far as the display has room. The
+// window shows the frame scaled to fill it at the stage's shape, 4:3 by
+// default or square pixels with JOEYLIB_ASPECT=square, black around it,
+// through StretchDIBits. JOEYLIB_SCALE=N opens it N times 320 wide (default
+// 2), JOEYLIB_FULLSCREEN=1 on the whole screen (a borderless window over it;
+// the display mode is left alone), all in the DOS environment Windows
+// started from; Alt+Enter switches. The pointer is hidden over the window:
+// JoeyLib programs draw their own.
+//
+// Input: keys by the set-1 scan code every key message carries (src/x86's
+// table, as on DOS), typed characters from WM_CHAR (the user's layout and
+// shift applied; Delete, which sends none, as JL_CHAR_DELETE), the mouse in
+// stage pixels, and the first two joysticks through MMSYSTEM's joyGetPos,
+// read once a virtual frame. Keys still held when the window loses the
+// keyboard are let go. Closing the window ends the program.
+//
+// Time: MMSYSTEM's timeGetTime at its finest resolution (1 ms). Sound:
+// 8-bit unsigned mono at the core's 22.05 kHz, in AUDIO_BUFFER_FRAMES
+// buffers written as each fills, so a Sound Blaster driver sees a few dozen
+// writes a second however the core tops its queue up.
+//
+// Files: the program's folder is the current directory (drive and folder)
+// from the moment the window opens; SAVES/ is made and files are deleted
+// with the C library's DOS calls, and free space is DOS's (_dos_getdiskfree;
+// Windows' GetFreeSpace is memory).
+
+#define INCLUDE_MMSYSTEM_H
+#include 
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "port.h"
+#include "inputInternal.h"
+#include "hosted.h"
+#include "scanSet1.h"
+
+// ----- Constants -----
+
+#define WINDOW_CLASS_PREFIX    "JoeyLib"
+#define WINDOW_CLASS_BYTES     16
+#define WINDOW_TITLE_DEFAULT   "JoeyLib"
+#define WINDOW_SCALE_DEFAULT   2
+#define WINDOW_SCALE_MAX       8
+#define PATH_BYTES             _MAX_PATH
+// A drive's root folder, "C:\", is the one whose name keeps its separator.
+#define DRIVE_ROOT_CHARS       3
+
+// The displayed shape of the 320x200 stage: 4:3 like a CRT, or 16:10 when
+// its pixels are kept square.
+#define ASPECT_CRT_W           4
+#define ASPECT_CRT_H           3
+#define ASPECT_SQUARE_W        SURFACE_WIDTH
+#define ASPECT_SQUARE_H        SURFACE_HEIGHT
+
+#define US_PER_SECOND          1000000u
+#define US_PER_MS              1000u
+#define TIMER_RESOLUTION_MS    1u
+#define TIME_WRAP_SHIFT        32
+
+// The frame's colour table: every palette's sixteen, (palette << 4) | colour.
+#define FRAME_COLORS           (SURFACE_PALETTE_COUNT * SURFACE_COLORS_PER_PALETTE)
+#define FRAME_BITS_PER_PIXEL   8
+#define LOG_PALETTE_VERSION    0x300
+#define RGB24_RED_SHIFT        16
+#define RGB24_GREEN_SHIFT      8
+
+// Key messages: bits 16..23 of lParam are the scan code, bit 30 says the key
+// was already down (a repeat).
+#define KEY_SCAN_SHIFT         16
+#define KEY_SCAN_MASK          0x7Fu
+#define KEY_REPEAT_BIT         (1ul << 30)
+#define KEY_STATE_DOWN         0x8000
+
+#define ASCII_LIMIT            0x80u
+
+// Sound: AUDIO_BUFFERS buffers of AUDIO_BUFFER_FRAMES 8-bit mono frames,
+// filled in turn and written when full; together more than the core ever
+// keeps queued.
+#define AUDIO_BUFFERS          16
+#define AUDIO_BUFFER_FRAMES    256u
+#define AUDIO_BITS             8
+#define AUDIO_CHANNELS         1
+#define AUDIO_FRAME_BYTES      1u
+#define AUDIO_SAMPLE_SHIFT     8
+#define AUDIO_SAMPLE_BIAS      0x80
+
+// joyGetPos: the two sticks, and their buttons 1 and 2.
+#define JOY_BUTTON_0_BIT       JOY_BUTTON1
+#define JOY_BUTTON_1_BIT       JOY_BUTTON2
+#define JOY_POLL_US            (US_PER_SECOND / HOSTED_FRAME_HZ)
+
+// ----- Types -----
+
+typedef struct {
+    BITMAPINFOHEADER header;
+    RGBQUAD          colors[FRAME_COLORS];
+} FrameInfoT;
+
+typedef struct {
+    WORD         version;
+    WORD         count;
+    PALETTEENTRY entries[FRAME_COLORS];
+} LogPaletteT;
+
+// One waveOut buffer, in a block of its own from GlobalAlloc.
+typedef struct {
+    WAVEHDR header;
+    uint8_t samples[AUDIO_BUFFER_FRAMES * AUDIO_FRAME_BYTES];
+} AudioBlockT;
+
+typedef struct {
+    HGLOBAL             memory;
+    AudioBlockT __far  *block;     // what this code writes through
+    DWORD               block16;   // the same block as the 16:16 pointer Windows takes
+    bool                queued;
+} AudioBufferT;
+
+// ----- Prototypes -----
+
+static bool             audioBufferFree(AudioBufferT *buffer);
+static void             frameRect(RECT *dst);
+static void             fullScreenSet(bool on);
+static void             joysticksPoll(void);
+static void             keysRelease(void);
+static void             messagesDispatch(void);
+static int              optionInt(const char *name, int fallback, int max);
+static void             pointerMove(int32_t windowX, int32_t windowY);
+static void             programFolder(char *path, char **name);
+static void             show(HDC dc);
+static LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam);
+
+// The program's own main, which WinMain calls.
+int main(int argc, char **argv);
+
+// ----- Module state -----
+
+static HINSTANCE           gInstance    = NULL;
+static int                 gShowCommand = SW_SHOWNORMAL;
+static HWND                gWindow      = NULL;
+static char                gClassName[WINDOW_CLASS_BYTES];
+static bool                gClassMade   = false;
+static char                gTitle[PATH_BYTES];
+static bool                gSquare      = false;
+static bool                gFullScreen  = false;
+static RECT                gWindowedRect;
+static bool                gAtExit      = false;
+static const HostedPixelT *gFrame       = NULL;
+static DWORD               gFrame16     = 0;
+static FrameInfoT          gFrameInfo;
+static DWORD               gFrameInfo16 = 0;
+static LogPaletteT         gLogPalette;
+static HPALETTE            gPalette     = NULL;
+static DWORD               gTimeLast    = 0;
+static uint32_t            gTimeWraps   = 0;
+static uint64_t            gJoyPolledUs = 0;
+static HWAVEOUT            gWaveOut     = NULL;
+static AudioBufferT        gAudio[AUDIO_BUFFERS];
+static uint16_t            gAudioNext   = 0;
+static uint16_t            gAudioFill   = 0;
+
+// Open Watcom's license (the Sybase Open Watcom Public License, section
+// 2.2(d)) asks a program that carries its code in object form -- every Win16
+// program carries its C library and the Win386 supervisor -- for a notice,
+// in the program and in its documentation, that the source is available and
+// where. This is the program's copy, never shown; it is not static, so the
+// compiler keeps it although nothing reads it. docs/Portability.adoc gives
+// the same text for a program's documentation.
+const char hostWin16OwplNotice[] = "This software contains code from Open Watcom. Its source code is available under the terms of the Sybase Open Watcom Public License version 1.0 from https://github.com/open-watcom/open-watcom-v2";
+
+// ----- Internal helpers (alphabetical) -----
+
+// A buffer that has finished playing is given back for filling; false while
+// the driver still has it.
+static bool audioBufferFree(AudioBufferT *buffer) {
+    if (!buffer->queued) {
+        return true;
+    }
+    if ((buffer->block->header.dwFlags & WHDR_DONE) == 0) {
+        return false;
+    }
+    _16waveOutUnprepareHeader(gWaveOut, buffer->block16, sizeof(WAVEHDR));
+    buffer->queued = false;
+    return true;
+}
+
+
+// Where the frame goes in the window: as large as fits at the stage's shape,
+// centred.
+static void frameRect(RECT *dst) {
+    RECT    client;
+    int32_t outW;
+    int32_t outH;
+    int32_t w;
+    int32_t h;
+    int32_t shapeW;
+    int32_t shapeH;
+
+    GetClientRect(gWindow, &client);
+    outW   = client.right - client.left;
+    outH   = client.bottom - client.top;
+    shapeW = gSquare ? ASPECT_SQUARE_W : ASPECT_CRT_W;
+    shapeH = gSquare ? ASPECT_SQUARE_H : ASPECT_CRT_H;
+    if (outW * shapeH > outH * shapeW) {
+        h = outH;
+        w = h * shapeW / shapeH;
+    } else {
+        w = outW;
+        h = w * shapeH / shapeW;
+    }
+    SetRect(dst, (int)((outW - w) / 2), (int)((outH - h) / 2), (int)((outW - w) / 2 + w), (int)((outH - h) / 2 + h));
+}
+
+
+// The whole screen: a borderless window over it; back: the window as it was.
+static void fullScreenSet(bool on) {
+    if (on == gFullScreen) {
+        return;
+    }
+    if (on) {
+        GetWindowRect(gWindow, &gWindowedRect);
+        SetWindowLong(gWindow, GWL_STYLE, WS_POPUP | WS_VISIBLE);
+        SetWindowPos(gWindow, HWND_TOP, 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), SWP_FRAMECHANGED);
+    } else {
+        SetWindowLong(gWindow, GWL_STYLE, WS_OVERLAPPEDWINDOW | WS_VISIBLE);
+        SetWindowPos(gWindow, HWND_TOP, gWindowedRect.left, gWindowedRect.top, gWindowedRect.right - gWindowedRect.left, gWindowedRect.bottom - gWindowedRect.top, SWP_FRAMECHANGED);
+    }
+    gFullScreen = on;
+}
+
+
+// The first two joysticks, once a virtual frame: their position scaled from
+// the driver's range to JoeyLib's, and their first two buttons.
+static void joysticksPoll(void) {
+    JOYINFO  info;
+    JOYCAPS  caps;
+    uint64_t now;
+    UINT     js;
+
+    now = hostBackendMicros();
+    if (now - gJoyPolledUs < JOY_POLL_US) {
+        return;
+    }
+    gJoyPolledUs = now;
+    for (js = 0; js < JOYSTICK_COUNT; js++) {
+        if (joyGetPos(JOYSTICKID1 + js, &info) != JOYERR_NOERROR || joyGetDevCaps(JOYSTICKID1 + js, &caps, sizeof(caps)) != JOYERR_NOERROR) {
+            if (gJoyConnected[js]) {
+                hostedJoystickConnect((jlJoystickE)js, false);
+            }
+            continue;
+        }
+        if (!gJoyConnected[js]) {
+            hostedJoystickConnect((jlJoystickE)js, true);
+        }
+        hostedJoystickMove((jlJoystickE)js, hostedAxisScale((int32_t)info.wXpos, (int32_t)caps.wXmin, (int32_t)caps.wXmax), hostedAxisScale((int32_t)info.wYpos, (int32_t)caps.wYmin, (int32_t)caps.wYmax));
+        hostedJoyButtonEvent((jlJoystickE)js, JOY_BUTTON_0, (info.wButtons & JOY_BUTTON_0_BIT) != 0);
+        hostedJoyButtonEvent((jlJoystickE)js, JOY_BUTTON_1, (info.wButtons & JOY_BUTTON_1_BIT) != 0);
+    }
+}
+
+
+// The window lost the keyboard: what was held will never be seen let go.
+static void keysRelease(void) {
+    uint16_t key;
+
+    for (key = KEY_NONE + 1u; key < KEY_COUNT; key++) {
+        hostedKeyEvent((jlKeyE)key, false);
+    }
+}
+
+
+// Every message waiting; when there are none, Windows runs the other
+// programs before this returns.
+static void messagesDispatch(void) {
+    MSG message;
+
+    while (PeekMessage(&message, NULL, 0, 0, PM_REMOVE)) {
+        TranslateMessage(&message);
+        DispatchMessage(&message);
+    }
+}
+
+
+// An environment variable as a number 1..max, or fallback.
+static int optionInt(const char *name, int fallback, int max) {
+    const char *text;
+    int         value;
+
+    text = getenv(name);
+    if (text == NULL) {
+        return fallback;
+    }
+    value = atoi(text);
+    return value >= 1 && value <= max ? value : fallback;
+}
+
+
+// The pointer at a client-area position, in stage pixels, clamped to the
+// stage.
+static void pointerMove(int32_t windowX, int32_t windowY) {
+    RECT    dst;
+    int32_t stageX;
+    int32_t stageY;
+
+    frameRect(&dst);
+    if (dst.right <= dst.left || dst.bottom <= dst.top) {
+        return;
+    }
+    stageX = (windowX - dst.left) * SURFACE_WIDTH / (dst.right - dst.left);
+    stageY = (windowY - dst.top) * SURFACE_HEIGHT / (dst.bottom - dst.top);
+    stageX = stageX < 0 ? 0 : (stageX >= SURFACE_WIDTH ? SURFACE_WIDTH - 1 : stageX);
+    stageY = stageY < 0 ? 0 : (stageY >= SURFACE_HEIGHT ? SURFACE_HEIGHT - 1 : stageY);
+    hostedMouseMove((int16_t)stageX, (int16_t)stageY);
+}
+
+
+// The executable's path cut at its folder (path keeps the trailing
+// separator), and *name pointed at its file name.
+static void programFolder(char *path, char **name) {
+    char *slash;
+
+    GetModuleFileName(gInstance, path, PATH_BYTES);
+    path[PATH_BYTES - 1] = '\0';
+    slash = strrchr(path, '\\');
+    *name = slash != NULL ? slash + 1 : path;
+}
+
+
+// The last frame into the window, black around it, through the logical
+// palette on a palette display.
+static void show(HDC dc) {
+    RECT     client;
+    RECT     dst;
+    RECT     band;
+    HBRUSH   black;
+    HPALETTE previous;
+
+    if (gFrame == NULL) {
+        return;
+    }
+    GetClientRect(gWindow, &client);
+    frameRect(&dst);
+    black = (HBRUSH)GetStockObject(BLACK_BRUSH);
+    SetRect(&band, client.left, client.top, client.right, dst.top);
+    FillRect(dc, &band, black);
+    SetRect(&band, client.left, dst.bottom, client.right, client.bottom);
+    FillRect(dc, &band, black);
+    SetRect(&band, client.left, dst.top, dst.left, dst.bottom);
+    FillRect(dc, &band, black);
+    SetRect(&band, dst.right, dst.top, client.right, dst.bottom);
+    FillRect(dc, &band, black);
+    previous = NULL;
+    if (gPalette != NULL) {
+        previous = SelectPalette(dc, gPalette, FALSE);
+        RealizePalette(dc);
+    }
+    SetStretchBltMode(dc, COLORONCOLOR);
+    _16StretchDIBits(dc, dst.left, dst.top, dst.right - dst.left, dst.bottom - dst.top, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT, (LPSTR)gFrame16, (LPBITMAPINFO)gFrameInfo16, DIB_RGB_COLORS, SRCCOPY);
+    if (previous != NULL) {
+        SelectPalette(dc, previous, FALSE);
+    }
+}
+
+
+static LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) {
+    PAINTSTRUCT paint;
+    HDC         dc;
+    uint8_t     scan;
+
+    switch (message) {
+        case WM_KEYDOWN:
+        case WM_SYSKEYDOWN:
+            scan = (uint8_t)((lParam >> KEY_SCAN_SHIFT) & KEY_SCAN_MASK);
+            if (wParam == VK_RETURN && (GetKeyState(VK_MENU) & KEY_STATE_DOWN) != 0) {
+                if ((lParam & KEY_REPEAT_BIT) == 0) {
+                    fullScreenSet(!gFullScreen);
+                }
+                return 0;
+            }
+            if (wParam == VK_DELETE) {
+                jlInputCharPush(JL_CHAR_DELETE);
+            }
+            if ((lParam & KEY_REPEAT_BIT) == 0) {
+                hostedKeyEvent((jlKeyE)gScanSet1ToKey[scan], true);
+            }
+            // Alt and F10 would open the (absent) window menu.
+            return 0;
+
+        case WM_KEYUP:
+        case WM_SYSKEYUP:
+            scan = (uint8_t)((lParam >> KEY_SCAN_SHIFT) & KEY_SCAN_MASK);
+            hostedKeyEvent((jlKeyE)gScanSet1ToKey[scan], false);
+            return 0;
+
+        case WM_CHAR:
+            if (wParam < ASCII_LIMIT) {
+                jlInputCharPush((uint8_t)wParam);
+            }
+            return 0;
+
+        case WM_SYSCHAR:
+            // Alt+key: no beep, no menu.
+            return 0;
+
+        case WM_MOUSEMOVE:
+            pointerMove((int16_t)LOWORD(lParam), (int16_t)HIWORD(lParam));
+            return 0;
+
+        case WM_LBUTTONDOWN:
+        case WM_LBUTTONUP:
+            hostedMouseButtonEvent(MOUSE_BUTTON_LEFT, message == WM_LBUTTONDOWN);
+            return 0;
+
+        case WM_RBUTTONDOWN:
+        case WM_RBUTTONUP:
+            hostedMouseButtonEvent(MOUSE_BUTTON_RIGHT, message == WM_RBUTTONDOWN);
+            return 0;
+
+        case WM_MBUTTONDOWN:
+        case WM_MBUTTONUP:
+            hostedMouseButtonEvent(MOUSE_BUTTON_MIDDLE, message == WM_MBUTTONDOWN);
+            return 0;
+
+        case WM_SETCURSOR:
+            if (LOWORD(lParam) == HTCLIENT) {
+                SetCursor(NULL);
+                return TRUE;
+            }
+            break;
+
+        case WM_KILLFOCUS:
+            keysRelease();
+            return 0;
+
+        case WM_QUERYNEWPALETTE:
+            // The window takes the system palette back: show() realizes it.
+            if (gPalette == NULL) {
+                break;
+            }
+            InvalidateRect(window, NULL, FALSE);
+            return TRUE;
+
+        case WM_PALETTECHANGED:
+            if (gPalette != NULL && (HWND)wParam != window) {
+                InvalidateRect(window, NULL, FALSE);
+            }
+            return 0;
+
+        case WM_ERASEBKGND:
+            // show() paints every pixel.
+            return 1;
+
+        case WM_PAINT:
+            dc = BeginPaint(window, &paint);
+            show(dc);
+            EndPaint(window, &paint);
+            return 0;
+
+        case WM_CLOSE:
+            exit(EXIT_SUCCESS);
+            break;
+
+        default:
+            break;
+    }
+    return DefWindowProc(window, message, wParam, lParam);
+}
+
+// ----- Backend API (alphabetical) -----
+
+void hostBackendAudioClose(void) {
+    uint16_t i;
+
+    if (gWaveOut == NULL) {
+        return;
+    }
+    waveOutReset(gWaveOut);
+    for (i = 0; i < AUDIO_BUFFERS; i++) {
+        if (gAudio[i].queued) {
+            _16waveOutUnprepareHeader(gWaveOut, gAudio[i].block16, sizeof(WAVEHDR));
+        }
+        if (gAudio[i].memory != NULL) {
+            GlobalUnlock(gAudio[i].memory);
+            GlobalFree(gAudio[i].memory);
+        }
+    }
+    memset(gAudio, 0, sizeof(gAudio));
+    waveOutClose(gWaveOut);
+    gWaveOut = NULL;
+}
+
+
+// The device, and its buffers: each locked in place for good, as the driver
+// reads them while the program runs on.
+bool hostBackendAudioOpen(void) {
+    PCMWAVEFORMAT format;
+    uint16_t      i;
+
+    if (gWaveOut != NULL) {
+        return true;
+    }
+    memset(&format, 0, sizeof(format));
+    format.wf.wFormatTag      = WAVE_FORMAT_PCM;
+    format.wf.nChannels       = AUDIO_CHANNELS;
+    format.wf.nSamplesPerSec  = HOSTED_AUDIO_RATE;
+    format.wf.nAvgBytesPerSec = HOSTED_AUDIO_RATE * AUDIO_FRAME_BYTES;
+    format.wf.nBlockAlign     = AUDIO_FRAME_BYTES;
+    format.wBitsPerSample     = AUDIO_BITS;
+    if (waveOutOpen(&gWaveOut, WAVE_MAPPER, (LPWAVEFORMAT)&format, 0, 0, 0) != 0) {
+        gWaveOut = NULL;
+        return false;
+    }
+    memset(gAudio, 0, sizeof(gAudio));
+    for (i = 0; i < AUDIO_BUFFERS; i++) {
+        gAudio[i].memory = GlobalAlloc(GMEM_MOVEABLE | GMEM_SHARE | GMEM_ZEROINIT, sizeof(AudioBlockT));
+        if (gAudio[i].memory == NULL) {
+            hostBackendAudioClose();
+            return false;
+        }
+        gAudio[i].block16                   = (DWORD)GlobalLock(gAudio[i].memory);
+        gAudio[i].block                     = (AudioBlockT __far *)MK_FP32((void *)gAudio[i].block16);
+        gAudio[i].block->header.lpData         = (LPSTR)(gAudio[i].block16 + offsetof(AudioBlockT, samples));
+        gAudio[i].block->header.dwBufferLength = sizeof(gAudio[i].block->samples);
+    }
+    gAudioNext = 0;
+    gAudioFill = 0;
+    return true;
+}
+
+
+// Into the buffer being filled, as unsigned 8-bit samples; each full one is
+// written. The core never queues more than the buffers hold, so the next
+// one has always played.
+void hostBackendAudioQueue(const int16_t *frames, uint32_t count) {
+    AudioBufferT *buffer;
+    uint32_t      i;
+
+    for (i = 0; i < count; i++) {
+        buffer = &gAudio[gAudioNext];
+        if (gAudioFill == 0 && !audioBufferFree(buffer)) {
+            return;
+        }
+        buffer->block->samples[gAudioFill] = (uint8_t)((frames[i] >> AUDIO_SAMPLE_SHIFT) + AUDIO_SAMPLE_BIAS);
+        gAudioFill++;
+        if (gAudioFill == AUDIO_BUFFER_FRAMES) {
+            buffer->block->header.dwFlags = 0;
+            _16waveOutPrepareHeader(gWaveOut, buffer->block16, sizeof(WAVEHDR));
+            _16waveOutWrite(gWaveOut, buffer->block16, sizeof(WAVEHDR));
+            buffer->queued = true;
+            gAudioNext     = (uint16_t)((gAudioNext + 1u) % AUDIO_BUFFERS);
+            gAudioFill     = 0;
+        }
+    }
+}
+
+
+// Frames written and not yet played, and those waiting in the buffer being
+// filled.
+uint32_t hostBackendAudioQueued(void) {
+    uint32_t frames;
+    uint16_t i;
+
+    frames = gAudioFill;
+    for (i = 0; i < AUDIO_BUFFERS; i++) {
+        if (gAudio[i].queued && (gAudio[i].block->header.dwFlags & WHDR_DONE) == 0) {
+            frames += AUDIO_BUFFER_FRAMES;
+        }
+    }
+    return frames;
+}
+
+
+void hostBackendClose(void) {
+    hostBackendAudioClose();
+    if (gWindow != NULL) {
+        DestroyWindow(gWindow);
+        gWindow = NULL;
+        timeEndPeriod(TIMER_RESOLUTION_MS);
+    }
+    if (gPalette != NULL) {
+        DeleteObject(gPalette);
+        gPalette = NULL;
+    }
+    if (gFrame16 != 0) {
+        FreeAlias16(gFrame16);
+        gFrame16 = 0;
+    }
+    if (gFrameInfo16 != 0) {
+        FreeAlias16(gFrameInfo16);
+        gFrameInfo16 = 0;
+    }
+    gFrame      = NULL;
+    gFullScreen = false;
+}
+
+
+void hostBackendColors(const uint32_t *colors, uint16_t palettes) {
+    uint16_t p;
+    uint16_t first;
+    uint16_t i;
+
+    for (p = 0; p < SURFACE_PALETTE_COUNT; p++) {
+        if ((palettes & (1u << p)) == 0) {
+            continue;
+        }
+        first = (uint16_t)(p * SURFACE_COLORS_PER_PALETTE);
+        for (i = first; i < first + SURFACE_COLORS_PER_PALETTE; i++) {
+            gFrameInfo.colors[i].rgbRed     = (BYTE)(colors[i] >> RGB24_RED_SHIFT);
+            gFrameInfo.colors[i].rgbGreen   = (BYTE)(colors[i] >> RGB24_GREEN_SHIFT);
+            gFrameInfo.colors[i].rgbBlue    = (BYTE)colors[i];
+            gLogPalette.entries[i].peRed    = gFrameInfo.colors[i].rgbRed;
+            gLogPalette.entries[i].peGreen  = gFrameInfo.colors[i].rgbGreen;
+            gLogPalette.entries[i].peBlue   = gFrameInfo.colors[i].rgbBlue;
+        }
+        if (gPalette != NULL) {
+            SetPaletteEntries(gPalette, first, SURFACE_COLORS_PER_PALETTE, &gLogPalette.entries[first]);
+        }
+    }
+}
+
+
+// timeGetTime's milliseconds, carried past its 49.7-day wrap.
+uint64_t hostBackendMicros(void) {
+    DWORD now;
+
+    now = timeGetTime();
+    if (now < gTimeLast) {
+        gTimeWraps++;
+    }
+    gTimeLast = now;
+    return (((uint64_t)gTimeWraps << TIME_WRAP_SHIFT) | now) * US_PER_MS;
+}
+
+
+const char *hostBackendOpen(void) {
+    WNDCLASS windowClass;
+    RECT     frame;
+    HDC      dc;
+    char     folder[PATH_BYTES];
+    char    *name;
+    char    *dot;
+    char    *aspect;
+    int      scale;
+    int      height;
+
+    // The program's own folder is the current directory, as on every
+    // machine port: its drive, then the folder on it. Its title is the
+    // executable's name.
+    programFolder(folder, &name);
+    strcpy(gTitle, name);
+    dot = strrchr(gTitle, '.');
+    if (dot != NULL) {
+        *dot = '\0';
+    }
+    if (gTitle[0] == '\0') {
+        strcpy(gTitle, WINDOW_TITLE_DEFAULT);
+    }
+    if (name - folder > DRIVE_ROOT_CHARS) {
+        name[-1] = '\0';
+    } else {
+        *name = '\0';
+    }
+    if (_chdrive(toupper((unsigned char)folder[0]) - 'A' + 1) != 0 || chdir(folder) != 0) {
+        return "cannot make the program's folder the current directory";
+    }
+    // Every running copy has a class of its own: the window procedure is in
+    // its own 32-bit image.
+    if (!gClassMade) {
+        sprintf(gClassName, "%s%04X", WINDOW_CLASS_PREFIX, (unsigned)gInstance);
+        memset(&windowClass, 0, sizeof(windowClass));
+        windowClass.style         = CS_HREDRAW | CS_VREDRAW;
+        windowClass.lpfnWndProc   = (LPVOID)windowProc;
+        windowClass.hInstance     = gInstance;
+        windowClass.hCursor       = LoadCursor(NULL, IDC_ARROW);
+        windowClass.lpszClassName = gClassName;
+        if (RegisterClass(&windowClass) == 0) {
+            return "cannot register the window class";
+        }
+        gClassMade = true;
+    }
+    aspect  = getenv("JOEYLIB_ASPECT");
+    gSquare = aspect != NULL && strcmp(aspect, "square") == 0;
+    scale   = optionInt("JOEYLIB_SCALE", WINDOW_SCALE_DEFAULT, WINDOW_SCALE_MAX);
+    height  = gSquare ? SURFACE_HEIGHT * scale : SURFACE_WIDTH * scale * ASPECT_CRT_H / ASPECT_CRT_W;
+    SetRect(&frame, 0, 0, SURFACE_WIDTH * scale, height);
+    AdjustWindowRect(&frame, WS_OVERLAPPEDWINDOW, FALSE);
+    gWindow = CreateWindow(gClassName, gTitle, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, frame.right - frame.left, frame.bottom - frame.top, NULL, NULL, gInstance, NULL);
+    if (gWindow == NULL) {
+        return "cannot open the window";
+    }
+    memset(&gFrameInfo, 0, sizeof(gFrameInfo));
+    gFrameInfo.header.biSize     = sizeof(BITMAPINFOHEADER);
+    gFrameInfo.header.biWidth    = SURFACE_WIDTH;
+    gFrameInfo.header.biHeight   = SURFACE_HEIGHT;
+    gFrameInfo.header.biPlanes   = 1;
+    gFrameInfo.header.biBitCount = FRAME_BITS_PER_PIXEL;
+    gFrameInfo.header.biClrUsed  = FRAME_COLORS;
+    gFrameInfo16                 = AllocAlias16(&gFrameInfo);
+    // A 256-colour display shows the frame's colours through a logical
+    // palette of them.
+    memset(&gLogPalette, 0, sizeof(gLogPalette));
+    gLogPalette.version = LOG_PALETTE_VERSION;
+    gLogPalette.count   = FRAME_COLORS;
+    dc                  = GetDC(gWindow);
+    if ((GetDeviceCaps(dc, RASTERCAPS) & RC_PALETTE) != 0) {
+        gPalette = CreatePalette((LOGPALETTE *)&gLogPalette);
+    }
+    ReleaseDC(gWindow, dc);
+    ShowWindow(gWindow, gShowCommand);
+    UpdateWindow(gWindow);
+    if (optionInt("JOEYLIB_FULLSCREEN", 0, 1) != 0) {
+        fullScreenSet(true);
+    }
+    timeBeginPeriod(TIMER_RESOLUTION_MS);
+    // exit() from anywhere -- the program's own, or closing the window --
+    // must still give the sound device back.
+    if (!gAtExit) {
+        atexit(hostBackendClose);
+        gAtExit = true;
+    }
+    return NULL;
+}
+
+
+void hostBackendPump(void) {
+    messagesDispatch();
+    joysticksPoll();
+}
+
+
+// The frame stays where the core keeps it: its alias is made the first
+// time it is shown.
+void hostBackendShow(const HostedPixelT *pixels) {
+    HDC dc;
+
+    if (pixels != gFrame) {
+        if (gFrame16 != 0) {
+            FreeAlias16(gFrame16);
+        }
+        gFrame   = pixels;
+        gFrame16 = AllocAlias16((void *)pixels);
+    }
+    dc = GetDC(gWindow);
+    show(dc);
+    ReleaseDC(gWindow, dc);
+}
+
+
+// Windows 3.x has no sleep: the message loop gives the time to the other
+// programs until the moment comes.
+void hostBackendSleepUntil(uint64_t micros) {
+    while (hostBackendMicros() < micros) {
+        messagesDispatch();
+    }
+}
+
+// ----- HAL API (alphabetical) -----
+
+// Free bytes on the current (the program's) drive, saturated at what a
+// uint32_t holds.
+uint32_t jlpDiskFree(void) {
+    struct diskfree_t space;
+    uint64_t          bytes;
+
+    if (_dos_getdiskfree(0, &space) != 0) {
+        return 0u;
+    }
+    bytes = (uint64_t)space.avail_clusters * space.sectors_per_cluster * space.bytes_per_sector;
+    return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes;
+}
+
+
+bool jlpSaveDelete(const char *path) {
+    return remove(path) == 0;
+}
+
+
+bool jlpSaveDirEnsure(const char *dir) {
+    struct stat st;
+
+    if (mkdir(dir) == 0) {
+        return true;
+    }
+    // mkdir failed -- success only if it failed because a directory is there.
+    return stat(dir, &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+// ----- Program entry -----
+
+// A Windows program starts here: the window is made by jlInit, so this only
+// keeps what it needs and runs the program.
+int PASCAL WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR commandLine, int showCommand) {
+    (void)previous;
+    (void)commandLine;
+    gInstance    = instance;
+    gShowCommand = showCommand;
+    return main(__argc, __argv);
+}
diff --git a/src/hosted/win16/win16Serial.c b/src/hosted/win16/win16Serial.c
new file mode 100644
index 0000000..f8018da
--- /dev/null
+++ b/src/hosted/win16/win16Serial.c
@@ -0,0 +1,220 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Win16 serial HAL: a COM port through Windows 3.1's communications calls
+// (OpenComm, ReadComm, WriteComm and the rest of USER's), reached through the
+// Win386 supervisor like every other Windows call this port makes. The
+// communications driver fills its receive queue from the UART's interrupt by
+// itself, so the poll stays the generic no-op; ReadComm takes what is queued
+// and WriteComm queues what fits in the driver's transmit queue, neither
+// waiting: the non-blocking model joey/serial.h asks for, on the program's
+// thread.
+//
+// DEFAULT, MODEM and PRINTER are COM1; SLOT is COM, COM1..COM9 as
+// Windows 3.1 numbers them.
+//
+// The DCB's rate is 16 bits: the driver takes a value below its CBR_ indices
+// as the rate itself, up to 57600. A faster rate is 115200, asked for with
+// KPCOMM's index for it (KPCOMM.DRV, the communications driver JoeyLib's
+// Windows runs with); a driver that refuses it -- Windows 3.1's own COMM.DRV
+// has no 115200 -- gets 57600 instead.
+//
+// The open raises DTR (and RTS, unless RTS/CTS handshakes). One promise of
+// joey/serial.h cannot be kept here: CloseComm gives the port back to the
+// driver, and Windows has no call that keeps DTR up after it, so a program
+// that must not hang up the line keeps the port open.
+
+#include 
+
+#include 
+#include 
+
+#include "joey/serial.h"
+#include "port.h"
+
+// ----- Constants -----
+
+#define COM_FIRST           1u     // jlSerialConfigT.unit numbers COM ports from 1
+#define COM_LAST            9u
+#define COM_NAME_BYTES      8
+#define COMM_CLOSED         (-1)
+#define DRIVER_QUEUE_BYTES  4096u
+#define BAUD_RAW_MAX        57600u
+#define KPCBR_115200        0xFF24u    // KPCOMM.DRV's CBR_-style index for 115200
+#define XON_CHAR            0x11
+#define XOFF_CHAR           0x13
+#define XON_LIMIT           (DRIVER_QUEUE_BYTES / 4u)
+#define XOFF_LIMIT          (DRIVER_QUEUE_BYTES / 4u)
+// FlushComm's queue numbers.
+#define QUEUE_RECEIVE       1
+// ReadComm and WriteComm take a 16-bit signed count.
+#define COMM_COUNT_MAX      0x7FFFu
+
+// ----- Prototypes -----
+
+static uint8_t  comForDevice(jlSerialDeviceE device, const jlSerialConfigT *config);
+static uint16_t commCount(int16_t result);
+static bool     commStateSet(DCB *dcb);
+
+// ----- Module state -----
+
+static int16_t gPort = COMM_CLOSED;
+
+static const BYTE gParity[] = {
+    [JL_SERIAL_PARITY_NONE] = NOPARITY,
+    [JL_SERIAL_PARITY_ODD]  = ODDPARITY,
+    [JL_SERIAL_PARITY_EVEN] = EVENPARITY
+};
+
+// ----- Internal helpers (alphabetical) -----
+
+// The COM number for a device, or 0 for none.
+static uint8_t comForDevice(jlSerialDeviceE device, const jlSerialConfigT *config) {
+    if (device != JL_SERIAL_SLOT) {
+        return COM_FIRST;
+    }
+    if (config->unit < COM_FIRST || config->unit > COM_LAST) {
+        return 0u;
+    }
+    return config->unit;
+}
+
+
+// The bytes a ReadComm or WriteComm moved. A negative result is an error
+// with the count negated: the count is kept and the error cleared, as the
+// driver takes nothing more until GetCommError has seen it.
+static uint16_t commCount(int16_t result) {
+    if (result >= 0) {
+        return (uint16_t)result;
+    }
+    GetCommError(gPort, NULL);
+    return (uint16_t)-result;
+}
+
+
+// SetCommState; a driver that refuses KPCOMM's 115200 gets 57600.
+static bool commStateSet(DCB *dcb) {
+    if (SetCommState(dcb) == 0) {
+        return true;
+    }
+    if (dcb->BaudRate != KPCBR_115200) {
+        return false;
+    }
+    dcb->BaudRate = BAUD_RAW_MAX;
+    return SetCommState(dcb) == 0;
+}
+
+// ----- HAL API (alphabetical) -----
+
+uint16_t jlpSerialAvailable(void) {
+    COMSTAT status;
+
+    if (gPort == COMM_CLOSED) {
+        return 0u;
+    }
+    GetCommError(gPort, &status);
+    return status.cbInQue;
+}
+
+
+void jlpSerialClose(void) {
+    if (gPort == COMM_CLOSED) {
+        return;
+    }
+    CloseComm(gPort);
+    gPort = COMM_CLOSED;
+}
+
+
+void jlpSerialFlush(void) {
+    if (gPort != COMM_CLOSED) {
+        FlushComm(gPort, QUEUE_RECEIVE);
+    }
+}
+
+
+bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) {
+    DCB     dcb;
+    char    name[COM_NAME_BYTES];
+    uint8_t com;
+    bool    rtsCts;
+    bool    xonXoff;
+
+    if (gPort != COMM_CLOSED || config->parity > JL_SERIAL_PARITY_EVEN || config->flow > JL_SERIAL_FLOW_XONXOFF) {
+        return false;
+    }
+    com = comForDevice(device, config);
+    if (com == 0u) {
+        return false;
+    }
+    sprintf(name, "COM%u", (unsigned)com);
+    gPort = OpenComm(name, DRIVER_QUEUE_BYTES, DRIVER_QUEUE_BYTES);
+    if (gPort < 0) {
+        gPort = COMM_CLOSED;
+        return false;
+    }
+    memset(&dcb, 0, sizeof(dcb));
+    if (GetCommState(gPort, &dcb) != 0) {
+        jlpSerialClose();
+        return false;
+    }
+    rtsCts           = config->flow == JL_SERIAL_FLOW_RTSCTS;
+    xonXoff          = config->flow == JL_SERIAL_FLOW_XONXOFF;
+    dcb.BaudRate     = (UINT)(config->baud > BAUD_RAW_MAX ? KPCBR_115200 : config->baud);
+    dcb.ByteSize     = config->dataBits;
+    dcb.Parity       = gParity[config->parity];
+    dcb.StopBits     = config->stopBits == 2u ? TWOSTOPBITS : ONESTOPBIT;
+    dcb.fBinary      = 1;
+    dcb.fParity      = config->parity != JL_SERIAL_PARITY_NONE;
+    dcb.fDtrDisable  = 0;
+    dcb.fDtrflow     = 0;
+    dcb.fOutxDsrFlow = 0;
+    dcb.fRtsDisable  = 0;
+    dcb.fRtsflow     = rtsCts;
+    dcb.fOutxCtsFlow = rtsCts;
+    dcb.fOutX        = xonXoff;
+    dcb.fInX         = xonXoff;
+    dcb.fNull        = 0;
+    dcb.fPeChar      = 0;
+    dcb.XonChar      = XON_CHAR;
+    dcb.XoffChar     = XOFF_CHAR;
+    dcb.XonLim       = XON_LIMIT;
+    dcb.XoffLim      = XOFF_LIMIT;
+    if (!commStateSet(&dcb)) {
+        jlpSerialClose();
+        return false;
+    }
+    return true;
+}
+
+
+uint16_t jlpSerialRead(uint8_t *buf, uint16_t max) {
+    if (gPort == COMM_CLOSED) {
+        return 0u;
+    }
+    return commCount(ReadComm(gPort, buf, max > COMM_COUNT_MAX ? COMM_COUNT_MAX : max));
+}
+
+
+void jlpSerialSetDtr(bool on) {
+    if (gPort != COMM_CLOSED) {
+        EscapeCommFunction(gPort, on ? SETDTR : CLRDTR);
+    }
+}
+
+
+// With RTS/CTS the driver owns RTS: the line handshakes.
+void jlpSerialSetRts(bool on) {
+    if (gPort != COMM_CLOSED) {
+        EscapeCommFunction(gPort, on ? SETRTS : CLRRTS);
+    }
+}
+
+
+uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) {
+    if (gPort == COMM_CLOSED) {
+        return 0u;
+    }
+    return commCount(WriteComm(gPort, buf, len > COMM_COUNT_MAX ? COMM_COUNT_MAX : len));
+}
diff --git a/src/hosted/win32/win32Backend.c b/src/hosted/win32/win32Backend.c
new file mode 100644
index 0000000..b45ead3
--- /dev/null
+++ b/src/hosted/win32/win32Backend.c
@@ -0,0 +1,620 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Win32 backend for the hosted core (JOEYLIB_HOSTED_WIN32).
+//
+// For Windows 95, 98, ME, NT 4 and 2000 -- the Windows SDL3 does not reach
+// -- and everything since: only calls those systems have (USER, GDI, WINMM
+// and KERNEL32, the C library being MSVCRT.DLL). Everything runs on the
+// program's thread: the window is made there, so its messages queue there
+// and hostBackendPump dispatches them, and the sound goes out through waveOut
+// buffers the pump hands over, with no callback.
+//
+// The window shows the 320x200 frame scaled to fill it at the stage's shape,
+// 4:3 by default or square pixels with JOEYLIB_ASPECT=square, black around
+// it, through StretchDIBits from a top-down 32-bit DIB. JOEYLIB_SCALE=N
+// opens it N times 320 wide (default 3), JOEYLIB_FULLSCREEN=1 on the whole
+// screen (a borderless window over the monitor; the display mode is left
+// alone); Alt+Enter switches. The pointer is hidden over the window:
+// JoeyLib programs draw their own.
+//
+// Input: keys by the set-1 scan code every key message carries (src/x86's
+// table, as on DOS), typed characters from WM_CHAR (the user's layout and
+// shift applied; Delete, which sends none, as JL_CHAR_DELETE), the mouse in
+// stage pixels, and the first two joysticks through joyGetPosEx, read once a
+// virtual frame. Keys still held when the window loses the keyboard are let
+// go. Closing the window ends the program.
+//
+// Files: the program's folder is the current directory from the moment the
+// window opens; SAVES/ is made with CreateDirectory and free space comes from
+// GetDiskFreeSpace (the 95 call; it tops out at 2 GB there, enough for a
+// save).
+
+#include 
+#include 
+
+#include 
+#include 
+
+#include "port.h"
+#include "inputInternal.h"
+#include "hosted.h"
+#include "scanSet1.h"
+
+// ----- Constants -----
+
+#define WINDOW_CLASS_NAME      "JoeyLibWindow"
+#define WINDOW_TITLE_DEFAULT   "JoeyLib"
+#define WINDOW_SCALE_DEFAULT   3
+#define WINDOW_SCALE_MAX       8
+#define OPTION_TEXT_BYTES      16
+
+// The displayed shape of the 320x200 stage: 4:3 like a CRT, or 16:10 when
+// its pixels are kept square.
+#define ASPECT_CRT_W           4
+#define ASPECT_CRT_H           3
+#define ASPECT_SQUARE_W        SURFACE_WIDTH
+#define ASPECT_SQUARE_H        SURFACE_HEIGHT
+
+#define US_PER_SECOND          1000000u
+#define US_PER_MS              1000u
+#define TIMER_RESOLUTION_MS    1u
+
+// Key messages: bits 16..23 of lParam are the scan code, bit 30 says the key
+// was already down (a repeat).
+#define KEY_SCAN_SHIFT         16
+#define KEY_SCAN_MASK          0x7Fu
+#define KEY_REPEAT_BIT         (1ul << 30)
+
+#define ASCII_LIMIT            0x80u
+
+// Sound: AUDIO_BUFFERS waveOut buffers of up to AUDIO_BUFFER_FRAMES frames
+// each, in turn; together more than the core ever keeps queued.
+#define AUDIO_BUFFERS          8
+#define AUDIO_BUFFER_FRAMES    1024u
+#define AUDIO_BITS             16
+#define AUDIO_FRAME_BYTES      (HOSTED_AUDIO_CHANNELS * sizeof(int16_t))
+
+// joyGetPosEx: the two sticks, and their buttons 1 and 2.
+#define JOY_BUTTON_0_BIT       JOY_BUTTON1
+#define JOY_BUTTON_1_BIT       JOY_BUTTON2
+#define JOY_POLL_US            (US_PER_SECOND / HOSTED_FRAME_HZ)
+
+// ----- Types -----
+
+typedef struct {
+    WAVEHDR  header;
+    bool     queued;
+    int16_t  samples[AUDIO_BUFFER_FRAMES * HOSTED_AUDIO_CHANNELS];
+} AudioBufferT;
+
+// ----- Prototypes -----
+
+static void            fullScreenSet(bool on);
+static void            frameRect(RECT *dst);
+static void            joysticksPoll(void);
+static void            keysRelease(void);
+static int             optionInt(const char *name, int fallback, int max);
+static void            pointerMove(int32_t windowX, int32_t windowY);
+static void            programFolder(char *path, char **name);
+static void            show(HDC dc);
+static LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam);
+
+// ----- Module state -----
+
+static HWND             gWindow      = NULL;
+static const uint32_t  *gLastFrame   = NULL;
+static BITMAPINFO       gFrameInfo;
+static bool             gSquare      = false;
+static bool             gFullScreen  = false;
+static RECT             gWindowedRect;
+static bool             gAtExit      = false;
+static bool             gClassMade   = false;
+static LARGE_INTEGER    gCounterHz;
+static char             gTitle[MAX_PATH];
+static uint64_t         gJoyPolledUs = 0;
+static HWAVEOUT         gWaveOut     = NULL;
+static AudioBufferT     gAudio[AUDIO_BUFFERS];
+static uint16_t         gAudioNext   = 0;
+
+// ----- Internal helpers (alphabetical) -----
+
+// Where the frame goes in the window: as large as fits at the stage's shape,
+// centred.
+static void frameRect(RECT *dst) {
+    RECT client;
+    LONG outW;
+    LONG outH;
+    LONG w;
+    LONG h;
+    LONG shapeW;
+    LONG shapeH;
+
+    GetClientRect(gWindow, &client);
+    outW   = client.right - client.left;
+    outH   = client.bottom - client.top;
+    shapeW = gSquare ? ASPECT_SQUARE_W : ASPECT_CRT_W;
+    shapeH = gSquare ? ASPECT_SQUARE_H : ASPECT_CRT_H;
+    if (outW * shapeH > outH * shapeW) {
+        h = outH;
+        w = h * shapeW / shapeH;
+    } else {
+        w = outW;
+        h = w * shapeH / shapeW;
+    }
+    dst->left   = (outW - w) / 2;
+    dst->top    = (outH - h) / 2;
+    dst->right  = dst->left + w;
+    dst->bottom = dst->top + h;
+}
+
+
+// The whole screen: a borderless window over the monitor; back: the window
+// as it was.
+static void fullScreenSet(bool on) {
+    if (on == gFullScreen) {
+        return;
+    }
+    if (on) {
+        GetWindowRect(gWindow, &gWindowedRect);
+        SetWindowLongA(gWindow, GWL_STYLE, WS_POPUP | WS_VISIBLE);
+        SetWindowPos(gWindow, HWND_TOP, 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), SWP_FRAMECHANGED);
+    } else {
+        SetWindowLongA(gWindow, GWL_STYLE, WS_OVERLAPPEDWINDOW | WS_VISIBLE);
+        SetWindowPos(gWindow, HWND_NOTOPMOST, gWindowedRect.left, gWindowedRect.top, gWindowedRect.right - gWindowedRect.left, gWindowedRect.bottom - gWindowedRect.top, SWP_FRAMECHANGED);
+    }
+    gFullScreen = on;
+}
+
+
+// The first two joysticks, once a virtual frame: their position scaled from
+// the driver's range to JoeyLib's, and their first two buttons.
+static void joysticksPoll(void) {
+    JOYINFOEX info;
+    JOYCAPSA  caps;
+    uint64_t  now;
+    UINT      js;
+
+    now = hostBackendMicros();
+    if (now - gJoyPolledUs < JOY_POLL_US) {
+        return;
+    }
+    gJoyPolledUs = now;
+    for (js = 0; js < JOYSTICK_COUNT; js++) {
+        memset(&info, 0, sizeof(info));
+        info.dwSize  = sizeof(info);
+        info.dwFlags = JOY_RETURNX | JOY_RETURNY | JOY_RETURNBUTTONS;
+        if (joyGetPosEx(JOYSTICKID1 + js, &info) != JOYERR_NOERROR || joyGetDevCapsA(JOYSTICKID1 + js, &caps, sizeof(caps)) != JOYERR_NOERROR) {
+            if (gJoyConnected[js]) {
+                hostedJoystickConnect((jlJoystickE)js, false);
+            }
+            continue;
+        }
+        if (!gJoyConnected[js]) {
+            hostedJoystickConnect((jlJoystickE)js, true);
+        }
+        hostedJoystickMove((jlJoystickE)js, hostedAxisScale((int32_t)info.dwXpos, (int32_t)caps.wXmin, (int32_t)caps.wXmax), hostedAxisScale((int32_t)info.dwYpos, (int32_t)caps.wYmin, (int32_t)caps.wYmax));
+        hostedJoyButtonEvent((jlJoystickE)js, JOY_BUTTON_0, (info.dwButtons & JOY_BUTTON_0_BIT) != 0);
+        hostedJoyButtonEvent((jlJoystickE)js, JOY_BUTTON_1, (info.dwButtons & JOY_BUTTON_1_BIT) != 0);
+    }
+}
+
+
+// The window lost the keyboard: what was held will never be seen let go.
+static void keysRelease(void) {
+    uint16_t key;
+
+    for (key = KEY_NONE + 1u; key < KEY_COUNT; key++) {
+        hostedKeyEvent((jlKeyE)key, false);
+    }
+}
+
+
+// An environment variable as a number 1..max, or fallback.
+static int optionInt(const char *name, int fallback, int max) {
+    char  text[OPTION_TEXT_BYTES];
+    DWORD length;
+    int   value;
+
+    length = GetEnvironmentVariableA(name, text, sizeof(text));
+    if (length == 0 || length >= sizeof(text)) {
+        return fallback;
+    }
+    value = atoi(text);
+    return value >= 1 && value <= max ? value : fallback;
+}
+
+
+// The pointer at a client-area position, in stage pixels, clamped to the
+// stage.
+static void pointerMove(int32_t windowX, int32_t windowY) {
+    RECT    dst;
+    int32_t stageX;
+    int32_t stageY;
+
+    frameRect(&dst);
+    if (dst.right <= dst.left || dst.bottom <= dst.top) {
+        return;
+    }
+    stageX = (windowX - dst.left) * SURFACE_WIDTH / (dst.right - dst.left);
+    stageY = (windowY - dst.top) * SURFACE_HEIGHT / (dst.bottom - dst.top);
+    stageX = stageX < 0 ? 0 : (stageX >= SURFACE_WIDTH ? SURFACE_WIDTH - 1 : stageX);
+    stageY = stageY < 0 ? 0 : (stageY >= SURFACE_HEIGHT ? SURFACE_HEIGHT - 1 : stageY);
+    hostedMouseMove((int16_t)stageX, (int16_t)stageY);
+}
+
+
+// The executable's path cut at its folder (path keeps the trailing
+// separator), and *name pointed at its file name.
+static void programFolder(char *path, char **name) {
+    char *slash;
+
+    GetModuleFileNameA(NULL, path, MAX_PATH);
+    path[MAX_PATH - 1] = '\0';
+    slash = strrchr(path, '\\');
+    *name = slash != NULL ? slash + 1 : path;
+}
+
+
+// The last frame into the window, black around it.
+static void show(HDC dc) {
+    RECT   client;
+    RECT   dst;
+    RECT   band;
+    HBRUSH black;
+
+    if (gLastFrame == NULL) {
+        return;
+    }
+    GetClientRect(gWindow, &client);
+    frameRect(&dst);
+    black = (HBRUSH)GetStockObject(BLACK_BRUSH);
+    SetRect(&band, client.left, client.top, client.right, dst.top);
+    FillRect(dc, &band, black);
+    SetRect(&band, client.left, dst.bottom, client.right, client.bottom);
+    FillRect(dc, &band, black);
+    SetRect(&band, client.left, dst.top, dst.left, dst.bottom);
+    FillRect(dc, &band, black);
+    SetRect(&band, dst.right, dst.top, client.right, dst.bottom);
+    FillRect(dc, &band, black);
+    SetStretchBltMode(dc, COLORONCOLOR);
+    StretchDIBits(dc, dst.left, dst.top, dst.right - dst.left, dst.bottom - dst.top, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT, gLastFrame, &gFrameInfo, DIB_RGB_COLORS, SRCCOPY);
+}
+
+
+static LRESULT CALLBACK windowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) {
+    PAINTSTRUCT paint;
+    HDC         dc;
+    uint8_t     scan;
+
+    switch (message) {
+        case WM_KEYDOWN:
+        case WM_SYSKEYDOWN:
+            scan = (uint8_t)((lParam >> KEY_SCAN_SHIFT) & KEY_SCAN_MASK);
+            if (wParam == VK_RETURN && (GetKeyState(VK_MENU) & 0x8000) != 0) {
+                if ((lParam & KEY_REPEAT_BIT) == 0) {
+                    fullScreenSet(!gFullScreen);
+                }
+                return 0;
+            }
+            if (wParam == VK_DELETE) {
+                jlInputCharPush(JL_CHAR_DELETE);
+            }
+            if ((lParam & KEY_REPEAT_BIT) == 0) {
+                hostedKeyEvent((jlKeyE)gScanSet1ToKey[scan], true);
+            }
+            // Alt and F10 would open the (absent) window menu.
+            return 0;
+
+        case WM_KEYUP:
+        case WM_SYSKEYUP:
+            scan = (uint8_t)((lParam >> KEY_SCAN_SHIFT) & KEY_SCAN_MASK);
+            hostedKeyEvent((jlKeyE)gScanSet1ToKey[scan], false);
+            return 0;
+
+        case WM_CHAR:
+            if (wParam < ASCII_LIMIT) {
+                jlInputCharPush((uint8_t)wParam);
+            }
+            return 0;
+
+        case WM_SYSCHAR:
+            // Alt+key: no beep, no menu.
+            return 0;
+
+        case WM_MOUSEMOVE:
+            pointerMove((int16_t)LOWORD(lParam), (int16_t)HIWORD(lParam));
+            return 0;
+
+        case WM_LBUTTONDOWN:
+        case WM_LBUTTONUP:
+            hostedMouseButtonEvent(MOUSE_BUTTON_LEFT, message == WM_LBUTTONDOWN);
+            return 0;
+
+        case WM_RBUTTONDOWN:
+        case WM_RBUTTONUP:
+            hostedMouseButtonEvent(MOUSE_BUTTON_RIGHT, message == WM_RBUTTONDOWN);
+            return 0;
+
+        case WM_MBUTTONDOWN:
+        case WM_MBUTTONUP:
+            hostedMouseButtonEvent(MOUSE_BUTTON_MIDDLE, message == WM_MBUTTONDOWN);
+            return 0;
+
+        case WM_SETCURSOR:
+            if (LOWORD(lParam) == HTCLIENT) {
+                SetCursor(NULL);
+                return TRUE;
+            }
+            break;
+
+        case WM_KILLFOCUS:
+            keysRelease();
+            return 0;
+
+        case WM_ERASEBKGND:
+            // show() paints every pixel.
+            return 1;
+
+        case WM_PAINT:
+            dc = BeginPaint(window, &paint);
+            show(dc);
+            EndPaint(window, &paint);
+            return 0;
+
+        case WM_CLOSE:
+            exit(EXIT_SUCCESS);
+            break;
+
+        default:
+            break;
+    }
+    return DefWindowProcA(window, message, wParam, lParam);
+}
+
+// ----- Backend API (alphabetical) -----
+
+void hostBackendAudioClose(void) {
+    uint16_t i;
+
+    if (gWaveOut == NULL) {
+        return;
+    }
+    waveOutReset(gWaveOut);
+    for (i = 0; i < AUDIO_BUFFERS; i++) {
+        if (gAudio[i].queued) {
+            waveOutUnprepareHeader(gWaveOut, &gAudio[i].header, sizeof(WAVEHDR));
+            gAudio[i].queued = false;
+        }
+    }
+    waveOutClose(gWaveOut);
+    gWaveOut = NULL;
+}
+
+
+bool hostBackendAudioOpen(void) {
+    WAVEFORMATEX format;
+
+    if (gWaveOut != NULL) {
+        return true;
+    }
+    memset(&format, 0, sizeof(format));
+    format.wFormatTag      = WAVE_FORMAT_PCM;
+    format.nChannels       = HOSTED_AUDIO_CHANNELS;
+    format.nSamplesPerSec  = HOSTED_AUDIO_RATE;
+    format.wBitsPerSample  = AUDIO_BITS;
+    format.nBlockAlign     = (WORD)AUDIO_FRAME_BYTES;
+    format.nAvgBytesPerSec = HOSTED_AUDIO_RATE * AUDIO_FRAME_BYTES;
+    if (waveOutOpen(&gWaveOut, WAVE_MAPPER, &format, 0, 0, CALLBACK_NULL) != MMSYSERR_NOERROR) {
+        gWaveOut = NULL;
+        return false;
+    }
+    memset(gAudio, 0, sizeof(gAudio));
+    gAudioNext = 0;
+    return true;
+}
+
+
+// Into the next buffer, if it has played; the core never queues more than
+// the buffers hold, so one is always free.
+void hostBackendAudioQueue(const int16_t *frames, uint32_t count) {
+    AudioBufferT *buffer;
+
+    buffer = &gAudio[gAudioNext];
+    if (buffer->queued) {
+        if ((buffer->header.dwFlags & WHDR_DONE) == 0) {
+            return;
+        }
+        waveOutUnprepareHeader(gWaveOut, &buffer->header, sizeof(WAVEHDR));
+        buffer->queued = false;
+    }
+    if (count > AUDIO_BUFFER_FRAMES) {
+        count = AUDIO_BUFFER_FRAMES;
+    }
+    memcpy(buffer->samples, frames, count * AUDIO_FRAME_BYTES);
+    memset(&buffer->header, 0, sizeof(WAVEHDR));
+    buffer->header.lpData         = (LPSTR)buffer->samples;
+    buffer->header.dwBufferLength = (DWORD)(count * AUDIO_FRAME_BYTES);
+    waveOutPrepareHeader(gWaveOut, &buffer->header, sizeof(WAVEHDR));
+    waveOutWrite(gWaveOut, &buffer->header, sizeof(WAVEHDR));
+    buffer->queued = true;
+    gAudioNext     = (uint16_t)((gAudioNext + 1u) % AUDIO_BUFFERS);
+}
+
+
+// Frames in buffers that have not finished playing.
+uint32_t hostBackendAudioQueued(void) {
+    uint32_t frames;
+    uint16_t i;
+
+    frames = 0;
+    for (i = 0; i < AUDIO_BUFFERS; i++) {
+        if (gAudio[i].queued && (gAudio[i].header.dwFlags & WHDR_DONE) == 0) {
+            frames += gAudio[i].header.dwBufferLength / AUDIO_FRAME_BYTES;
+        }
+    }
+    return frames;
+}
+
+
+void hostBackendClose(void) {
+    hostBackendAudioClose();
+    if (gWindow != NULL) {
+        DestroyWindow(gWindow);
+        gWindow = NULL;
+        timeEndPeriod(TIMER_RESOLUTION_MS);
+    }
+    gLastFrame  = NULL;
+    gFullScreen = false;
+}
+
+
+uint64_t hostBackendMicros(void) {
+    LARGE_INTEGER count;
+
+    QueryPerformanceCounter(&count);
+    return (uint64_t)count.QuadPart / (uint64_t)gCounterHz.QuadPart * US_PER_SECOND + (uint64_t)count.QuadPart % (uint64_t)gCounterHz.QuadPart * US_PER_SECOND / (uint64_t)gCounterHz.QuadPart;
+}
+
+
+const char *hostBackendOpen(void) {
+    WNDCLASSA   windowClass;
+    RECT        frame;
+    char        folder[MAX_PATH];
+    char       *name;
+    char       *dot;
+    char        aspect[OPTION_TEXT_BYTES];
+    HINSTANCE   instance;
+    int         scale;
+    int         height;
+
+    if (!QueryPerformanceFrequency(&gCounterHz) || gCounterHz.QuadPart == 0) {
+        return "this machine has no performance counter";
+    }
+    // The program's own folder is the current directory, as on every
+    // machine port; its title is the executable's name.
+    programFolder(folder, &name);
+    strcpy(gTitle, name);
+    dot = strrchr(gTitle, '.');
+    if (dot != NULL) {
+        *dot = '\0';
+    }
+    if (gTitle[0] == '\0') {
+        strcpy(gTitle, WINDOW_TITLE_DEFAULT);
+    }
+    *name = '\0';
+    if (!SetCurrentDirectoryA(folder)) {
+        return "cannot make the program's folder the current directory";
+    }
+    instance = GetModuleHandleA(NULL);
+    if (!gClassMade) {
+        memset(&windowClass, 0, sizeof(windowClass));
+        windowClass.lpfnWndProc   = windowProc;
+        windowClass.hInstance     = instance;
+        windowClass.hCursor       = LoadCursorA(NULL, IDC_ARROW);
+        windowClass.lpszClassName = WINDOW_CLASS_NAME;
+        if (RegisterClassA(&windowClass) == 0) {
+            return "cannot register the window class";
+        }
+        gClassMade = true;
+    }
+    gSquare = GetEnvironmentVariableA("JOEYLIB_ASPECT", aspect, sizeof(aspect)) != 0 && strcmp(aspect, "square") == 0;
+    scale   = optionInt("JOEYLIB_SCALE", WINDOW_SCALE_DEFAULT, WINDOW_SCALE_MAX);
+    height  = gSquare ? SURFACE_HEIGHT * scale : SURFACE_WIDTH * scale * ASPECT_CRT_H / ASPECT_CRT_W;
+    SetRect(&frame, 0, 0, SURFACE_WIDTH * scale, height);
+    AdjustWindowRect(&frame, WS_OVERLAPPEDWINDOW, FALSE);
+    gWindow = CreateWindowA(WINDOW_CLASS_NAME, gTitle, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, frame.right - frame.left, frame.bottom - frame.top, NULL, NULL, instance, NULL);
+    if (gWindow == NULL) {
+        return "cannot open the window";
+    }
+    memset(&gFrameInfo, 0, sizeof(gFrameInfo));
+    gFrameInfo.bmiHeader.biSize        = sizeof(BITMAPINFOHEADER);
+    gFrameInfo.bmiHeader.biWidth       = SURFACE_WIDTH;
+    gFrameInfo.bmiHeader.biHeight      = -SURFACE_HEIGHT;
+    gFrameInfo.bmiHeader.biPlanes      = 1;
+    gFrameInfo.bmiHeader.biBitCount    = (WORD)(sizeof(uint32_t) * 8u);
+    gFrameInfo.bmiHeader.biCompression = BI_RGB;
+    ShowWindow(gWindow, SW_SHOWNORMAL);
+    UpdateWindow(gWindow);
+    if (optionInt("JOEYLIB_FULLSCREEN", 0, 1) != 0) {
+        fullScreenSet(true);
+    }
+    timeBeginPeriod(TIMER_RESOLUTION_MS);
+    // exit() from anywhere -- the program's own, or closing the window --
+    // must still give the sound device back.
+    if (!gAtExit) {
+        atexit(hostBackendClose);
+        gAtExit = true;
+    }
+    return NULL;
+}
+
+
+void hostBackendPump(void) {
+    MSG message;
+
+    while (PeekMessageA(&message, NULL, 0, 0, PM_REMOVE)) {
+        TranslateMessage(&message);
+        DispatchMessageA(&message);
+    }
+    joysticksPoll();
+}
+
+
+void hostBackendShow(const uint32_t *pixels) {
+    HDC dc;
+
+    gLastFrame = pixels;
+    dc         = GetDC(gWindow);
+    show(dc);
+    ReleaseDC(gWindow, dc);
+}
+
+
+// Sleep the whole milliseconds, then give the time slice away until the
+// moment comes.
+void hostBackendSleepUntil(uint64_t micros) {
+    uint64_t now;
+
+    now = hostBackendMicros();
+    if (micros > now + US_PER_MS) {
+        Sleep((DWORD)((micros - now) / US_PER_MS));
+    }
+    while (hostBackendMicros() < micros) {
+        Sleep(0);
+    }
+}
+
+// ----- HAL API (alphabetical) -----
+
+// Free bytes on the program's drive, saturated at what a uint32_t holds.
+uint32_t jlpDiskFree(void) {
+    DWORD    sectorsPerCluster;
+    DWORD    bytesPerSector;
+    DWORD    freeClusters;
+    DWORD    totalClusters;
+    uint64_t bytes;
+
+    if (!GetDiskFreeSpaceA(NULL, §orsPerCluster, &bytesPerSector, &freeClusters, &totalClusters)) {
+        return 0u;
+    }
+    bytes = (uint64_t)sectorsPerCluster * bytesPerSector * freeClusters;
+    return bytes > UINT32_MAX ? UINT32_MAX : (uint32_t)bytes;
+}
+
+
+bool jlpSaveDelete(const char *path) {
+    return DeleteFileA(path) != 0;
+}
+
+
+bool jlpSaveDirEnsure(const char *dir) {
+    DWORD attributes;
+
+    if (CreateDirectoryA(dir, NULL)) {
+        return true;
+    }
+    attributes = GetFileAttributesA(dir);
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
+}
diff --git a/src/hosted/win32/win32Serial.c b/src/hosted/win32/win32Serial.c
new file mode 100644
index 0000000..e1e215f
--- /dev/null
+++ b/src/hosted/win32/win32Serial.c
@@ -0,0 +1,185 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// Win32 serial HAL: a COM port through the Win32 communications calls,
+// which every Windows since 95 has. The port is opened without overlapped
+// I/O and with timeouts that make a read return at once with what is
+// buffered (the driver's own receive buffer is the ring) and keep a write to
+// a bounded wait: the non-blocking model joey/serial.h asks for, on the
+// program's thread. The driver fills its receive buffer by itself, so the
+// poll is the generic no-op.
+//
+// DEFAULT, MODEM and PRINTER are COM1; SLOT is COM, as on DOS.
+//
+// The open raises DTR (and RTS, unless RTS/CTS handshakes). One promise of
+// joey/serial.h cannot be kept here: closing the handle lets Windows' serial
+// driver drop DTR, and Win32 has no call to stop it, so a program that must
+// not hang up the line keeps the port open.
+
+#include 
+
+#include 
+#include 
+
+#include "joey/serial.h"
+#include "port.h"
+
+// ----- Constants -----
+
+#define COM_FIRST           1u     // jlSerialConfigT.unit numbers COM ports from 1
+#define COM_LAST            255u
+#define COM_NAME_BYTES      16
+#define DRIVER_BUFFER_BYTES 4096u
+// A write gives up after this long on top of what its bytes take at the
+// line rate, so a flow-controlled peer cannot stall the program.
+#define WRITE_TIMEOUT_MS    100u
+#define WRITE_MS_PER_BYTE   1u
+#define XON_LIMIT           (DRIVER_BUFFER_BYTES / 4u)
+#define XOFF_LIMIT          (DRIVER_BUFFER_BYTES / 4u)
+
+// ----- Prototypes -----
+
+static uint8_t comForDevice(jlSerialDeviceE device, const jlSerialConfigT *config);
+
+// ----- Module state -----
+
+static HANDLE gPort = INVALID_HANDLE_VALUE;
+
+static const BYTE gParity[] = {
+    [JL_SERIAL_PARITY_NONE] = NOPARITY,
+    [JL_SERIAL_PARITY_ODD]  = ODDPARITY,
+    [JL_SERIAL_PARITY_EVEN] = EVENPARITY
+};
+
+// ----- Internal helpers -----
+
+// The COM number for a device, or 0 for none.
+static uint8_t comForDevice(jlSerialDeviceE device, const jlSerialConfigT *config) {
+    if (device != JL_SERIAL_SLOT) {
+        return COM_FIRST;
+    }
+    if (config->unit < COM_FIRST || config->unit > COM_LAST) {
+        return 0u;
+    }
+    return config->unit;
+}
+
+// ----- HAL API (alphabetical) -----
+
+uint16_t jlpSerialAvailable(void) {
+    COMSTAT status;
+    DWORD   errors;
+
+    if (gPort == INVALID_HANDLE_VALUE || !ClearCommError(gPort, &errors, &status)) {
+        return 0u;
+    }
+    return status.cbInQue > UINT16_MAX ? UINT16_MAX : (uint16_t)status.cbInQue;
+}
+
+
+void jlpSerialClose(void) {
+    if (gPort == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    CloseHandle(gPort);
+    gPort = INVALID_HANDLE_VALUE;
+}
+
+
+void jlpSerialFlush(void) {
+    if (gPort != INVALID_HANDLE_VALUE) {
+        PurgeComm(gPort, PURGE_RXCLEAR);
+    }
+}
+
+
+bool jlpSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config) {
+    COMMTIMEOUTS timeouts;
+    DCB          dcb;
+    char         name[COM_NAME_BYTES];
+    uint8_t      com;
+    bool         rtsCts;
+
+    if (gPort != INVALID_HANDLE_VALUE || config->parity > JL_SERIAL_PARITY_EVEN || config->flow > JL_SERIAL_FLOW_XONXOFF) {
+        return false;
+    }
+    com = comForDevice(device, config);
+    if (com == 0u) {
+        return false;
+    }
+    // "\\.\COMn" reaches every COM port on NT; 95 and 98 know only COM1..9
+    // by their plain names.
+    snprintf(name, sizeof(name), com <= 9u ? "COM%u" : "\\\\.\\COM%u", (unsigned)com);
+    gPort = CreateFileA(name, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
+    if (gPort == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    rtsCts = config->flow == JL_SERIAL_FLOW_RTSCTS;
+    memset(&dcb, 0, sizeof(dcb));
+    dcb.DCBlength = sizeof(dcb);
+    if (!SetupComm(gPort, DRIVER_BUFFER_BYTES, DRIVER_BUFFER_BYTES) || !GetCommState(gPort, &dcb)) {
+        jlpSerialClose();
+        return false;
+    }
+    dcb.BaudRate     = config->baud;
+    dcb.ByteSize     = config->dataBits;
+    dcb.Parity       = gParity[config->parity];
+    dcb.StopBits     = config->stopBits == 2u ? TWOSTOPBITS : ONESTOPBIT;
+    dcb.fBinary      = TRUE;
+    dcb.fParity      = config->parity != JL_SERIAL_PARITY_NONE;
+    dcb.fOutxCtsFlow = rtsCts;
+    dcb.fOutxDsrFlow = FALSE;
+    dcb.fDtrControl  = DTR_CONTROL_ENABLE;
+    dcb.fRtsControl  = rtsCts ? RTS_CONTROL_HANDSHAKE : RTS_CONTROL_ENABLE;
+    dcb.fOutX        = config->flow == JL_SERIAL_FLOW_XONXOFF;
+    dcb.fInX         = config->flow == JL_SERIAL_FLOW_XONXOFF;
+    dcb.XonLim       = XON_LIMIT;
+    dcb.XoffLim      = XOFF_LIMIT;
+    // Reads return at once with what is there; writes wait a bounded time.
+    timeouts.ReadIntervalTimeout         = MAXDWORD;
+    timeouts.ReadTotalTimeoutMultiplier  = 0;
+    timeouts.ReadTotalTimeoutConstant    = 0;
+    timeouts.WriteTotalTimeoutMultiplier = WRITE_MS_PER_BYTE;
+    timeouts.WriteTotalTimeoutConstant   = WRITE_TIMEOUT_MS;
+    if (!SetCommState(gPort, &dcb) || !SetCommTimeouts(gPort, &timeouts)) {
+        jlpSerialClose();
+        return false;
+    }
+    return true;
+}
+
+
+uint16_t jlpSerialRead(uint8_t *buf, uint16_t max) {
+    DWORD got;
+
+    if (gPort == INVALID_HANDLE_VALUE || !ReadFile(gPort, buf, max, &got, NULL)) {
+        return 0u;
+    }
+    return (uint16_t)got;
+}
+
+
+void jlpSerialSetDtr(bool on) {
+    if (gPort != INVALID_HANDLE_VALUE) {
+        EscapeCommFunction(gPort, on ? SETDTR : CLRDTR);
+    }
+}
+
+
+// With RTS/CTS the driver owns RTS and refuses this: the line handshakes.
+void jlpSerialSetRts(bool on) {
+    if (gPort != INVALID_HANDLE_VALUE) {
+        EscapeCommFunction(gPort, on ? SETRTS : CLRRTS);
+    }
+}
+
+
+uint16_t jlpSerialWrite(const uint8_t *buf, uint16_t len) {
+    DWORD sent;
+
+    if (gPort == INVALID_HANDLE_VALUE || !WriteFile(gPort, buf, len, &sent, NULL)) {
+        return 0u;
+    }
+    return (uint16_t)sent;
+}
diff --git a/src/iigs/audio_full.c b/src/iigs/audio_full.c
index 0a59c7d..7d501cd 100644
--- a/src/iigs/audio_full.c
+++ b/src/iigs/audio_full.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Apple IIgs audio HAL -- single source linked into every IIgs demo.
 // Memory Manager + the 34 KB NTP replayer only need to be present in
 // AUDIO binaries; link-time dead-stripping drops this implementation
diff --git a/src/iigs/hal.c b/src/iigs/hal.c
index 9673bfa..70fb2e0 100644
--- a/src/iigs/hal.c
+++ b/src/iigs/hal.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Apple IIgs HAL: enable SHR, write pixels / SCBs / palettes into the
 // $E1 bank at the stock addresses the shifter reads from.
 //
diff --git a/src/iigs/input.c b/src/iigs/input.c
index a14e8a6..fdbec1f 100644
--- a/src/iigs/input.c
+++ b/src/iigs/input.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Apple IIgs keyboard + mouse input via classic Apple II softswitches.
 //
 // Keyboard: $C000 (data) and $C010 (clear strobe). The Event Manager
diff --git a/src/iigs/joeyDraw.s b/src/iigs/joeyDraw.s
index a7d9927..c8dbdec 100644
--- a/src/iigs/joeyDraw.s
+++ b/src/iigs/joeyDraw.s
@@ -1,3 +1,7 @@
+; Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+; See LICENSE.md for license information.
+; SPDX-License-Identifier: MIT
+;
 ; joeyDraw.s - Apple IIgs ASM inner loops for graphics primitives.
 ;
 ; GAS syntax for the llvm-mos w65816 toolchain. Each function is the
@@ -2979,279 +2983,658 @@ friHasOddMid:
 
 
 ; ====================================================================
-; NATIVE-PERF Phase 6 W1: FUSED stage-sprite entries.
+; Stage sprite entries: a compiled sprite's draw, save, save-and-draw
+; and restore on the stage, each in ONE JSL straight from the sprite
+; and backup structs, plus the IIgs bodies of the public trusted
+; save-and-draw / restore pair.
 ;
-; jlSpriteDraw / jlSpriteRestoreUnder used to pay a C-side dirty mark
-; (surfaceMarkDirtyRect -> iigsMarkDirtyRowsInner JSL, ~870 cyc for a
-; 16-row sprite) plus an indirect C call into the codegen arena. Each
-; entry here does the whole tail in ONE JSL: derive the stage
-; destination from the pinned base ($01:2000 + gRowOffsetLut[y]),
-; long-call the compiled arena routine (push target-1 + rtl -- the
-; 65816 has no indirect JSL and self-modifying stubs stay banned,
-; #19), then widen the dirty band via stageRectMark. C resolves
-; fnAddr (resolved-fnAddr cache, spriteDispatch.h) and passes it as
-; arg0; the entries never read ArenaSlotT.
-; REOPENS .text.iigsFusedRect so the 16-bit jsr/pea self-references
-; stay same-bank by construction (the fused-tile trailer rule; GAS
-; same-name sections concatenate within the TU). Stage-gated by the C
-; dispatch (s == gStage); callable ONLY from C (DBR must be the C
-; data bank for the gStage* / gRowOffsetLut abs,x reads and the
-; friMarkBand scratch). The compiled callees preserve D/DBR and
-; return M=16/X=16 (spriteEmitIigs.c contract), so the dp-parked
-; JSL args stay readable for the post-call band widen. No SEI.
+; They replace C glue that derived the arena address, the routine
+; offsets and the bank-patch cache indexes through far pointers and
+; then made one or two more JSLs (W1's fused draw/restore marks, the
+; rank-10 fused save): a one-tile star's trusted save-and-draw cost
+; ~2.9k cycles and its restore ~2.2k, most of it that glue -- the save
+; MVNs, the draw stores and the band widen are a few hundred each.
+; Here the sprite, the backup and their fields sit in this routine's
+; own stack frame as the direct page and are read [dp],y, and the
+; steps are macros, inline in each entry: no jsr/rts between them.
+;
+; Each entry: resolves the arena slot (NULL -> fail), the routine for
+; the op at this shift (SPRITE_NOT_COMPILED -> fail), checks and if
+; need be re-patches the save/restore MVN bank operands (the
+; patchMvnBanks rule, spriteDispatch.h), fills the backup's metadata
+; (save), derives the stage offset from the pinned base ($01:2000 +
+; gRowOffsetLut[y] + x/2), long-calls the routine(s) and widens the
+; dirty band (stageRectMark). The save, the restore and the band cover
+; only the sprite's content rows (contentRow0 / contentRows,
+; spriteInternal.h): the draw never writes the transparent rows above
+; and below. A failed entry has done nothing and returns non-zero; the
+; C caller falls back to its generic path.
+;
+; Callable only from C with DBR = the C data bank (gCodegenBankAddr,
+; gRowOffsetLut, gStage and stageRectMark's band tables are read abs,x)
+; and, for the iigsSprite*Stage entries, only for gStage, fully on it
+; (callers own both). Nothing here changes DBR, and the compiled
+; routines preserve D and DBR and return M=16/X=16 (spriteEmitIigs.c),
+; so the frame survives them; C callers expect no flags back, so P is
+; not saved either. In .text.iigsFusedRect: the jsr to stageRectMark
+; stays inside one section (the fused-tile trailer rule).
+;
+; ABI (clang cdecl): arg0 in A:X, the rest stacked from 4,s on JSL
+; entry. ISP_ENTER pushes D and makes ISP_LOCALS bytes of locals with
+; D = SP + 1, so the locals sit at dp 0.. and the first stacked argument
+; at ISP_ARGS. The iigsSprite*Stage entries return A = 0 when done, 1
+; when the caller must fall back.
 ; ====================================================================
 .section .text.iigsFusedRect,"ax"
 
-; ====================================================================
-; iigsSpriteDrawMark(uint32_t fnAddr, uint16_t x, uint16_t y,
-;                    uint16_t widthPx, uint16_t heightPx)
-;
-; ABI: arg0 fnAddr in A:X (A = arena routine low16, X = bank word).
-;      x@4,s y@6,s w@8,s h@10,s on JSL entry. After php+phb+phd (+4)
-;      and phx+pha parking fnAddr (+4), D = SP+1 and the equates below
-;      hold. Long-calls the compiled DRAW routine (C ABI: A = dstRow0
-;      low16, X = dstRow0 bank), then widens rows y..y+h-1 to
-;      [x>>2, (x+w-1)>>2]. h is arbitrary (>16-row sprites fine);
-;      caller guarantees fully-on-surface.
-; ====================================================================
-sdmFnLo = 0
-sdmFnHi = 2
-sdmX    = 11
-sdmY    = 13
-sdmW    = 15
-sdmH    = 17
-.globl iigsSpriteDrawMark
-iigsSpriteDrawMark:
-                php
-                phb
+; jlSpriteT and jlSpriteBackupT field offsets -- pinned against the C
+; layout by spriteDispatch.h's offset checks.
+SPR_WPX       = 8                   ; uint16_t widthPx
+SPR_SLOT      = 12                  ; ArenaSlotT *slot
+SPR_ROUTES    = 16                  ; uint16_t routineOffsets[2][3]
+SPR_CDST      = 28                  ; uint8_t  cachedDstBank[2][3]
+SPR_CSRC      = 34                  ; uint8_t  cachedSrcBank[2][3]
+SPR_SIZE      = 40                  ; uint16_t cachedSizeBytes[2]
+SPR_ROW0      = 44                  ; uint16_t contentRow0
+SPR_ROWS      = 46                  ; uint16_t contentRows
+BK_SPRITE     = 0
+BK_X          = 4
+BK_Y          = 6
+BK_W          = 8
+BK_H          = 10
+BK_BYTES      = 12
+BK_SIZE       = 16
+; routineOffsets / bank-cache index = shift * 3 + op (spriteInternal.h)
+OP_DRAW       = 0
+OP_SAVE       = 1
+OP_RESTORE    = 2
+NOT_COMPILED  = 0xFFFF
+STAGE_BANK    = 1
+STAGE_BASE    = 0x2000
+; emitMvnCabiRoutine: row 0's MVN bank operands (dst, then src) and the
+; distance to the next row's.
+MVN_BANKS_AT  = 8
+MVN_ROW_BYTES = 12
+
+; Locals (dp offsets after ISP_ENTER).
+ispSpr        = 0                   ; sprite far ptr
+ispBk         = 4                   ; backup far ptr
+ispFn         = 8                   ; routine far ptr (the slot ptr while resolving)
+ispBase       = 12                  ; slot base far ptr
+ispX          = 16                  ; x (the backup's x for a restore)
+ispY          = 18                  ; y, then the first content row's
+ispShift      = 20                  ; x & 1, or the restore class
+ispW          = 22                  ; widthPx (the backup's width for a restore)
+ispH          = 24                  ; contentRows: the rows saved, restored, marked
+ispScr        = 26                  ; the stage offset of (ispX, ispY)
+ispBytes      = 28                  ; backup->bytes far ptr
+ispIdx        = 32                  ; routine index of the last route
+ispTmp        = 34
+ispDraw       = 36                  ; save-and-draw: the draw routine's low word
+ispDrawScr    = 38                  ; save-and-draw: the draw's stage offset
+ispRow0       = 40                  ; contentRow0
+ispSurf       = 42                  ; the public entries' surface argument
+ISP_LOCALS    = 46
+; The stacked arguments: past the locals, the saved D and the return
+; address.
+ISP_ARGS      = ISP_LOCALS + 5
+
+; ---- steps (M=16/X=16 in and out) ----------------------------------
+
+; Push D, make the locals, point D at them. arg0 comes back in Y:X.
+.macro ISP_ENTER
                 phd
                 rep     #0x30
                 .a16
                 .i16
-                phx                          ; fn bank word -> dp 2
-                pha                          ; fn low16     -> dp 0
+                tay
                 tsc
+                sec
+                sbc     #ISP_LOCALS
+                tcs
                 inc     a
                 tcd                          ; D = SP + 1
-                ; dstRow0 low16 = $2000 + gRowOffsetLut[y] + (x >> 1)
-                lda     sdmY
+.endm
+
+; Drop the locals and D. Keeps A.
+.macro ISP_LEAVE
+                tay
+                tsc
+                clc
+                adc     #ISP_LOCALS
+                tcs
+                tya
+                pld
+.endm
+
+; ispSpr's arena slot base -> ispBase, and its widthPx, contentRow0 and
+; contentRows -> ispW, ispRow0, ispH; to \fail when the sprite is not
+; compiled (slot NULL). A slot's offset holds its arena bank index in the
+; top byte; a bank never crosses a 64 KB boundary, so the base's low word
+; cannot carry.
+.macro ISP_RESOLVE fail
+                ldy     #SPR_SLOT
+                lda     [ispSpr], y
+                sta     ispFn
+                ldy     #SPR_SLOT+2
+                lda     [ispSpr], y
+                sta     ispFn+2
+                ora     ispFn
+                beq     \fail
+                ldy     #2
+                lda     [ispFn], y           ; slot->offset, high word
+                xba
+                and     #0x00FF
+                asl     a
+                asl     a
+                tax                          ; bank index * sizeof(uint32_t)
+                ldy     #0
+                lda     [ispFn], y           ; slot->offset, low word
+                clc
+                adc     gCodegenBankAddr, x
+                sta     ispBase
+                lda     gCodegenBankAddr+2, x
+                sta     ispBase+2
+                ldy     #SPR_WPX
+                lda     [ispSpr], y
+                sta     ispW
+                ldy     #SPR_ROW0
+                lda     [ispSpr], y
+                sta     ispRow0
+                ldy     #SPR_ROWS
+                lda     [ispSpr], y
+                sta     ispH
+.endm
+
+; A = routine index (shift * 3 + op) -> ispFn = the routine, ispIdx = A;
+; to \fail when that routine was not compiled.
+.macro ISP_ROUTE fail
+                sta     ispIdx
+                asl     a
+                adc     #SPR_ROUTES
+                tay
+                lda     [ispSpr], y
+                cmp     #NOT_COMPILED
+                beq     \fail
+                clc
+                adc     ispBase
+                sta     ispFn
+                lda     ispBase+2
+                sta     ispFn+2
+.endm
+
+; ispScr (and A) = the stage offset of (ispX, ispY).
+.macro ISP_SCREEN
+                lda     ispY
                 asl     a
                 tax
-                lda     sdmX
+                lda     ispX
                 lsr     a
                 clc
-                adc     gRowOffsetLut,x
+                adc     gRowOffsetLut, x
                 clc
-                adc     #0x2000
-                tay                          ; park dstRow0 low16 in Y
-                ; long-call the compiled DRAW routine (push target-1 + rtl)
-                phk                          ; return bank
-                pea     sdmRet-1             ; return PC - 1
+                adc     #STAGE_BASE
+                sta     ispScr
+.endm
+
+; ispY moves from the sprite's top row to its first content row.
+.macro ISP_CONTENT_Y
+                lda     ispY
+                clc
+                adc     ispRow0
+                sta     ispY
+.endm
+
+; backup->bytes -> ispBytes; A = its bank word.
+.macro ISP_BACKUP_BYTES
+                ldy     #BK_BYTES
+                lda     [ispBk], y
+                sta     ispBytes
+                ldy     #BK_BYTES+2
+                lda     [ispBk], y
+                sta     ispBytes+2
+.endm
+
+; A = MVN dst bank | src bank << 8 for the routine at ispFn (index
+; ispIdx): when the sprite's bank cache says they differ, re-stamp them.
+.macro ISP_BANKS
+                sta     ispTmp
+                lda     ispIdx
+                clc
+                adc     #SPR_CDST
+                tay
+                lda     [ispSpr], y          ; cachedDstBank[idx] in the low byte
+                eor     ispTmp
+                and     #0x00FF
+                bne     1f
+                tya
+                clc
+                adc     #SPR_CSRC - SPR_CDST
+                tay
+                lda     [ispSpr], y          ; cachedSrcBank[idx] in the low byte
+                xba
+                eor     ispTmp
+                and     #0xFF00
+                beq     2f
+1:
+                jsr     ispBanksPatch
+2:
+.endm
+
+; Long-call the routine at ispFn with A and X as its arguments (the
+; 65816 has no indirect JSL; self-modifying stubs stay banned, #19).
+.macro ISP_CALL
+                tay                          ; park the A argument
+                phk
+                pea     1f-1
                 sep     #0x20
                 .a8
-                lda     sdmFnHi              ; arena bank byte
+                lda     ispFn+2
                 pha
                 rep     #0x20
                 .a16
-                lda     sdmFnLo
+                lda     ispFn
                 dec     a
                 pha                          ; target - 1
-                tya                          ; A = dstRow0 low16 (arg0 lo)
-                ldx     #0x0001              ; X = stage bank $01 (arg0 hi)
-                rtl                          ; jumps into the arena routine
-sdmRet:
-                ; callee returned M=16/X=16, D/DBR preserved, dp args intact
-                lda     sdmX
-                clc
-                adc     sdmW
-                dec     a
-                lsr     a
-                lsr     a                    ; maxWord = (x + w - 1) >> 2
-                xba                          ; -> high byte
-                sta     friMarkBand
-                lda     sdmX
-                lsr     a
-                lsr     a                    ; minWord = x >> 2
-                ora     friMarkBand          ; A = (maxWord << 8) | minWord
-                ldx     sdmY                 ; X = first row
-                ldy     sdmH                 ; Y = row count
-                jsr     stageRectMark
-                tsc
-                clc
-                adc     #4                   ; drop the parked fnAddr
-                tcs
-                pld
-                plb
-                plp
+                tya
                 rtl
+1:
+.endm
 
-; ====================================================================
-; iigsSpriteRestoreMark(uint32_t fnAddr, uint16_t backupLo, uint16_t bx,
-;                       uint16_t by, uint16_t bw, uint16_t bh)
-;
-; ABI: arg0 fnAddr in A:X; backupLo@4,s bx@6,s by@8,s bw@10,s bh@12,s
-;      on JSL entry -> the equates below (same frame shape as draw).
-; The compiled RESTORE routine is void copy(uint32_t packed) with
-; packed = srcOff | dstOff<<16 -> A = srcOff (backup), X = dstOff
-; (screen); its MVN banks are pre-patched by the C caller
-; (spriteCompiledRestoreMark) and it restores DBR itself (MVN sets
-; DBR = dst bank; the emitted phb/plb pair covers it). Screen offset
-; derived here from the pinned stage base; then widens rows
-; by..by+bh-1 to [bx>>2, (bx+bw-1)>>2]. bx/bw arrive even (validated
-; by the caller); bh arbitrary.
-; ====================================================================
-isrmFnLo = 0
-isrmFnHi = 2
-isrmBkLo = 11
-isrmBx   = 13
-isrmBy   = 15
-isrmBw   = 17
-isrmBh   = 19
-.globl iigsSpriteRestoreMark
-iigsSpriteRestoreMark:
-                php
-                phb
-                phd
-                rep     #0x30
-                .a16
-                .i16
-                phx                          ; fn bank word -> dp 2
-                pha                          ; fn low16     -> dp 0
-                tsc
-                inc     a
-                tcd                          ; D = SP + 1
-                ; screen dstOff low16 = $2000 + gRowOffsetLut[by] + (bx >> 1)
-                lda     isrmBy
-                asl     a
-                tax
-                lda     isrmBx
-                lsr     a
+; Widen the dirty band: rows ispY .. ispY + ispH - 1 to the words of
+; ispX .. ispX + ispW - 1.
+.macro ISP_MARK
+                lda     ispX
                 clc
-                adc     gRowOffsetLut,x
-                clc
-                adc     #0x2000
-                tay                          ; park screen offset in Y
-                ; long-call the compiled RESTORE routine
-                phk
-                pea     isrmRet-1
-                sep     #0x20
-                .a8
-                lda     isrmFnHi
-                pha
-                rep     #0x20
-                .a16
-                lda     isrmFnLo
-                dec     a
-                pha
-                tyx                          ; X = dstOff (screen)
-                lda     isrmBkLo             ; A = srcOff (backup)
-                rtl
-isrmRet:
-                lda     isrmBx
-                clc
-                adc     isrmBw
+                adc     ispW
                 dec     a
                 lsr     a
-                lsr     a                    ; maxWord = (bx + bw - 1) >> 2
+                lsr     a                    ; last word
                 xba
-                sta     friMarkBand
-                lda     isrmBx
+                sta     ispTmp
+                lda     ispX
                 lsr     a
-                lsr     a                    ; minWord = bx >> 2
-                ora     friMarkBand          ; A = (maxWord << 8) | minWord
-                ldx     isrmBy               ; X = first row
-                ldy     isrmBh               ; Y = row count
+                lsr     a                    ; first word
+                ora     ispTmp
+                ldx     ispY
+                ldy     ispH
                 jsr     stageRectMark
-                tsc
+.endm
+
+; The save, its routine at ispFn (index ispIdx), ispY on the first
+; content row: patch its banks if need be, fill the backup's metadata as
+; spriteCompiledSaveUnder does, call it.
+.macro ISP_SAVE
+                ISP_BACKUP_BYTES
+                and     #0x00FF
+                ora     #STAGE_BANK << 8     ; MVN dst = the backup, src = the stage
+                ISP_BANKS
+                ldy     #BK_SPRITE
+                lda     ispSpr
+                sta     [ispBk], y
+                ldy     #BK_SPRITE+2
+                lda     ispSpr+2
+                sta     [ispBk], y
+                lda     ispX
+                and     #0xFFFE
+                ldy     #BK_X
+                sta     [ispBk], y
+                lda     ispY
+                ldy     #BK_Y
+                sta     [ispBk], y
+                lda     ispW
+                lsr     a
                 clc
-                adc     #4                   ; drop the parked fnAddr
-                tcs
-                pld
-                plb
-                plp
+                adc     ispShift             ; copyBytes
+                sta     ispTmp
+                asl     a
+                ldy     #BK_W
+                sta     [ispBk], y
+                lda     ispH
+                ldy     #BK_H
+                sta     [ispBk], y
+                ; sizeBytes: cachedSizeBytes[shift], filled on first use
+                lda     ispShift
+                asl     a
+                adc     #SPR_SIZE
+                tay
+                lda     [ispSpr], y
+                bne     1f
+                ldx     ispH
+3:
+                clc
+                adc     ispTmp               ; copyBytes * contentRows
+                dex
+                bne     3b
+                sta     [ispSpr], y
+1:
+                ldy     #BK_SIZE
+                sta     [ispBk], y
+                ISP_SCREEN
+                ldx     ispBytes             ; save(packed): X = dstOff (backup)
+                ISP_CALL                     ; A = srcOff (stage)
+.endm
+
+; The save-and-draw: ispSpr, ispBk, ispX, ispY in. Both routines are
+; checked before either runs, as the C pair did; to \fail (nothing done)
+; when either is not compiled.
+.macro ISP_SAVE_DRAW fail
+                ISP_RESOLVE \fail
+                lda     ispX
+                and     #1
+                sta     ispShift
+                asl     a
+                adc     ispShift             ; shift * 3 + OP_DRAW
+                ISP_ROUTE \fail
+                lda     ispFn
+                sta     ispDraw
+                lda     ispIdx
+                inc     a                    ; + OP_SAVE
+                ISP_ROUTE \fail
+                ISP_SCREEN
+                sta     ispDrawScr           ; the draw starts at row y
+                ISP_CONTENT_Y
+                ISP_SAVE
+                lda     ispDraw
+                sta     ispFn                ; same slot, same bank
+                lda     ispDrawScr
+                ldx     #STAGE_BANK
+                ISP_CALL
+                ISP_MARK
+.endm
+
+; The restore of the backup at ispBk. Takes only a whole compiled-format
+; backup: its height is the sprite's content rows and its byte width the
+; sprite's or one more (SPRITE_RESTORE_CLASS); to \fail (nothing done)
+; for anything else.
+.macro ISP_RESTORE fail
+                ldy     #BK_SPRITE
+                lda     [ispBk], y
+                sta     ispSpr
+                ldy     #BK_SPRITE+2
+                lda     [ispBk], y
+                sta     ispSpr+2
+                ora     ispSpr
+                beq     \fail
+                ISP_RESOLVE \fail
+                ldy     #BK_H
+                lda     [ispBk], y
+                cmp     ispH
+                bne     \fail
+                ; class = copyBytes - widthPx / 2, which must be 0 or 1
+                lda     ispW
+                lsr     a
+                sta     ispShift
+                ldy     #BK_W
+                lda     [ispBk], y
+                sta     ispW                 ; the band covers the backup's width
+                lsr     a
+                sec
+                sbc     ispShift
+                cmp     #2
+                bcs     \fail
+                sta     ispShift
+                asl     a
+                adc     ispShift
+                adc     #OP_RESTORE          ; class * 3 + OP_RESTORE
+                ISP_ROUTE \fail
+                ldy     #BK_X
+                lda     [ispBk], y
+                sta     ispX
+                ldy     #BK_Y
+                lda     [ispBk], y
+                sta     ispY
+                ISP_BACKUP_BYTES
+                xba
+                and     #0xFF00
+                ora     #STAGE_BANK          ; MVN dst = the stage, src = the backup
+                ISP_BANKS
+                ISP_SCREEN
+                ldx     ispScr               ; restore(packed): X = dstOff (stage)
+                lda     ispBytes             ; A = srcOff (backup)
+                ISP_CALL
+                ISP_MARK
+.endm
+
+; ---- iigsSprite*Stage: internal, for sprite.c's checked paths -------
+
+; uint16_t iigsSpriteDrawStage(jlSpriteT *sp, int16_t x, int16_t y)
+.globl iigsSpriteDrawStage
+iigsSpriteDrawStage:
+                ISP_ENTER
+                sty     ispSpr
+                stx     ispSpr+2
+                lda     ISP_ARGS
+                sta     ispX
+                lda     ISP_ARGS+2
+                sta     ispY
+                ISP_RESOLVE isdFail
+                lda     ispX
+                and     #1
+                sta     ispShift
+                asl     a
+                adc     ispShift             ; shift * 3 + OP_DRAW
+                ISP_ROUTE isdFail
+                ISP_SCREEN
+                ldx     #STAGE_BANK          ; draw(dstRow0): A = offset, X = bank
+                ISP_CALL
+                ISP_CONTENT_Y
+                ISP_MARK
+                lda     #0
+                ISP_LEAVE
+                rtl
+isdFail:
+                lda     #1
+                ISP_LEAVE
                 rtl
 
-
-; ====================================================================
-; iigsSpriteSaveStage(uint32_t fnAddr, uint16_t backupLo, uint16_t x,
-;                     uint16_t y)
-;
-; Fused stage SAVE (perf-hunt-2 rank 10): the SAVE twin of
-; iigsSpriteRestoreMark minus the mark tail (save is a read -- no band
-; widen). Derives the SCREEN offset from the pinned stage base
-; asm-side, replacing the C glue's row-offset pointer math +
-; SPLIT_POINTER + indirect-fn-pointer call with this one JSL. The
-; compiled SAVE routine is void copy(uint32_t packed) with
-; packed = srcOff | dstOff<<16 -> A = srcOff (SCREEN), X = dstOff
-; (backup); its MVN banks are pre-patched by the C caller and it
-; restores DBR itself. x arrives CLIPPED EVEN (x & ~1, the C caller
-; owns that), y in 0..199.
-;
-; P7-2 STAGE 2 WAS BUILT AND REVERTED HERE (2026-07-19): a 10-arg
-; variant that also filled the backup metadata through one [dp] frame
-; benched jlSpriteSaveUnder 722 -> 602 -- the llvm-mos C-side
-; marshalling of 9 stack args + three SPLIT_POINTERs cost MORE than
-; the far-pointer field stores it deleted (same compiler wall as the
-; fnAddr-cache autopsy). Do not resurrect without a compiler-level
-; change to argument lowering.
-;
-; ABI: arg0 fnAddr in A:X; backupLo@4,s x@6,s y@8,s on JSL entry ->
-; the equates below (same frame shape as iigsSpriteRestoreMark:
-; php+phb+phd then phx+pha, D = SP + 1, parked fn at dp 0..3).
-; ====================================================================
-isvsFnLo = 0
-isvsFnHi = 2
-isvsBkLo = 11
-isvsX    = 13
-isvsY    = 15
+; uint16_t iigsSpriteSaveStage(jlSpriteT *sp, int16_t x, int16_t y,
+;                              jlSpriteBackupT *backup)
 .globl iigsSpriteSaveStage
 iigsSpriteSaveStage:
-                php
-                phb
-                phd
-                rep     #0x30
-                .a16
-                .i16
-                phx                          ; fn bank word -> dp 2
-                pha                          ; fn low16     -> dp 0
-                tsc
-                inc     a
-                tcd                          ; D = SP + 1
-                ; screen srcOff low16 = $2000 + gRowOffsetLut[y] + (x >> 1)
-                lda     isvsY
+                ISP_ENTER
+                sty     ispSpr
+                stx     ispSpr+2
+                lda     ISP_ARGS
+                sta     ispX
+                lda     ISP_ARGS+2
+                sta     ispY
+                lda     ISP_ARGS+4
+                sta     ispBk
+                lda     ISP_ARGS+6
+                sta     ispBk+2
+                ISP_RESOLVE issFail
+                lda     ispX
+                and     #1
+                sta     ispShift
                 asl     a
-                tax
-                lda     isvsX
-                lsr     a
+                adc     ispShift
+                adc     #OP_SAVE             ; shift * 3 + OP_SAVE
+                ISP_ROUTE issFail
+                ISP_CONTENT_Y
+                ISP_SAVE
+                lda     #0
+                ISP_LEAVE
+                rtl
+issFail:
+                lda     #1
+                ISP_LEAVE
+                rtl
+
+; uint16_t iigsSpriteSaveDrawStage(jlSpriteT *sp, int16_t x, int16_t y,
+;                                  jlSpriteBackupT *backup)
+.globl iigsSpriteSaveDrawStage
+iigsSpriteSaveDrawStage:
+                ISP_ENTER
+                sty     ispSpr
+                stx     ispSpr+2
+                lda     ISP_ARGS
+                sta     ispX
+                lda     ISP_ARGS+2
+                sta     ispY
+                lda     ISP_ARGS+4
+                sta     ispBk
+                lda     ISP_ARGS+6
+                sta     ispBk+2
+                ISP_SAVE_DRAW isdsFail
+                lda     #0
+                ISP_LEAVE
+                rtl
+isdsFail:
+                lda     #1
+                ISP_LEAVE
+                rtl
+
+; uint16_t iigsSpriteRestoreStage(const jlSpriteBackupT *backup)
+.globl iigsSpriteRestoreStage
+iigsSpriteRestoreStage:
+                ISP_ENTER
+                sty     ispBk
+                stx     ispBk+2
+                ISP_RESTORE isrFail
+                lda     #0
+                ISP_LEAVE
+                rtl
+isrFail:
+                lda     #1
+                ISP_LEAVE
+                rtl
+
+; ---- the public trusted pair ----------------------------------------
+;
+; void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x,
+;                                 int16_t y, jlSpriteBackupT *backup)
+; void jlSpriteRestoreUnderTrusted(jlSurfaceT *s,
+;                                  const jlSpriteBackupT *backup)
+;
+; The IIgs bodies of these two public calls (joey/sprite.h; sprite.c
+; leaves them out here): the inner-loop pair, so they skip the C wrapper
+; frame and the second JSL with its arguments pushed again that a C body
+; costs (~190 cycles a call). s == gStage goes straight into the stage
+; code; anything else -- another surface, a sprite or an op that is not
+; compiled, a backup that is not whole compiled-format -- goes on to the
+; checked jlSpriteSaveAndDraw / jlSpriteRestoreUnder with the same
+; arguments, once this routine's frame is gone (C code needs the C direct
+; page back). s arrives in A:X; sp, x, y, backup (the restore: backup)
+; are stacked from 4,s.
+.globl jlSpriteSaveAndDrawTrusted
+jlSpriteSaveAndDrawTrusted:
+                ISP_ENTER
+                sty     ispSurf
+                stx     ispSurf+2
+                cpy     gStage
+                bne     istsChecked
+                cpx     gStage+2
+                bne     istsChecked
+                lda     ISP_ARGS
+                sta     ispSpr
+                lda     ISP_ARGS+2
+                sta     ispSpr+2
+                lda     ISP_ARGS+4
+                sta     ispX
+                lda     ISP_ARGS+6
+                sta     ispY
+                lda     ISP_ARGS+8
+                sta     ispBk
+                lda     ISP_ARGS+10
+                sta     ispBk+2
+                ISP_SAVE_DRAW istsChecked
+                ISP_LEAVE
+                rtl
+istsChecked:
+                lda     ispSurf
+                ldx     ispSurf+2
+                ISP_LEAVE
+                tay                          ; s for the checked call: Y:X
+                ; The caller's five arguments sit at 4,s .. 15,s again; push
+                ; them in the same order for the checked call.
+                lda     14,s
+                pha
+                lda     14,s
+                pha
+                lda     14,s
+                pha
+                lda     14,s
+                pha
+                lda     14,s
+                pha
+                lda     14,s
+                pha
+                tya
+                jsl     jlSpriteSaveAndDraw
+                tsc
                 clc
-                adc     gRowOffsetLut,x
+                adc     #12
+                tcs
+                rtl
+
+.globl jlSpriteRestoreUnderTrusted
+jlSpriteRestoreUnderTrusted:
+                ISP_ENTER
+                sty     ispSurf
+                stx     ispSurf+2
+                cpy     gStage
+                bne     istrChecked
+                cpx     gStage+2
+                bne     istrChecked
+                lda     ISP_ARGS
+                sta     ispBk
+                lda     ISP_ARGS+2
+                sta     ispBk+2
+                ISP_RESTORE istrChecked
+                ISP_LEAVE
+                rtl
+istrChecked:
+                lda     ispSurf
+                ldx     ispSurf+2
+                ISP_LEAVE
+                tay
+                lda     6,s
+                pha
+                lda     6,s
+                pha
+                tya
+                jsl     jlSpriteRestoreUnder
+                ply
+                ply
+                rtl
+
+; ISP_BANKS's rare arm: re-stamp the ispH rows of MVN bank operands of
+; the routine at ispFn with ispTmp's dst, src bytes, and the sprite's
+; cache with them.
+ispBanksPatch:
+                ldx     ispH
+                ldy     #MVN_BANKS_AT
+ispbRow:
+                lda     ispTmp
+                sta     [ispFn], y           ; this row's MVN dst, src operands
+                tya
                 clc
-                adc     #0x2000
-                tay                          ; park screen offset in Y
-                ; long-call the compiled SAVE routine
-                phk
-                pea     isvsRet-1
+                adc     #MVN_ROW_BYTES
+                tay
+                dex
+                bne     ispbRow
+                lda     ispIdx
+                clc
+                adc     #SPR_CDST
+                tay
                 sep     #0x20
                 .a8
-                lda     isvsFnHi
-                pha
+                lda     ispTmp
+                sta     [ispSpr], y
                 rep     #0x20
                 .a16
-                lda     isvsFnLo
-                dec     a
-                pha
-                ldx     isvsBkLo             ; X = dstOff (backup)
-                tya                          ; A = srcOff (screen)
-                rtl
-isvsRet:
-                tsc
+                tya
                 clc
-                adc     #4                   ; drop the parked fnAddr
-                tcs
-                pld
-                plb
-                plp
-                rtl
+                adc     #SPR_CSRC - SPR_CDST
+                tay
+                sep     #0x20
+                .a8
+                lda     ispTmp+1
+                sta     [ispSpr], y
+                rep     #0x20
+                .a16
+                rts
 
 ; ====================================================================
 ; iigsDrawPixelInner(uint8_t *pixels, uint16_t x, uint16_t y,
diff --git a/src/iigs/peislam.s b/src/iigs/peislam.s
index 146d7f2..8d2744b 100644
--- a/src/iigs/peislam.s
+++ b/src/iigs/peislam.s
@@ -1,3 +1,7 @@
+; Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+; See LICENSE.md for license information.
+; SPDX-License-Identifier: MIT
+;
 ; peislam.s - VBL tick counter and ReadBParam trampoline for the IIgs.
 ;
 ; GAS syntax for the llvm-mos w65816 toolchain. The old PEI-slam present
diff --git a/src/iigs/save.c b/src/iigs/save.c
index 264080b..b78b694 100644
--- a/src/iigs/save.c
+++ b/src/iigs/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Apple IIgs save-file HAL.
 //
 // GS/OS-backed stdio (fopen/fread/fwrite -- the runtime's libc GS/OS layer)
diff --git a/src/iigs/serial.c b/src/iigs/serial.c
index fe88834..b19ac06 100644
--- a/src/iigs/serial.c
+++ b/src/iigs/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Apple IIgs serial HAL -- direct register access, interrupt-driven receive.
 //
 // Three endpoints behind one API, selected at open:
diff --git a/src/iigs/serialIrq.s b/src/iigs/serialIrq.s
index 5e0fbd0..fb87a4e 100644
--- a/src/iigs/serialIrq.s
+++ b/src/iigs/serialIrq.s
@@ -1,3 +1,7 @@
+; Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+; See LICENSE.md for license information.
+; SPDX-License-Identifier: MIT
+;
 ; serialIrq.s - interrupt-driven receive for the IIgs serial add-on (serial.c).
 ;
 ; GAS syntax for the llvm-mos w65816 toolchain (conventions: peislam.s,
diff --git a/src/iigs/sfxCopy.s b/src/iigs/sfxCopy.s
index 78d7fd4..86fdefa 100644
--- a/src/iigs/sfxCopy.s
+++ b/src/iigs/sfxCopy.s
@@ -1,3 +1,7 @@
+; Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+; See LICENSE.md for license information.
+; SPDX-License-Identifier: MIT
+;
 ; ====================================================================
 ; sfxCopy.s - stage a sound effect's sample for the DOC (llvm-mc w65816)
 ;
diff --git a/src/iigs/spriteEmitIigs.c b/src/iigs/spriteEmitIigs.c
index db3a3db..509c1a2 100644
--- a/src/iigs/spriteEmitIigs.c
+++ b/src/iigs/spriteEmitIigs.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // IIgs (65816) sprite codegen. Emits position-independent, C-ABI
 // routines that jlSpriteCompile places in the codegen arena and the
 // IIgs dispatchers in spriteCompile.c invoke through ordinary C
@@ -57,6 +61,7 @@
 // shiftedByteAt / spriteSourceByte -- the transparency contract shared
 // with the x86 emitter -- come from spriteEmitChunky.h.
 static uint32_t emitMvnCabiRoutine(uint8_t *out, uint32_t cap, uint16_t heightPx, uint16_t copyBytes, bool advanceX);
+static bool     rowHasPixels(const jlSpriteT *sp, uint16_t row);
 
 
 // ----- Emit helpers -----
@@ -162,13 +167,61 @@ static uint32_t emitMvnCabiRoutine(uint8_t *out, uint32_t cap, uint16_t heightPx
 }
 
 
-// SAVE (screen -> backup). The prologue leaves X = screen low offset
-// (the source), Y = backup low offset (the destination); MVN advances
-// both by copyBytes per row. Backup rows are contiguous in memory so Y
-// is already correct for the next row; screen rows are
+// True when pixel row `row` of the sprite holds an opaque pixel: a
+// nonzero nibble in any tile of that tile row.
+static bool rowHasPixels(const jlSpriteT *sp, uint16_t row) {
+    const uint8_t *tileRow;
+    uint16_t       tx;
+    uint16_t       b;
+
+    tileRow = sp->tileData + (uint32_t)(row / TILE_PIXELS_PER_SIDE) * sp->widthTiles * TILE_BYTES
+                           + (uint32_t)(row % TILE_PIXELS_PER_SIDE) * TILE_BYTES_PER_ROW;
+    for (tx = 0; tx < sp->widthTiles; tx++) {
+        for (b = 0; b < TILE_BYTES_PER_ROW; b++) {
+            if (tileRow[(uint32_t)tx * TILE_BYTES + b] != 0u) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+
+// The rows from the first with an opaque pixel through the last, which
+// the save and restore cover (spriteInternal.h); the whole height for a
+// sprite with none, so a save never covers zero rows.
+void spriteContentRowsIigs(jlSpriteT *sp) {
+    uint16_t first;
+    uint16_t last;
+    uint16_t row;
+
+    first = sp->heightPx;
+    last  = 0u;
+    for (row = 0; row < sp->heightPx; row++) {
+        if (rowHasPixels(sp, row)) {
+            if (first == sp->heightPx) {
+                first = row;
+            }
+            last = row;
+        }
+    }
+    if (first == sp->heightPx) {
+        first = 0u;
+        last  = (uint16_t)(sp->heightPx - 1u);
+    }
+    sp->contentRow0 = first;
+    sp->contentRows = (uint16_t)(last - first + 1u);
+}
+
+
+// SAVE (screen -> backup), over the sprite's content rows
+// (contentRow0 .. + contentRows, spriteInternal.h): the dispatcher passes
+// the screen offset of row contentRow0. The prologue leaves X = screen
+// low offset (the source), Y = backup low offset (the destination); MVN
+// advances both by copyBytes per row. Backup rows are contiguous in
+// memory so Y is already correct for the next row; screen rows are
 // SURFACE_BYTES_PER_ROW apart so X needs an explicit ADC between rows.
 uint32_t spriteEmitSaveIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) {
-    uint16_t heightPx;
     uint16_t spriteBytesPerRow;
     uint16_t copyBytes;
 
@@ -176,22 +229,20 @@ uint32_t spriteEmitSaveIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uin
         return 0u;
     }
 
-    heightPx          = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE);
     spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW);
     copyBytes         = (uint16_t)(spriteBytesPerRow + shift);  /* shift is 0 or 1 */
 
     // SAVE: src = screen (X advances SURFACE_BYTES_PER_ROW per row), dst =
     // backup (Y, contiguous). The dispatcher packs srcOff=screen, dstOff=backup.
-    return emitMvnCabiRoutine(out, cap, heightPx, copyBytes, /*advanceX=*/true);
+    return emitMvnCabiRoutine(out, cap, sp->contentRows, copyBytes, /*advanceX=*/true);
 }
 
 
-// RESTORE (backup -> screen). The prologue leaves X = backup low
-// offset, Y = screen low offset. Backup is contiguous so X advances
-// correctly via MVN; screen needs explicit advance, so Y is the one
-// we ADC.
+// RESTORE (backup -> screen), over the same content rows as the save.
+// The prologue leaves X = backup low offset, Y = screen low offset.
+// Backup is contiguous so X advances correctly via MVN; screen needs
+// explicit advance, so Y is the one we ADC.
 uint32_t spriteEmitRestoreIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp, uint8_t shift) {
-    uint16_t heightPx;
     uint16_t spriteBytesPerRow;
     uint16_t copyBytes;
 
@@ -199,14 +250,13 @@ uint32_t spriteEmitRestoreIigs(uint8_t *out, uint32_t cap, const jlSpriteT *sp,
         return 0u;
     }
 
-    heightPx          = (uint16_t)(sp->heightTiles * TILE_PIXELS_PER_SIDE);
     spriteBytesPerRow = (uint16_t)(sp->widthTiles * TILE_BYTES_PER_ROW);
     copyBytes         = (uint16_t)(spriteBytesPerRow + shift);  /* shift is 0 or 1 */
 
     // RESTORE: src = backup (X, contiguous), dst = screen (Y advances
     // SURFACE_BYTES_PER_ROW per row). The dispatcher packs srcOff=backup,
     // dstOff=screen.
-    return emitMvnCabiRoutine(out, cap, heightPx, copyBytes, /*advanceX=*/false);
+    return emitMvnCabiRoutine(out, cap, sp->contentRows, copyBytes, /*advanceX=*/false);
 }
 
 
diff --git a/src/m68k/planarFill.s b/src/m68k/planarFill.s
index 873c0f9..1827604 100644
--- a/src/m68k/planarFill.s
+++ b/src/m68k/planarFill.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Shared 68000 separate-plane area fills -- hand-rolled.
 |
 | Used by BOTH separate-plane 68k ports, the Amiga and the X68000: a 320px
diff --git a/src/m68k/planarPlot.s b/src/m68k/planarPlot.s
index b40c044..cf1ecba 100644
--- a/src/m68k/planarPlot.s
+++ b/src/m68k/planarPlot.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Shared 68000 separate-plane pixel walkers -- hand-rolled.
 |
 |   surface68kPlanarCircle   jlDrawCircle: Bresenham midpoint outline
diff --git a/src/m68k/planarTile.h b/src/m68k/planarTile.h
index 59e3ce7..042af57 100644
--- a/src/m68k/planarTile.h
+++ b/src/m68k/planarTile.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Planar tile ops shared by the Amiga and the X68000, as always-inline
 // functions (NATIVE-PERF Phase 2 R4a). Both ports keep their surfaces as four
 // separate bitplanes of 40 bytes a row with the same portData layout
diff --git a/src/m68k/spriteEmitC2p.h b/src/m68k/spriteEmitC2p.h
index 48912eb..25f9907 100644
--- a/src/m68k/spriteEmitC2p.h
+++ b/src/m68k/spriteEmitC2p.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Shared 68k emit-time helpers: chunky->planar column conversion and
 // big-endian opcode-word emission. Included (as static functions) by
 // the two 68k emitters -- spriteEmitPlanar68k.c (Amiga) and
diff --git a/src/m68k/spriteEmitInterleaved68k.c b/src/m68k/spriteEmitInterleaved68k.c
index 21255b4..f0afd6a 100644
--- a/src/m68k/spriteEmitInterleaved68k.c
+++ b/src/m68k/spriteEmitInterleaved68k.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // 68k sprite codegen for the ST word-interleaved planar layout.
 //
 // Emits a position-independent, cdecl-callable DRAW routine that blits
diff --git a/src/m68k/spriteEmitPlanar68k.c b/src/m68k/spriteEmitPlanar68k.c
index ac799da..a6b88f6 100644
--- a/src/m68k/spriteEmitPlanar68k.c
+++ b/src/m68k/spriteEmitPlanar68k.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Planar 68k sprite codegen for the Amiga (4 separate bitplanes).
 //
 // Emits a position-independent, cdecl-callable DRAW routine that blits
diff --git a/src/m68k/stageBands.s b/src/m68k/stageBands.s
index 4bf043e..2ab3257 100644
--- a/src/m68k/stageBands.s
+++ b/src/m68k/stageBands.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Shared 68000 stage dirty bands: the multi-row mark and the whole-stage fill.
 |
 | Used by all three 68000 ports (Atari ST, Amiga, X68000). gStageMinWord and
diff --git a/src/x68000/audio.c b/src/x68000/audio.c
index e05e10e..cb1b758 100644
--- a/src/x68000/audio.c
+++ b/src/x68000/audio.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 audio HAL -- YM2151 (OPM) tone/noise voices.
 //
 // SCOPE. This implements the CHIP side only: jlAudioVoice / jlAudioTone /
diff --git a/src/x68000/audioPcm.c b/src/x68000/audioPcm.c
index 488221e..69b470a 100644
--- a/src/x68000/audioPcm.c
+++ b/src/x68000/audioPcm.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 sample audio: MOD playback + the 5-slot digital SFX overlay,
 // output through the OKI MSM6258 ADPCM channel.
 //
diff --git a/src/x68000/hal.c b/src/x68000/hal.c
index 98040f2..1bb2403 100644
--- a/src/x68000/hal.c
+++ b/src/x68000/hal.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 HAL: lifecycle, present, SCB, clocks and input.
 //
 // The stage is native planar (src/x68000/halPlanar.c) and jlpPresent copies
diff --git a/src/x68000/halPlanar.c b/src/x68000/halPlanar.c
index 71e0da6..2d9f45e 100644
--- a/src/x68000/halPlanar.c
+++ b/src/x68000/halPlanar.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 planar graphics HAL.
 //
 // Adapted from src/amiga/hal.c: a 320px 4bpp surface is 40 bytes per row per
diff --git a/src/x68000/save.c b/src/x68000/save.c
index ead9a63..c1f4057 100644
--- a/src/x68000/save.c
+++ b/src/x68000/save.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 save-file + disk-space HAL.
 //
 // Structurally the Atari ST HAL (src/atarist/save.c): SAVES/ is a real
diff --git a/src/x68000/serial.c b/src/x68000/serial.c
index 648e7a8..8c3722d 100644
--- a/src/x68000/serial.c
+++ b/src/x68000/serial.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 serial HAL: RS-232C on SCC channel A.
 //
 // Transmit goes through IOCS, whose calls map one-for-one onto the ST HAL's
diff --git a/src/x68000/x68kPlanar.h b/src/x68000/x68kPlanar.h
index 8155a16..92b76cb 100644
--- a/src/x68000/x68kPlanar.h
+++ b/src/x68000/x68kPlanar.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Sharp X68000 per-surface planar storage.
 //
 // WHY THIS IS ALMOST amigaPlanar.h
diff --git a/src/x68000/x68kPresent.s b/src/x68000/x68kPresent.s
index 4c7f9f0..6e38cd5 100644
--- a/src/x68000/x68kPresent.s
+++ b/src/x68000/x68kPresent.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Sharp X68000 present: copy the stage's dirty rows into the text plane.
 |
 | The stage is four planes of 40 bytes a row and the text plane is four planes
diff --git a/src/x68000/x68kRasterIsr.s b/src/x68000/x68kRasterIsr.s
index a75812f..d148072 100644
--- a/src/x68000/x68kRasterIsr.s
+++ b/src/x68000/x68kRasterIsr.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Sharp X68000 SCB raster interrupt.
 |
 | Per-band palettes need the palette changed as the beam scans, because the
diff --git a/src/x68000/x68kSccRxIsr.s b/src/x68000/x68kSccRxIsr.s
index 6ca71d6..63f1386 100644
--- a/src/x68000/x68kSccRxIsr.s
+++ b/src/x68000/x68kSccRxIsr.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Sharp X68000 RS-232C receive: own the Z8530 SCC channel A RX interrupt.
 |
 | RSDRV.SYS's receive ring is ~64 bytes -- far too small for a binary protocol
diff --git a/src/x68000/x68kTickIsr.s b/src/x68000/x68kTickIsr.s
index 08b9299..f18b560 100644
--- a/src/x68000/x68kTickIsr.s
+++ b/src/x68000/x68kTickIsr.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Sharp X68000 millisecond tick.
 |
 | Human68k already runs MFP Timer C as its system tick, so rather than program
diff --git a/src/x68000/x68kTile.h b/src/x68000/x68kTile.h
index a60da98..529c9f1 100644
--- a/src/x68000/x68kTile.h
+++ b/src/x68000/x68kTile.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // X68000 tile ops: the shared planar bodies in src/m68k/planarTile.h, bound to
 // the X68000's planar surface data, which mirrors the Amiga's field for field
 // (40 bytes a row, 4 planes), so the .tbk payload is byte-compatible too
diff --git a/src/x68000/x68kVdispIsr.s b/src/x68000/x68kVdispIsr.s
index c2ebc1f..559be5d 100644
--- a/src/x68000/x68kVdispIsr.s
+++ b/src/x68000/x68kVdispIsr.s
@@ -1,3 +1,7 @@
+| Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+| See LICENSE.md for license information.
+| SPDX-License-Identifier: MIT
+|
 | Sharp X68000 frame counter.
 |
 | Entered DIRECTLY from 68000 exception vector $134 (MFP channel 13, Timer A),
diff --git a/src/x86/scanSet1.h b/src/x86/scanSet1.h
index e588881..7740227 100644
--- a/src/x86/scanSet1.h
+++ b/src/x86/scanSet1.h
@@ -1,6 +1,11 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // PC keyboard scan code set 1 -> JoeyLib key, shared by the ports that see
-// raw PC scan codes: DOS (its own INT 9 handler) and DVX (the held-key table
-// DVX keeps from its INT 9 chain). Indexed by the make code with the break
+// raw PC scan codes: DOS (its own INT 9 handler), DVX (the held-key table
+// DVX keeps from its INT 9 chain) and the hosted Win32 backend (Windows hands
+// every key message the key's set-1 make code). Indexed by the make code with the break
 // bit and any 0xE0 prefix stripped, so the grey arrows land on the same
 // keys as the keypad ones, right Ctrl on KEY_LCTRL and right Alt on
 // KEY_LALT. Slots left 0 (KEY_NONE) are keys JoeyLib does not name.
diff --git a/src/x86/spriteEmitX86.c b/src/x86/spriteEmitX86.c
index c004b64..645d0de 100644
--- a/src/x86/spriteEmitX86.c
+++ b/src/x86/spriteEmitX86.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // x86 sprite codegen (the DOS and DVX ports). Emits 32-bit cdecl-callable PIC
 // draw / save / restore routines that read or write 4bpp packed
 // surface bytes via [esi+disp8] chains. The C side calls them
diff --git a/src/x86/x86Expand.h b/src/x86/x86Expand.h
index c0e9b23..bb6f88b 100644
--- a/src/x86/x86Expand.h
+++ b/src/x86/x86Expand.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // x86 stage-row expansion to 8-bit palette indexes, shared by the DOS port
 // (VGA mode 13h) and the DVX port (its full screen at 8 bits a pixel). Both
 // load all 16 palettes x 16 colours into the screen's 256 entries, so a
diff --git a/src/x86/x86Tile.h b/src/x86/x86Tile.h
index 4fe2bb6..108208b 100644
--- a/src/x86/x86Tile.h
+++ b/src/x86/x86Tile.h
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // x86 whole-map tile paste engine (NATIVE-PERF Phase 6 W3), shared by
 // the DOS and DVX ports. DOS was the only port whose jlTileMapPaste ran
 // the generic per-cell path: each cell paid a cdecl call into
diff --git a/tests/host/dirtyMarkHost.c b/tests/host/dirtyMarkHost.c
index 8a3f3dd..3305da1 100644
--- a/tests/host/dirtyMarkHost.c
+++ b/tests/host/dirtyMarkHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // dirtyMarkHost.c - randomized check of the stage's dirty-band writers
 // (src/core/surface.c, src/m68k/stageBands.s) against the plain per-row
 // widen they must match byte for byte.
diff --git a/tests/host/fileRootHost.c b/tests/host/fileRootHost.c
index 6926f73..dc3d6af 100644
--- a/tests/host/fileRootHost.c
+++ b/tests/host/fileRootHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // fileRootHost.c - Host-cc gate for the run-time file roots
 // (src/core/fileRoot.h, JL_HAS_FILE_ROOTS): where jlDataOpen reads DATA/,
 // and where the save calls and the log write SAVES/ and joeylog.txt.
diff --git a/tests/host/inputHost.c b/tests/host/inputHost.c
index 86362af..4edad18 100644
--- a/tests/host/inputHost.c
+++ b/tests/host/inputHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // inputHost.c - Host-cc harness for the typed-character queue
 // (jlInputGetChar / jlInputCharPush in src/core/input.c). Exercises
 // FIFO ordering, the character filter, drop-newest overflow, reset
diff --git a/tests/host/millisHost.c b/tests/host/millisHost.c
index a54aa6d..e065846 100644
--- a/tests/host/millisHost.c
+++ b/tests/host/millisHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // millisHost.c - Host-cc harness for jlpGenericMillisElapsed (the
 // IIgs/Amiga millisecond clock). Drives simulated frame-count sequences,
 // including the 16-bit wrap at 65536 frames, and asserts the millisecond
diff --git a/tests/host/saveHost.c b/tests/host/saveHost.c
index f739182..4bb150a 100644
--- a/tests/host/saveHost.c
+++ b/tests/host/saveHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // saveHost.c - Host-cc harness for the save-file core (src/core/save.c).
 //
 // Built by scripts/check-save.sh on the serialHost.c pattern: compiled with
diff --git a/tests/host/serialHost.c b/tests/host/serialHost.c
index d4f8240..ec3ffe0 100644
--- a/tests/host/serialHost.c
+++ b/tests/host/serialHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // serialHost.c - Host-cc harness for the shared serial gate/normalize layer
 // (src/core/serial.c). Built by scripts/check-serial.sh on the millisHost.c
 // pattern: compiled with -DJOEYLIB_PLATFORM_BLANK plus every -DJL_HAS_SERIAL_*
diff --git a/tests/host/sfxMixHost.c b/tests/host/sfxMixHost.c
index b615c47..bed96dd 100644
--- a/tests/host/sfxMixHost.c
+++ b/tests/host/sfxMixHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // sfxMixHost.c - Host-cc differential harness for the shared SFX
 // overlay mixer (src/core/audioSfxMix.c). Built by
 // scripts/check-sfxmix.sh on the millisHost.c / check-millis.sh
diff --git a/tests/host/tileBankHost.c b/tests/host/tileBankHost.c
index 66de522..e771416 100644
--- a/tests/host/tileBankHost.c
+++ b/tests/host/tileBankHost.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // tileBankHost.c - Host-cc gate for the baked tile-bank loaders,
 // jlTileBankLoad and jlTileBankLoadToSurface (src/core/assetLoad.c).
 //
diff --git a/tests/host/tileBankStage.py b/tests/host/tileBankStage.py
index 399fb5b..42a1548 100644
--- a/tests/host/tileBankStage.py
+++ b/tests/host/tileBankStage.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # tileBankStage.py -- stage the tile-bank fixture for one target's check.
 #
 # Bakes an indexed PNG (tests/host/tileBank.png) with assetbake for TARGET and
diff --git a/tools/adfTool.py b/tools/adfTool.py
index b4c0117..68d2ff2 100755
--- a/tools/adfTool.py
+++ b/tools/adfTool.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """adfTool.py - read files out of an Amiga OFS/FFS disk image (.adf).
 
     adfTool.py list IMAGE                 every file, one path per line
diff --git a/tools/assetbake/assetbake.py b/tools/assetbake/assetbake.py
index 53f5e5a..3800571 100755
--- a/tools/assetbake/assetbake.py
+++ b/tools/assetbake/assetbake.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # assetbake.py -- bake indexed PNG assets into JoeyLib native binary
 # formats. Replaces the .jas pipeline (png2jas + the runtime asset
 # loader's chunky->planar conversion path).
diff --git a/tools/diff-uber-hashes b/tools/diff-uber-hashes
index 3c77611..428de4e 100755
--- a/tools/diff-uber-hashes
+++ b/tools/diff-uber-hashes
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """Compare two UBER joeylog.txt files by per-op surface hash.
 
 Used by the planar 68k rewrite (project_planar_68k_plan.md): IIgs
diff --git a/tools/diff-uber-perf b/tools/diff-uber-perf
index 82df37d..9caaf7e 100755
--- a/tools/diff-uber-perf
+++ b/tools/diff-uber-perf
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """Compare two UBER joeylog.txt files by per-op ops/sec.
 
 Sibling of diff-uber-hashes (which compares pixel correctness). This
diff --git a/tools/gradeAudioPhases.py b/tools/gradeAudioPhases.py
index c28fa62..daacff0 100755
--- a/tools/gradeAudioPhases.py
+++ b/tools/gradeAudioPhases.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """Grade a recorded WAV against expected loudness phases.
 
 Shared by scripts/verify-amiga-audio.sh and scripts/verify-dos-audio.sh.
diff --git a/tools/iigsStart/iigsStart.c b/tools/iigsStart/iigsStart.c
index c8d02f1..986c358 100644
--- a/tools/iigsStart/iigsStart.c
+++ b/tools/iigsStart/iigsStart.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // iigsStart -- a GS/OS boot launcher for the headless IIgs harnesses.
 //
 // Installed as /System/Start on a copy of the GS/OS system disk, this runs
diff --git a/tools/iigsStart/iigsStartQuit.s b/tools/iigsStart/iigsStartQuit.s
index 31c63ec..b02b512 100644
--- a/tools/iigsStart/iigsStartQuit.s
+++ b/tools/iigsStart/iigsStartQuit.s
@@ -1,3 +1,7 @@
+; Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+; See LICENSE.md for license information.
+; SPDX-License-Identifier: MIT
+;
 ; GS/OS inline-call stubs for iigsStart.
 ;
 ; The same inline form and the same self-patch as the llvm816 runtime's
diff --git a/tools/joeymod/joeymod b/tools/joeymod/joeymod
index dbcce22..f1e9a6b 100755
--- a/tools/joeymod/joeymod
+++ b/tools/joeymod/joeymod
@@ -1,4 +1,8 @@
 #!/usr/bin/env bash
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+#
 # joeymod: convert a Protracker .MOD into the runtime form a target
 # platform expects.
 #
diff --git a/tools/modloopend/modloopend.c b/tools/modloopend/modloopend.c
index 815f563..96ec9fd 100644
--- a/tools/modloopend/modloopend.c
+++ b/tools/modloopend/modloopend.c
@@ -1,3 +1,7 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
 // Append a one-shot stop marker to a Protracker .MOD file so the Amiga
 // audio HAL (PTPlayer-based) can honor `jlAudioPlayMod(loop=false)`.
 //
diff --git a/tools/rawCd.py b/tools/rawCd.py
new file mode 100644
index 0000000..7ba3dbb
--- /dev/null
+++ b/tools/rawCd.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
+"""rawCd.py - read data out of a raw CD image (2352-byte sectors, as CloneCD's
+.img and a .bin keep them).
+
+    rawCd.py track IMAGE FIRST COUNT OUT
+        COUNT Mode 1 sectors from FIRST (a track's start, as the image's
+        .ccd or .cue gives it), their 2048 data bytes each, into OUT: the
+        track as a plain image (an ISO 9660 or a BFS volume, say)
+    rawCd.py eltorito ISO OUT
+        the boot image an El Torito ISO boots with floppy emulation, 1.44 MB,
+        into OUT; the ISO is 2048-byte sectors (such as a track `track`
+        made), or a raw image whose first track is the ISO
+
+Read-only on its inputs.
+"""
+
+import struct
+import sys
+
+RAW_SECTOR = 2352
+RAW_SYNC = b"\x00" + b"\xff" * 10 + b"\x00"   # how every raw sector starts
+MODE1_DATA_AT = 16          # 12 sync bytes, 4 header bytes
+DATA_SECTOR = 2048
+BOOT_RECORD_SECTOR = 17     # ISO 9660's El Torito boot record volume descriptor
+CATALOG_POINTER_AT = 0x47   # in the boot record: the boot catalog's sector
+DEFAULT_ENTRY_AT = 32       # in the catalog: after the validation entry
+BOOTABLE = 0x88
+MEDIA_FLOPPY_144 = 2
+LOAD_RBA_AT = 8
+FLOPPY_144_BYTES = 1474560
+
+
+def eltorito(iso, out):
+    with open(iso, "rb") as f:
+        raw = f.read(len(RAW_SYNC)) == RAW_SYNC
+        record = readData(f, raw, BOOT_RECORD_SECTOR, DATA_SECTOR)
+        if record[1:6] != b"CD001" or b"EL TORITO" not in record[7:39]:
+            sys.exit("rawCd: %s has no El Torito boot record" % iso)
+        catalog = struct.unpack_from(" start + 1 and lines[at - 1].strip() == '':
+        at -= 1
+    lines.insert(at, line)
+
+
+def keyOf(line):
+    return line.split('=', 1)[0].strip().lower() if '=' in line else None
+
+
+def apply(lines, op, section, key, value):
+    line   = key + '=' + value
+    bounds = sectionBounds(lines, section)
+    if bounds is None:
+        while lines and lines[-1].strip() == '':
+            lines.pop()
+        lines.extend(['', '[' + section + ']', line, ''])
+        return
+    start, end = bounds
+    for i in range(start + 1, end):
+        if op == 'set' and keyOf(lines[i]) == key.lower():
+            lines[i] = line
+            return
+        if op == 'add' and lines[i].strip().lower() == line.lower():
+            return
+    insertAtEnd(lines, start, end, line)
+
+
+def main(argv):
+    if len(argv) < 2 + GROUP or (len(argv) - 2) % GROUP != 0:
+        sys.exit(__doc__)
+    path  = argv[1]
+    lines = open(path, 'rb').read().decode(ENCODING).split(NEWLINE)
+    for at in range(2, len(argv), GROUP):
+        op, section, key, value = argv[at:at + GROUP]
+        if op not in OPS:
+            sys.exit('winIni.py: unknown op ' + op)
+        apply(lines, op, section, key, value)
+    open(path, 'wb').write(NEWLINE.join(lines).encode(ENCODING))
+
+
+if __name__ == '__main__':
+    main(sys.argv)
diff --git a/tools/xdftool.py b/tools/xdftool.py
index 44cf966..0fb0beb 100755
--- a/tools/xdftool.py
+++ b/tools/xdftool.py
@@ -1,4 +1,8 @@
 #!/usr/bin/env python3
+# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+# See LICENSE.md for license information.
+# SPDX-License-Identifier: MIT
+
 """xdftool - read/write Human68k FAT12 floppy images (.XDF) from Linux.
 
 Why this exists: mtools cannot handle these images. Human68k 2HD floppies use