diff --git a/LICENSES.md b/LICENSES.md index bdb405f..eee80f6 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -6,7 +6,7 @@ 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), and the -BeOS palette in `tools/iconBake.py` (Haiku's, MIT; noted below). +BeOS palette in `tools/iconBake/bePalette.h` (Haiku's, MIT; noted below). 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. @@ -46,8 +46,9 @@ most. 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. Its - `ntpconverter.php` makes the `.NTP` files (tools/joeymod). + next to each IIgs program and is loaded at run time. The `.NTP` files it + plays are made by JoeyLib's own `tools/modToNtp` (written from the + archive's `file_format.txt`); the archive's PHP converter is not used. - **Installed at:** `toolchains/opt/ntp/` (source; installer component `ntp`) ### PTPlayer 6.4 @@ -405,15 +406,27 @@ above. - **Installed at:** `toolchains/opt/haikuTools/` (installer component `haikuTools`) -### The BeOS palette (in `tools/iconBake.py`) +### The BeOS palette (in `tools/iconBake/bePalette.h`) - **Used on:** Haiku, Haiku x86, BeOS - **URL:** (`headers/private/interface/Palette.h`) - **License:** MIT (Haiku). -- **Role:** the system's 256 colours, copied into `tools/iconBake.py` as - `BE_PALETTE`, which the Be API programs' icons are baked to (an icon is +- **Role:** the system's 256 colours, copied into `tools/iconBake/bePalette.h` + as `kBePalette`, which the Be API programs' icons are baked to (an icon is indices into it; the palette itself is not in the program). -- **Installed at:** nowhere; it is part of `tools/iconBake.py`. +- **Installed at:** nowhere; it is part of `tools/iconBake`. + +### PHP's sort order (in `tools/modToNtp/modToNtp.c`) + +- **Used on:** Apple IIgs (the `.NTP` files `modToNtp` makes) +- **URL:** (`Zend/zend_sort.c`, PHP 8.3) +- **License:** Zend Engine License 2.00; its quicksort is after LLVM + libc++'s `std::sort` (MIT or the University of Illinois/NCSA license). +- **Role:** `modToNtp` places a song's instruments and stream buffers in + DOC RAM in the order Ninjaforce's converter, a PHP program, gets from + PHP's sort, so its files match that converter's byte for byte. Its + `sortClaims` (JoeyLib's code) takes the same steps `zend_sort` does. +- **Installed at:** nowhere; it is part of `tools/modToNtp`. --- @@ -421,13 +434,13 @@ above. ### Furnace 0.6.8.3 -- **Used on:** every port (chip-tracker music, through `tools/vgm2song.py`) +- **Used on:** every port (chip-tracker music, through `tools/vgm2song`) - **URL:** - **License:** GPLv2 or later. Its demo songs are not under the GPL: they are their authors', all rights reserved, and nothing made from them is kept or shipped (`scripts/check-vgm.sh` converts them in a scratch folder). - **Role:** the recommended tracker for writing chip-tracker songs - (docs/Assets.adoc); its VGM export is what `tools/vgm2song.py` converts. + (docs/Assets.adoc); its VGM export is what `tools/vgm2song` converts. - **Installed at:** `toolchains/opt/furnace/` (installer component `furnace`, optional: `./install.sh install furnace`) @@ -513,25 +526,27 @@ developer's machine. Not part of any redistributed artifact. Installed by ## 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` and `headless`); 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). +- **host:** GNU make, the host C compiler (gcc), gawk, patch, tar, and + diffutils (GNU GPL v3+); git and socat (GNU GPL v2); potrace (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). - **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. +- **tools/traceLogo**, which made `assets/joeylibLogo.svg` and is run + only if the logo art changes, runs the potrace program (Peter Selinger's, + GNU GPL v2+; the distribution's `potrace` package, which the `host` + component installs) as a separate program. The tool holds none of + potrace's code, and the logo it made is not covered by potrace'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. + rest), each under its own license; they, llvm816, the Haiku compilers and + tools, emsdk, and elf2x68k (whose linker runs a Python script of its own) + also take Python 3 (PSF License) to build or run. JoeyLib's own tools and + gates hold no Python. --- diff --git a/Makefile b/Makefile index d8e5bb4..6c39ad2 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ # 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 tools builds the host tools (the asset, icon, sprite, and music converters) into build/tools # 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) @@ -133,7 +134,7 @@ verify-audio: @$(REPO_DIR)/scripts/verify-all.sh audio $(PORTS) # Every recording port against the one audio contract (AUDPAR, graded by -# tools/gradeAudioConformance.py), sequentially, with the same exit codes. +# tests/gate/audioGate.c), sequentially, with the same exit codes. # Takes a subset: make verify-conform PORTS="sdl3 dos" verify-conform: @$(REPO_DIR)/scripts/verify-all.sh conform $(PORTS) diff --git a/README.md b/README.md index 52823b3..d1d97c5 100644 --- a/README.md +++ b/README.md @@ -173,24 +173,25 @@ make -f make/x68000.mk EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE ## Conformance gate `examples/uber` is the conformance vehicle: it exercises every public op and -logs a surface hash per op. `tools/diff-uber-hashes ` -compares two `joeylog.txt` captures and exits non-zero on any mismatch. The +logs a surface hash per op. +`build/tools/diffUberHashes ` (`make tools` builds +it) 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, 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 +`build/tools/diffUberHashes`). 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 +scripts/bench-sdl3.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt +scripts/bench-web.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt +scripts/bench-psp.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt ``` The X68000 has gates of its own: @@ -232,17 +233,32 @@ src/m68k/ 68k code shared across machines (planar and word- src/x86/ x86 code DOS and DVX share (the sprite emitter, the whole-map tile walker, the set-1 scan code table) src/blank/ copy-to-start template for a new port -tools/assetbake/ PNG -> native .tbk / .spr baker (Python) -tools/joeymod/ Protracker .MOD converter (passthrough or .NTP) -tools/spritebake/ offline .spr -> .spc pre-compiled sprite baker -tools/songbake/ chip-tracker text notation -> JYM1 stream -tools/vgm2song.py a tracker's VGM export (Furnace) -> chip-tracker .song -tools/iconBake.py one picture -> every system's program icon -tools/xdftool.py Human68k FAT12 .XDF disk image reader / writer -tools/diff-uber-hashes UBER golden-hash comparator +tools/ the host tools, all C (make tools builds them into build/tools/): + adfTool/ read files out of an Amiga disk image (.adf) + assetbake/ PNG -> native .tbk / .spr baker + common/ the C tools' shared code (PNG, DEFLATE, XML, JSON, base64) + diffUberHashes/ UBER golden-hash comparator + diffUberPerf/ UBER ops/sec comparator + iconBake/ one picture -> every system's program icon + iigsStart/ GS/OS boot launcher for the headless IIgs harnesses + joeymod/ Protracker .MOD converter (passthrough, .amod, or .NTP) + modloopend/ an E8F stop marker added to a .MOD (the Amiga's .amod) + modToNtp/ Protracker .MOD -> NinjaTrackerPlus .NTP (the IIgs) + rawCd/ a raw CD image's track or El Torito boot floppy + scanPtrBug/ scan IIgs assembly for a far-pointer miscompile pattern + songbake/ chip-tracker text notation -> JYM1 stream + spritebake/ offline .spr -> .spc pre-compiled sprite baker + szddExpand/ expand a COMPRESS.EXE (SZDD) file + tiled2joey/ a Tiled map (TMX or TMJ) -> tile sheet + tile maps + traceLogo/ the logo PNG -> its traced SVG (runs potrace) + uberPerfTable/ the UBER ops/sec table across ports + vgm2song/ a tracker's VGM export (Furnace) -> chip-tracker .song + winIni/ edit a Windows 3.x .INI file + xdftool/ Human68k FAT12 .XDF disk image reader / writer examples/ example programs joey-run run a built program in its emulator scripts/ bench / verification scripts (scripts/lib: shared helpers) +tests/gate/ the gates' C helpers (screenshot, audio, and serial checks) tests/goldens/uber/ frozen UBER conformance hashes install.sh installs toolchains, emulators and firmware installer/ the installer's component recipes, patches, lists @@ -410,7 +426,7 @@ void jlTilePasteMono (jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in, uint8_t fgColor, uint8_t bgColor); // Load up to maxTiles tiles from a baked .tbk file (per-target planar -// bytes from tools/assetbake/assetbake.py --type tile --target ...). +// bytes from tools/assetbake --type tile --target ...). // Refuses files baked for the wrong target. outValid (optional) marks // each loaded slot; outPalette (optional) receives the embedded // 16-entry $0RGB palette if present. @@ -448,7 +464,7 @@ jlSpriteT *jlSpriteCreateFromSurface (const jlSurfaceT *src, int16_t x, int16_t void jlSpriteDestroy (jlSpriteT *sp); // Load up to maxCels cels from a baked .spr file (cross-target chunky -// 4bpp blob from tools/assetbake/assetbake.py --type sprite). Each +// 4bpp blob from tools/assetbake --type sprite). Each // cel becomes a freshly-allocated jlSpriteT (release with // jlSpriteDestroy). outPalette (optional) receives the embedded // 16-entry $0RGB palette if present. @@ -494,8 +510,8 @@ uint32_t jlSpriteCodegenBytesTotal (void); ### Assets (baked at build time) Asset PNGs live in each example's `assets/` directory and are baked -to native binary blobs at build time by `tools/assetbake/assetbake.py` -(Python+PIL). The runtime loads those blobs directly with no +to native binary blobs at build time by `tools/assetbake` (C, built +into `build/tools/`). The runtime loads those blobs directly with no conversion -- there is no in-memory chunky-to-planar or palette re-indexing on the device. @@ -513,8 +529,8 @@ Two blob formats: Both formats embed an optional 16-entry `$0RGB` palette. ``` -tools/assetbake/assetbake.py --type tile --target {amiga|atarist|dos|iigs|x68000} in.png out.tbk -tools/assetbake/assetbake.py --type sprite --cell WxH in.png out.spr +build/tools/assetbake --type tile --target {amiga|atarist|dos|iigs|x68000} in.png out.tbk +build/tools/assetbake --type sprite --cell WxH in.png out.spr ``` A program's description wires its bake rules once for every target @@ -533,7 +549,7 @@ on-the-fly path stays alongside the baked load API. Every program gets an icon on each system that shows programs by one -- the IIgs Finder, Workbench, the Program Managers of DVX and Windows 3.1, Explorer, Tracker, the PSP's menu, the SDL3 window, and the web page -- -baked by `tools/iconBake.py` from one picture: the program's `ID_ICON` (a +baked by `tools/iconBake` from one picture: the program's `ID_ICON` (a PNG), or JoeyLib's kangaroo (`assets/joeyIcon.png`). `docs/BuildIntegration.adoc` (Icons) says what each system gets, and `scripts/verify-all.sh icon` checks that each one shows it. @@ -543,7 +559,7 @@ that each one shows it. `docs/Assets.adoc` (Content Creation Tools) recommends a tool for each kind of asset: GrafX2 or LibreSprite for tile and sprite sheets, Furnace for chip-tracker music (`./install.sh install furnace`; its VGM exports become -`.song` files through `tools/vgm2song.py`), MilkyTracker or OpenMPT for +`.song` files through `tools/vgm2song`), MilkyTracker or OpenMPT for `.MOD` music, and sfxr, jfxr, Audacity, or SoX for sound effects. @@ -643,7 +659,7 @@ void jlAudioStopSfx (uint8_t slot); // Portable chip-tracker music: a JYM1 event stream baked by tools/songbake // (from text notation, or from a tracker: write it in Furnace for the -// SN76489 or AY-3-8910, export VGM, convert with tools/vgm2song.py), +// SN76489 or AY-3-8910, export VGM, convert with tools/vgm2song), // played through the tone/noise layer at register-write cost on every port. // No CPU mixing, so it is viable where jlAudioPlayMod is not. bool jlMusicPlay (const uint8_t *data, uint32_t length, bool loop); @@ -769,9 +785,13 @@ Building with `EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE` additionally mirrors every unbounded, needs no disk, and arrives in real time, which is what makes a wedged run diagnosable. It is OFF by default and is currently implemented only for the X68000 (`jlpLogTee` in `src/x68000/hal.c`); the host end is -`scripts/x68kSerialPeer.py`, which must be run under `python3 -u` -- it never -passes `flush=True`, so a killed peer loses its whole capture to stdio -buffering. +the `x68kSerialPeer` gate tool (`tests/gate/x68kSerialPeer.c`, built by +`make -f make/tools.mk x68kSerialPeer` into `build/tools/`), whose output is +line-buffered, so a killed peer keeps everything it captured: + +``` +build/tools/x68kSerialPeer --listen-only --until '----- end -----' --timeout 3600 | tee x68.log +``` ### Platform macros (`joey/platform.h`) diff --git a/assets/joeylibLogo.svg b/assets/joeylibLogo.svg index 243c693..50f4138 100644 --- a/assets/joeylibLogo.svg +++ b/assets/joeylibLogo.svg @@ -1,9 +1,8 @@ assets/joeylib_logo_-_320x200.png - - - - - + + + + diff --git a/docs/ApiCore.adoc b/docs/ApiCore.adoc index 264f61b..342e8b1 100644 --- a/docs/ApiCore.adoc +++ b/docs/ApiCore.adoc @@ -233,8 +233,8 @@ uint16_t jlFrameHz(void); *Parameters:* none. *Returns:* the nominal display frame rate in Hz -- 60 on the IIgs, or 50 on a -PAL machine; 50 on the Amiga, whose screen is PAL, or 60 on an NTSC Amiga that -cannot show PAL; 50 or 60 on the ST, as the machine is set (71 with a +PAL machine; 50 on a PAL Amiga and 60 on an NTSC one, whose screen is in the +machine's own standard; 50 or 60 on the ST, as the machine is set (71 with a monochrome monitor); 55 on the X68000; 70 on DOS's VGA 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); and 60 on every hosted port, whose window diff --git a/docs/Assets.adoc b/docs/Assets.adoc index f27046e..1b191c0 100644 --- a/docs/Assets.adoc +++ b/docs/Assets.adoc @@ -12,17 +12,17 @@ type: what you author, which tool bakes it, and which API call loads it. |You author |Tool |Baked file |Loaded by |Indexed PNG tile sheet -|`assetbake.py --type tile` +|`assetbake --type tile` |`.tbk` |`jlTileBankLoad`, `jlTileBankLoadToSurface` |A Tiled map (TMX or TMJ) with indexed tilesets -|`tiled2joey.py`, then `assetbake.py --type tile` for its sheet +|`tiled2joey`, then `assetbake --type tile` for its sheet |a `.map` per layer, and a `.tbk` |`jlTileMapPaste` (the map read with `jlDataOpen`) |Indexed PNG sprite sheet -|`assetbake.py --type sprite` +|`assetbake --type sprite` |`.spr` |`jlSpriteBankLoad` @@ -32,12 +32,12 @@ type: what you author, which tool bakes it, and which API call loads it. |`jlSpriteBankLoadPrecompiled` |Text note notation -|`songbake.py` +|`songbake` |`.jym` or a C header |`jlMusicPlay` |A tracker song (Furnace) for the SN76489 or AY-3-8910, exported as VGM -|`vgm2song.py`, then `songbake.py` +|`vgm2song`, then `songbake` |`.jym` or a C header |`jlMusicPlay` @@ -55,6 +55,9 @@ type: what you author, which tool bakes it, and which API call loads it. Baked assets are staged into `DATA/` beside the executable, and the program opens them with bare names. +The bakers are C programs built for the computer you build on: `make tools` +puts them in `build/tools/`, and a port's build makes the ones its rules run. + === Content Creation Tools These are the tools we recommend for making each kind of asset. All of them @@ -77,7 +80,7 @@ run on Linux, and all but OpenMPT are native there. |Tile maps |*Tiled* (GPLv2; what you make with it is yours), a map editor that places tiles from tilesets in layers. -|Orthogonal maps of 8x8 tiles from indexed tilesets, then `tiled2joey.py` +|Orthogonal maps of 8x8 tiles from indexed tilesets, then `tiled2joey` (<<_maps_from_tiled>>). |Program icons @@ -131,7 +134,7 @@ row by row. [source,console] ---- -$ python3 tools/assetbake/assetbake.py --type tile --target atarist \ +$ build/tools/assetbake --type tile --target atarist \ font.png build/atarist/bin/DATA/font.tbk ---- @@ -145,16 +148,16 @@ loader refuses a file baked for a different target rather than drawing garbage. ==== Maps from Tiled -`tools/tiled2joey.py` turns a map made in Tiled into what a JoeyLib program -pastes: a tile sheet, which `assetbake.py` bakes into a tile bank for each +`tools/tiled2joey` turns a map made in Tiled into what a JoeyLib program +pastes: a tile sheet, which `assetbake` bakes into a tile bank for each port, and a map for each tile layer, which `jlTileMapPaste` takes as it is: [source,console] ---- -$ python3 tools/tiled2joey.py --header build/level1.h level1.tmx \ +$ build/tools/tiled2joey --header build/level1.h level1.tmx \ build/level1.png build/level1 tiled2joey: 60x25 map, 87 tiles, 14 colours, layers: ground.map, detail.map -$ python3 tools/assetbake/assetbake.py --type tile --target iigs \ +$ build/tools/assetbake --type tile --target iigs \ build/level1.png build/iigs/bin/DATA/level1.tbk ---- @@ -202,7 +205,7 @@ layer = jlTileLayerCreate(tiles, level, LEVEL1_WIDTH, LEVEL1_HEIGHT, 0, 25); [source,console] ---- -$ python3 tools/assetbake/assetbake.py --type sprite --cell 2x3 \ +$ build/tools/assetbake --type sprite --cell 2x3 \ player.png build/atarist/bin/DATA/player.spr ---- @@ -295,8 +298,8 @@ Bake it either as a binary blob or directly as a C array: [source,console] ---- -$ python3 tools/songbake/songbake.py song.song song.jym -$ python3 tools/songbake/songbake.py -c gDemoSong song.song song.h +$ build/tools/songbake song.song song.jym +$ build/tools/songbake -c gDemoSong song.song song.h ---- The C header form is usually what you want for a small song: it compiles into @@ -313,7 +316,7 @@ same time; `songbake` warns when they overlap. A tracker is easier than a text file for anything longer than a jingle. Write the song in Furnace, export it as VGM (a log of the chip's register writes as -the song plays), and `tools/vgm2song.py` turns that into a `.song` file, which +the song plays), and `tools/vgm2song` turns that into a `.song` file, which is still plain text you can read and touch up: . Start the song on a system with a *TI SN76489* (the Sega Master System, BBC @@ -328,8 +331,8 @@ is still plain text you can read and touch up: + [source,console] ---- -$ python3 tools/vgm2song.py song.vgm song.song -$ python3 tools/songbake/songbake.py -c gSong song.song song.h +$ build/tools/vgm2song song.vgm song.song +$ build/tools/songbake -c gSong song.song song.h ---- What comes across: @@ -384,10 +387,9 @@ The output extension selects the conversion: does not loop at the marker too, and the IIgs's replayer ignores `E8F`, so an `.amod` plays the same anywhere (looping, with the marker's pattern of silence at its end). -* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, produced by the - NinjaTrackerPlus converter the installer's `ntp` component provides (part of - `toolchain:iigs`; it needs the PHP command-line interpreter, which the - installer's host packages include). +* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, made by JoeyLib's + `modToNtp` (`build/tools/modToNtp IN.MOD OUT.NTP`, which `joeymod` builds + and runs). Its files are byte for byte those of Ninjaforce's own converter. Keep modules modest. A four-channel mix is real CPU work on DOS, the ST, and the X68000, and the sample data has to fit in memory alongside your game. @@ -459,18 +461,19 @@ And a bake rule looks like: [source,makefile] ---- -$(call programDataDir,MYGAME)/font.tbk: assets/font.png +$(call programDataDir,MYGAME)/font.tbk: assets/font.png $(ASSET_BAKE) @mkdir -p $(dir $@) - python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type tile --target $(ASSET_PLATFORM) $< $@ + $(ASSET_BAKE) --type tile --target $(ASSET_PLATFORM) $< $@ ---- Tile banks and `.spc` files differ per platform, while `.spr`, `.jym`, and -`.sfx` files are shared, as are the `.map` files `tiled2joey.py` writes: +`.sfx` files are shared, as are the `.map` files `tiled2joey` writes: `ASSET_PLATFORM` names the target a bake is for (DVX and every hosted port -- SDL3, the web, macOS, Win32, Win16, the PSP, Haiku, and BeOS -- take the DOS bakes; DVX and Win16 run the x86 code of a `.spc`, the others use only its -cels; the STe build takes the ST's) and `SPRITEBAKE` the matching host -`spritebake`. +cels; the STe build takes the ST's), `SPRITEBAKE` the matching host +`spritebake`, and `ASSET_BAKE` the host `assetbake`; a rule that runs a baker +lists it as a prerequisite, so make builds it first. `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`), @@ -509,16 +512,21 @@ and the streaming loader needs only a small internal scratch buffer. === The Logo The JoeyLib logo lives in `assets/` as both the original 320x200 PNG and a -traced SVG. If the source art ever changes, regenerate the vector with: +traced SVG. If the source art ever changes, regenerate the vector with +`tools/traceLogo`, which needs the potrace program (the distribution's +`potrace` package, or set `POTRACE` to one installed elsewhere): [source,console] ---- -$ python3 -m venv /tmp/tracevenv -$ /tmp/tracevenv/bin/pip install potracer pillow numpy -$ /tmp/tracevenv/bin/python tools/traceLogo.py \ - assets/joeylib_logo_-_320x200.png assets/joeylibLogo.svg --colors 5 +$ make -f make/tools.mk traceLogo +$ build/tools/traceLogo assets/joeylib_logo_-_320x200.png \ + assets/joeylibLogo.svg ---- -Five colour clusters measured as the closest match to the original artwork; -more clusters start carving the antialiased edges into separate layers, which -renders as noise rather than detail. +The art is four flat colours (ink, skin, the green shorts, and the beige +monitor), so the default of four clusters is the trace that looks best: each +layer is filled with the exact colour most of its pixels have, and the +clustering is done in CIELAB, where an antialiased grey edge joins the ink or +the beige it lies between rather than the skin colour nearest it in RGB. A +fifth cluster and beyond only carve those grey edges into layers of their +own, which render as speckled outlines rather than detail. diff --git a/docs/BuildIntegration.adoc b/docs/BuildIntegration.adoc index d6f7716..62c1854 100644 --- a/docs/BuildIntegration.adoc +++ b/docs/BuildIntegration.adoc @@ -204,7 +204,8 @@ the others take its cels without their code. The STe build takes the ST's. |`SPRITEBAKE` |The host `spritebake` for this target (its rule is JoeyLib's). |`PROJECT_DIR`, `PROJECT_BUILD` |The project and its `build/`. -|`REPO_DIR` |The JoeyLib checkout, for its tools: `$(REPO_DIR)/tools/assetbake/assetbake.py`. +|`ASSET_BAKE` |The host `assetbake` (its rule is JoeyLib's). +|`REPO_DIR` |The JoeyLib checkout. |`HOST_CC`, `HOST_CFLAGS` |For a host tool of the project's own. |=== @@ -220,13 +221,13 @@ $(MYGAME_RUN)/theme.jym: assets/theme.jym @mkdir -p $(dir $@) cp $< $@ -$(MYGAME_RUN)/tiles.tbk: assets/tiles.png +$(MYGAME_RUN)/tiles.tbk: assets/tiles.png $(ASSET_BAKE) @mkdir -p $(dir $@) - python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type tile --target $(ASSET_PLATFORM) $< $@ + $(ASSET_BAKE) --type tile --target $(ASSET_PLATFORM) $< $@ -$(MYGAME_GEN)/player.spr: assets/player.png +$(MYGAME_GEN)/player.spr: assets/player.png $(ASSET_BAKE) @mkdir -p $(dir $@) - python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type sprite --cell 16x16 $< $@ + $(ASSET_BAKE) --type sprite --cell 16x16 $< $@ $(MYGAME_RUN)/player.spc: $(MYGAME_GEN)/player.spr $(SPRITEBAKE) @mkdir -p $(dir $@) @@ -333,7 +334,7 @@ stick; the PSP shows it by its icon and its `ID_TITLE`. The unstripped ELF stays A program has an icon on every system that shows programs by one. `ID_ICON` names a picture, a PNG, best square and transparent around the figure, and -`make` bakes it for each target with `tools/iconBake.py`; a program without +`make` bakes it for each target with `tools/iconBake`; a program without one gets JoeyLib's kangaroo, `assets/joeyIcon.png`. A picture of 64 pixels or less is taken for pixel art and enlarged by whole pixels; a bigger one is resampled smoothly. Where an icon has few colours, each pixel takes the @@ -469,7 +470,7 @@ if a gate had to be skipped. |`scripts/bench-.sh` |Per-operation throughput for that port. -|`tools/diff-uber-hashes` +|`build/tools/diffUberHashes` |Compares a port's rendered output against the IIgs golden reference, operation by operation, by surface hash. |=== diff --git a/docs/Portability.adoc b/docs/Portability.adoc index c4cc618..4b76bd8 100644 --- a/docs/Portability.adoc +++ b/docs/Portability.adoc @@ -227,7 +227,7 @@ The API is uniform; these are the places where what you get differs. |Frame rate |60 Hz (50 on a PAL machine) -|50 Hz (PAL; 60 on an NTSC machine without PAL) +|50 Hz on a PAL machine, 60 on an NTSC one |50 or 60 Hz, as the machine is set |70 Hz |55 Hz (56 under MAME) diff --git a/docs/ProgramLayout.adoc b/docs/ProgramLayout.adoc index 7c50777..f317a7c 100644 --- a/docs/ProgramLayout.adoc +++ b/docs/ProgramLayout.adoc @@ -140,8 +140,8 @@ too. ==== Frame-Rate Independence -The targets do not share a refresh rate: 50 Hz (the Amiga, and a PAL IIgs or -ST), 55 Hz (X68000), 60 Hz (the IIgs, an NTSC ST, and every hosted port's +The targets do not share a refresh rate: 50 Hz (a PAL Amiga, IIgs, or ST), +55 Hz (X68000), 60 Hz (an NTSC Amiga, IIgs, or ST, and every hosted port'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/audio/demoSong.h b/examples/audio/demoSong.h index 8b1c04a..666a2d9 100644 --- a/examples/audio/demoSong.h +++ b/examples/audio/demoSong.h @@ -1,6 +1,6 @@ -// Generated by tools/songbake/songbake.py from examples/audio/demoSong.song. +// Generated by tools/songbake from examples/audio/demoSong.song. // Do not hand-edit; edit the .song source and rebake: -// python3 tools/songbake/songbake.py -c gDemoSong examples/audio/demoSong.song +// build/tools/songbake -c gDemoSong examples/audio/demoSong.song #ifndef JOEYLIB_BAKED_GDEMOSONG_H #define JOEYLIB_BAKED_GDEMOSONG_H diff --git a/examples/audio/demoSong.song b/examples/audio/demoSong.song index c9f9e88..7f78721 100644 --- a/examples/audio/demoSong.song +++ b/examples/audio/demoSong.song @@ -5,7 +5,7 @@ ; around the drum rows because the ST shares channel C's volume ; between tone voice 2 and noise. ; -; Bake: python3 tools/songbake/songbake.py -c gDemoSong \ +; Bake: build/tools/songbake -c gDemoSong \ ; examples/audio/demoSong.song examples/audio/demoSong.h tickms 110 diff --git a/examples/sermouse/sermouse.c b/examples/sermouse/sermouse.c index cd80f3e..f171317 100644 --- a/examples/sermouse/sermouse.c +++ b/examples/sermouse/sermouse.c @@ -22,7 +22,7 @@ // select-then-write register sequence in the path of both channels' // interrupts for the whole run. // SPACE pauses and resumes transmitting. The peer answers byte for byte -// (scripts/lib/serialStreamPeer.py), so a pause quiets the line both ways. +// (tests/gate/serialStreamPeer.c), so a pause quiets the line both ways. // ESC quits. // // Mouse: a small pointer follows it. @@ -290,7 +290,7 @@ static void stampSerialRow(jlSurfaceT *screen, const StreamStatsT *stats, uint16 // Byte `index` of a stream: the index's low byte XOR its next byte, XOR the // stream's mask. A lost or repeated byte throws every byte after it off its // expected value, where a plain counter would miss any gap that is a multiple -// of 256. scripts/lib/serialStreamPeer.py uses the same formula and masks. +// of 256. tests/gate/serialStreamPeer.c uses the same formula and masks. static uint8_t streamByte(uint32_t index, uint8_t mask) { return (uint8_t)(index ^ (index >> BYTE_BITS) ^ mask); } diff --git a/examples/tilebank/tilebank.c b/examples/tilebank/tilebank.c index 1625643..9df6c3a 100644 --- a/examples/tilebank/tilebank.c +++ b/examples/tilebank/tilebank.c @@ -9,8 +9,8 @@ // that assetbake's encoder for THIS target writes exactly the layout this // port's native tile paste reads, and that this build refuses the others. // -// DATA/tilebank/ is staged by tests/host/tileBankStage.py (the make rule in -// make/.mk), from tests/host/tileBank.png: +// DATA/tilebank/ is staged by tests/host/tileBankStage.sh (the make rule in +// make/examples.mk), from tests/host/tileBank.png: // own.tbk this port's bake: the bank that must load // refuse.tbk n = 1, 2, ...: every other target's bake // expect.bin the PNG decoded by PIL, independent of assetbake's @@ -49,7 +49,7 @@ // ----- Constants ----- -// The staged files (tests/host/tileBankStage.py names them). +// The staged files (tests/host/tileBankStage.sh names them). #define OWN_BANK "tilebank/own.tbk" #define EXPECT_NAME "tilebank/expect.bin" #define REFUSE_BANK_FORMAT "tilebank/refuse%u.tbk" diff --git a/examples/uber/uber.c b/examples/uber/uber.c index 5d22d70..1f9f7f8 100644 --- a/examples/uber/uber.c +++ b/examples/uber/uber.c @@ -254,7 +254,7 @@ static unsigned long runForFrames(OpFn op, unsigned int targetFrames, uint16_t * // regardless of CPU speed or display refresh rate. Also logs an // FNV-1a hash of the surface state after timing -- this is the // pixel-perfect comparison input for the cross-port validation -// harness (tools/diff-uber-hashes.py). Captured against IIgs as the +// harness (build/tools/diffUberHashes). Captured against IIgs as the // golden reference; planar 68k rewrites validate by matching it. // The window is `frames` long. settle (NULL for none) runs between the // timing and the hash, for an op whose last call leaves a picture that @@ -1704,7 +1704,7 @@ static void DRAWSHOWCASE_ATTR drawShowcase(void) { // ----- Non-timed correctness checks (Phase 0 verification harness) ----- // // Each check draws a deterministic scene and logs the surface hash so -// tools/diff-uber-hashes can compare ports against each other and against +// build/tools/diffUberHashes can compare ports against each other and against // the frozen golden logs. Several checks intentionally capture the CURRENT // behavior of known bugs (claudeDocs/PERF-AUDIT.md #1, #11, #12, #72): their hash // lines are EXPECTED to change when the Phase 1 fixes land -- re-golden diff --git a/examples/wblaunch/wblaunch.c b/examples/wblaunch/wblaunch.c index 623bd2f..feb9c0e 100644 --- a/examples/wblaunch/wblaunch.c +++ b/examples/wblaunch/wblaunch.c @@ -64,7 +64,7 @@ static bool launch(const char *folder, const char *name, ULONG stack); static void logLine(const char *format, ...); // Workbench's four colours (2.0 on), 4 bits a gun: grey, black, white and -// blue (tools/iconBake.py's AMIGA_COLOURS). +// blue (tools/iconBake/iconBake.c's kAmigaColours). static const UWORD gWorkbenchColours[] = { 0xAAA, 0x000, 0xFFF, 0x68B }; static FILE *gLog = NULL; diff --git a/include/joey/sprite.h b/include/joey/sprite.h index 4257a94..fb2cc17 100644 --- a/include/joey/sprite.h +++ b/include/joey/sprite.h @@ -61,7 +61,7 @@ typedef struct { } jlSpriteBackupT; // Load up to maxCels cels from a baked .spr file produced by -// tools/assetbake/assetbake.py. Each cel becomes a freshly-allocated +// tools/assetbake/assetbake.c. Each cel becomes a freshly-allocated // jlSpriteT (release with jlSpriteDestroy); the file's cellCount is // capped at maxCels. // diff --git a/include/joey/tile.h b/include/joey/tile.h index cff8a94..3890b51 100644 --- a/include/joey/tile.h +++ b/include/joey/tile.h @@ -72,7 +72,7 @@ void jlTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy, const jlSur void jlTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex); // Load up to maxTiles tiles from a baked .tbk file produced by -// tools/assetbake/assetbake.py. The file's per-target byte is checked +// tools/assetbake/assetbake.c. The file's per-target byte is checked // against the build platform and the loader refuses mismatches. // // outValid (optional, NULL OK) gets one flag per slot in [0, maxTiles): diff --git a/installer/components/beosR5.sh b/installer/components/beosR5.sh index e18fd5e..9de0288 100644 --- a/installer/components/beosR5.sh +++ b/installer/components/beosR5.sh @@ -13,7 +13,7 @@ compDescription="toolchain: BeOS R5 headers + libraries from your BeOS 5 Pro CD (beos; private media: import)" compGroups="toolchain:beos" compRequires="haikuGcc2 haikuTools" -compHostDeps="python3" +compHostDeps="build-tools make" # 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". diff --git a/installer/components/beosVm.sh b/installer/components/beosVm.sh index 5b98b77..2032ddd 100644 --- a/installer/components/beosVm.sh +++ b/installer/components/beosVm.sh @@ -12,7 +12,7 @@ compDescription="emulator: BeOS R5 VM from your BeOS 5 Pro CD (beos; private media: import)" compGroups="emulator:beos" compRequires="qemu haikuTools" -compHostDeps="python3" +compHostDeps="build-tools make" # shellcheck source=installer/lib/beos.sh source "$JOEY_INSTALLER/lib/beos.sh" diff --git a/installer/components/furnace.sh b/installer/components/furnace.sh index 3a935f3..10e78b6 100644 --- a/installer/components/furnace.sh +++ b/installer/components/furnace.sh @@ -7,7 +7,7 @@ # later), the recommended tool for JoeyLib's chip-tracker music # (docs/Assets.adoc, "Content Creation Tools"): write the song for the TI # SN76489 (or the AY-3-8910 / YM2149), export it as VGM, and -# tools/vgm2song.py turns that into a .song for tools/songbake. Its own Linux +# tools/vgm2song turns that into a .song for tools/songbake. Its own Linux # x86_64 release, unpacked into toolchains/opt/furnace. Nothing builds with # it, so no group installs it: ./install.sh install furnace. # scripts/check-vgm.sh exports the SN76489 and AY demo songs it comes with diff --git a/installer/components/haikuVm.sh b/installer/components/haikuVm.sh index 5e77c3a..71a0169 100644 --- a/installer/components/haikuVm.sh +++ b/installer/components/haikuVm.sh @@ -13,7 +13,7 @@ compDescription="emulator: Haiku R1/beta6 x86_64 VM image (haiku)" compGroups="emulator:haiku" compRequires="qemu haikuTools" -compHostDeps="python3" +compHostDeps="" haikuArch="x86_64" # shellcheck source=installer/lib/haiku.sh diff --git a/installer/components/haikuVm32.sh b/installer/components/haikuVm32.sh index 9ea64a0..09bbea4 100644 --- a/installer/components/haikuVm32.sh +++ b/installer/components/haikuVm32.sh @@ -13,7 +13,7 @@ compDescription="emulator: Haiku R1/beta6 x86 gcc2h VM image (haiku32)" compGroups="emulator:haiku32" compRequires="qemu haikuTools" -compHostDeps="python3" +compHostDeps="" haikuArch="x86_gcc2" # shellcheck source=installer/lib/haiku.sh diff --git a/installer/components/host.sh b/installer/components/host.sh index b3ddd70..61657f5 100644 --- a/installer/components/host.sh +++ b/installer/components/host.sh @@ -5,14 +5,17 @@ # # 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 -# for the asset converters, and socat for joey-run's serial bridging. +# archive tools the other components unpack with, socat for joey-run's +# serial bridging, gawk and patch for the scripts and the IIgs build, and +# potrace for tools/traceLogo. JoeyLib's own tools and gates are C and +# bash; the components that build third-party code with Python list python3 +# themselves. -compDescription="host tools: make, host compiler, git-lfs, python3 + Pillow, archivers, socat" +compDescription="host tools: make, host compiler, git-lfs, archivers, socat, potrace" # Not in any port group: every other component requires it. compGroups="host" compRequires="" -compHostDeps="build-tools make git git-lfs curl tar xz bzip2 unzip lhasa python3 python3-pil procps diffutils socat" +compHostDeps="build-tools make gawk patch git git-lfs curl tar xz bzip2 unzip lhasa procps diffutils socat potrace" compPin="1" @@ -48,7 +51,7 @@ compInstall() { compInstalled() { - command -v make >/dev/null && command -v cc >/dev/null && command -v python3 >/dev/null && command -v git-lfs >/dev/null + command -v make >/dev/null && command -v cc >/dev/null && command -v git-lfs >/dev/null && command -v gawk >/dev/null && command -v potrace >/dev/null } @@ -61,7 +64,6 @@ compVerify() { [[ "$makeVersion" =~ ([0-9]+\.[0-9]+) ]] && makeVersion="${BASH_REMATCH[1]}" # Grouped targets (&:) in the port makefiles need GNU make 4.3. printf '%s\n4.3\n' "$makeVersion" | sort -V -C -r || { warn "GNU make $makeVersion is older than 4.3"; return 1; } - python3 -c 'import PIL' || { warn "python3 cannot import PIL (Pillow)"; return 1; } stubs="$(lfsPointerFiles | wc -l)" [ "$stubs" = "0" ] || { warn "$stubs Git LFS files are still pointer stubs"; return 1; } } diff --git a/installer/components/ntp.sh b/installer/components/ntp.sh index e1aebe3..f1052de 100644 --- a/installer/components/ntp.sh +++ b/installer/components/ntp.sh @@ -4,14 +4,15 @@ # SPDX-License-Identifier: MIT # # NinjaTrackerPlus: Ninjaforce's IIgs MOD replayer, shipped as Merlin -# source plus the PHP converter tools/joeymod uses for .MOD -> .NTP. +# source (the archive's PHP converter is not used: tools/modToNtp makes the +# .NTP files). # Redistribution is granted by the copyright holder. The URL is not # versioned, so the pin is the archive's checksum. -compDescription="IIgs audio: NinjaTrackerPlus replayer source + ntpconverter.php" +compDescription="IIgs audio: NinjaTrackerPlus replayer source" compGroups="toolchain:iigs" compRequires="host" -compHostDeps="unzip php-cli" +compHostDeps="unzip" compUrl="https://www.ninjaforce.com/downloads/ntpsources.zip" compSha256="461260e35dc0b10176c05865d01c4e31694f704c0c3a9e5760fc093623dc52fb" compPin="$compSha256" @@ -32,7 +33,7 @@ compInstall() { compInstalled() { - [ -f "$compDir/ninjatrackerplus.s" ] && [ -f "$compDir/ntpconverter.php" ] + [ -f "$compDir/ninjatrackerplus.s" ] } diff --git a/installer/components/win311.sh b/installer/components/win311.sh index 461bd87..ccb2471 100644 --- a/installer/components/win311.sh +++ b/installer/components/win311.sh @@ -14,7 +14,7 @@ compDescription="emulator: Windows for Workgroups 3.11 on DOSBox (win16; private media: import)" compGroups="emulator:win16" compRequires="dosbox" -compHostDeps="python3 xvfb" +compHostDeps="build-tools make xvfb" # shellcheck source=installer/lib/win16.sh source "$JOEY_INSTALLER/lib/win16.sh" diff --git a/installer/components/wing.sh b/installer/components/wing.sh index c8921bf..a75b368 100644 --- a/installer/components/wing.sh +++ b/installer/components/wing.sh @@ -19,7 +19,7 @@ compDescription="win16: Microsoft WinG 1.0 runtime for its Windows (optional; private: import WING10.EXE)" compGroups="optional" compRequires="win311" -compHostDeps="python3 unzip" +compHostDeps="build-tools make unzip" compPrivateFile="toolchains/firmware/win16/WING10.EXE" compPin="$(privateSha1For "$compPrivateFile")" @@ -36,7 +36,7 @@ compInstall() { mkdir -p "$compDir" # The SDK's compressed names end in _ where the last letter was. for file in $wingRuntime; do - python3 "$JOEY_REPO/tools/szddExpand.py" "$tmp/${file%?}_" "$compDir/$file" || { rm -rf "$tmp"; die "$file did not expand"; } + "$(hostTool szddExpand)" "$tmp/${file%?}_" "$compDir/$file" || { rm -rf "$tmp"; die "$file did not expand"; } done cp "$tmp/LICENSE.TXT" "$compDir/LICENSE.TXT" rm -rf "$tmp" diff --git a/installer/distroPackages.txt b/installer/distroPackages.txt index 1cc69b0..3aba170 100644 --- a/installer/distroPackages.txt +++ b/installer/distroPackages.txt @@ -19,9 +19,10 @@ unzip unzip unzip wget wget wget lhasa lhasa lhasa python3 python3 python3 -python3-pil python3-pil python3-pillow -php-cli php-cli php-cli patch patch patch +gawk gawk gawk +# tools/traceLogo traces the logo art with the potrace program. +potrace potrace potrace # joey-run --serial bridges some emulators' serial ports to TCP with socat. socat socat socat diff --git a/installer/lib/beos.sh b/installer/lib/beos.sh index 2414c5e..08ca3bd 100644 --- a/installer/lib/beos.sh +++ b/installer/lib/beos.sh @@ -46,7 +46,6 @@ BEOS_CD_FILE="toolchains/firmware/beos/beos5pro.img" # (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" @@ -92,7 +91,7 @@ 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" || + "$(hostTool rawCd)" 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" } @@ -111,7 +110,7 @@ beosVmPrepare() { 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" || + "$(hostTool rawCd)" 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" diff --git a/installer/lib/bundle.sh b/installer/lib/bundle.sh index 02eb262..6f11464 100644 --- a/installer/lib/bundle.sh +++ b/installer/lib/bundle.sh @@ -87,7 +87,7 @@ importPaths() { *.adf|*.ADF) rm -rf "$tmp/adf" mkdir -p "$tmp/adf" - if python3 "$JOEY_REPO/tools/adfTool.py" extractall "$file" "$tmp/adf" 2>/dev/null; then + if "$(hostTool adfTool)" extractall "$file" "$tmp/adf" 2>/dev/null; then find "$tmp/adf" -type f -print0 | while IFS= read -r -d '' inner; do importOneFile "$inner" done diff --git a/installer/lib/common.sh b/installer/lib/common.sh index be62c0c..a08482a 100644 --- a/installer/lib/common.sh +++ b/installer/lib/common.sh @@ -136,8 +136,8 @@ The upstream file changed. Check it, then update the pin in the component file." 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" + token="$(curl -fsSL "https://ghcr.io/token?scope=repository:$1:pull" | sed -n 's/.*"token" *: *"\([^"]*\)".*/\1/p')" \ + && [ -n "$token" ] || die "cannot get a registry token for $1" fi fetchFile "https://ghcr.io/v2/$1/blobs/sha256:$2" "$2" "$3" "Authorization: Bearer $token" } @@ -199,6 +199,15 @@ freshBuildDir() { } +# hostTool NAME -- print the path of one of JoeyLib's host tools +# (make/tools.mk's, in build/tools/), built first if it is not current; a +# component that runs one lists "build-tools make" among its compHostDeps. +hostTool() { + make -s -f "$JOEY_REPO/make/tools.mk" "$1" > /dev/null || die "cannot build tools/$1 (the host component installs make and a C compiler)" + printf '%s\n' "${JOEY_BUILD:-$JOEY_REPO/build}/tools/$1" +} + + # dropBuildDir ID -- delete ID's scratch tree unless --keep-build. dropBuildDir() { if [ "$JOEY_KEEP_BUILD" = "1" ]; then diff --git a/installer/lib/haiku.sh b/installer/lib/haiku.sh index 665d199..b8c17f5 100644 --- a/installer/lib/haiku.sh +++ b/installer/lib/haiku.sh @@ -75,20 +75,17 @@ haikuBfsNew() { # haikuBootOffset IMAGE -- the byte offset of an anyboot image's first -# partition, its BFS system volume (from the MBR: entry 1's start sector). +# partition, its BFS system volume (from the MBR: entry 1, at byte 446, +# holds its start sector, little-endian, at byte 8). haikuBootOffset() { - python3 - "$1" <<'PY' -import struct -import sys + local mbrEntry1=446 + local entryStartLba=8 + local sectorBytes=512 + local b -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/tcp/127.0.0.1/$1") 2> /dev/null } @@ -486,7 +477,7 @@ mameSerial() { local bitb="socket.$serialHost:$serialPort" [ -n "$serialMode" ] || return 0 mkdir -p "$work/cfg" - python3 "$repo/scripts/lib/mameNullModemCfg.py" "$driver" ":$slot:null_modem" "$serialBaud" \ + "$repo/scripts/lib/mameNullModemCfg.sh" "$driver" ":$slot:null_modem" "$serialBaud" \ > "$work/cfg/$driver.cfg" || fail "--serial-baud $serialBaud is not a rate MAME's null modem has" if [ "$serialMode" = "listen" ]; then relaySerial "unix-listen:serial.sock" "$work/serial.sock" diff --git a/make/amiga.mk b/make/amiga.mk index 721c3cd..f41b9b1 100644 --- a/make/amiga.mk +++ b/make/amiga.mk @@ -149,7 +149,7 @@ $(call programBin,$(1)): $(call programVar,$(1),SRCS) $(LIB) $(AMIGA_CC) $(CFLAGS) $(call programVar,$(1),CFLAGS) $(call programVar,$(1),SRCS) $(LIB) -o $$@ $(LDFLAGS) $(call programVar,$(1),LDFLAGS) -include $(call programBin,$(1)).d -$(call programBin,$(1)).info: $(call programIcon,$(1)) $(ICON_BAKE_DEPS) +$(call programBin,$(1)).info: $(call programIcon,$(1)) $(ICON_BAKE) @mkdir -p $$(dir $$@) $(ICON_BAKE) info $$< $$@ $(AMIGA_ICON_STACK) endef diff --git a/make/be.mk b/make/be.mk index cbb7f4c..4089c79 100644 --- a/make/be.mk +++ b/make/be.mk @@ -26,7 +26,7 @@ HOSTED_SDK_CFLAGS := HOSTED_LINK := -lbe -lmedia -ldevice $(BE_CXX_RUNTIME) PROGRAM_EXT := # Its icons, the BeOS kind Haiku shows too: 32x32 and 16x16 in the BeOS -# palette, raw (tools/iconBake.py be), put into the linked program's +# palette, raw (tools/iconBake be), put into the linked program's # resources by xres (component haikuTools) as BEOS:L:STD_ICON and # BEOS:M:STD_ICON, BE_ICON_ID each; and the 32x32 one as Haiku's vector # icon, BEOS:ICON, which Haiku draws at any size (BeOS R5 ignores it). An diff --git a/make/common.mk b/make/common.mk index c3e6b6e..dc0c71a 100644 --- a/make/common.mk +++ b/make/common.mk @@ -182,13 +182,18 @@ programOptionalDir = $(if $(PROGRAM_OPTIONAL_APART),$(PROGRAM_BUILD)/data/$(call # $(call programIconDir,ID), where the port's bakes of it go, by its name (the # IIgs disk scripts look icons up by it), or by its id for a program built # apart (ID_BINDIR: a probe build may share a shipped program's name). -# ICON_BAKE is the baker (tools/iconBake.py says what each port gets); a rule -# that runs it depends on ICON_BAKE_DEPS too, so a change to the baker -# rebakes. programIcon = $(or $(call programVar,$(1),ICON),$(REPO_DIR)/assets/joeyIcon.png) programIconDir = $(PROGRAM_BUILD)/icon/$(if $(call programVar,$(1),BINDIR),$(1),$(call programName,$(1))) -ICON_BAKE := python3 $(REPO_DIR)/tools/iconBake.py -ICON_BAKE_DEPS := $(REPO_DIR)/tools/iconBake.py + +# The host bakers a port's rules run, built by make/tools.mk (make/programs.mk +# hands their builds to it): ASSET_BAKE (tools/assetbake, indexed PNGs into +# tile banks and sprite cels), ICON_BAKE (tools/iconBake, a program's icon +# for each system; its usage says what each port gets), and MOD_TO_NTP +# (tools/modToNtp, a .MOD as the IIgs's .NTP, which tools/joeymod runs). A +# rule that runs one depends on it too, so a change to the baker rebakes. +ASSET_BAKE := $(JOEY_BUILD)/tools/assetbake +ICON_BAKE := $(JOEY_BUILD)/tools/iconBake +MOD_TO_NTP := $(JOEY_BUILD)/tools/modToNtp # Rules that hand a host tool's build to make/tools.mk depend on FORCE, so diff --git a/make/dvx.mk b/make/dvx.mk index a4f780f..4bf081d 100644 --- a/make/dvx.mk +++ b/make/dvx.mk @@ -185,7 +185,7 @@ app-$(2): $(LIB) $(call programVar,$(1),SRCS) $(PORT_ENTRY) $(call programVar,$( # Its icon, the one Program Manager shows: icon32 among its resources, a # 32x32 BMP on DVX's grey, which sdk.mk takes from the project's folder. -$(APPSDIR)/$(2)/icon32.bmp: $(call programIcon,$(1)) $(ICON_BAKE_DEPS) +$(APPSDIR)/$(2)/icon32.bmp: $(call programIcon,$(1)) $(ICON_BAKE) @mkdir -p $$(dir $$@) $(ICON_BAKE) bmp $$< $$@ endef diff --git a/make/examples.mk b/make/examples.mk index 948a09f..5a7ff4f 100644 --- a/make/examples.mk +++ b/make/examples.mk @@ -8,7 +8,7 @@ # # Worth knowing which ones the gates use: # UBER the golden-hash vehicle: every public op, a hash per op, which -# tools/diff-uber-hashes compares against tests/goldens/uber (the +# build/tools/diffUberHashes compares against tests/goldens/uber (the # X68000 against the IIgs's, DVX against the DOS port's: the same # stage). UBERPROB is UBER with its UBER_PROBE rows. # PATTERN the SCB acceptance vehicle: 8 bands of 25 lines with 8 gradient @@ -208,7 +208,7 @@ AUDPAR_IIGS_BINDIR := $(PROGRAM_BUILD)/probe/bin AUDPAR_DATA := $(call programDataDir,AUDPAR)/test.mod AUDPAR_IIGS_DATA := $(call programDataDir,AUDPAR)/TEST.NTP -$(call programDataDir,AUDPAR)/TEST.NTP: $(REPO_DIR)/assets/test.mod $(REPO_DIR)/tools/joeymod/joeymod +$(call programDataDir,AUDPAR)/TEST.NTP: $(REPO_DIR)/assets/test.mod $(REPO_DIR)/tools/joeymod/joeymod $(MOD_TO_NTP) @mkdir -p $(dir $@) $(REPO_DIR)/tools/joeymod/joeymod $< $@ @@ -230,9 +230,13 @@ $(call programDataDir,AUDIO)/%: $(REPO_DIR)/assets/% # DATA/tilebank holds this port's bake of it, every other target's bake (each # must be refused) and the expected pixels. TILEBANK_PNG := $(REPO_DIR)/tests/host/tileBank.png -TILEBANK_STAGE := $(REPO_DIR)/tests/host/tileBankStage.py -TILEBANK_BAKE_DEPS := $(TILEBANK_PNG) $(TILEBANK_STAGE) $(REPO_DIR)/tools/assetbake/assetbake.py +TILEBANK_STAGE := $(REPO_DIR)/tests/host/tileBankStage.sh +TILEBANK_EXPECT := $(JOEY_BUILD)/tools/tileBankExpect +TILEBANK_BAKE_DEPS := $(TILEBANK_PNG) $(TILEBANK_STAGE) $(ASSET_BAKE) $(TILEBANK_EXPECT) TILEBANK_DATA := $(call programDataDir,TILEBANK)/tilebank/own.tbk +$(TILEBANK_EXPECT): FORCE + $(MAKE) -f $(REPO_DIR)/make/tools.mk $(notdir $@) + $(TILEBANK_DATA): $(TILEBANK_BAKE_DEPS) - python3 $(TILEBANK_STAGE) $(ASSET_PLATFORM) $(TILEBANK_PNG) $(dir $@) + $(TILEBANK_STAGE) $(ASSET_PLATFORM) $(TILEBANK_PNG) $(dir $@) diff --git a/make/hosted.mk b/make/hosted.mk index fef807d..fd16ca5 100644 --- a/make/hosted.mk +++ b/make/hosted.mk @@ -30,7 +30,7 @@ # 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). # A port whose programs show an icon (make/common.mk's programIcon, baked by -# tools/iconBake.py) sets HOSTED_ICON, the file one program's icon is baked +# tools/iconBake) sets HOSTED_ICON, the file one program's icon is baked # to, and HOSTED_ICON_BAKE, the command that bakes it there from the # program's picture; the program is rebuilt after it, and HOSTED_ICON_LINK # is what of it the link takes (a resource object, a resource script), or @@ -160,14 +160,14 @@ $(if $(call HOSTED_ICON,$(1)),$(call hostedIconRules,$(1))) endef # $(call hostedIconScript,ID,FORMAT): a Windows port's bake: the program's -# icon in iconBake.py's FORMAT (ico, ico16) as icon.ico in its icon folder, +# icon in iconBake's FORMAT (ico, ico16) as icon.ico in its icon folder, # and icon.rc, the resource script that gives it the number HOSTED_ICON_ID # (by its whole path, so the resource compiler may run anywhere). hostedIconScript = $(ICON_BAKE) $(2) $(call programIcon,$(1)) $(call programIconDir,$(1))/icon.ico && printf '%s ICON "%s"\n' $(HOSTED_ICON_ID) $(call programIconDir,$(1))/icon.ico > $(call programIconDir,$(1))/icon.rc # One program's icon, $(1) its id, baked by the port's command. define hostedIconRules -$(call HOSTED_ICON,$(1)): $(call programIcon,$(1)) $(ICON_BAKE_DEPS) +$(call HOSTED_ICON,$(1)): $(call programIcon,$(1)) $(ICON_BAKE) @mkdir -p $$(dir $$@) $(call HOSTED_ICON_BAKE,$(1)) endef diff --git a/make/iigs.mk b/make/iigs.mk index 5dc6517..a4fb4d8 100644 --- a/make/iigs.mk +++ b/make/iigs.mk @@ -60,15 +60,16 @@ IIGS_LIB_HDRS := $(wildcard $(INCLUDE_DIR)/joey/*.h) $(wildcard $(SRC_CORE)/*.h) # binary that ships next to the program; jlpAudioInit loads "ntpplayer" from # disk at run time (src/iigs/audio_full.c NTP_PATH), so every disk a program # goes on carries it (make-iigs-disk.sh). The source is assembled -# with JoeyLib's interrupt window added to NTPstreamsound (NTP_PATCH). +# with JoeyLib's interrupt window added to NTPstreamsound (NTP_PATCH, applied +# exactly: a hunk that does not apply fails the build). NTP_SRC := $(IIGS_NTP_DIR)/ninjatrackerplus.s -NTP_PATCH := $(REPO_DIR)/make/support/iigs/ntpIrqWindow.py +NTP_PATCH := $(REPO_DIR)/make/support/iigs/ntpIrqWindow.patch NTP_BIN := $(BUILD)/audio/ntpplayer.bin IIGS_MERLIN := $(IIGS_AS) $(NTP_BIN): $(NTP_SRC) $(NTP_PATCH) $(IIGS_MERLIN) @mkdir -p $(BUILD)/audio - @python3 $(NTP_PATCH) $(NTP_SRC) $(BUILD)/audio/ninjatrackerplus.s + patch --quiet --fuzz=0 --no-backup-if-mismatch -r - -o $(BUILD)/audio/ninjatrackerplus.s $(NTP_SRC) $(NTP_PATCH) cd $(BUILD)/audio && $(IIGS_MERLIN) . ninjatrackerplus.s mv $(BUILD)/audio/ntpplayer $@ @@ -117,7 +118,7 @@ programIconFile = $(call programIconDir,$(1))/finder.icons programTarget = $(call programBin,$(1)) $(call programIconFile,$(1)) # One program, $(1) its id: clang-build.sh compiles, links and wraps it, and -# writes one header-dependency file for all its sources; iconBake.py bakes +# writes one header-dependency file for all its sources; iconBake bakes # its Finder icon. ID_IIGS_OVERFLOW_CAP # opts into large overflow segments (see clang-build.sh). NTP rides on every # disk a program goes on, so it is built first. @@ -127,7 +128,7 @@ $(call programBin,$(1)): $(call programVar,$(1),SRCS) $(LIB) $(IIGS_CLANG_BUILD) @mkdir -p $$(dir $$@) $(DEP_DIR) $(IIGS_CLANG_BUILD) -M $(DEP_DIR)/$(1).d $(call programVar,$(1),CFLAGS) $(INCLUDES) -o $$@ $(call programVar,$(1),SRCS) $(LIB) -$(call programIconFile,$(1)): $(call programIcon,$(1)) $(ICON_BAKE_DEPS) +$(call programIconFile,$(1)): $(call programIcon,$(1)) $(ICON_BAKE) @mkdir -p $$(dir $$@) $(ICON_BAKE) iigs $$< $$@ $(call programName,$(1)) endef diff --git a/make/programs.mk b/make/programs.mk index 2f92d30..60d8e24 100644 --- a/make/programs.mk +++ b/make/programs.mk @@ -70,9 +70,10 @@ programs: $(foreach p,$(PORT_PROGRAMS),$(call programTarget,$(p)) $(call program .PHONY: $(addprefix program-,$(PORT_PROGRAMS) $(PORT_TEST_PROGRAMS)) $(foreach p,$(PORT_PROGRAMS) $(PORT_TEST_PROGRAMS),$(eval program-$(p): $(call programTarget,$(p)) $(call programVar,$(p),DATA))) -# The host sprite pre-compiler a description's bakes use; make/tools.mk owns -# its sources. -$(SPRITEBAKE): FORCE +# The host bakers a description's bakes use (the sprite pre-compiler, the +# asset and icon bakers, the .NTP converter); make/tools.mk owns their +# sources. +$(SPRITEBAKE) $(ASSET_BAKE) $(ICON_BAKE) $(MOD_TO_NTP): FORCE $(MAKE) -f $(REPO_DIR)/make/tools.mk $(notdir $@) # A project's goal for one port: its programs, their DATA and the packages diff --git a/make/support/iigs/ntpIrqWindow.patch b/make/support/iigs/ntpIrqWindow.patch new file mode 100644 index 0000000..ccf4a22 --- /dev/null +++ b/make/support/iigs/ntpIrqWindow.patch @@ -0,0 +1,108 @@ +Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +See LICENSE.md for license information. +SPDX-License-Identifier: MIT + +ntpIrqWindow.patch - bound NinjaTrackerPlus's interrupts-off stretches. +make/iigs.mk applies it to the replayer's source with patch --fuzz=0 before +Merlin32 assembles it. + +NinjaTrackerPlus moves sample data into the DOC with interrupts masked, +~4 us a byte (measured under MAME), and the Z8530 SCC's receive FIFO only +lasts ~3.1 ms at 9600 baud (~2.3 ms under MAME). Two stretches outlast it: + - NTPstreamsound fills a stream's 512-byte DOC page with two unrolled + 256-byte copies under its own SEI: ~2.4 ms. stream_window goes between + the halves: pending interrupts run there, then the DOC's mode and + address are put back for the second half. The stream's oscillators are + already halted, and its job is cleared first, so a refill interrupt left + pending by the slot's previous sound cannot stream into the page. + - The DOC interrupt handler refills every streaming oscillator that is + due before it returns, 256 bytes each: ~1 ms apiece, ~2.5 ms for two. + It returns after one instead; the DOC keeps its IRQ up for the rest, so + the handler is re-entered at once, after anything the ROM's interrupt + manager puts first (the SCC is on its fast path). +Both only while the word at offset 27 of the replayer (irq_window, right +after the documented jump table) is nonzero. The word after it, at offset +29 (playing_addr), holds the address of the replayer's playing flag, so +audio_full.c can see a song that does not loop come to its end. audio_full.c +sets it from hal.c's tight interrupt-latency bound before every +NTPstreamsound, so with the bound loose the replayer does exactly what it +always did. + +The source is the installer's pinned ntpsources.zip (checksummed), so every +hunk applies exactly; a hunk that does not means NTP changed and this patch +must be revisited, so the build fails instead of guessing. + +--- ninjatrackerplus.s ++++ ninjatrackerplus.s +@@ -138,6 +138,14 @@ + ; 1 byte channel (0-7 placed in the upper 4 bits, example: $00 for left, $10 for right) + jmp streamsound + ++; irq_window (JoeyLib, offset 27): nonzero bounds the interrupts-off ++; stretches -- see stream_window and interrupt_handler_exit. ++irq_window dw 0 ++ ++; playing_addr (JoeyLib, offset 29): where the replayer keeps its playing ++; flag (0 once a song played once has ended), for jlAudioIsPlayingMod. ++playing_addr dw playing ++ + *------------------------------- + + ; to stay out of the way, do not use the direct page at all +@@ -1301,6 +1309,7 @@ + + ldy streamsound_osc2 + jsr stream_copy ;fill buffer with 512 bytes for first play ++ jsr stream_window ;JoeyLib: interrupts in between the halves, if asked + jsr stream_copy_no_init + + mx %00 ;we're in 16bit mode on return +@@ -1383,6 +1392,31 @@ + clc + rtl + ++; stream_window (JoeyLib): between the two halves of streamsound's first ++; DOC fill, when irq_window is nonzero, let pending interrupts run, then ++; point the DOC back at the second half (a handler may have used it). ++; In and out: mx %00, Y = the stream's track * 2. ++ mx %00 ++stream_window = * ++ lda irq_window ++ beq stream_window_1 ++ sep #$20 ++ mx %10 ++ ldx streamsound_osc ;no refill job left pending from the slot's last sound ++ stz oscillator_job_tbl,x ++ cli ++ nop ;a pending interrupt is taken here ++ sei ++ jsr access_doc_ram ++ rep #$20 ++ mx %00 ++ lda stream_doc_page,y ;the page's second half ++ inc ++ xba ++ sta sound_address ++stream_window_1 = * ++ rts ++ + mx %10 + streamsound_copy = * ;copy a byte from stream structure to multiple doc registers + ldy streamsound_osc_count +@@ -1454,6 +1488,8 @@ + ldx interrupt_job + jsr (interrupt_jump_tbl,x) + ++ lda irq_window ;JoeyLib: one oscillator an interrupt when asked; ++ bne interrupt_handler_exit ;the DOC keeps its IRQ up for any others + ]loop lda sound_control + bmi ]loop + +@@ -1465,6 +1501,7 @@ + lda sound_data + bpl interrupt_handler_loop + ++interrupt_handler_exit = * + sep #$30 + pld + plb diff --git a/make/support/iigs/ntpIrqWindow.py b/make/support/iigs/ntpIrqWindow.py deleted file mode 100755 index 3700fff..0000000 --- a/make/support/iigs/ntpIrqWindow.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/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, -# ~4 us a byte (measured under MAME), and the Z8530 SCC's receive FIFO only -# lasts ~3.1 ms at 9600 baud (~2.3 ms under MAME). Two stretches outlast it: -# - NTPstreamsound fills a stream's 512-byte DOC page with two unrolled -# 256-byte copies under its own SEI: ~2.4 ms. stream_window goes between -# the halves: pending interrupts run there, then the DOC's mode and -# address are put back for the second half. The stream's oscillators are -# already halted, and its job is cleared first, so a refill interrupt left -# pending by the slot's previous sound cannot stream into the page. -# - The DOC interrupt handler refills every streaming oscillator that is -# due before it returns, 256 bytes each: ~1 ms apiece, ~2.5 ms for two. -# It returns after one instead; the DOC keeps its IRQ up for the rest, so -# the handler is re-entered at once, after anything the ROM's interrupt -# manager puts first (the SCC is on its fast path). -# Both only while the word at offset 27 of the replayer (irq_window, right -# after the documented jump table) is nonzero. The word after it, at offset -# 29 (playing_addr), holds the address of the replayer's playing flag, so -# audio_full.c can see a song that does not loop come to its end. audio_full.c sets it from -# hal.c's tight interrupt-latency bound before every NTPstreamsound, so with -# the bound loose the replayer does exactly what it always did. -# -# The source is the installer's pinned ntpsources.zip (checksummed), so -# every anchor below is exact; a miss means NTP changed and this script -# must be revisited, so it fails the build instead of guessing. -import sys - -TABLE_END = """ jmp streamsound -""" -TABLE_END_NEW = """ jmp streamsound - -; irq_window (JoeyLib, offset 27): nonzero bounds the interrupts-off -; stretches -- see stream_window and interrupt_handler_exit. -irq_window dw 0 - -; playing_addr (JoeyLib, offset 29): where the replayer keeps its playing -; flag (0 once a song played once has ended), for jlAudioIsPlayingMod. -playing_addr dw playing -""" -FILL = """ ldy streamsound_osc2 - jsr stream_copy ;fill buffer with 512 bytes for first play - jsr stream_copy_no_init -""" -FILL_NEW = """ ldy streamsound_osc2 - jsr stream_copy ;fill buffer with 512 bytes for first play - jsr stream_window ;JoeyLib: interrupts in between the halves, if asked - jsr stream_copy_no_init -""" -COPY_HELPERS = """ mx %10 -streamsound_copy = * ;copy a byte from stream structure to multiple doc registers -""" -COPY_HELPERS_NEW = """; stream_window (JoeyLib): between the two halves of streamsound's first -; DOC fill, when irq_window is nonzero, let pending interrupts run, then -; point the DOC back at the second half (a handler may have used it). -; In and out: mx %00, Y = the stream's track * 2. - mx %00 -stream_window = * - lda irq_window - beq stream_window_1 - sep #$20 - mx %10 - ldx streamsound_osc ;no refill job left pending from the slot's last sound - stz oscillator_job_tbl,x - cli - nop ;a pending interrupt is taken here - sei - jsr access_doc_ram - rep #$20 - mx %00 - lda stream_doc_page,y ;the page's second half - inc - xba - sta sound_address -stream_window_1 = * - rts - - mx %10 -streamsound_copy = * ;copy a byte from stream structure to multiple doc registers -""" -HANDLER_JOB = """ ldx interrupt_job - jsr (interrupt_jump_tbl,x) - -]loop lda sound_control -""" -HANDLER_JOB_NEW = """ ldx interrupt_job - jsr (interrupt_jump_tbl,x) - - lda irq_window ;JoeyLib: one oscillator an interrupt when asked; - bne interrupt_handler_exit ;the DOC keeps its IRQ up for any others -]loop lda sound_control -""" -HANDLER_END = """ bpl interrupt_handler_loop - - sep #$30 -""" -HANDLER_END_NEW = """ bpl interrupt_handler_loop - -interrupt_handler_exit = * - sep #$30 -""" - - -def patchOnce(text, old, new): - if text.count(old) != 1: - sys.exit("ntpIrqWindow: anchor not found exactly once -- NinjaTrackerPlus changed:\n" + old) - return text.replace(old, new) - - -def main(): - if len(sys.argv) != 3: - sys.exit("usage: ntpIrqWindow.py SRC DST") - with open(sys.argv[1], "r", encoding="ascii") as f: - text = f.read() - text = patchOnce(text, TABLE_END, TABLE_END_NEW) - text = patchOnce(text, FILL, FILL_NEW) - text = patchOnce(text, COPY_HELPERS, COPY_HELPERS_NEW) - text = patchOnce(text, HANDLER_JOB, HANDLER_JOB_NEW) - text = patchOnce(text, HANDLER_END, HANDLER_END_NEW) - with open(sys.argv[2], "w", encoding="ascii") as f: - f.write(text) - - -main() diff --git a/make/tools.mk b/make/tools.mk index d26204e..96b3336 100644 --- a/make/tools.mk +++ b/make/tools.mk @@ -6,20 +6,60 @@ # # These build with the host's default `cc` (no cross-toolchain) into # build/tools/ (JOEY_BUILD's, make/common.mk). Currently: +# adfTool -- read files out of an Amiga disk image (.adf): the +# installer's import. +# assetbake -- indexed PNG -> a target's tile bank (.tbk) or sprite +# cels (.spr). +# diffUberHashes -- two UBER logs compared op by op by surface hash: the +# golden gates' check against tests/goldens/uber. +# diffUberPerf -- two UBER logs compared op by op by ops/sec, the worst +# gaps against a threshold first. +# iconBake -- one picture -> each system's program icon. # modloopend -- inject an E8F stop-marker pattern into a .MOD ahead # of time (the Amiga audio HAL marks a song played with # loop false itself). joeymod invokes it for .amod output. +# modToNtp -- a .MOD -> a NinjaTrackerPlus song (.NTP), the IIgs +# replayer's: joeymod invokes it for .ntp output. +# rawCd -- a raw CD image's track, or its El Torito boot floppy: +# the installer's BeOS VM. +# scanPtrBug -- find the w65816 compiler's far-pointer bug (a pointer's +# bank in its offset half) in its assembly output. +# songbake -- chip-tracker text notation (.song) -> a JYM1 stream. # spritebake -- the sprite pre-compiler, one per target (below). -# -# Asset baking (PNG -> .tbk / .spr) is handled by tools/assetbake/ -# assetbake.py (Python). It is invoked directly from each platform's -# Makefile and does not need a C build step. +# szddExpand -- expand a COMPRESS.EXE (SZDD) file: the installer's WinG. +# tiled2joey -- a Tiled map (TMX or TMJ) -> a tile sheet and maps. +# tileBankExpect -- the tile-bank gates' expected pixels +# (tests/host/tileBankStage.sh). +# traceLogo -- a flat-colour PNG logo -> a layered SVG, through the +# potrace program: remakes assets/joeylibLogo.svg. +# uberPerfTable -- every port's UBER log -> one Markdown table of ops/sec +# against a reference port. +# vgm2song -- a tracker's VGM export (Furnace) -> a .song. +# winIni -- edit a Windows 3.x .INI file: the installer's Windows 3.11 +# and the Win16 gates. +# xdftool -- read and write Human68k floppy images (.XDF): joey-run +# and the X68000 gates stage programs with it. include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk BUILD_DIR := $(JOEY_BUILD)/tools TOOLS_DIR := $(REPO_DIR)/tools +# The tools of one C file (and any headers beside it) over tools/common (PNG, +# DEFLATE, whole files, byte buffers): tools//.c, or a test's +# helper in tests/host. +COMMON_TOOLS := adfTool assetbake diffUberHashes diffUberPerf iconBake modToNtp rawCd scanPtrBug songbake szddExpand tiled2joey traceLogo uberPerfTable vgm2song winIni xdftool +TOOL_COMMON_SRC := $(wildcard $(TOOLS_DIR)/common/*.c) +TOOL_COMMON_HDRS := $(wildcard $(TOOLS_DIR)/common/*.h) + +# The gates' helpers (scripts/verify-*.sh and friends, which run them through +# joeyTool): tests/gate/.c, each over tools/common and the gates' own +# shared code in tests/gate/common. +GATE_DIR := $(REPO_DIR)/tests/gate +GATE_TOOLS := $(basename $(notdir $(wildcard $(GATE_DIR)/*.c))) +GATE_COMMON_SRC := $(wildcard $(GATE_DIR)/common/*.c) +GATE_COMMON_HDRS := $(wildcard $(GATE_DIR)/common/*.h) + MODLOOPEND_SRC := $(TOOLS_DIR)/modloopend/modloopend.c $(SRC_CORE)/modStopMarker.c MODLOOPEND_BIN := $(BUILD_DIR)/modloopend @@ -35,16 +75,37 @@ SPRITEBAKE_INC := -I$(INCLUDE_DIR) -I$(SRC_CORE) -I$(SRC_CG) # 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 \ +.PHONY: all tools clean-tools modloopend tileBankExpect $(COMMON_TOOLS) $(GATE_TOOLS) \ spritebake-dos spritebake-amiga spritebake-atarist spritebake-iigs spritebake-x68000 -all tools: $(MODLOOPEND_BIN) +all tools: $(MODLOOPEND_BIN) $(addprefix $(BUILD_DIR)/,$(COMMON_TOOLS)) modloopend: $(MODLOOPEND_BIN) +tileBankExpect: $(BUILD_DIR)/tileBankExpect +$(foreach t,$(COMMON_TOOLS),$(eval $(t): $(BUILD_DIR)/$(t))) +$(foreach t,$(GATE_TOOLS),$(eval $(t): $(BUILD_DIR)/$(t))) spritebake-dos: $(BUILD_DIR)/spritebake-dos spritebake-amiga: $(BUILD_DIR)/spritebake-amiga spritebake-atarist: $(BUILD_DIR)/spritebake-atarist spritebake-iigs: $(BUILD_DIR)/spritebake-iigs spritebake-x68000: $(BUILD_DIR)/spritebake-x68000 +# $(call commonToolRule,NAME,SOURCE): NAME from SOURCE and tools/common. +define commonToolRule +$(BUILD_DIR)/$(1): $(2) $(wildcard $(dir $(2))*.h) $(TOOL_COMMON_SRC) $(TOOL_COMMON_HDRS) + @mkdir -p $$(dir $$@) + $(HOST_CC) $(HOST_CFLAGS) -I$(TOOLS_DIR)/common $$(filter %.c,$$^) -o $$@ -lm +endef +$(foreach t,$(COMMON_TOOLS),$(eval $(call commonToolRule,$(t),$(TOOLS_DIR)/$(t)/$(t).c))) +$(eval $(call commonToolRule,tileBankExpect,$(REPO_DIR)/tests/host/tileBankExpect.c)) + +# $(call gateToolRule,NAME): tests/gate/NAME.c over tools/common and +# tests/gate/common. +define gateToolRule +$(BUILD_DIR)/$(1): $(GATE_DIR)/$(1).c $(GATE_COMMON_SRC) $(GATE_COMMON_HDRS) $(TOOL_COMMON_SRC) $(TOOL_COMMON_HDRS) + @mkdir -p $$(dir $$@) + $(HOST_CC) $(HOST_CFLAGS) -I$(TOOLS_DIR)/common -I$(GATE_DIR)/common $$(filter %.c,$$^) -o $$@ -lm +endef +$(foreach t,$(GATE_TOOLS),$(eval $(call gateToolRule,$(t)))) + $(MODLOOPEND_BIN): $(MODLOOPEND_SRC) $(SRC_CORE)/modStopMarker.h @mkdir -p $(dir $@) $(HOST_CC) $(HOST_CFLAGS) -I$(SRC_CORE) $(MODLOOPEND_SRC) -o $@ diff --git a/scripts/bench-amiga.sh b/scripts/bench-amiga.sh index 1b6ae21..f99077c 100755 --- a/scripts/bench-amiga.sh +++ b/scripts/bench-amiga.sh @@ -10,7 +10,7 @@ # the real Kickstart on a private Xvfb display, polls the staged # joeylog.txt until UBER prints its "press any key to exit" completion # marker (or a wall-clock timeout), then kills the emulator and copies -# the log to build/amiga/bin/joeylog.txt for tools/uber-perf-table. +# the log to build/amiga/bin/joeylog.txt for build/tools/uberPerfTable. # # scripts/bench-amiga.sh # default 150s timeout # TIMEOUT=240 scripts/bench-amiga.sh # override diff --git a/scripts/bench-atarist.sh b/scripts/bench-atarist.sh index 7122e82..a0ed329 100755 --- a/scripts/bench-atarist.sh +++ b/scripts/bench-atarist.sh @@ -19,7 +19,7 @@ # 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//bin/joeylog.txt (for the ST build -# where tools/uber-perf-table expects it). A copy is kept as +# where build/tools/uberPerfTable expects it). A copy is kept as # joeylog-MACHINE.txt beside it, so runs on several machines can be # compared afterwards. # diff --git a/scripts/bench-be.sh b/scripts/bench-be.sh index fc677ae..5b69c52 100755 --- a/scripts/bench-be.sh +++ b/scripts/bench-be.sh @@ -10,9 +10,9 @@ # # 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 +# scripts/bench-be.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt +# scripts/bench-be.sh haiku32 && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt +# scripts/bench-be.sh beos && build/tools/diffUberHashes 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) diff --git a/scripts/bench-dos.sh b/scripts/bench-dos.sh index 787a994..d89cec8 100755 --- a/scripts/bench-dos.sh +++ b/scripts/bench-dos.sh @@ -8,7 +8,7 @@ # Mounts build/dos/bin as C: in a headless DOSBox (Xvfb), runs UBER.EXE # (which writes JOEYLOG.TXT to its CWD == C: == the host bin dir), polls # that log for UBER's "press any key to exit" completion marker, then -# kills DOSBox. tools/uber-perf-table reads build/dos/bin/JOEYLOG.TXT. +# kills DOSBox. build/tools/uberPerfTable reads build/dos/bin/JOEYLOG.TXT. # # Uses the same dosbox-386sx16.conf as joey-run so the cycle budget # matches the claudeDocs/PERF.md reference machine. @@ -74,7 +74,7 @@ if [[ ! -f $log ]]; then exit 1 fi -# uber-perf-table reads build/dos/bin/JOEYLOG.TXT directly (uppercase). +# uberPerfTable reads build/dos/bin/JOEYLOG.TXT directly (uppercase). ops=$(grep -c "ops/sec" "$log" || true) done_marker=$(grep -c "press any key to exit" "$log" || true) if (( done_marker == 0 )); then diff --git a/scripts/bench-dvx.sh b/scripts/bench-dvx.sh index 0d89be0..ea1e23e 100755 --- a/scripts/bench-dvx.sh +++ b/scripts/bench-dvx.sh @@ -9,11 +9,11 @@ # it in DOSBox Staging under Xvfb, and waits for UBER's "press any key to # exit" line in its joeylog.txt -- which a DVX program writes to its settings # folder, CONFIG\APPS\\UBER on the staged drive. The log is copied to -# build/dvx/bin/joeylog.txt (tools/diff-uber-hashes and tools/uber-perf-table -# read it from there) and DOSBox is stopped. +# build/dvx/bin/joeylog.txt (build/tools/diffUberHashes and +# build/tools/uberPerfTable read it from there) and DOSBox is stopped. # # The stage is the DOS stage, so the hashes must equal the DOS port's: -# scripts/bench-dvx.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt +# scripts/bench-dvx.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt # The ops/sec are the emulated machine's at DOSBox's full host speed, not a # reference machine's: compare them only run against run. # diff --git a/scripts/bench-iigs.sh b/scripts/bench-iigs.sh index 2d6fbae..ac95a74 100755 --- a/scripts/bench-iigs.sh +++ b/scripts/bench-iigs.sh @@ -13,7 +13,7 @@ # polls the Super Hi-Res framebuffer for UBER's solid-green "done" screen # (jlSurfaceClear color 2 -> SHR bytes 0x22). When seen, MAME exits and # cadius extracts JOEYLOG.TXT from the work image into build/iigs/bin/ -# joeylog.txt, where tools/uber-perf-table and diff-uber-hashes expect it. +# joeylog.txt, where build/tools/uberPerfTable and diffUberHashes expect it. # # Usage: scripts/bench-iigs.sh # MAME_ROMPATH override ROM dir (default ~/.mame/roms) diff --git a/scripts/bench-psp.sh b/scripts/bench-psp.sh index b4ca181..296faa4 100755 --- a/scripts/bench-psp.sh +++ b/scripts/bench-psp.sh @@ -11,7 +11,7 @@ # # 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 +# scripts/bench-psp.sh && build/tools/diffUberHashes 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. # diff --git a/scripts/bench-sdl3.sh b/scripts/bench-sdl3.sh index 84fdce5..8387815 100755 --- a/scripts/bench-sdl3.sh +++ b/scripts/bench-sdl3.sh @@ -11,7 +11,7 @@ # # 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 +# scripts/bench-sdl3.sh && build/tools/diffUberHashes 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. # diff --git a/scripts/bench-web.sh b/scripts/bench-web.sh index 7712f74..dec332f 100755 --- a/scripts/bench-web.sh +++ b/scripts/bench-web.sh @@ -11,7 +11,7 @@ # # 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 +# scripts/bench-web.sh && build/tools/diffUberHashes 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 diff --git a/scripts/bench-win16.sh b/scripts/bench-win16.sh index 60dcd85..1249edc 100755 --- a/scripts/bench-win16.sh +++ b/scripts/bench-win16.sh @@ -9,7 +9,7 @@ # # 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 +# scripts/bench-win16.sh && build/tools/diffUberHashes 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. # diff --git a/scripts/bench-win32.sh b/scripts/bench-win32.sh index ff0e332..919f07f 100755 --- a/scripts/bench-win32.sh +++ b/scripts/bench-win32.sh @@ -10,7 +10,7 @@ # # 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 +# scripts/bench-win32.sh && build/tools/diffUberHashes 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 diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 4172426..fc27d3a 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -15,7 +15,7 @@ # folder beside it. # # The HTML build fails if any code it shows holds a character that is not -# ASCII (scripts/lib/checkDocsCode.py): Asciidoctor turns `->` into an arrow +# ASCII (tests/gate/checkDocsCode.c): Asciidoctor turns `->` into an arrow # and `...` into an ellipsis even in a code span, so write those as `+text+`. # # Usage: scripts/build-docs.sh [html|pdf] @@ -25,6 +25,8 @@ set -euo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=scripts/lib/joeyEnv.sh +source "$repo/scripts/lib/joeyEnv.sh" src=$repo/docs/Manual.adoc out=$repo/build/docs what=${1:-both} @@ -53,7 +55,7 @@ common=(-a "revdate=$(date +%Y-%m-%d)" -a allow-uri-read) if [ "$what" = "html" ] || [ "$what" = "both" ]; then asciidoctor "${common[@]}" -a data-uri \ "$src" -o "$out/JoeyLib-Manual.html" - python3 "$repo/scripts/lib/checkDocsCode.py" "$out/JoeyLib-Manual.html" + "$(joeyTool checkDocsCode)" "$out/JoeyLib-Manual.html" echo "build-docs: $out/JoeyLib-Manual.html" fi diff --git a/scripts/check-blank.sh b/scripts/check-blank.sh index b7ba469..aba7120 100755 --- a/scripts/check-blank.sh +++ b/scripts/check-blank.sh @@ -45,18 +45,23 @@ done # Link-completeness: any JoeyLib symbol undefined across the whole object set # (and not defined by any of them) is a real gap in blank.c or the generics. -missing=$(python3 - "$work" <<'PY' -import subprocess, sys, glob -defs=set(); und=set() -for o in glob.glob(sys.argv[1]+"/*.o"): - for line in subprocess.run(["nm",o],capture_output=True,text=True).stdout.splitlines(): - p=line.split() - if len(p)>=3 and p[1] in "TDBRtdbr": defs.add(p[2]) - elif len(p)>=3 and p[1]=="U": und.add(p[2]) - elif len(p)==2 and p[0]=="U": und.add(p[1]) -print(" ".join(sorted(s for s in und-defs if s.startswith(("jlp","jlSprite","jlSpriteCompiledDraw"))))) -PY -) +missing=$(nm "$work"/*.o 2> /dev/null | LC_ALL=C awk ' + NF >= 3 && index("TDBRtdbr", $2) > 0 { defs[$3] = 1; next } + NF >= 3 && $2 == "U" { undefined[$3] = 1; next } + NF == 2 && $1 == "U" { undefined[$2] = 1 } + END { + n = 0 + for (s in undefined) { + if (!(s in defs) && (s ~ /^jlp/ || s ~ /^jlSprite/)) { + names[++n] = s + } + } + asort(names) + for (i = 1; i <= n; i++) { + printf "%s%s", (i > 1 ? " " : ""), names[i] + } + print "" + }') if [ -n "$missing" ]; then echo "check-blank: FAIL - unresolved JoeyLib symbols (blank.c / generics gap):" >&2 echo " $missing" >&2 diff --git a/scripts/check-icons.sh b/scripts/check-icons.sh index bfc6ad0..bb6664d 100755 --- a/scripts/check-icons.sh +++ b/scripts/check-icons.sh @@ -5,8 +5,8 @@ # # check-icons.sh - Host gate for programs' icons: on every port built here, # DRAW (examples/draw) carries the icon make baked it (make/common.mk's -# programIcon, tools/iconBake.py), where its system looks for it. Read with -# the systems' own tools where they are installed, otherwise by the format: +# programIcon, tools/iconBake), where its system looks for it. Read with the +# systems' own tools where they are installed, otherwise by the format: # # amiga Draw.info beside it: a DiskObject (magic $E310) for a tool, # AMIGA_ICON_STACK bytes of stack, the image iconBake makes @@ -39,6 +39,44 @@ label=check-icons fails="" checked="" +# The Amiga's Workbench icon (workbench.h): a DiskObject's magic and a tool's +# do_Type, at their offsets with do_StackSize's and its Image's Width, +# Height, and Depth; iconBake's tool icons are 48x24 in 2 planes. +WB_DISKMAGIC=$((0xE310)) +WBTOOL=3 +DO_TYPE_AT=48 +DO_STACK_AT=74 +IMAGE_SIZE_AT=82 +AMIGA_ICON="48 24 2" +# Win16's NE header (its offset at 0x3C): the resource and resident-name +# tables' offsets, equal when there are no resources; a type's entry is 8 +# bytes, a resource's 12 with its ID 6 bytes in; an integer ID has bit 15. +NE_HEADER_AT=$((0x3C)) +NE_RESOURCES_AT=$((0x24)) +NE_NAMES_AT=$((0x26)) +NE_TYPE_BYTES=8 +NE_RESOURCE_BYTES=12 +NE_RESOURCE_ID_AT=6 +RT_GROUP_ICON=$((0x800E)) +INTEGER_ID=$((0x8000)) +# A PNG's signature, and the width and height in its IHDR. +PNG_SIGNATURE=89504e470d0a1a0a +PNG_WIDTH_AT=16 +PNG_HEIGHT_AT=20 +# The PSP's EBOOT: "\0PBP", then the parts' offsets from byte 8, ICON0 the +# second (and the third's start its end). +PBP_MAGIC=00504250 +PBP_ICON0_AT=12 +PBP_ICON1_AT=16 +# The IIgs icon file: its file ID, and in its first record (from byte 26) +# the application's name (a Pascal string at 66) and file type (at 82). +IIGS_FILE_ID_AT=4 +IIGS_RECORD_AT=26 +IIGS_NAME_AT=66 +IIGS_TYPE_AT=82 +IIGS_ICON_FILE_ID=1 +IIGS_APPLICATION=$((0xB3)) + fail() { fails="${fails:+$fails; }$1: $2" } @@ -48,31 +86,60 @@ makeValue() { make -s -f "$repo/make/$1.mk" --eval "valueOf: ; @echo \$($2)" valueOf 2> /dev/null } +# fileNumber FILE OFFSET BYTES be|le: an unsigned number in FILE; fails past +# its end. +fileNumber() { + od -An -tu1 -v -j "$2" -N "$3" "$1" 2> /dev/null | awk -v n="$3" -v endian="$4" ' + { for (i = 1; i <= NF; i++) { b[k++] = $i } } + END { if (k < n) { exit 1 } v = 0; for (i = 0; i < n; i++) { v = v * 256 + b[endian == "be" ? i : n - 1 - i] } print v }' +} + +# fileHex FILE OFFSET BYTES: those bytes as lower-case hex. +fileHex() { + od -An -tx1 -v -j "$2" -N "$3" "$1" 2> /dev/null | tr -d ' \n' +} + +# neGroupIcon EXE ID: true when the Win16 EXE's NE resource table holds a +# group icon numbered ID. +neGroupIcon() { + local exe=$1 + local want=$((INTEGER_ID | $2)) + local ne + local table + local at + local typeId + local count + local i + + ne=$(fileNumber "$exe" "$NE_HEADER_AT" 4 le) || return 1 + table=$((ne + $(fileNumber "$exe" $((ne + NE_RESOURCES_AT)) 2 le))) + [ "$table" -ne $((ne + $(fileNumber "$exe" $((ne + NE_NAMES_AT)) 2 le))) ] || return 1 + at=$((table + 2)) + while true; do + typeId=$(fileNumber "$exe" "$at" 2 le) || return 1 + count=$(fileNumber "$exe" $((at + 2)) 2 le) || return 1 + [ "$typeId" -ne 0 ] || return 1 + at=$((at + NE_TYPE_BYTES)) + for ((i = 0; i < count; i++)); do + if [ "$typeId" -eq "$RT_GROUP_ICON" ] && [ "$(fileNumber "$exe" $((at + NE_RESOURCE_ID_AT)) 2 le)" = "$want" ]; then + return 0 + fi + at=$((at + NE_RESOURCE_BYTES)) + done + done +} + for port in amiga win32 win16 sdl3 macos web psp haiku haiku32 beos dvx iigs; do program=$(joeyExampleProgram draw "$port" 2> /dev/null) || continue [ -f "$program" ] || continue checked="${checked:+$checked }$port" case "$port" in amiga) - python3 - "$program.info" "$(makeValue amiga AMIGA_ICON_STACK)" "$repo/tools" <<'PY' || fail amiga "Draw.info" -import struct -import sys - -info, stack, tools = sys.argv[1], int(sys.argv[2]), sys.argv[3] -sys.path.insert(0, tools) -import iconBake # noqa: E402 - -data = open(info, "rb").read() -magic, version = struct.unpack_from(">HH", data, 0) -doType = data[48] -doStack = struct.unpack_from(">I", data, 74)[0] -w, h, depth = struct.unpack_from(">hhh", data, 82) -want = (iconBake.WB_DISKMAGIC, iconBake.WBTOOL, stack, iconBake.AMIGA_W, iconBake.AMIGA_H, iconBake.AMIGA_DEPTH) -got = (magic, doType, doStack, w, h, depth) -if got != want: - print(" amiga: magic, type, stack, image %s, want %s" % (got, want)) - sys.exit(1) -PY + info=$program.info + want="$WB_DISKMAGIC $WBTOOL $(makeValue amiga AMIGA_ICON_STACK) $AMIGA_ICON" + got="$(fileNumber "$info" 0 2 be) $(fileNumber "$info" "$DO_TYPE_AT" 1 be) $(fileNumber "$info" "$DO_STACK_AT" 4 be)" + got="$got $(fileNumber "$info" "$IMAGE_SIZE_AT" 2 be) $(fileNumber "$info" $((IMAGE_SIZE_AT + 2)) 2 be) $(fileNumber "$info" $((IMAGE_SIZE_AT + 4)) 2 be)" + [ "$got" = "$want" ] || fail amiga "Draw.info: magic, type, stack, image $got, want $want" ;; win32) readobj=$(dirname "$WIN32_CC")/llvm-readobj @@ -81,72 +148,35 @@ PY awk -v want="Name: (ID $id)" '/Type: GROUP_ICON/ { group = 1; next } group && /Name:/ { found = index($0, want) > 0; exit } END { exit !found }' <<< "$listing" || fail win32 "no group icon $id among DRAW.EXE's resources" ;; win16) - python3 - "$program" "$(makeValue win16 HOSTED_ICON_ID)" <<'PY' || fail win16 "no group icon in DRAW.EXE's NE resources" -import struct -import sys - -RT_GROUP_ICON = 0x800E -INTEGER_ID = 0x8000 -data = open(sys.argv[1], "rb").read() -wantId = INTEGER_ID | int(sys.argv[2]) -ne = struct.unpack_from(" /dev/null) grep -q ' jlgHostedIconRgba$' <<< "$symbols" || fail sdl3 "DRAW links no window icon" ;; macos) - python3 - "$(dirname "$(dirname "$(dirname "$program")")")" <<'PY' || fail macos "DRAW.app's icon" -import plistlib -import sys - -from PIL import Image - -app = sys.argv[1] -with open(app + "/Contents/Info.plist", "rb") as f: - info = plistlib.load(f) -icon = Image.open("%s/Contents/Resources/%s.icns" % (app, info["CFBundleIconFile"])) -sys.exit(icon.format != "ICNS") -PY + app=$(dirname "$(dirname "$(dirname "$program")")") + iconName=$(awk '/CFBundleIconFile<\/key>/ { getline; gsub(/.*|<\/string>.*/, ""); print; exit }' "$app/Contents/Info.plist" 2> /dev/null) + icns=$app/Contents/Resources/$iconName.icns + if [ -z "$iconName" ] || [ "$(head -c 4 "$icns" 2> /dev/null)" != icns ] || [ "$(fileNumber "$icns" 4 4 be)" != "$(wc -c < "$icns" | tr -d " ")" ]; then + fail macos "DRAW.app's icon" + fi ;; web) folder=$(dirname "$program") grep -qE ']*rel=("?)icon\1[^>]*>' "$program" && grep -qE 'href=("?)icon\.png' "$program" || fail web "DRAW.html names no icon.png" - python3 -c 'import sys; from PIL import Image; sys.exit(Image.open(sys.argv[1]).format != "PNG")' "$folder/icon.png" 2> /dev/null || fail web "no icon.png beside DRAW.html" + [ "$(fileHex "$folder/icon.png" 0 8)" = "$PNG_SIGNATURE" ] || fail web "no icon.png beside DRAW.html" ;; psp) - python3 - "$program" "$repo/build/psp/icon/DRAW/icon0.png" "$(makeValue psp PSP_ICON0_SIZE)" <<'PY' || fail psp "the EBOOT's ICON0" -import io -import struct -import sys - -from PIL import Image - -eboot, baked, size = sys.argv[1], sys.argv[2], sys.argv[3] -data = open(eboot, "rb").read() -if data[:4] != b"\0PBP": - sys.exit(1) -offsets = struct.unpack_from("<8I", data, 8) -icon = data[offsets[1]:offsets[2]] -picture = Image.open(io.BytesIO(icon)) -if icon != open(baked, "rb").read() or "%dx%d" % picture.size != size: - print(" psp: ICON0 is %d bytes, %dx%d" % (len(icon), picture.size[0], picture.size[1])) - sys.exit(1) -PY + baked=$repo/build/psp/icon/DRAW/icon0.png + size=$(makeValue psp PSP_ICON0_SIZE) + start=$(fileNumber "$program" "$PBP_ICON0_AT" 4 le) + end=$(fileNumber "$program" "$PBP_ICON1_AT" 4 le) + if [ "$(fileHex "$program" 0 4)" != "$PBP_MAGIC" ] || ! cmp -s <(tail -c +$((start + 1)) "$program" | head -c $((end - start))) "$baked"; then + fail psp "the EBOOT's ICON0 is not the baked icon" + elif [ "$(fileNumber "$baked" "$PNG_WIDTH_AT" 4 be)x$(fileNumber "$baked" "$PNG_HEIGHT_AT" 4 be)" != "$size" ]; then + fail psp "the EBOOT's ICON0 is not $size" + fi ;; haiku|haiku32|beos) listing=$("$HAIKU_TOOLS/xres" -l "$program" 2> /dev/null) @@ -169,21 +199,9 @@ PY iconFile=$(find "$out" -iname 'DRAW.ICONS*' -not -name '_FileInformation.txt' -print -quit) listing=$("$CADIUS" CATALOG "$disk" 2> /dev/null) grep -qE '^ +DRAW\.ICONS +ICN ' <<< "$listing" || fail iigs "no DRAW.ICONS of type \$CA in joey.2mg's Icons folder" - python3 - "$iconFile" <<'PY' || fail iigs "DRAW.ICONS is no icon file for DRAW" -import struct -import sys - -ICON_FILE_ID = 0x0001 -APPLICATION = 0x00B3 -data = open(sys.argv[1], "rb").read() -fileId = struct.unpack_from(" /dev/null) $(fileNumber "$iconFile" $((IIGS_RECORD_AT + IIGS_TYPE_AT)) 2 le)" + [ "$got" = "$IIGS_ICON_FILE_ID DRAW $IIGS_APPLICATION" ] || fail iigs "DRAW.ICONS is no icon file for DRAW (id, name, type $got)" rm -rf "$out" ;; esac diff --git a/scripts/check-macos-apps.sh b/scripts/check-macos-apps.sh index cf4867e..a6ccce8 100755 --- a/scripts/check-macos-apps.sh +++ b/scripts/check-macos-apps.sh @@ -5,7 +5,7 @@ # # check-macos-apps.sh - Host gate for the macOS port's apps (make macos), # which nothing here can run: every app in build/macos/bin is one macOS can -# open. Its Info.plist parses and names, as an application for the macOS +# open. Its Info.plist names, as an application for the macOS # make/support/macos/cc.sh builds for, a program that is in Contents/MacOS # and an icon that is in Contents/Resources. The program is universal: an # Apple silicon (arm64) and an Intel (x86_64) program, each built for that @@ -31,68 +31,92 @@ if ! compgen -G "$bin/*.app" > /dev/null; then exit 2 fi -python3 - "$bin" "$MACOS_LIPO" "$objdump" "$minimum" "$label" <<'PY' -import glob -import os -import plistlib -import subprocess -import sys +archs="arm64 x86_64" +signed="arm64" -bin, lipo, objdump, minimum, label = sys.argv[1:] -ARCHS = {"arm64", "x86_64"} -SIGNED = {"arm64"} -SYSTEM = ("/System/Library/", "/usr/lib/") +# joinBy SEPARATOR WORD... -- the words with SEPARATOR between them. +joinBy() { + local separator="$1" + local out="$2" + shift 2 + for word in "$@"; do + out+="$separator$word" + done + printf '%s\n' "$out" +} -def run(*args): - return subprocess.run(args, capture_output=True, text=True, check=True).stdout +# plistString PLIST KEY -- the string KEY holds in an Info.plist (the +# build's own XML, each value on the line after its key). +plistString() { + awk -v key="$2" ' + found { + if (match($0, /[^<]*<\/string>/)) { + print substr($0, RSTART + length(""), RLENGTH - length("")) + } + exit + } + index($0, key) { + found = 1 + }' "$1" +} -def sliceProblems(program, arch): - problems = [] - headers = run(objdump, "--macho", "--private-headers", "--arch=" + arch, program).split() - if "LC_BUILD_VERSION" not in headers or headers[headers.index("minos") + 1] != minimum: - problems.append("%s not built for macOS %s" % (arch, minimum)) - if arch in SIGNED and "LC_CODE_SIGNATURE" not in headers: - problems.append("%s unsigned" % arch) - for line in run(objdump, "--macho", "--dylibs-used", "--arch=" + arch, program).splitlines()[1:]: - library = line.split()[0] - if not library.startswith(SYSTEM): - problems.append("%s links %s" % (arch, library)) - return problems +# sliceProblems PROGRAM ARCH -- what is wrong with one arch of PROGRAM. +sliceProblems() { + local headers + local library + headers=" $("$objdump" --macho --private-headers --arch="$2" "$1" | tr -s ' \t\n' ' ') " + if [[ $headers != *" LC_BUILD_VERSION "* || $headers != *" minos $minimum "* ]]; then + problems+=("$2 not built for macOS $minimum") + fi + if [[ " $signed " == *" $2 "* && $headers != *" LC_CODE_SIGNATURE "* ]]; then + problems+=("$2 unsigned") + fi + while read -r library _; do + [[ $library == /System/Library/* || $library == /usr/lib/* ]] || problems+=("$2 links $library") + done < <("$objdump" --macho --dylibs-used --arch="$2" "$1" | tail -n +2) +} -def appProblems(app): - contents = os.path.join(app, "Contents") - with open(os.path.join(contents, "Info.plist"), "rb") as f: - info = plistlib.load(f) - problems = [] - if info.get("CFBundlePackageType") != "APPL" or info.get("LSMinimumSystemVersion") != minimum: - problems.append("Info.plist is not an application for macOS %s" % minimum) - if not os.path.isfile(os.path.join(contents, "Resources", info.get("CFBundleIconFile", "") + ".icns")): - problems.append("no icon") - program = os.path.join(contents, "MacOS", info.get("CFBundleExecutable", "")) - if not (os.path.isfile(program) and os.access(program, os.X_OK)): - return problems + ["no program"] - archs = set(run(lipo, "-archs", program).split()) - if archs != ARCHS: - problems.append("holds %s, not %s" % (" ".join(sorted(archs)), " ".join(sorted(ARCHS)))) - for arch in sorted(archs & ARCHS): - problems += sliceProblems(program, arch) - return problems +# appProblems APP -- what is wrong with APP, in problems. +appProblems() { + local contents="$1/Contents" + local plist="$contents/Info.plist" + local program + local held + local arch + problems=() + [ -f "$plist" ] || { problems=("no Info.plist"); return; } + if [ "$(plistString "$plist" CFBundlePackageType)" != APPL ] || [ "$(plistString "$plist" LSMinimumSystemVersion)" != "$minimum" ]; then + problems+=("Info.plist is not an application for macOS $minimum") + fi + [ -f "$contents/Resources/$(plistString "$plist" CFBundleIconFile).icns" ] || problems+=("no icon") + program="$contents/MacOS/$(plistString "$plist" CFBundleExecutable)" + if [ ! -f "$program" ] || [ ! -x "$program" ]; then + problems+=("no program") + return + fi + # shellcheck disable=SC2046 + held=$(printf '%s\n' $("$MACOS_LIPO" -archs "$program") | sort | paste -sd' ') + [ "$held" = "$archs" ] || problems+=("holds $held, not $archs") + for arch in $held; do + [[ " $archs " == *" $arch "* ]] && sliceProblems "$program" "$arch" + done +} -apps = sorted(glob.glob(os.path.join(bin, "*.app"))) -fails = [] -for app in apps: - try: - problems = appProblems(app) - except (OSError, ValueError, KeyError, plistlib.InvalidFileException, subprocess.CalledProcessError) as error: - problems = [str(error)] - if problems: - fails.append("%s: %s" % (os.path.basename(app), ", ".join(problems))) -if fails: - print("%s: FAIL - %s" % (label, "; ".join(fails))) - sys.exit(1) -print("%s: PASS (%d app%s: universal %s, arm64 signed, macOS %s on, system libraries only)" % (label, len(apps), "" if len(apps) == 1 else "s", "+".join(sorted(ARCHS)), minimum)) -PY +fails=() +count=0 +for app in "$bin"/*.app; do + count=$((count + 1)) + appProblems "$app" + if [ ${#problems[@]} -gt 0 ]; then + fails+=("$(basename "$app"): $(joinBy ', ' "${problems[@]}")") + fi +done +if [ ${#fails[@]} -gt 0 ]; then + echo "$label: FAIL - $(joinBy '; ' "${fails[@]}")" + exit 1 +fi +echo "$label: PASS ($count app$([ "$count" = 1 ] || echo s): universal ${archs// /+}, arm64 signed, macOS $minimum on, system libraries only)" diff --git a/scripts/check-modplayer.sh b/scripts/check-modplayer.sh index 27e4c3a..d4935c1 100755 --- a/scripts/check-modplayer.sh +++ b/scripts/check-modplayer.sh @@ -37,22 +37,39 @@ fi tables=() asm="${AMIGA_PTPLAYER:-}/ptplayer.asm" if [ -f "$asm" ]; then - python3 - "$asm" "$work/tables.txt" <<'EOF' -import re, sys -asm = open(sys.argv[1]).read() -def values(start, stop): - block = asm[asm.index(start):asm.index(stop, asm.index(start))] - out = [] - for line in block.splitlines(): - m = re.match(r'\s*dc\.[bw]\s+([^;]*)', line) - if m: - out += [int(v) for v in m.group(1).split(',')] - return out -periods = values('mt_PeriodTable:', '\n\n') -waves = values('mt_VibratoSineTable:', 'mt_VibratoSawTable:') + values('mt_VibratoSawTable:', 'mt_VibratoRectTable:') + values('mt_VibratoRectTable:', 'endc') -with open(sys.argv[2], 'w') as f: - f.write('periods ' + ' '.join(map(str, periods)) + '\nvibrato ' + ' '.join(map(str, waves)) + '\n') -EOF + awk -v out="$work/tables.txt" ' + index($0, "mt_PeriodTable:") && !periodsDone { + inPeriods = 1 + } + inPeriods && $0 == "" { + inPeriods = 0 + periodsDone = 1 + } + index($0, "mt_VibratoSineTable:") && !wavesDone { + inWaves = 1 + } + index($0, "mt_VibratoRectTable:") { + afterRect = 1 + } + inWaves && afterRect && index($0, "endc") { + inWaves = 0 + wavesDone = 1 + } + (inPeriods || inWaves) && match($0, /^[ \t]*dc\.[bw][ \t]+/) { + list = substr($0, RLENGTH + 1) + sub(/;.*/, "", list) + n = split(list, values, ",") + for (i = 1; i <= n; i++) { + if (inPeriods) { + periods = periods (periods == "" ? "" : " ") (values[i] + 0) + } else { + waves = waves (waves == "" ? "" : " ") (values[i] + 0) + } + } + } + END { + printf "periods %s\nvibrato %s\n", periods, waves > out + }' "$asm" tables=("$work/tables.txt") fi diff --git a/scripts/check-sfxmix.sh b/scripts/check-sfxmix.sh index b2d6a56..e12c092 100755 --- a/scripts/check-sfxmix.sh +++ b/scripts/check-sfxmix.sh @@ -62,68 +62,87 @@ else echo "check-sfxmix: comparing against baseline (exact hash mode)" fi -if python3 - "$baseline" "$work/out1.txt" "$tolerance" <<'PY' -import sys - - -def parse(path): - order = [] - scen = {} - name = None - with open(path) as f: - for line in f: - line = line.strip() - if line.startswith("name="): - fields = dict(p.split("=", 1) for p in line.split()) - name = fields["name"] - order.append(name) - scen[name] = {"hash": fields["hash"], "slots": int(fields["slots"]), "raw": b""} - elif line.startswith("raw=") and name is not None: - scen[name]["raw"] = bytes.fromhex(line[4:]) - return order, scen - - -baseOrder, base = parse(sys.argv[1]) -curOrder, cur = parse(sys.argv[2]) -tolerance = int(sys.argv[3]) if sys.argv[3] else None - -fail = False -if curOrder != baseOrder: - print("check-sfxmix: scenario list changed vs baseline") - print(" baseline: " + " ".join(baseOrder)) - print(" current: " + " ".join(curOrder)) - fail = True - -for name in baseOrder: - if name not in cur: - continue - b = base[name] - c = cur[name] - label = " %-24s" % (name + ":") - if len(b["raw"]) != len(c["raw"]): - print("%s FAIL (length %d -> %d)" % (label, len(b["raw"]), len(c["raw"]))) - fail = True - continue - if tolerance is None: - ok = (b["hash"] == c["hash"]) and (b["raw"] == c["raw"]) - if ok: - print("%s PASS (hash %s)" % (label, c["hash"])) - else: - print("%s FAIL (hash %s -> %s)" % (label, b["hash"], c["hash"])) - fail = True - else: - maxDelta = 0 - for x, y in zip(b["raw"], c["raw"]): - d = abs(x - y) - if d > maxDelta: - maxDelta = d - allowed = tolerance * b["slots"] - ok = maxDelta <= allowed - print("%s maxAbsDelta=%d (allowed %d) %s" % (label, maxDelta, allowed, "PASS" if ok else "FAIL")) - fail = fail or not ok - -sys.exit(1 if fail else 0) -PY +if awk -v tolerance="$tolerance" ' + BEGIN { + for (i = 0; i < 256; i++) { + byteOf[sprintf("%02X", i)] = i + } + } + FNR == 1 { + name = "" + } + { + gsub(/^[ \t\r]+|[ \t\r]+$/, "") + } + /^name=/ { + delete fields + for (i = 1; i <= NF; i++) { + at = index($i, "=") + fields[substr($i, 1, at - 1)] = substr($i, at + 1) + } + name = fields["name"] + order[ARGIND, ++count[ARGIND]] = name + hash[ARGIND, name] = fields["hash"] + slots[ARGIND, name] = fields["slots"] + 0 + raw[ARGIND, name] = "" + seen[ARGIND, name] = 1 + } + /^raw=/ && name != "" { + raw[ARGIND, name] = toupper(substr($0, 5)) + } + END { + same = count[1] == count[2] + for (i = 1; i <= count[1] && same; i++) { + same = order[1, i] == order[2, i] + } + if (!same) { + print "check-sfxmix: scenario list changed vs baseline" + line = " baseline:" + for (i = 1; i <= count[1]; i++) { + line = line " " order[1, i] + } + print (count[1] > 0 ? line : line " ") + line = " current: " + for (i = 1; i <= count[2]; i++) { + line = line " " order[2, i] + } + print (count[2] > 0 ? line : line " ") + failed = 1 + } + for (i = 1; i <= count[1]; i++) { + name = order[1, i] + if (!((2, name) in seen)) { + continue + } + label = sprintf(" %-24s", name ":") + baseLen = length(raw[1, name]) / 2 + curLen = length(raw[2, name]) / 2 + if (baseLen != curLen) { + printf "%s FAIL (length %d -> %d)\n", label, baseLen, curLen + failed = 1 + continue + } + if (tolerance == "") { + if (hash[1, name] == hash[2, name] && raw[1, name] == raw[2, name]) { + printf "%s PASS (hash %s)\n", label, hash[2, name] + } else { + printf "%s FAIL (hash %s -> %s)\n", label, hash[1, name], hash[2, name] + failed = 1 + } + continue + } + maxDelta = 0 + for (b = 1; b <= baseLen; b++) { + delta = byteOf[substr(raw[1, name], 2 * b - 1, 2)] - byteOf[substr(raw[2, name], 2 * b - 1, 2)] + delta = delta < 0 ? -delta : delta + maxDelta = delta > maxDelta ? delta : maxDelta + } + allowed = tolerance * slots[1, name] + printf "%s maxAbsDelta=%d (allowed %d) %s\n", label, maxDelta, allowed, (maxDelta <= allowed ? "PASS" : "FAIL") + failed = failed || maxDelta > allowed + } + exit failed ? 1 : 0 + }' "$baseline" "$work/out1.txt" then echo "check-sfxmix: PASS" else diff --git a/scripts/check-tilebank.sh b/scripts/check-tilebank.sh index 96b296c..5462ee4 100755 --- a/scripts/check-tilebank.sh +++ b/scripts/check-tilebank.sh @@ -5,7 +5,7 @@ # # 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, +# end: assetbake bakes tests/host/tileBank.png, the real loader reads it, # the real generic tile paste draws it, and tests/host/tileBankHost.c checks # every pixel against the PNG. Also checks the refusals (a bank baked for any # other target, bad magic, short header, missing file), a truncated bank, @@ -17,12 +17,13 @@ # target byte there, so the DOS bake is the bank that must load. Every other # target assetbake knows is baked too and must be refused. # -# tests/host/tileBankStage.py stages the banks and the expected pixels and -# palette (decoded from the PNG with PIL, independent of assetbake's -# encoders), exactly as make/.mk stages them for examples/tilebank on -# the real ports. The harness prints the surface hash of its to-surface load; -# scripts/verify--tilebank.sh run this script and require each port's -# own bake to hash the same. Exits nonzero on any failed check. +# tests/host/tileBankStage.sh stages the banks and the expected pixels and +# palette (decoded from the PNG by tileBankExpect, independent of assetbake's +# encoders; make/tools.mk builds both), exactly as make/examples.mk stages +# them for examples/tilebank on the real ports. The harness prints the +# surface hash of its to-surface load; scripts/verify--tilebank.sh run +# this script and require each port's own bake to hash the same. Exits +# nonzero on any failed check. set -euo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -38,7 +39,8 @@ cd "$repo" src/core/*.c src/generic/*.c src/blank/blank.c tests/host/tileBankHost.c \ -o "$work/tileBankHost" -python3 "$repo/tests/host/tileBankStage.py" "$hostTarget" "$png" "$work/DATA" +make -s -f "$repo/make/tools.mk" assetbake tileBankExpect > /dev/null +"$repo/tests/host/tileBankStage.sh" "$hostTarget" "$png" "$work/DATA" refuse=() for bank in "$work"/DATA/refuse*.tbk; do refuse+=("$(basename "$bank")") diff --git a/scripts/check-tiled.sh b/scripts/check-tiled.sh index c190b3a..da8af97 100755 --- a/scripts/check-tiled.sh +++ b/scripts/check-tiled.sh @@ -3,26 +3,31 @@ # See LICENSE.md for license information. # SPDX-License-Identifier: MIT # -# check-tiled.sh - Host gate for tools/tiled2joey.py, which turns a map -# made in Tiled into a tile sheet (baked into tile banks by assetbake.py) and -# one jlTileMapPaste map per tile layer (docs/Assets.adoc). tests/host/ -# tiledCheck.py writes one map as TMX and as TMJ, with every flip Tiled can -# ask for, two tilesets (one external, with a margin and spacing), and every -# layer encoding the tool reads; both must convert to the sheet and maps -# worked out by hand, the sheet must bake, and maps it cannot take (16x16 -# tiles, an RGB tileset) must be refused. +# check-tiled.sh - Host gate for tools/tiled2joey, which turns a map +# made in Tiled into a tile sheet (baked into tile banks by assetbake) and +# one jlTileMapPaste map per tile layer (docs/Assets.adoc). tiledCheck +# (tests/gate/tiledCheck.c) writes one map as TMX and as TMJ, with every flip +# Tiled can ask for, two tilesets (one external, with a margin and spacing), +# and every layer encoding the tool reads; both must convert to the sheet and +# maps worked out by hand, the sheet must bake, and maps it cannot take (16x16 +# tiles, a tileset that is not indexed) must be refused. # # Usage: scripts/check-tiled.sh # Exit: 0 PASS, 1 FAIL set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/scripts/lib/joeyEnv.sh" label=check-tiled work=$(mktemp -d -t joeylib-tiled.XXXXXX) trap 'rm -rf "$work"' EXIT -out=$(python3 "$repo/tests/host/tiledCheck.py" "$repo" "$work") +# The converter, and the assetbake the sheet is baked with: make/tools.mk's, +# which tiledCheck runs from JOEY_BUILD's tools/. +make -s -f "$repo/make/tools.mk" tiled2joey assetbake > /dev/null || exit 1 +tiledCheck=$(joeyTool tiledCheck) || exit 1 +out=$("$tiledCheck" "$repo" "$work") status=$? echo "$out" checks=$(grep -c '^TILED:' <<< "$out") diff --git a/scripts/check-vgm.sh b/scripts/check-vgm.sh index f68fb29..abebc0f 100755 --- a/scripts/check-vgm.sh +++ b/scripts/check-vgm.sh @@ -3,18 +3,18 @@ # See LICENSE.md for license information. # SPDX-License-Identifier: MIT # -# check-vgm.sh - Host gate for tools/vgm2song.py, the VGM-to-.song converter +# check-vgm.sh - Host gate for tools/vgm2song, the VGM-to-.song converter # that brings tracker-made chip music (Furnace's, docs/Assets.adoc) to # JoeyLib's chip tracker. # -# 1. tests/host/vgmCheck.py synthetic: VGM files written to known register +# 1. tests/gate/vgmCheck.c synthetic: VGM files written to known register # writes, converted and baked; every baked row must hold the voices worked # out by hand. # 2. With Furnace installed (./install.sh install furnace): every SN76489 and # AY-3-8910 demo song Furnace comes with that uses that chip alone is # exported by Furnace to VGM and to audio (WAV), converted and baked; each # must bake, and Furnace's own audio must sound the pitches the baked song -# plays (tests/host/vgmCheck.py pitch): at least PITCH_MIN_SONG percent of +# plays (tests/gate/vgmCheck.c pitch): at least PITCH_MIN_SONG percent of # each song's pitched windows, PITCH_MIN_MEAN on average (a wrong clock or # divider gives under 10). The demos are their authors' and stay where the # installer put them; nothing made from them is kept. @@ -34,8 +34,14 @@ DEMO_FOLDERS="sn7:sn ay8910:ay" work=$(mktemp -d -t joeylib-vgm.XXXXXX) trap 'rm -rf "$work"' EXIT mkdir -p "$work/synthetic" "$work/demos" "$work/home" +# The converter, the baker, and the checker, make/tools.mk's (vgmCheck finds +# the first two in JOEY_BUILD's tools/ too). +vgm2song=$(joeyTool vgm2song) || exit 1 +songbake=$(joeyTool songbake) || exit 1 +vgmCheck=$(joeyTool vgmCheck) || exit 1 +export JOEY_BUILD=${JOEY_BUILD:-$repo/build} -synthetic=$(python3 "$repo/tests/host/vgmCheck.py" synthetic "$repo" "$work/synthetic") +synthetic=$("$vgmCheck" synthetic "$repo" "$work/synthetic") status=$? echo "$synthetic" | grep -v '^SYNTHETIC' counts=$(echo "$synthetic" | sed -n 's/^SYNTHETIC //p') @@ -65,7 +71,7 @@ for spec in $DEMO_FOLDERS; do fails="${fails:+$fails; }$folder/$name: Furnace did not export it" continue fi - if ! python3 "$repo/tools/vgm2song.py" --chip "$chip" "$base.vgm" "$base.song" 2> "$base.warn"; then + if ! "$vgm2song" --chip "$chip" "$base.vgm" "$base.song" 2> "$base.warn"; then fails="${fails:+$fails; }$folder/$name: $(tr '\n' ' ' < "$base.warn")" continue fi @@ -75,11 +81,11 @@ for spec in $DEMO_FOLDERS; do echo "$label: $folder/$name: skipped (the song uses another chip too)" continue fi - if ! python3 "$repo/tools/songbake/songbake.py" "$base.song" "$base.jym" > /dev/null 2> "$base.bake"; then + if ! "$songbake" "$base.song" "$base.jym" > /dev/null 2> "$base.bake"; then fails="${fails:+$fails; }$folder/$name: songbake: $(grep -v warning "$base.bake" | tr '\n' ' ')" continue fi - result=$(python3 "$repo/tests/host/vgmCheck.py" pitch "$base.wav" "$base.jym" | sed -n 's/^PITCH //p') + result=$("$vgmCheck" pitch "$base.wav" "$base.jym" | sed -n 's/^PITCH //p') matched=${result%%/*} tested=${result##*/} if [ -z "$tested" ] || [ "$tested" -eq 0 ]; then diff --git a/scripts/lib/beGate.sh b/scripts/lib/beGate.sh index 0787e9a..e9e5809 100644 --- a/scripts/lib/beGate.sh +++ b/scripts/lib/beGate.sh @@ -24,7 +24,7 @@ # 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 +# beQmp WORK ARGS... tests/gate/qmp.c 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 @@ -92,27 +92,23 @@ beCrashed() { # 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). +# seconds before it). The logo's red in the 16-bit mode, the corner looked +# at, and how many of the logo's pixels show there with Deskbar up (about +# 280). +BE_LOGO_RED=(206 0 49) +BE_LOGO_CORNER_W=120 +BE_LOGO_CORNER_H=24 +BE_LOGO_PIXELS_MIN=100 beDesktopUp() { + local probe + local width + local red 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 + probe=$(joeyTool pngProbe) || return 1 + [ "$("$probe" size "$1/mode.png")" = "$BEOS_VIDEO_MODE" ] || return 1 + width=${BEOS_VIDEO_MODE%x*} + red=$("$probe" count "$1/mode.png" $((width - BE_LOGO_CORNER_W)) 0 "$BE_LOGO_CORNER_W" "$BE_LOGO_CORNER_H" "${BE_LOGO_RED[@]}") || return 1 + [ "$red" -ge "$BE_LOGO_PIXELS_MIN" ] } @@ -195,7 +191,7 @@ beNeed() { beQmp() { local work="$1" shift - python3 "$JOEY_REPO/scripts/lib/qmp.py" "$work/qmp.sock" "$@" + "$(joeyTool qmp)" "$work/qmp.sock" "$@" } diff --git a/scripts/lib/checkDocsCode.py b/scripts/lib/checkDocsCode.py deleted file mode 100755 index e209136..0000000 --- a/scripts/lib/checkDocsCode.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/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 -# -"""checkDocsCode.py - fail when the built manual shows source code with a -character that is not ASCII. - -Asciidoctor's replacements turn `->` into an arrow, `...` into an ellipsis and -a backtick next to an apostrophe into a curly quote, even inside a backtick -code span, so code the reader copies out of the manual would not compile. -Write such a span as `+text+`, which Asciidoctor leaves as it is. - -Usage: scripts/lib/checkDocsCode.py MANUAL.html -Exit: 0 every code span and block is ASCII; 1 otherwise (each one listed). -""" - -import html -import re -import sys - -CODE = re.compile(r"<(code|pre)\b[^>]*>(.*?)", re.S) -TAG = re.compile(r"<[^>]+>") -SAMPLE_CHARS = 70 - - -def main(): - if len(sys.argv) != 2: - print(__doc__.strip().split("\n\n")[-1], file=sys.stderr) - return 2 - page = open(sys.argv[1], encoding="utf-8").read() - bad = 0 - for match in CODE.finditer(page): - text = html.unescape(TAG.sub("", match.group(2))) - glyphs = sorted({ch for ch in text if ord(ch) > 127}) - if glyphs: - bad += 1 - names = " ".join(f"U+{ord(ch):04X}" for ch in glyphs) - print(f"checkDocsCode: {names} in <{match.group(1)}>: {text[:SAMPLE_CHARS]!r}", file=sys.stderr) - if bad: - print(f"checkDocsCode: {bad} code span(s) or block(s) not ASCII; write them as `+text+`", file=sys.stderr) - return 1 - return 0 - - -sys.exit(main()) diff --git a/scripts/lib/dvxGate.sh b/scripts/lib/dvxGate.sh index 5cca057..6e68a13 100644 --- a/scripts/lib/dvxGate.sh +++ b/scripts/lib/dvxGate.sh @@ -122,28 +122,39 @@ dvxFullScreen() { # no first-run Welcome window, the gate video mode, and no desktop sounds (a # recording gate must hear only the program). DOS line ends are kept. dvxIniSet() { - python3 - "$1" "$2" "$DVX_VIDEO_W" "$DVX_VIDEO_H" "$DVX_VIDEO_BPP" <<'PY' -import sys -path, desktop, width, height, bpp = sys.argv[1:] -sets = {("shell", "desktop"): desktop, ("video", "width"): width, ("video", "height"): height, ("video", "bpp"): bpp} -lines = open(path, newline="").read().split("\r\n") -out = [] -section = "" -for line in lines: - stripped = line.strip() - if stripped.startswith("["): - section = stripped.strip("[]").lower() - elif "=" in stripped and not stripped.startswith(";"): - key = stripped.split("=")[0].strip() - if section == "shell" and key.lower() == "welcome": - continue - if section == "sounds": - line = "%s =" % key - elif (section, key.lower()) in sets: - line = "%s = %s" % (key, sets[(section, key.lower())]) - out.append(line) -open(path, "w", newline="").write("\r\n".join(out)) -PY + awk -v desktop="$2" -v width="$DVX_VIDEO_W" -v height="$DVX_VIDEO_H" -v bpp="$DVX_VIDEO_BPP" ' + BEGIN { + RS = "\r\n" + sets["shell/desktop"] = desktop + sets["video/width"] = width + sets["video/height"] = height + sets["video/bpp"] = bpp + } + { + line = $0 + stripped = $0 + gsub(/^[ \t\r\n\v\f]+|[ \t\r\n\v\f]+$/, "", stripped) + if (substr(stripped, 1, 1) == "[") { + section = stripped + gsub(/^[][]+|[][]+$/, "", section) + section = tolower(section) + } else if (index(stripped, "=") > 0 && substr(stripped, 1, 1) != ";") { + key = substr(stripped, 1, index(stripped, "=") - 1) + gsub(/^[ \t\r\n\v\f]+|[ \t\r\n\v\f]+$/, "", key) + if (section == "shell" && tolower(key) == "welcome") { + next + } + if (section == "sounds") { + line = key " =" + } else if ((section "/" tolower(key)) in sets) { + line = key " = " sets[section "/" tolower(key)] + } + } + out = out (NR > 1 ? "\r\n" : "") line + } + END { + printf "%s%s", out, (RT == "" ? "" : "\r\n") > FILENAME + }' "$1" } diff --git a/scripts/lib/joeyEnv.sh b/scripts/lib/joeyEnv.sh index f2cde7d..4c60725 100644 --- a/scripts/lib/joeyEnv.sh +++ b/scripts/lib/joeyEnv.sh @@ -186,3 +186,18 @@ joeyStop() { wait "$pid" 2>/dev/null || true done } + + +# joeyLe16 FILE -- print the little-endian 16-bit number FILE starts with. +joeyLe16() { + od -An -tu2 -N2 --endian=little "$1" | tr -d ' ' +} + + +# joeyTool NAME -- print the path of one of JoeyLib's host tools +# (make/tools.mk's, in JOEY_BUILD's tools/), built first if it is not +# current. Fails, with make's message, when it cannot be built. +joeyTool() { + make -s -f "$JOEY_REPO/make/tools.mk" "$1" > /dev/null || return 1 + printf '%s\n' "${JOEY_BUILD:-$JOEY_REPO/build}/tools/$1" +} diff --git a/scripts/lib/joyGate.sh b/scripts/lib/joyGate.sh index acb02b4..038f9ae 100644 --- a/scripts/lib/joyGate.sh +++ b/scripts/lib/joyGate.sh @@ -5,7 +5,7 @@ # # 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 +# (tests/gate/joyScreen.c) 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 @@ -32,12 +32,14 @@ # 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 +# (its scratch folder). JOY_SCALE (joyScreen --scale) is the stage's +# scale in the screenshot, empty to let joyScreen find it; +# JOY_SCREEN_ARGS are more joyScreen 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). +# takes to show. A read with a "?" in it (a dot caught between places) is +# taken again JOY_RETRY_SECONDS later, up to JOY_SHOT_TRIES screenshots in +# all, about two seconds; 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 @@ -45,7 +47,7 @@ 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_SHOT_TRIES="${JOY_SHOT_TRIES:-5}" JOY_RETRY_SECONDS=0.5 JOY_FAILS="" JOY_CHECKED=0 @@ -99,7 +101,7 @@ joyCheck() { joyFail "$1: no screenshot" return fi - got=$(python3 "$JOEY_REPO/scripts/lib/joyScreen.py" "$png" "${args[@]}") + got=$("$(joeyTool joyScreen)" "$png" "${args[@]}") [[ "${got% scale=*}" == *"?"* ]] || break done if [ -n "${JOY_SHOTS:-}" ]; then diff --git a/scripts/lib/joyScreen.py b/scripts/lib/joyScreen.py deleted file mode 100644 index a2e7c00..0000000 --- a/scripts/lib/joyScreen.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/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 -middle at rest and moves DOT_TRAVEL stage pixels per full axis -(jlJoystickX/Y * DOT_TRAVEL / 127, grey at rest, green away from it), two -button squares (yellow while down) and a patch under them, green when the -port reports a joystick and red when it does not. This finds the stage in -a screenshot (on a desktop the window is wherever DVX put it, at the -window's scale) by the two patches, and prints each stick's state as five -characters: connected (1/0), X and Y (- 0 +: the dot at least half its -travel left or up, in the middle, at least half its travel right or -down), buttons 0 and 1 (1/0); '?' for anything else (a dot part way from -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] [--st] [--at X,Y] - ---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). --at: the -stage starts there on the screen (at the scale --scale gives, else 1), so -the patches need not be seen: where something covers one (the PSP's -keyboard), its stick's connected character is '?'. - -Prints "A= B= scale= dots= "; exit 1 -when the stage is not found or a stick cannot be read. -""" - -import sys - -from PIL import Image - -# examples/joy/joy.c: its layout (stage pixels) and colours ($0RGB). -STICK_LEFT = (24, 216) -STICK_TOP = 16 -FRAME_SIZE = 80 -FRAME_INSET = 2 -DOT_SIZE = 8 -DOT_TRAVEL = (FRAME_SIZE - DOT_SIZE) // 2 - FRAME_INSET -BUTTON_W = 24 -BUTTON_H = 16 -BUTTON_GAP = 4 -ROW_GAP = 6 -BUTTON_TOP = STICK_TOP + FRAME_SIZE + ROW_GAP -STATUS_W = 80 -STATUS_H = 4 -STATUS_TOP = BUTTON_TOP + BUTTON_H + ROW_GAP -COLOURS = { - "background": 0x000, "frame": 0x444, "dotOff": 0x666, "dotOn": 0x0F0, - "buttonOff": 0x333, "buttonOn": 0xFF0, "disconnected": 0xF00, -} -# The patch's green is the dot's (COLOR_CONNECTED = COLOR_DOT_ON). -CONNECTED = "dotOn" -# A 15-bit desktop is off by at most 8 a channel, the 6-bit VGA DAC by at -# most 3; JOY's colours sit much further apart. (All are exact in DVX's -# 256-colour desktop palette.) -MAX_DIST = 3 * 9 ** 2 -SCALES = (1, 2, 3) -UNKNOWN = "?" - - -def rgb(v): - return (((v >> 8) & 0xF) * 17, ((v >> 4) & 0xF) * 17, (v & 0xF) * 17) - - -# 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, colours): - self.w, self.h = img.size - self.pixels = list(img.getdata()) - self.colours = colours - self.names = {} - - def name(self, x, y): - """JOY's colour name at screen (x, y), or None.""" - if not (0 <= x < self.w and 0 <= y < self.h): - return None - c = self.pixels[y * self.w + x] - if c not in self.names: - best, bestDist = None, MAX_DIST + 1 - 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 - self.names[c] = best - return self.names[c] - - -def findStage(scr, scale): - """The screen position of stage (0, 0), found by the two status patches: - runs of STATUS_W stage pixels in either patch colour on one row, as far - apart as the two frames.""" - run = STATUS_W * scale - gap = (STICK_LEFT[1] - STICK_LEFT[0]) * scale - patch = (CONNECTED, "disconnected") - for y in range(scr.h - STATUS_H * scale + 1): - x = 0 - while x < scr.w: - if scr.name(x, y) not in patch: - x += 1 - continue - start = x - while x < scr.w and scr.name(x, y) in patch: - x += 1 - if x - start != run: - continue - other = start + gap - if all(scr.name(other + i, y) in patch for i in range(run)) and scr.name(other + run, y) not in patch: - ox = start - STICK_LEFT[0] * scale - oy = y - STATUS_TOP * scale - if ox >= 0 and oy >= 0: - return ox, oy - return None - - -def stagePixel(scr, origin, scale, x, y): - """JOY's colour name of stage pixel (x, y): its block's middle.""" - return scr.name(origin[0] + x * scale + scale // 2, origin[1] + y * scale + scale // 2) - - -def axisChar(offset): - if offset == 0: - return "0" - if offset <= -DOT_TRAVEL // 2: - return "-" - if offset >= DOT_TRAVEL // 2: - return "+" - return UNKNOWN - - -def stick(scr, origin, scale, left): - """The five-character state of the stick whose frame starts at left, and - its dot's offset from the middle ("x,y", "?" when there is no dot).""" - patch = stagePixel(scr, origin, scale, left + STATUS_W // 2, STATUS_TOP + STATUS_H // 2) - connected = {CONNECTED: "1", "disconnected": "0"}.get(patch, UNKNOWN) - - dots = [(x, y, stagePixel(scr, origin, scale, x, y)) - for y in range(STICK_TOP, STICK_TOP + FRAME_SIZE) - for x in range(left, left + FRAME_SIZE)] - dots = [(x, y, n) for x, y, n in dots if n in ("dotOn", "dotOff")] - ax = ay = UNKNOWN - offset = UNKNOWN - if dots: - x0 = min(d[0] for d in dots) - y0 = min(d[1] for d in dots) - whole = len(dots) == DOT_SIZE * DOT_SIZE and max(d[0] for d in dots) == x0 + DOT_SIZE - 1 and max(d[1] for d in dots) == y0 + DOT_SIZE - 1 - colours = set(d[2] for d in dots) - dx = x0 - (left + FRAME_SIZE // 2 - DOT_SIZE // 2) - dy = y0 - (STICK_TOP + FRAME_SIZE // 2 - DOT_SIZE // 2) - offset = "%+d,%+d" % (dx, dy) - if whole and len(colours) == 1: - ax = axisChar(dx) - ay = axisChar(dy) - # Green away from the middle, grey in it. - atRest = ax == "0" and ay == "0" - if colours != ({"dotOff"} if atRest else {"dotOn"}): - ax = ay = UNKNOWN - - centre = left + FRAME_SIZE // 2 - buttons = "" - for bx in (centre - BUTTON_W - BUTTON_GAP // 2, centre + BUTTON_GAP // 2): - n = stagePixel(scr, origin, scale, bx + BUTTON_W // 2, BUTTON_TOP + BUTTON_H // 2) - buttons += {"buttonOn": "1", "buttonOff": "0"}.get(n, UNKNOWN) - return connected + ax + ay + buttons, offset - - -def main(argv): - if len(argv) < 2: - sys.stderr.write(__doc__) - return 2 - scales = SCALES - mask = 0xFFF - at = None - 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:] - elif args[0] == "--at" and len(args) > 1: - at = tuple(int(v) for v in args[1].split(",")) - args = args[2:] - else: - sys.stderr.write(__doc__) - return 2 - - scr = Screen(Image.open(argv[1]).convert("RGB"), palette(mask)) - origin = None - for scale in scales: - origin = at if at else findStage(scr, scale) - if origin: - break - if not origin: - print("joyScreen: FAIL - JOY's two status patches are not on the screen") - return 1 - read = [stick(scr, origin, scale, left) for left in STICK_LEFT] - print("A=%s B=%s scale=%d dots=%s %s" % (read[0][0], read[1][0], scale, read[0][1], read[1][1])) - return 1 if any(UNKNOWN in state for state, offset in read) else 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/keysCells.py b/scripts/lib/keysCells.py deleted file mode 100644 index d69fd72..0000000 --- a/scripts/lib/keysCells.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/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 - -"""keysCells.py - read which of KEYS's named-key cells are lit off a screenshot. - -KEYS (examples/keys) draws a cell for each named key, lit (colour 2, green) -while jlKeyDown says it is down and dark grey (colour 1) otherwise. This -reads the cells of its fourth row that a port gives a second physical key -for -- Return, Ctrl, Alt, Up, Down, Left, and Right -- at the middle of -each, and prints one digit a cell, in that order: 1 lit, 0 not, ? neither -colour. - - keysCells.py PNG [--at X,Y] [--scale S] - ---at: where the stage's top left is in the screenshot (default 0,0). ---scale: screen pixels a stage pixel (default 1). -""" - -import sys - -from PIL import Image - -# examples/keys: CELL_W x CELL_H cells GAP apart from (MARGIN_X, MARGIN_Y), -# the named keys in row 4 at these columns. -CELL_W = 28 -CELL_H = 26 -GAP = 4 -MARGIN_X = 2 -MARGIN_Y = 2 -ROW = 4 -COLUMNS = (2, 3, 4, 5, 6, 7, 8) # Return, Ctrl, Alt, Up, Down, Left, Right -# KEYS's colours 1 and 2 (12-bit $333 and $0F0, each channel widened x17). -UNLIT = (0x33, 0x33, 0x33) -LIT = (0x00, 0xFF, 0x00) -# A 15- or 16-bit screen is off by at most 8 a channel. -MAX_DIST = 3 * 9 ** 2 - - -def near(got, want): - return sum((g - w) ** 2 for g, w in zip(got, want)) <= MAX_DIST - - -def main(argv): - args = argv[1:] - at = (0, 0) - scale = 1 - png = None - while args: - arg = args.pop(0) - if arg == "--at": - at = tuple(int(v) for v in args.pop(0).split(",")) - elif arg == "--scale": - scale = int(args.pop(0)) - else: - png = arg - if png is None: - print(__doc__.strip(), file=sys.stderr) - return 2 - image = Image.open(png).convert("RGB") - y = MARGIN_Y + ROW * (CELL_H + GAP) + CELL_H // 2 - digits = [] - for column in COLUMNS: - x = MARGIN_X + column * (CELL_W + GAP) + CELL_W // 2 - pixel = image.getpixel((at[0] + x * scale, at[1] + y * scale)) - digits.append("1" if near(pixel, LIT) else ("0" if near(pixel, UNLIT) else "?")) - print("".join(digits)) - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/keysGate.sh b/scripts/lib/keysGate.sh index 7aef50d..48c440e 100644 --- a/scripts/lib/keysGate.sh +++ b/scripts/lib/keysGate.sh @@ -10,7 +10,7 @@ # go first, which must leave Up down (src/core/inputInternal.h, # INPUT_KEY_SECOND). Each step holds its keys (letting go of every other), # waits KEYS_SETTLE_SECONDS, and reads KEYS's Return, Ctrl, Alt, Up, Down, -# Left, and Right cells (examples/keys) as seven digits, keysCells.py's. +# Left, and Right cells (examples/keys) as seven digits, keysCells's. # # The gate sets KEYS_LABEL (its name, for the lines it prints) and defines, # by the key names below: diff --git a/scripts/lib/keysRow.py b/scripts/lib/keysRow.py deleted file mode 100755 index 45eda34..0000000 --- a/scripts/lib/keysRow.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/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 -pixel nibbles: the sentinel A 5 A 5, the count byte, a zero byte, then each -character, two pixels a byte, high nibble first. This finds that row in a -screenshot (on a desktop the window is wherever DVX put it, at the window's -scale: every stage pixel a SCALE x SCALE block) and checks it holds exactly -TARGET, and that every block of it is whole: each of its screen pixels the -same colour. - - keysRow.py PNG TARGET [--size WxH] [--at X,Y] [--scale S] - ---size: the screenshot must be that size (the full-screen mode, not the -desktop). --at: the row must start exactly there (the stage's own row 198 -at 1x on a full screen). --scale: the row must be at that scale (else the -first of 1, 2, 3 it is found at). Prints the row it found; exit 0 when it -is right. -""" - -import sys - -from PIL import Image - -# KEYS palette 0 (buildPalette): a gray ramp with four demo colours and red -# at 15, 12-bit $0RGB, each channel widened x17 as the DVX port's tables do. -PAL12 = [(i << 8) | (i << 4) | i for i in range(16)] -PAL12[0], PAL12[1], PAL12[2], PAL12[3] = 0x000, 0x333, 0x0F0, 0xFFF -PAL12[15] = 0xF00 -PAL_RGB = [(((v >> 8) & 0xF) * 17, ((v >> 4) & 0xF) * 17, (v & 0xF) * 17) for v in PAL12] -# A 15- or 16-bit desktop is off by at most 8 a channel after the round -# trip, the 6-bit VGA DAC by at most 3, while the palette entries sit 17 -# apart: nearest is exact. (Every KEYS colour is also one of DVX's 256-colour -# desktop palette, its cube or its grey ramp, so 8 bits is exact too.) -MAX_DIST = 3 * 9 ** 2 -NO_NIBBLE = -1 -SENTINEL = (0xA, 0x5, 0xA, 0x5) -SCALES = (1, 2, 3) - - -def nibbleMap(img): - """Every pixel's KEYS palette index, NO_NIBBLE where no entry is near.""" - nearest = {} - for rgb in set(img.getdata()): - best, bestDist = NO_NIBBLE, MAX_DIST + 1 - for n, pal in enumerate(PAL_RGB): - d = sum((c - p) ** 2 for c, p in zip(rgb, pal)) - if d < bestDist: - best, bestDist = n, d - nearest[rgb] = best - return [nearest[rgb] for rgb in img.getdata()] - - -def findRow(nibs, pixels, w, h, scale, expect): - """The (x, y, bytes) of the first row at scale whose header matches.""" - header = expect[:4] - span = 2 * len(expect) * scale - for y in range(h): - base = y * w - for x in range(w - span + 1): - if nibs[base + x] != SENTINEL[0]: - continue - if tuple(nibs[base + x + i * scale] for i in range(len(SENTINEL))) != SENTINEL: - continue - row = bytes(((nibs[base + x + 2 * i * scale] & 0xF) << 4) | (nibs[base + x + (2 * i + 1) * scale] & 0xF) for i in range(len(expect))) - if row[:4] == header: - return x, y, row - return None - - -def splitBlocks(pixels, w, h, x, y, scale, count): - """How many of the count stage pixels from (x, y) are not whole blocks.""" - split = 0 - for i in range(count): - left = x + i * scale - want = pixels[y * w + left] - block = [pixels[(y + dy) * w + left + dx] for dy in range(scale) if y + dy < h for dx in range(scale)] - if len(block) != scale * scale or any(p != want for p in block): - split += 1 - return split - - -def main(argv): - if len(argv) < 3: - sys.stderr.write(__doc__) - return 2 - png, target = argv[1], argv[2] - size = None - at = None - scales = SCALES - args = argv[3:] - while args: - if args[0] == "--size" and len(args) > 1: - size = tuple(int(n) for n in args[1].split("x")) - elif args[0] == "--at" and len(args) > 1: - at = tuple(int(n) for n in args[1].split(",")) - elif args[0] == "--scale" and len(args) > 1: - scales = (int(args[1]),) - else: - sys.stderr.write(__doc__) - return 2 - args = args[2:] - - img = Image.open(png).convert("RGB") - w, h = img.size - if size is not None and img.size != size: - print("keysRow: FAIL - %s is %dx%d, want %dx%d" % (png, w, h, size[0], size[1])) - return 1 - - pixels = list(img.getdata()) - nibs = nibbleMap(img) - expect = bytes([0xA5, 0xA5, len(target), 0x00]) + target.encode("ascii") - found = None - for scale in scales: - found = findRow(nibs, pixels, w, h, scale, expect) - if found: - break - if not found: - print("keysRow: FAIL - no verification row (A5A5 %02X00) on the screen at %s" % (len(target), " or ".join("%dx" % s for s in scales))) - return 1 - x, y, row = found - print("keysRow: row at (%d,%d) at %dx=%s" % (x, y, scale, row.hex().upper())) - if row != expect: - print("keysRow: FAIL - screen row %s, want %s" % (row.hex().upper(), expect.hex().upper())) - return 1 - split = splitBlocks(pixels, w, h, x, y, scale, 2 * len(expect)) - if split: - print("keysRow: FAIL - %d of the row's %d stage pixels are not whole %dx%d blocks" % (split, 2 * len(expect), scale, scale)) - return 1 - if at is not None and (x, y) != at: - print("keysRow: FAIL - the row is at (%d,%d), want (%d,%d)" % (x, y, at[0], at[1])) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/mameNullModemCfg.py b/scripts/lib/mameNullModemCfg.py deleted file mode 100644 index 0977dc8..0000000 --- a/scripts/lib/mameNullModemCfg.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/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 -configuration says otherwise, so a guest whose UART runs at any other rate -sees garbage. The line is a CONFIG input port on the device, which MAME -reads back from cfg/SYSTEM.cfg in its -cfg_directory. - - mameNullModemCfg.py SYSTEM TAG BAUD [DATABITS PARITY STOPBITS] - -SYSTEM is the driver (x68000, apple2gs, apple2gsr1), TAG the null_modem's -device tag (":rs232c:null_modem", ":modem:null_modem"). The line may also -come as one quoted argument ("4800 7 E 1"). DATABITS PARITY STOPBITS -default to 8 N 1. Prints the cfg; exit 2 for a line MAME cannot set. -""" - -import sys - -# MAME's RS232_* enum values (src/devices/bus/rs232/rs232.h). -BAUD = {"50": 14, "75": 15, "110": 0, "150": 1, "200": 17, "300": 2, "600": 3, - "1200": 4, "1800": 18, "2000": 19, "2400": 5, "3600": 20, "4800": 6, - "7200": 21, "9600": 7, "14400": 8, "19200": 9, "28800": 10, - "38400": 11, "57600": 12, "115200": 13} -DATA = {"5": 0, "6": 1, "7": 2, "8": 3} -PARITY = {"N": 0, "O": 1, "E": 2} -STOP = {"1": 1, "2": 3} -DEFAULT_FORMAT = ["8", "N", "1"] -# The null_modem's own defaults (src/devices/bus/rs232/null_modem.cpp). -BAUD_DEFAULT = 7 -DATA_DEFAULT = 3 -PARITY_DEFAULT = 0 -STOP_DEFAULT = 1 - - -def main(argv): - if len(argv) < 4: - sys.stderr.write(__doc__) - return 2 - system = argv[1] - tag = argv[2] - line = " ".join(argv[3:]).split() - if len(line) == 1: - line += DEFAULT_FORMAT - if len(line) != 4: - sys.stderr.write("mameNullModemCfg.py: the line is BAUD [DATABITS PARITY STOPBITS]\n") - return 2 - baud, data, parity, stop = line - parity = parity.upper() - for value, table, what in ((baud, BAUD, "baud rate"), (data, DATA, "data bits"), - (parity, PARITY, "parity"), (stop, STOP, "stop bits")): - if value not in table: - sys.stderr.write("mameNullModemCfg.py: MAME's null_modem has no %s %s (it takes %s)\n" - % (what, value, " ".join(table))) - return 2 - ports = [("RS232_TXBAUD", BAUD_DEFAULT, BAUD[baud]), ("RS232_RXBAUD", BAUD_DEFAULT, BAUD[baud]), - ("RS232_DATABITS", DATA_DEFAULT, DATA[data]), ("RS232_PARITY", PARITY_DEFAULT, PARITY[parity]), - ("RS232_STOPBITS", STOP_DEFAULT, STOP[stop])] - print('\n\n \n ' % system) - for name, default, value in ports: - print(f' ') - print(' \n \n') - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/mameNullModemCfg.sh b/scripts/lib/mameNullModemCfg.sh new file mode 100755 index 0000000..63fb131 --- /dev/null +++ b/scripts/lib/mameNullModemCfg.sh @@ -0,0 +1,82 @@ +#!/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 +# +# mameNullModemCfg.sh - 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 +# configuration says otherwise, so a guest whose UART runs at any other rate +# sees garbage. The line is a CONFIG input port on the device, which MAME +# reads back from cfg/SYSTEM.cfg in its -cfg_directory. +# +# mameNullModemCfg.sh SYSTEM TAG BAUD [DATABITS PARITY STOPBITS] +# +# SYSTEM is the driver (x68000, apple2gs, apple2gsr1), TAG the null_modem's +# device tag (":rs232c:null_modem", ":modem:null_modem"). The line may also +# come as one quoted argument ("4800 7 E 1"). DATABITS PARITY STOPBITS +# default to 8 N 1. Prints the cfg; exit 2 for a line MAME cannot set. +set -uo pipefail + +label=mameNullModemCfg.sh +# MAME's RS232_* enum values (src/devices/bus/rs232/rs232.h), and each +# table's keys in the order a message lists them. +declare -A BAUD=([50]=14 [75]=15 [110]=0 [150]=1 [200]=17 [300]=2 [600]=3 + [1200]=4 [1800]=18 [2000]=19 [2400]=5 [3600]=20 [4800]=6 + [7200]=21 [9600]=7 [14400]=8 [19200]=9 [28800]=10 + [38400]=11 [57600]=12 [115200]=13) +BAUD_KEYS="50 75 110 150 200 300 600 1200 1800 2000 2400 3600 4800 7200 9600 14400 19200 28800 38400 57600 115200" +declare -A DATA=([5]=0 [6]=1 [7]=2 [8]=3) +DATA_KEYS="5 6 7 8" +declare -A PARITY=([N]=0 [O]=1 [E]=2) +PARITY_KEYS="N O E" +declare -A STOP=([1]=1 [2]=3) +STOP_KEYS="1 2" +DEFAULT_FORMAT=(8 N 1) +# The null_modem's own defaults (src/devices/bus/rs232/null_modem.cpp). +BAUD_DEFAULT=7 +DATA_DEFAULT=3 +PARITY_DEFAULT=0 +STOP_DEFAULT=1 + +if [ $# -lt 3 ]; then + echo "usage: $label SYSTEM TAG BAUD [DATABITS PARITY STOPBITS]" >&2 + exit 2 +fi +system=$1 +tag=$2 +shift 2 +read -ra line <<< "$*" +if [ "${#line[@]}" -eq 1 ]; then + line+=("${DEFAULT_FORMAT[@]}") +fi +if [ "${#line[@]}" -ne 4 ]; then + echo "$label: the line is BAUD [DATABITS PARITY STOPBITS]" >&2 + exit 2 +fi +baud=${line[0]} +data=${line[1]} +parity=${line[2]^^} +stop=${line[3]} + +# check VALUE TABLE KEYS WHAT -- exit 2 unless TABLE has VALUE. +check() { + local -n table=$2 + if [ -z "${table[$1]+x}" ]; then + echo "$label: MAME's null_modem has no $4 $1 (it takes $3)" >&2 + exit 2 + fi +} + +check "$baud" BAUD "$BAUD_KEYS" "baud rate" +check "$data" DATA "$DATA_KEYS" "data bits" +check "$parity" PARITY "$PARITY_KEYS" "parity" +check "$stop" STOP "$STOP_KEYS" "stop bits" +printf '\n\n \n \n' "$system" +for port in "RS232_TXBAUD $BAUD_DEFAULT ${BAUD[$baud]}" "RS232_RXBAUD $BAUD_DEFAULT ${BAUD[$baud]}" \ + "RS232_DATABITS $DATA_DEFAULT ${DATA[$data]}" "RS232_PARITY $PARITY_DEFAULT ${PARITY[$parity]}" \ + "RS232_STOPBITS $STOP_DEFAULT ${STOP[$stop]}"; do + read -r name default value <<< "$port" + printf ' \n' "$tag" "$name" "$default" "$value" +done +printf ' \n \n\n' diff --git a/scripts/lib/netWmIcon.py b/scripts/lib/netWmIcon.py deleted file mode 100755 index 01a1dfe..0000000 --- a/scripts/lib/netWmIcon.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/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 - -"""netWmIcon.py - an X window's icon against the picture it should be. - -A window's icon on X is its _NET_WM_ICON property: one or more pictures, -each its width, its height and then its pixels as 32-bit ARGB, row after -row. SDL3 sets it from SDL_SetWindowIcon; Wine from the window class's icon. -xprop prints it as numbers with - - xprop -id WINDOW -f _NET_WM_ICON 32c ' = $0+' _NET_WM_ICON > XPROP - -and this compares the picture of the wanted size in that against WANT -(any picture PIL reads; an .ico's 32-bit picture of that size). A pixel -at least half opaque must be so in both, and then of the same colour, -give or take TOLERANCE a gun (a system may round its own way). - - netWmIcon.py XPROP WANT SIZE LABEL - -Prints LABEL: PASS or LABEL: FAIL, and exits 0 or 1. -""" - -import re -import sys - -from PIL import Image - -TOLERANCE = 2 -ALPHA_OPAQUE_MIN = 128 -ICO_BPP = 32 - - -def pictures(text): - """Every (width, height, pixels) in xprop's numbers.""" - match = re.search(r"=\s*([\d,\s]+)", text) - if match is None: - return [] - values = [int(v) for v in match.group(1).replace(",", " ").split()] - out = [] - at = 0 - while at + 2 <= len(values): - w, h = values[at], values[at + 1] - at += 2 - out.append((w, h, values[at:at + w * h])) - at += w * h - return out - - -def wanted(path, size): - """WANT as RGBA at SIZE x SIZE: an .ico's 32-bit picture of that size.""" - image = Image.open(path) - if image.format == "ICO": - image = image.ico.getimage((size, size), ICO_BPP) - return image.convert("RGBA") - - -def main(argv): - if len(argv) != 5: - sys.stderr.write(__doc__) - return 2 - xpropPath, wantPath, size, label = argv[1], argv[2], int(argv[3]), argv[4] - found = pictures(open(xpropPath, encoding="ascii", errors="replace").read()) - sizes = ", ".join("%dx%d" % (w, h) for w, h, _ in found) or "none" - shown = [pixels for w, h, pixels in found if w == size and h == size] - if not shown: - print("%s: FAIL - the window has no %dx%d icon (it has: %s)" % (label, size, size, sizes)) - return 1 - want = wanted(wantPath, size) - if want.size != (size, size): - print("%s: FAIL - %s is %dx%d, not %dx%d" % (label, wantPath, want.size[0], want.size[1], size, size)) - return 1 - wrong = 0 - opaqueCount = 0 - for argb, (r, g, b, a) in zip(shown[0], want.getdata()): - shownOpaque = (argb >> 24) >= ALPHA_OPAQUE_MIN - if shownOpaque != (a >= ALPHA_OPAQUE_MIN): - wrong += 1 - continue - if not shownOpaque: - continue - opaqueCount += 1 - rgb = ((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF) - if max(abs(rgb[0] - r), abs(rgb[1] - g), abs(rgb[2] - b)) > TOLERANCE: - wrong += 1 - if wrong or opaqueCount == 0: - print("%s: FAIL - %d of the %dx%d icon's pixels differ from %s (%d opaque)" % (label, wrong, size, size, wantPath, opaqueCount)) - return 1 - print("%s: PASS (the window's %dx%d icon is the baked one: %d opaque pixels and the transparency round them; icons on the window: %s)" % (label, size, size, opaqueCount, sizes)) - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/padGate.sh b/scripts/lib/padGate.sh index 9e1ef48..0760af6 100644 --- a/scripts/lib/padGate.sh +++ b/scripts/lib/padGate.sh @@ -5,7 +5,7 @@ # # 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, or two generic joysticks +# (tests/gate/virtualPad.c) as sticks A and B, or two generic joysticks # no gamepad mapping knows. 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, as a gamepad and as @@ -30,7 +30,7 @@ PAD_SENT=0 PAD_SWAP=0 PAD_READY_SECONDS=10 PAD_REPLY_SECONDS=5 -# The virtual pads' USB vendor/product (virtualPad.py's), by kind. +# The virtual pads' USB vendor/product (virtualPad's), by kind. PAD_IDS_XBOX360=0x045e/0x028e PAD_IDS_GENERIC=0x1209/0x0001 PAD_ONLY_ENV=(SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$PAD_IDS_XBOX360) @@ -58,7 +58,7 @@ padOrder() { 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[@]}") + got=$("$(joeyTool joyScreen)" "$JOY_WORK/order.png" "${JOY_SCREEN_ARGS[@]}") padSend release A-left sleep "$JOY_PRESS_SECONDS" case "$got" in @@ -93,6 +93,7 @@ padSend() { padStart() { local fifo="$1/pad.in" local kind=() + local pad 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 @@ -107,7 +108,8 @@ padStart() { PAD_SWAP=0 rm -f "$fifo" "$PAD_OUT" mkfifo "$fifo" - python3 "$JOEY_REPO/scripts/lib/virtualPad.py" "${kind[@]}" "JoeyLib gate" < "$fifo" > "$PAD_OUT" 2>&1 & + pad=$(joeyTool virtualPad) || exit 1 + "$pad" "${kind[@]}" "JoeyLib gate" < "$fifo" > "$PAD_OUT" 2>&1 & PAD_PID=$! exec {PAD_FD}> "$fifo" if ! padWait 0 "$PAD_READY_SECONDS"; then diff --git a/scripts/lib/pspAudioGrade.py b/scripts/lib/pspAudioGrade.py deleted file mode 100644 index a11801e..0000000 --- a/scripts/lib/pspAudioGrade.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/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/pspKeyboardPath.py b/scripts/lib/pspKeyboardPath.py deleted file mode 100644 index 764a5db..0000000 --- a/scripts/lib/pspKeyboardPath.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/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 - -"""pspKeyboardPath.py - the PSP keyboard's d-pad moves and presses for some -typing, for a gate to send the PSP. - -The keyboard (src/hosted/psp/pspKeyboard.c) is read from its own source: -its rows of KEY_* entries, each key's width in half keys, what it types and -its legend, and where the cursor starts. Its cursor is followed as the -keyboard moves it -- along a row wrapping round, and up or down a row onto -the key nearest the middle of the one it was on (ties: down-right going -down, up-left going up). Each step is printed on a line of its own: - - move left|right|up|down one d-pad press - press LEGEND cross on the key with that legend - - pspKeyboardPath.py SOURCE TOKEN... - -A TOKEN is "key:LEGEND" (a key by its legend: ESC, F5, SHIFT, ENTER...) or -"type:TEXT" (each character of TEXT, Shift pressed first for a shifted one, -which the keyboard lets go after the next key). -""" - -import re -import sys - - -def chars(text): - """A C character literal's character ('\\'' and '\\\\' unescaped).""" - text = text.strip() - match = re.fullmatch(r"\(uint8_t\)\((.*)\)", text) - if match: - text = match.group(1) - if text.startswith("'") and text.endswith("'"): - body = text[1:-1] - return body[1] if body.startswith("\\") else body - return None - - -def args(call): - """The arguments of a KEY_*(...) entry, split at top-level commas.""" - out = [] - depth = 0 - quote = None - current = "" - i = 0 - while i < len(call): - ch = call[i] - if quote: - current += ch - if ch == "\\": - current += call[i + 1] - i += 1 - elif ch == quote: - quote = None - elif ch in "'\"": - quote = ch - current += ch - elif ch == "(": - depth += 1 - current += ch - elif ch == ")": - depth -= 1 - current += ch - elif ch == "," and depth == 0: - out.append(current.strip()) - current = "" - else: - current += ch - i += 1 - out.append(current.strip()) - return out - - -def width(text): - return int(text.rstrip("u")) - - -def keyboard(source): - """The rows (each a list of keys: halves, legend, plain, shifted) and the - cursor's starting row and column.""" - text = open(source).read() - defines = dict(re.findall(r"#define (CURSOR_(?:ROW|COL)_START)\s+(\d+)u", text)) - rows = [] - for body in re.findall(r"static const BoardKeyT gRow\d+\[\] = \{(.*?)\n\};", text, re.S): - row = [] - for kind, call in re.findall(r"(KEY_[A-Z]+)\((.*)\),?\s*$", body, re.M): - a = args(call) - if kind == "KEY_LETTER": - row.append((2, a[0], chars(a[1]), a[0])) - elif kind == "KEY_DIGIT": - row.append((2, a[0], chars(a[1]), chars(a[2]))) - elif kind == "KEY_PUNCT": - row.append((width(a[0]), chars(a[1]), chars(a[1]), chars(a[2]))) - elif kind == "KEY_NAMED": - plain = chars(a[2]) - row.append((width(a[0]), a[3].strip('"'), plain, plain)) - elif kind == "KEY_LATCH": - row.append((width(a[0]), a[3].strip('"'), None, None)) - rows.append(row) - return rows, int(defines["CURSOR_ROW_START"]), int(defines["CURSOR_COL_START"]) - - -def center(row, col): - """Twice the middle of a key, in half keys.""" - start = sum(k[0] for k in row[:col]) - return 2 * start + row[col][0] - - -def path(rows, start, target): - """Moves from start (row, col) to target, and the cursor's place after - each, as the keyboard makes them: rows first, then along the row.""" - moves = [] - r, c = start - tr, tc = target - while r != tr: - middle = center(rows[r], c) - down = (tr - r) % len(rows) <= (r - tr) % len(rows) - r = (r + 1) % len(rows) if down else (r - 1) % len(rows) - best = None - for col in range(len(rows[r])): - d = abs(center(rows[r], col) - middle) - if best is None or d < best or (down and d == best): - best, c = d, col - moves.append("move " + ("down" if down else "up")) - count = len(rows[r]) - right = (tc - c) % count - left = (c - tc) % count - moves += ["move right"] * right if right <= left else ["move left"] * left - return moves, (r, tc) - - -def find(rows, want, byLegend): - """(row, col) of the key with legend want, or else of the key that types - want; and whether that is its shifted character.""" - for r, row in enumerate(rows): - for c, key in enumerate(row): - if byLegend and key[1] == want: - return (r, c), False - if not byLegend and key[2] == want: - return (r, c), False - for r, row in enumerate(rows): - for c, key in enumerate(row): - if not byLegend and key[3] == want: - return (r, c), True - raise SystemExit("pspKeyboardPath: no key shows or types %r" % want) - - -def main(): - if len(sys.argv) < 3: - raise SystemExit(__doc__) - rows, r0, c0 = keyboard(sys.argv[1]) - at = (r0, c0) - for token in sys.argv[2:]: - kind, _, value = token.partition(":") - wants = [value] if kind == "key" else list(value) - for want in wants: - spot, shifted = find(rows, want, kind == "key") - if shifted: - shiftSpot, _ = find(rows, "SHIFT", True) - moves, at = path(rows, at, shiftSpot) - print("\n".join(moves + ["press SHIFT"])) - moves, at = path(rows, at, spot) - print("\n".join(moves + ["press " + rows[at[0]][at[1]][1]])) - - -if __name__ == "__main__": - main() diff --git a/scripts/lib/qmp.py b/scripts/lib/qmp.py deleted file mode 100644 index edf0d4a..0000000 --- a/scripts/lib/qmp.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/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 down KEY press one key, and leave it down - qmp.py SOCKET up KEY let go of it - 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 key(qmp, code, down): - """Press or let go of one key.""" - qmp.execute('input-send-event', {'events': [ - {'type': 'key', 'data': {'down': down, 'key': {'type': 'qcode', 'data': code}}}, - ]}) - - -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 in ('down', 'up') and len(argv) == 4: - key(qmp, argv[3], command == 'down') - 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 deleted file mode 100755 index b98363d..0000000 --- a/scripts/lib/serialLoopPeer.py +++ /dev/null @@ -1,370 +0,0 @@ -#!/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 -every byte it receives. This peer waits for the banner (the guest's -transmit works), then sends a probe line and expects it back (its receive -works too). - - serialLoopPeer.py tcp-server PORT WALL [BANNER] listen; the emulator connects - serialLoopPeer.py tcp-client PORT WALL [BANNER] connect to the emulator - serialLoopPeer.py pty PATHFILE WALL [BANNER] make a raw pty pair, write the - guest end's path to PATHFILE - -WALL is the wall-clock budget in seconds. Prints one LOOP PASS / LOOP FAIL -line; exit 0 on pass, 1 on fail. - ---close-hold FILE (pty) then has SERTEST close the port: its DTR check, run -for the closes alone (a pty has no modem lines), every marker line -acknowledged, after which the port stays closed. Prints one CLOSE PASS / -CLOSE FAIL line more, and keeps the pty pair open until FILE exists or WALL -runs out, so the gate can read the guest end's settings after the close -(verify-sdl3-serial.sh). - ---stream N --rate RATE (TCP, nullmodem transparent:1) then streams N bytes of printable -ASCII through SERTEST's echo, at most STREAM_WINDOW bytes ahead of what has -come back, and checks every byte returns once, in order. Prints one STREAM -PASS / STREAM FAIL line more, with the round trip's bytes a second; PASS -needs that to be at least STREAM_MIN_PERCENT of what the line rate RATE -(bits a second) carries. - ---dtr-check (TCP, with DOSBox's nullmodem in transparent:0 mode, which puts -the guest's modem lines in the byte stream as they change: 0xFF 0xFF is a -data 0xFF, 0xFF the lines, bit 0 RTS, bit 1 DTR) then runs SERTEST's -DTR check: it sends DTR_CHECK, acknowledges each of SERTEST's marker lines -(so SERTEST changes a line only once the marker before has gone out whole), -and reads the line states against the markers. jlSerialSetDtr(false) and -(true) must reach the line, closing and opening the port again must keep -DTR up, and after a hang-up (DTR dropped, then the port closed) DTR must -stay down for HANGUP_WATCH_SECONDS. Prints one DTR PASS / DTR FAIL line -more. -""" - -import os -import select -import socket -import sys -import time -import tty - -DEFAULT_BANNER = b"JLSERTEST-READY" -PROBE = b"PING12345" -RESEND_SECONDS = 3.0 # the probe is re-sent until it comes back -POLL_SECONDS = 0.5 -CONNECT_RETRY_SECONDS = 0.5 -READ_SIZE = 256 -SHOWN_BYTES = 80 - -# --stream: what the window, the chunks, and the pass mark are, and the bytes -# it sends (printable ASCII, so never SERTEST's DTR_CHECK). -STREAM_WINDOW = 1024 -STREAM_CHUNK = 64 -STREAM_POLL_SECONDS = 0.01 -STREAM_SETTLE_SECONDS = 1.0 -STREAM_MIN_PERCENT = 75 -STREAM_FIRST_BYTE = 0x20 -STREAM_BYTE_VALUES = 95 -LINE_BITS_PER_BYTE = 10 # 8N1: a start bit, 8 data bits, a stop bit - -# SERTEST's DTR check (examples/sertest/sertest.c). -DTR_CHECK = b"\x04" -DTR_ACK = b"\x06" -MARKS = (("off", b"JLDTR-OFF\r\n"), ("on", b"JLDTR-ON\r\n"), ("reopened", b"JLDTR-REOPENED\r\n"), ("hangup", b"JLDTR-HANGUP\r\n")) -HANGUP_WATCH_SECONDS = 3.0 - -# DOSBox nullmodem transparent:0 framing. -LINE_ESCAPE = 0xFF -LINE_DTR = 0x02 - - -class LineDecoder: - """Splits a transparent:0 stream into data and the guest's DTR states, - each state kept with the count of data bytes that came before it.""" - - def __init__(self): - self.pending = b"" - self.dataCount = 0 - self.dtr = [] - - def feed(self, raw): - buf = self.pending + raw - data = bytearray() - i = 0 - while i < len(buf): - if buf[i] != LINE_ESCAPE: - data.append(buf[i]) - i += 1 - continue - if i + 1 >= len(buf): - break - if buf[i + 1] == LINE_ESCAPE: - data.append(LINE_ESCAPE) - else: - self.dtr.append((self.dataCount + len(data), (buf[i + 1] & LINE_DTR) != 0)) - i += 2 - self.pending = buf[i:] - self.dataCount += len(data) - return bytes(data) - - def dtrBefore(self, position): - """DTR as it was when the data byte at position arrived (None: never reported).""" - state = None - for at, dtr in self.dtr: - if at > position: - break - state = dtr - return state - - def dtrChanges(self, first, last, value): - """Whether DTR was reported as value after data byte first, up to last.""" - return any(first < at <= last and dtr == value for at, dtr in self.dtr) - - -def acceptOne(port, wall): - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", port)) - server.listen(1) - server.settimeout(wall) - try: - conn, _ = server.accept() - except socket.timeout: - return None - return conn - - -def connectOne(port, wall, start): - while time.time() - start < wall: - try: - return socket.create_connection(("127.0.0.1", port), timeout=2) - except OSError: - time.sleep(CONNECT_RETRY_SECONDS) - return None - - -def openPty(pathFile): - master, slave = os.openpty() - tty.setraw(slave) - tty.setraw(master) - # Publish atomically: the gate polls for the file. - with open(pathFile + ".tmp", "w") as f: - f.write(os.ttyname(slave)) - os.rename(pathFile + ".tmp", pathFile) - return master - - -def ackMarks(send, got, base, marks): - # Acknowledge each of SERTEST's marker lines (after got[base:]) as it - # completes, recording where it is in marks. - while len(marks) < len(MARKS): - name, mark = MARKS[len(marks)] - at = got.find(mark, base) - if at < 0: - return - marks[name] = at - send(DTR_ACK) - - -def closeHold(send, receive, wall, start, got, holdFile): - # After the loopback: SERTEST's DTR check for its closes alone, then the - # pty held open until holdFile exists. - base = len(got) - send(DTR_CHECK) - marks = {} - while len(marks) < len(MARKS) and time.time() - start < wall: - data = receive() - if data is None: - break - got += data - ackMarks(send, got, base, marks) - if len(marks) == len(MARKS): - print("CLOSE PASS: every marker acknowledged; SERTEST has closed the port") - status = 0 - else: - print("CLOSE FAIL: no %r from SERTEST; stream=%r" % (MARKS[len(marks)][1], got[base:base + SHOWN_BYTES])) - status = 1 - sys.stdout.flush() - while not os.path.exists(holdFile) and time.time() - start < wall: - receive() - return status - - -def dtrCheck(send, receive, wall, start, lines, got): - # After the loopback: the stream so far is got; lines has decoded it. - base = len(got) - send(DTR_CHECK) - marks = {} - hangupAt = None - while time.time() - start < wall: - if hangupAt is not None and time.time() - hangupAt >= HANGUP_WATCH_SECONDS: - break - data = receive() - if data is None: - break - got += data - # The last marker starts the watch. - ackMarks(send, got, base, marks) - if hangupAt is None and len(marks) == len(MARKS): - hangupAt = time.time() - if len(marks) < len(MARKS): - print("DTR FAIL: no %r from SERTEST; stream=%r" % (MARKS[len(marks)][1], got[base:base + SHOWN_BYTES])) - return 1 - if time.time() - hangupAt < HANGUP_WATCH_SECONDS: - print("DTR FAIL: out of time before the watch after the hang-up ended") - return 1 - fails = [] - if lines.dtrBefore(marks["off"]) is not False: - fails.append("jlSerialSetDtr(false) did not drop DTR") - if lines.dtrBefore(marks["on"]) is not True: - fails.append("jlSerialSetDtr(true) did not raise DTR") - if lines.dtrChanges(marks["on"], marks["reopened"], False): - fails.append("closing and opening the port dropped DTR") - if lines.dtrBefore(marks["hangup"]) is not False: - fails.append("jlSerialSetDtr(false) after the reopen did not drop DTR") - if lines.dtrChanges(marks["hangup"], lines.dataCount, True): - fails.append("closing the port after the hang-up raised DTR") - if fails: - print("DTR FAIL: %s; DTR states (data byte, DTR) since the check: %r" % ("; ".join(fails), [x for x in lines.dtr if x[0] >= base])) - return 1 - print("DTR PASS: SetDtr(false/true) reached the line, close+open kept DTR up, a hang-up stayed down %.0f s after the close" % HANGUP_WATCH_SECONDS) - return 0 - - -def loop(send, receive, wall, start, banner): - # send(bytes); receive() -> bytes, b"" for "nothing yet", None at EOF. - # Returns the exit status and the stream. - got = b"" - sentAt = None - lastSend = 0.0 - while time.time() - start < wall: - if banner in got and time.time() - lastSend > RESEND_SECONDS: - send(PROBE + b"\r\n") - lastSend = time.time() - if sentAt is None: - sentAt = len(got) - data = receive() - if data is None: - break - got += data - if sentAt is not None and PROBE in got[sentAt:]: - print("LOOP PASS: banner %r seen, probe echoed; stream=%r" % (banner, got[:SHOWN_BYTES])) - return 0, got - print("LOOP FAIL: stream=%r" % got[:SHOWN_BYTES + 40]) - return 1, got - - -def stream(send, receive, wall, start, total, rate): - # The probe's last echoes go first, then the stream and its echo. - pattern = bytes(STREAM_FIRST_BYTE + (k % STREAM_BYTE_VALUES) for k in range(total)) - settle = time.time() + STREAM_SETTLE_SECONDS - while time.time() < settle: - if receive() is None: - print("STREAM FAIL: the connection closed before the stream") - return 1 - sent = 0 - got = b"" - began = time.time() - while len(got) < total and time.time() - start < wall: - if sent < total and sent - len(got) < STREAM_WINDOW: - chunk = pattern[sent:sent + STREAM_CHUNK] - send(chunk) - sent += len(chunk) - data = receive() - if data is None: - break - got += data - elapsed = time.time() - began - perSecond = len(got) / elapsed if elapsed > 0 else 0.0 - floor = rate / LINE_BITS_PER_BYTE * STREAM_MIN_PERCENT / 100.0 - if got != pattern: - at = next((k for k in range(min(len(got), total)) if got[k] != pattern[k]), min(len(got), total)) - print("STREAM FAIL: %d of %d bytes back, the first wrong at %d; %r" % (len(got), total, at, got[at:at + 16])) - return 1 - if perSecond < floor: - print("STREAM FAIL: %d bytes back in order, %.0f a second, under %.0f (%d%% of %d baud)" % (total, perSecond, floor, STREAM_MIN_PERCENT, rate)) - return 1 - print("STREAM PASS: %d bytes back in order, none lost, %.0f a second (%d baud carries %d)" % (total, perSecond, rate, rate // LINE_BITS_PER_BYTE)) - return 0 - - -def main(argv): - withDtrCheck = "--dtr-check" in argv - argv = [arg for arg in argv if arg != "--dtr-check"] - streamBytes = 0 - rate = 0 - if "--stream" in argv and argv.index("--stream") + 1 < len(argv): - at = argv.index("--stream") - streamBytes = int(argv[at + 1]) - argv = argv[:at] + argv[at + 2:] - if "--rate" in argv and argv.index("--rate") + 1 < len(argv): - at = argv.index("--rate") - rate = int(argv[at + 1]) - argv = argv[:at] + argv[at + 2:] - holdFile = None - if "--close-hold" in argv and argv.index("--close-hold") + 1 < len(argv): - at = argv.index("--close-hold") - holdFile = argv[at + 1] - argv = argv[:at] + argv[at + 2:] - if len(argv) < 4 or argv[1] not in ("tcp-server", "tcp-client", "pty") or (withDtrCheck and argv[1] == "pty") or (holdFile is not None and argv[1] != "pty") or (streamBytes > 0 and (argv[1] == "pty" or withDtrCheck or rate <= 0)): - sys.stderr.write(__doc__) - return 2 - mode = argv[1] - wall = float(argv[3]) - banner = argv[4].encode() if len(argv) > 4 else DEFAULT_BANNER - start = time.time() - - if mode == "pty": - master = openPty(argv[2]) - - def receive(): - ready, _, _ = select.select([master], [], [], POLL_SECONDS) - if not ready: - return b"" - try: - return os.read(master, READ_SIZE) - except OSError: - return b"" - - send = lambda data: os.write(master, data) - status, got = loop(send, receive, wall, start, banner) - if status != 0 or holdFile is None: - return status - return closeHold(send, receive, wall, start, got, holdFile) - - port = int(argv[2]) - conn = acceptOne(port, wall) if mode == "tcp-server" else connectOne(port, wall, start) - if conn is None: - print("LOOP FAIL: no connection on port %d" % port) - return 1 - conn.settimeout(POLL_SECONDS) - - lines = LineDecoder() - - def receive(): - try: - data = conn.recv(READ_SIZE) - except socket.timeout: - return b"" - except OSError: - return None - if not data: - return None - return lines.feed(data) if withDtrCheck else data - - status, got = loop(conn.sendall, receive, wall, start, banner) - if status != 0: - return status - if streamBytes > 0: - conn.settimeout(STREAM_POLL_SECONDS) - return stream(conn.sendall, receive, wall, start, streamBytes, rate) - if not withDtrCheck: - return status - return dtrCheck(conn.sendall, receive, wall, start, lines, got) - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/serialStreamPeer.py b/scripts/lib/serialStreamPeer.py deleted file mode 100755 index 40828f5..0000000 --- a/scripts/lib/serialStreamPeer.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/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 -endless stream from us, both byte-exact: - - byte k = (k ^ (k >> 8) ^ mask) & 0xFF - -with mask GUEST_MASK for the guest's stream and PEER_MASK for ours (the same -formula and masks as streamByte() in examples/sermouse/sermouse.c). A lost or -repeated byte throws every byte after it off its expected value. - -This peer listens (the emulator's null_modem connects out), checks the guest's -stream in order, and answers it: our stream is paced byte for byte on the -guest's, never more than WINDOW bytes ahead of what has come back. So when the -guest pauses its stream the line goes quiet both ways, and once the guest has -been silent for QUIET_SECONDS the counts are recorded as a quiet snapshot -"sent:received" -- at that moment every byte either side sent has arrived, so a -harness that reads the guest's own counts while it is paused can require them -to equal one of these exactly. - - serialStreamPeer.py PORT WALL MIN_BYTES - -Runs until the emulator closes the link or WALL seconds pass, then prints one -line, STREAM PASS or STREAM FAIL, with the counts and the quiet snapshots. PASS -means the guest's stream arrived with no bad byte and at least MIN_BYTES long. -Exit 0 on pass, 1 on fail. -""" - -import select -import sys -import time - -from serialLoopPeer import acceptOne - -GUEST_MASK = 0xA5 -PEER_MASK = 0x00 -WINDOW = 256 # our bytes allowed ahead of the guest's -QUIET_SECONDS = 0.25 # guest silence that counts as a pause -POLL_SECONDS = 0.05 -READ_SIZE = 4096 -BYTE_MASK = 0xFF -BYTE_BITS = 8 -NO_BAD_INDEX = -1 - - -def streamByte(index, mask): - return (index ^ (index >> BYTE_BITS) ^ mask) & BYTE_MASK - - -def run(conn, wall, start): - received = 0 - sent = 0 - bad = 0 - firstBad = NO_BAD_INDEX - lastRx = 0.0 - quietArmed = False - snapshots = [] - closed = False - - conn.setblocking(False) - while time.time() - start < wall: - # Our stream starts once the guest's has: it is open and listening. - want = received + WINDOW - sent if received > 0 else 0 - writers = [conn] if want > 0 else [] - readable, writable, _ = select.select([conn], writers, [], POLL_SECONDS) - now = time.time() - if readable: - try: - data = conn.recv(READ_SIZE) - except OSError: - data = b"" - if not data: - closed = True - break - for value in data: - if value != streamByte(received, GUEST_MASK): - if firstBad == NO_BAD_INDEX: - firstBad = received - bad += 1 - received += 1 - lastRx = now - quietArmed = True - if writable and want > 0: - chunk = bytes(streamByte(sent + i, PEER_MASK) for i in range(want)) - try: - sent += conn.send(chunk) - except OSError: - pass - if quietArmed and now - lastRx >= QUIET_SECONDS: - snapshots.append("%d:%d" % (sent, received)) - quietArmed = False - return received, sent, bad, firstBad, snapshots, closed - - -def main(argv): - if len(argv) != 4: - sys.stderr.write(__doc__) - return 2 - port = int(argv[1]) - wall = float(argv[2]) - minBytes = int(argv[3]) - start = time.time() - - print("STREAM listening on 127.0.0.1:%d" % port, flush=True) - conn = acceptOne(port, wall) - if conn is None: - print("STREAM FAIL: the emulator never connected") - return 1 - received, sent, bad, firstBad, snapshots, closed = run(conn, wall, start) - conn.close() - - ok = bad == 0 and received >= minBytes - print("STREAM %s received=%d sent=%d bad=%d firstBad=%d quiet=%s end=%s" % - ("PASS" if ok else "FAIL", received, sent, bad, firstBad, - ",".join(snapshots) if snapshots else "none", - "closed" if closed else "wall")) - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/scripts/lib/tileBankGate.sh b/scripts/lib/tileBankGate.sh index 550c6cf..63da583 100644 --- a/scripts/lib/tileBankGate.sh +++ b/scripts/lib/tileBankGate.sh @@ -5,7 +5,7 @@ # # 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 +# (tests/host/tileBankStage.sh: the port's own .tbk bake of # tests/host/tileBank.png, every other target's bake, the expected pixels), # gets the program's TILEBANK log line off the machine, and judges it here # against the host gate: scripts/check-tilebank.sh is run for its surface @@ -102,9 +102,11 @@ tileBankNeedBuild() { # pass (its pixel checks are what make its hash worth matching), and set # TILEBANK_HASH the surface hash of its to-surface load of its own bake # TILEBANK_OTHERS how many targets besides any one port assetbake knows +# (tests/host/tileBankStage.sh --targets) # Exits 2 without a host C compiler, 1 if the host gate fails. tileBankReference() { local out + local targets if ! command -v "${CC:-cc}" > /dev/null; then echo "$TILEBANK_GATE: no host C compiler (${CC:-cc}) for the reference run of scripts/check-tilebank.sh" >&2 @@ -120,11 +122,7 @@ tileBankReference() { echo "$TILEBANK_GATE: FAIL - scripts/check-tilebank.sh printed no to-surface hash" >&2 exit 1 fi - TILEBANK_OTHERS="$(python3 - "$JOEY_REPO/tools/assetbake" <<'PY' -import sys -sys.path.insert(0, sys.argv[1]) -from assetbake import TARGET_NAMES -print(len(TARGET_NAMES) - 1) -PY -)" + # The stager asks assetbake (check-tilebank.sh has built it). + targets="$("$JOEY_REPO/tests/host/tileBankStage.sh" --targets)" || exit 1 + TILEBANK_OTHERS=$(($(wc -w <<< "$targets") - 1)) } diff --git a/scripts/lib/virtualPad.py b/scripts/lib/virtualPad.py deleted file mode 100644 index ddc7faf..0000000 --- a/scripts/lib/virtualPad.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/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. -With --generic they are plain joysticks instead -- an X and a Y axis, a -trigger and a thumb button, and pid.codes' test ids -- which no gamepad -mapping knows, so SDL offers them only as raw joysticks. 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) -- on a generic -stick, the trigger and the thumb button. 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 [--generic] [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 -# pid.codes' vendor id and its test product, in no gamepad database. -GENERIC_VENDOR = 0x1209 -GENERIC_PRODUCT = 0x0001 -GENERIC_VERSION = 0x0100 -NAME_BYTES = 80 - -BTN_TRIGGER = 0x120 -BTN_THUMB = 0x121 -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 -XBOX360_BUTTONS = (BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST, BTN_TL, BTN_TR, - BTN_SELECT, BTN_START, BTN_MODE, BTN_THUMBL, BTN_THUMBR) -GENERIC_BUTTONS = (BTN_TRIGGER, BTN_THUMB) - -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) -STICK_AXES = {ABS_X: (STICK_MIN, STICK_MAX, 16, 128), ABS_Y: (STICK_MIN, STICK_MAX, 16, 128)} -XBOX360_AXES = { - **STICK_AXES, - 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 - - -class Layout: - def __init__(self, vendor, product, version, buttons, axes, line): - self.vendor = vendor - self.product = product - self.version = version - self.buttons = buttons - self.axes = axes - self.line = line - - -XBOX360 = Layout(XBOX360_VENDOR, XBOX360_PRODUCT, XBOX360_VERSION, XBOX360_BUTTONS, XBOX360_AXES, - {"button0": BTN_SOUTH, "button1": BTN_EAST}) -GENERIC = Layout(GENERIC_VENDOR, GENERIC_PRODUCT, GENERIC_VERSION, GENERIC_BUTTONS, STICK_AXES, - {"button0": BTN_TRIGGER, "button1": BTN_THUMB}) - - -class Pad: - def __init__(self, name, layout): - self.fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK) - self.line = layout.line - fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY) - for b in layout.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 layout.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, layout.vendor, layout.product, - layout.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 self.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): - args = argv[1:] - layout = XBOX360 - if args and args[0] == "--generic": - layout = GENERIC - args = args[1:] - prefix = args[0] if args else "JoeyLib gate" - pads = {} - try: - for stick in ("A", "B"): - pads[stick] = Pad("%s pad %s" % (prefix, stick), layout) - 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/webGate.sh b/scripts/lib/webGate.sh index 4d69a08..2d6e566 100644 --- a/scripts/lib/webGate.sh +++ b/scripts/lib/webGate.sh @@ -43,9 +43,11 @@ webNeed() { webServe() { local tries + local server + server=$(joeyTool httpServe) || exit 1 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 & + "$server" "$WEB_PORT" "$1" > /dev/null 2>&1 & WEB_SERVER_PID=$! sleep 0.5 if joeyAlive "$WEB_SERVER_PID"; then diff --git a/scripts/lib/win16Gate.sh b/scripts/lib/win16Gate.sh index f306db8..af13656 100644 --- a/scripts/lib/win16Gate.sh +++ b/scripts/lib/win16Gate.sh @@ -80,7 +80,7 @@ win16Stage() { 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" \ + "$(joeyTool winIni)" "$work/c/WINDOWS/SYSTEM.INI" \ set boot shell "D:\\$3" \ set boot comm.drv kpcomm.drv \ set drivers joystick kpjoy.drv @@ -107,13 +107,18 @@ WIN16_WING_PROFILE="ECHGECHGECHGECHGDDFFDDFFDDFFDDFFZZZZZZZZZZZZZZZZDDFGDGFGDDFF win16StageWinG() { local file local dosDate + local year + local month + local day for file in WING.DLL WINGDE.DLL WINGDIB.DRV WINGPAL.WND DVA.386; do cp "$WING_DIR/$file" "$1/c/WINDOWS/SYSTEM/$file" done - dosDate=$(python3 -c 'import os, sys, time; t = time.localtime(os.path.getmtime(sys.argv[1])); print("%04X" % (((t.tm_year - 1980) << 9) | (t.tm_mon << 5) | t.tm_mday))' "$1/c/WINDOWS/SYSTEM/$WIN16_WING_DRIVER") - python3 "$JOEY_REPO/tools/winIni.py" "$1/c/WINDOWS/SYSTEM.INI" \ + # The driver's date as a DOS date word: years from 1980, month, day. + read -r year month day <<< "$(date -r "$1/c/WINDOWS/SYSTEM/$WIN16_WING_DRIVER" '+%Y %m %d')" + dosDate=$(printf '%04X' $((((year - 1980) << 9) | (10#$month << 5) | 10#$day))) + "$(joeyTool winIni)" "$1/c/WINDOWS/SYSTEM.INI" \ add 386Enh device "C:\\WINDOWS\\SYSTEM\\DVA.386" - python3 "$JOEY_REPO/tools/winIni.py" "$1/c/WINDOWS/WIN.INI" \ + "$(joeyTool winIni)" "$1/c/WINDOWS/WIN.INI" \ set WinG "$WIN16_WING_PROFILE_HEAD-$dosDate-$WIN16_WING_PROFILE_TAIL" "$WIN16_WING_PROFILE" } diff --git a/scripts/lib/x68kDisk.sh b/scripts/lib/x68kDisk.sh index 813e20b..a0b5ad7 100644 --- a/scripts/lib/x68kDisk.sh +++ b/scripts/lib/x68kDisk.sh @@ -69,8 +69,12 @@ x68kDataDisk() { } -# x68kXdf ARGS... -- tools/xdftool.py (list, add, delete, deltree, empty, -# extract, free). +# x68kXdf ARGS... -- tools/xdftool (list, add, delete, deltree, empty, +# extract, free). make/tools.mk builds it the first time a shell calls this +# (staging a DATA tree calls it once a file). x68kXdf() { - python3 "$JOEY_REPO/tools/xdftool.py" "$@" + if [ -z "${X68K_XDFTOOL:-}" ]; then + X68K_XDFTOOL="$(joeyTool xdftool)" || return 1 + fi + "$X68K_XDFTOOL" "$@" } diff --git a/scripts/profile-iigs.sh b/scripts/profile-iigs.sh index dc8fef0..522de7a 100755 --- a/scripts/profile-iigs.sh +++ b/scripts/profile-iigs.sh @@ -142,93 +142,4 @@ if [ -n "${PROFILE_SAMPLES:-}" ]; then cp "$work/samples.txt" "$PROFILE_SAMPLES" fi -python3 - "$work/samples.txt" "$map" "$top" <<'PY' -import bisect -import json -import os -import re -import sys - -samplesPath, mapPath, top = sys.argv[1], sys.argv[2], int(sys.argv[3]) -mapBase = mapPath[:-4] if mapPath.endswith('.map') else mapPath - -# The segments: number -> (link bank, image bytes). -segments = {} -manifest = mapBase + '.json' -if os.path.exists(manifest): - for seg in json.load(open(manifest))['segments']: - segments[seg['num']] = int(seg['base'], 16) >> 16 -else: - segments[1] = 0 -images = {num: open('%s.seg%d.bin' % (mapBase, num), 'rb').read() for num in segments} - -# The symbols, by address: the globals, and the locals (static functions) -# when the map has them; a global wins an address both name. -byAddr = {} -section = None -for line in open(mapPath): - if line.startswith('#'): - section = 'global' if line.startswith('# global symbols') else ('local' if line.startswith('# local symbols') else None) - continue - m = re.match(r'^0x([0-9a-fA-F]{6})\s+(\S+)$', line.strip()) - if m and section is not None: - addr = int(m.group(1), 16) - if section == 'global' or addr not in byAddr: - byAddr[addr] = m.group(2) -syms = sorted(byAddr.items()) -symAddrs = [a for a, _ in syms] - -counts = {} -fingerprints = {} -total = 0 -for line in open(samplesPath): - parts = line.split() - if parts[0] == 'S': - counts[int(parts[1], 16)] = int(parts[2]) - elif parts[0] == 'F': - fingerprints[int(parts[1], 16)] = bytes.fromhex(parts[2]) - elif parts[0] == 'T': - total = int(parts[1]) - -# Runtime bank -> link bank, by the best-scoring segment image. -bankMap = {} -for bank, fp in fingerprints.items(): - best = None - for num, image in images.items(): - n = min(len(fp), len(image)) - if n < 16: - continue - same = sum(1 for i in range(n) if fp[i] == image[i]) - score = same / n - if best is None or score > best[0]: - best = (score, num) - if best is not None and best[0] >= 0.85: - bankMap[bank] = (segments[best[1]], best[1], best[0]) - -def where(addr): - bank = addr >> 16 - if bank in bankMap: - link = (bankMap[bank][0] << 16) | (addr & 0xFFFF) - i = bisect.bisect_right(symAddrs, link) - 1 - if i >= 0 and (symAddrs[i] >> 16) == (link >> 16): - return syms[i][1] - return 'seg%d+%04X' % (bankMap[bank][1], addr & 0xFFFF) - if bank >= 0xFC: - return 'ROM $%02X' % bank - if bank in (0xE0, 0xE1): - return 'bank $%02X (firmware/vectors)' % bank - return 'bank $%02X (GS/OS, tools, NTP)' % bank - -byName = {} -for addr, n in counts.items(): - key = where(addr) - byName[key] = byName.get(key, 0) + n - -print('profile-iigs: %d samples' % total) -for bank in sorted(bankMap): - link, num, score = bankMap[bank] - print(' bank $%02X = segment %d (link bank $%02X, %.0f%% of %d bytes match)' % (bank, num, link, score * 100, len(fingerprints[bank]))) -print('%7s %6s %s' % ('samples', 'pct', 'where')) -for key, n in sorted(byName.items(), key=lambda kv: -kv[1])[:top]: - print('%7d %5.1f%% %s' % (n, 100.0 * n / max(total, 1), key)) -PY +"$(joeyTool iigsProfile)" "$work/samples.txt" "$map" "$top" diff --git a/scripts/profile-x68000.sh b/scripts/profile-x68000.sh index 6c98738..5196b4a 100755 --- a/scripts/profile-x68000.sh +++ b/scripts/profile-x68000.sh @@ -63,12 +63,7 @@ textSize=$(m68k-xelf-readelf -S "$elf" | awk '/\.text/ { print strtonum("0x" $7) # The .X header is 64 bytes, so the file offset is 64 + the .text offset. relocSite=$(m68k-xelf-readelf -r "$elf" | awk '$2 ~ /01$/ && $3 == "R_68K_32" { print strtonum("0x" $1); exit }') [[ -n ${relocSite:-} ]] || { echo "profile-x68000: no R_68K_32 relocation to fingerprint with" >&2; exit 2; } -relocValue=$(python3 -c " -import sys -d = open('$bin','rb').read() -o = 64 + $relocSite -print(int.from_bytes(d[o:o+4],'big')) -") +relocValue=$(od -An -tu4 -N4 --endian=big -j $((64 + relocSite)) "$bin" | tr -d ' ') # jlgMouseX is written once per input poll, i.e. once per game frame. mouseXOff=$(m68k-xelf-nm "$elf" | awk '$3 == "jlgMouseX" { print strtonum("0x" $1); exit }') @@ -255,73 +250,113 @@ if [[ -n ${PROFILE_SAMPLES:-} ]]; then fi m68k-xelf-nm -n "$elf" > "$work/syms.txt" -python3 - "$work/prof.txt" "$work/syms.txt" <<'PY' -import sys -prof, symfile = sys.argv[1], sys.argv[2] -syms = [] -for line in open(symfile): - p = line.split() - if len(p) == 3 and p[1] in ('t', 'T'): - syms.append((int(p[0], 16), p[2])) -syms.sort() - -def owner(off): - lo, hi = 0, len(syms) - 1 - best = None - while lo <= hi: - mid = (lo + hi) // 2 - if syms[mid][0] <= off: - best = syms[mid][1] - lo = mid + 1 - else: - hi = mid - 1 - return best or "?" - -total = 0 -fps = None -hits = {} -buckets = {} -outside = 0 -for line in open(prof): - p = line.split() - if p and p[0] == 'total': - total = int(p[1]) - elif len(p) == 2 and p[0].lstrip('-').isdigit(): - off, n = int(p[0]), int(p[1]) - buckets[off] = buckets.get(off, 0) + n - if off < 0: +LC_ALL=C awk ' + # Most first; equal ones in the order first seen. + function byCount(i1, v1, i2, v2) { + if (count[v1] != count[v2]) { + return count[v2] - count[v1] + } + return first[v1] - first[v2] + } + # The last symbol at or below an offset (the highest address; of equal + # ones, the last name in order), as its index; 0 for none. + function owner(off, lo, hi, mid, best) { + lo = 1 + hi = symCount + best = 0 + while (lo <= hi) { + mid = int((lo + hi) / 2) + if (symAddr[mid] <= off) { + best = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + return best + } + function bySymbol(i1, v1, i2, v2) { + if (symAddr[v1] != symAddr[v2]) { + return symAddr[v1] - symAddr[v2] + } + return symName[v1] < symName[v2] ? -1 : (symName[v1] > symName[v2]) + } + FNR == NR { + if (NF == 3 && ($2 == "t" || $2 == "T")) { + rawCount++ + rawAddr[rawCount] = strtonum("0x" $1) + rawName[rawCount] = $3 + } + next + } + FNR == 1 { + for (i = 1; i <= rawCount; i++) { + symAddr[i] = rawAddr[i] + symName[i] = rawName[i] + order[i] = i + } + symCount = rawCount + asort(order, sorted, "bySymbol") + for (i = 1; i <= symCount; i++) { + addr[i] = symAddr[sorted[i]] + name[i] = symName[sorted[i]] + } + for (i = 1; i <= symCount; i++) { + symAddr[i] = addr[i] + symName[i] = name[i] + } + } + $1 == "total" { + total = $2 + 0 + next + } + NF == 2 && $1 ~ /^-*[0-9]+$/ { + off = $1 + 0 + n = $2 + 0 + key = "b" off + if (!(key in count)) { + first[key] = ++seen + buckets[++bucketCount] = key + bucketOff[key] = off + } + count[key] += n + if (off < 0) { outside += n - else: - hits[owner(off)] = hits.get(owner(off), 0) + n -if total == 0: - sys.exit("profile-x68000: no samples recorded") -rows = sorted(hits.items(), key=lambda kv: -kv[1]) -fpsText = f", game running at {fps:.1f} fps" if fps is not None and fps > 0.0 else " (frame count unavailable)" -print(f"profile-x68000: {total} samples, {outside} ({outside*100.0/total:.1f}%) outside the program (ROM/IOCS){fpsText}") -shown = 0 -for name, n in rows[:25]: - print(f" {n*100.0/total:5.1f}% {n:6d} {name}") - shown += n -print(f" {(total-outside-shown)*100.0/total:5.1f}% {total-outside-shown:6d} (everything else)") - -# The hottest 64-byte buckets, named as symbol+offset. Function totals say -# which routine to look at; these say which LINE of it, which is the -# difference between rewriting a loop and rewriting the wrong loop. -print("\nhottest addresses (symbol + offset into it):") -def ownerOff(off): - lo, hi = 0, len(syms) - 1 - best = None - while lo <= hi: - mid = (lo + hi) // 2 - if syms[mid][0] <= off: - best = syms[mid] - lo = mid + 1 - else: - hi = mid - 1 - return (best[1], off - best[0]) if best else ("?", 0) -for off, n in sorted(buckets.items(), key=lambda kv: -kv[1])[:20]: - if off < 0: - continue - name, delta = ownerOff(off) - print(f" {n*100.0/total:5.1f}% {n:6d} {name}+0x{delta:x}") -PY + next + } + s = owner(off) + key = "s" (s > 0 ? symName[s] : "?") + if (!(key in count)) { + first[key] = ++seen + hits[++hitCount] = key + } + count[key] += n + } + END { + if (total == 0) { + print "profile-x68000: no samples recorded" > "/dev/stderr" + exit 1 + } + printf "profile-x68000: %d samples, %d (%.1f%%) outside the program (ROM/IOCS) (frame count unavailable)\n", total, outside, outside * 100.0 / total + asort(hits, ranked, "byCount") + shown = 0 + for (i = 1; i <= hitCount && i <= 25; i++) { + printf " %5.1f%% %6d %s\n", count[ranked[i]] * 100.0 / total, count[ranked[i]], substr(ranked[i], 2) + shown += count[ranked[i]] + } + printf " %5.1f%% %6d (everything else)\n", (total - outside - shown) * 100.0 / total, total - outside - shown + # The hottest 64-byte buckets, named as symbol+offset. Function + # totals say which routine to look at; these say which LINE of it, + # which is the difference between rewriting a loop and rewriting + # the wrong loop. + print "\nhottest addresses (symbol + offset into it):" + asort(buckets, ranked, "byCount") + for (i = 1; i <= bucketCount && i <= 20; i++) { + off = bucketOff[ranked[i]] + if (off < 0) { + continue + } + s = owner(off) + printf " %5.1f%% %6d %s+0x%x\n", count[ranked[i]] * 100.0 / total, count[ranked[i]], (s > 0 ? symName[s] : "?"), (s > 0 ? off - symAddr[s] : 0) + } + }' "$work/syms.txt" "$work/prof.txt" diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh index 9561931..2901b6a 100755 --- a/scripts/verify-all.sh +++ b/scripts/verify-all.sh @@ -97,7 +97,7 @@ # 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; +# virtualPad; needs /dev/uinput), then unplugged; # sdl3 again with two joysticks SDL cannot map # web the page's Gamepad API, stood in for by the driver # psp: no keyboard; its d-pad, nub, cross and circle as joystick @@ -128,7 +128,7 @@ # timeline of tones, voice and noise steps, effects, pans, a # one-shot module and ticks; its sound is recorded and graded # against what it logged (scripts/verify-audio-conformance.sh, -# tools/gradeAudioConformance.py): pitch, level per attenuation +# tests/gate/audioGate.c): pitch, level per attenuation # step, noise rate, mix balance, centring and pan where the port # is stereo, the module's end, the tick rate. Every port that # records sound: all but beos, whose VM has no sound card @@ -226,6 +226,9 @@ KINDS="host golden visual tilebank input audio conform serial mouse save loss di ALL_PORTS="host iigs x68000 atarist ste amiga dos dvx sdl3 web macos win32 win16 psp haiku haiku32 beos" # UBER on the Amiga needs longer than bench-amiga.sh's interactive default. AMIGA_UBER_SECONDS=400 +# UBER's per-op hashes against a golden: make/tools.mk's diffUberHashes, built +# first when it is not current. +UBER_HASHES="make -s -f make/tools.mk diffUberHashes > /dev/null && \"${JOEY_BUILD:-build}/tools/diffUberHashes\"" # kind port name command...: every gate, in run order within its kind. GATES=$(cat < set -uo pipefail @@ -55,15 +55,14 @@ source "$repo/scripts/lib/amigaGate.sh" amigaMachine prog=${1:-Audio} binDir=$repo/build/amiga/bin -grader=$repo/tools/gradeAudioPhases.py +grader=$(joeyTool audioGate) || exit 1 pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-amiga-audio -[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; } [ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; } # shellcheck source=scripts/pulseRecordLib.sh . "$pulseLib" -for tool in Xvfb xdotool parec pactl python3; do +for tool in Xvfb xdotool parec pactl; 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 (tried XDG_RUNTIME_DIR and /run/user/$(id -u)/pulse/native)" >&2; exit 2; } @@ -178,7 +177,7 @@ sleep 1 # Thresholds sit far from the measured values (music ~5700, paused ~240, # sfx ~1170) so ordinary mixing jitter cannot flip the verdict. -python3 "$grader" "$label" "$work/out.wav" \ +"$grader" phases "$label" "$work/out.wav" \ "music:1:5:2000:" \ "paused:8:13::800" \ "sfx:16:22:800:" \ diff --git a/scripts/verify-amiga-icon.sh b/scripts/verify-amiga-icon.sh index 2eb9f03..23a2ff0 100755 --- a/scripts/verify-amiga-icon.sh +++ b/scripts/verify-amiga-icon.sh @@ -6,7 +6,7 @@ # verify-amiga-icon.sh - a program's Workbench icon: read and drawn by the # Amiga's own icon.library, and the program started from it. # -# make bakes Name.info beside every Amiga program (tools/iconBake.py info: +# make bakes Name.info beside every Amiga program (tools/iconBake info: # a tool's icon in Workbench's four colours, with the stack it starts # with). WBLAUNCH (examples/wblaunch) runs under FS-UAE on an A500 with # PALPROBE and its icon in a folder of their own, Prog/. It reads the icon @@ -91,99 +91,4 @@ if ! waitFor "WBLAUNCH: returned"; then fi cat "$log" -python3 - "$work" "$want_stack" "$WBTOOL" "$label" <<'PY' -import os -import re -import struct -import sys - -from PIL import Image - -work, wantStack, wbTool, label = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4] -fails = [] -log = open(os.path.join(work, "wblaunch.txt"), encoding="ascii", errors="replace").read() -for line in log.splitlines(): - if "FAIL" in line: - fails.append(line) - -# What the file holds: its image's size, depth and planes (after the 78-byte -# DiskObject and the 20-byte struct Image). -info = open(os.path.join(work, "Prog", "Palprobe.info"), "rb").read() -width, height, depth = struct.unpack(">hhh", info[82:88]) -rowBytes = (width + 15) // 16 * 2 -planeBytes = rowBytes * height -indices = [[0] * width for _ in range(height)] -for plane in range(depth): - for y in range(height): - for x in range(width): - byte = info[98 + plane * planeBytes + y * rowBytes + x // 8] - if byte & (0x80 >> (x % 8)): - indices[y][x] |= 1 << plane - -m = re.search(r"icon type=(\d+) stack=(\d+) gadget=(\d+)x(\d+) image=(\d+)x(\d+)x(\d+)", log) -if m is None: - fails.append("icon.library never read the icon") -else: - got = tuple(int(v) for v in m.groups()) - if got[0] != wbTool: - fails.append(f"icon type {got[0]}, want WBTOOL ({wbTool})") - if got[1] != wantStack: - fails.append(f"icon stack {got[1]}, want {wantStack}") - if got[4:] != (width, height, depth): - fails.append(f"icon.library's image {got[4]}x{got[5]}x{got[6]}, the file's {width}x{height}x{depth}") - -# Where the icon is on the screenshot: the screen is Workbench's grey, and -# the icon's coloured pixels lie inside it. The file's coloured pixels -# (index not 0) and the screenshot's (not grey) must have the same bounds, -# scaled; then each of the file's pixels is looked at in the middle of its -# cell. -colours = [(170, 170, 170), (0, 0, 0), (255, 255, 255), (102, 136, 187)] -shot = Image.open(os.path.join(work, "screen.png")).convert("RGB") -w, h = shot.size -pixels = shot.load() -grey = [(x, y) for y in range(0, h, 2) for x in range(0, w, 2) if pixels[x, y] == colours[0]] -if not grey: - fails.append("no Workbench grey on the screenshot") -else: - gx0 = min(p[0] for p in grey) - gx1 = max(p[0] for p in grey) - gy0 = min(p[1] for p in grey) - # The upper half: FS-UAE prints its notices over the bottom. - gy1 = (gy0 + max(p[1] for p in grey)) // 2 - shown = [(x, y) for y in range(gy0, gy1 + 1) for x in range(gx0, gx1 + 1) if pixels[x, y] != colours[0]] - held = [(x, y) for y in range(height) for x in range(width) if indices[y][x] != 0] - if not shown or not held: - fails.append("no icon on the screenshot" if held else "the icon file is empty") - else: - sx0 = min(p[0] for p in shown) - sx1 = max(p[0] for p in shown) - sy0 = min(p[1] for p in shown) - sy1 = max(p[1] for p in shown) - fx0 = min(p[0] for p in held) - fx1 = max(p[0] for p in held) - fy0 = min(p[1] for p in held) - fy1 = max(p[1] for p in held) - scaleX = (sx1 + 1 - sx0) / (fx1 + 1 - fx0) - scaleY = (sy1 + 1 - sy0) / (fy1 + 1 - fy0) - wrong = 0 - for y in range(fy0, fy1 + 1): - for x in range(fx0, fx1 + 1): - at = (int(sx0 + (x - fx0 + 0.5) * scaleX), int(sy0 + (y - fy0 + 0.5) * scaleY)) - if pixels[at] != colours[indices[y][x]]: - wrong += 1 - cells = (fx1 + 1 - fx0) * (fy1 + 1 - fy0) - print(f"{label}: icon shown at {sx0},{sy0} scaled {scaleX:.2f}x{scaleY:.2f}: {cells - wrong}/{cells} pixels right") - if wrong: - fails.append(f"{wrong} of the icon's {cells} pixels wrong on the screen") - -palLog = os.path.join(work, "Prog", "joeylog.txt") -if not os.path.exists(palLog) or "PALPROBE: done" not in open(palLog, encoding="ascii", errors="replace").read(): - fails.append("Palprobe's log is not in Prog/ with its last line: it did not run in its own folder") -if os.path.exists(os.path.join(work, "joeylog.txt")): - fails.append("a joeylog.txt is in the drive's root: Palprobe ran outside its folder") - -if fails: - print(f"{label}: FAIL - " + "; ".join(fails)) - sys.exit(1) -print(f"{label}: PASS (icon.library reads a tool's icon, {wantStack} bytes of stack, {width}x{height}x{depth}, and draws every pixel of it; started from it as Workbench does, Palprobe ran in its own folder and its message came back)") -PY +"$(joeyTool amigaCheck)" icon "$work" "$want_stack" "$WBTOOL" "$label" diff --git a/scripts/verify-amiga-input.sh b/scripts/verify-amiga-input.sh index 3c0ddf2..5b65515 100755 --- a/scripts/verify-amiga-input.sh +++ b/scripts/verify-amiga-input.sh @@ -16,9 +16,9 @@ # keypad's 8, 2, 4, and 6 as the arrows, its Enter as Return, and the right # Alt as Alt, each held alone, and Up and the keypad's 8 held together with # either let go first, which must leave Up down (scripts/lib/keysGate.sh) -- -# read off KEYS's cells in FS-UAE's screenshots (scripts/lib/keysCells.py), +# read off KEYS's cells in FS-UAE's screenshots (tests/gate/keysCells.c), # the stage found by its still empty verification row -# (scripts/lib/keysRow.py). +# (tests/gate/keysRow.c). # # Usage: scripts/verify-amiga-input.sh # Requires: fs-uae, Xvfb, xdotool; build/amiga/bin/Keys (make amiga); @@ -100,7 +100,7 @@ if ! amigaShot ":$display_num" "$shotKey" "$work/shots" "$work/stage.png"; then echo "verify-amiga-input: FAIL - FS-UAE took no screenshot" >&2 exit 1 fi -found=$(python3 "$repo/scripts/lib/keysRow.py" "$work/stage.png" "") || true +found=$("$(joeyTool keysRow)" "$work/stage.png" "") || true if [[ ! "$found" =~ row\ at\ \(([0-9]+),([0-9]+)\)\ at\ ([0-9]+)x ]]; then echo "verify-amiga-input: FAIL - KEYS's stage is not on the screen: $found" >&2 exit 1 @@ -118,7 +118,7 @@ keysRelease() { } keysRead() { if amigaShot ":$display_num" "$shotKey" "$work/shots" "$work/cells.png"; then - python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" --at "$stageAt" --scale "$scale" + "$(joeyTool keysCells)" "$work/cells.png" --at "$stageAt" --scale "$scale" else echo "no screenshot" fi diff --git a/scripts/verify-amiga-joy.sh b/scripts/verify-amiga-joy.sh index 1439338..04307eb 100755 --- a/scripts/verify-amiga-joy.sh +++ b/scripts/verify-amiga-joy.sh @@ -22,13 +22,13 @@ # 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 +# (its screenshot action, on F11) go through tests/gate/joyScreen.c # (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). +# Xvfb, xdotool; build/amiga/bin/Joy (make amiga). set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -119,7 +119,7 @@ joyShot() { # 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 + joyShot "$JOY_WORK/probe.png" && "$(joeyTool joyScreen)" "$JOY_WORK/probe.png" > /dev/null 2>&1 } diff --git a/scripts/verify-amiga-palette.sh b/scripts/verify-amiga-palette.sh index e3a5ba6..7e8c593 100755 --- a/scripts/verify-amiga-palette.sh +++ b/scripts/verify-amiga-palette.sh @@ -85,44 +85,4 @@ sleep 2 DISPLAY=$display import -window root "$work/screen.png" grep "PALPROBE:" "$log" -python3 - "$work/screen.png" "$log" "$MIN_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] -rates = {} -for line in open(log, encoding="ascii", errors="replace"): - m = re.search(r"PALPROBE: (palette|scb)\+present: \d+ in \d+ ms = (\d+)/s", line) - if m: - rates[m.group(1)] = int(m.group(2)) -if sorted(rates) != ["palette", "scb"]: - print(f"{label}: FAIL - the log does not have both rates ({rates})") - 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 = [] -for kind in ("palette", "scb"): - if rates[kind] < minRate: - fails.append(f"{kind} changes presented {rates[kind]}/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 {rates['palette']}/s, SCB changes {rates['scb']}/s; bands {', '.join(want)} after a palette change and a same-layout SCB change)") -PY +"$(joeyTool amigaCheck)" palette "$work/screen.png" "$log" "$MIN_RATE" "$label" diff --git a/scripts/verify-amiga-serial.sh b/scripts/verify-amiga-serial.sh index bc1268c..3382504 100755 --- a/scripts/verify-amiga-serial.sh +++ b/scripts/verify-amiga-serial.sh @@ -7,7 +7,7 @@ # # Boots SerTest (examples/sertest: banner, then echo) off a directory hard # drive in FS-UAE with the serial port on a TCP socket, and has the host -# peer (scripts/lib/serialLoopPeer.py) wait for the banner, send a probe and +# peer (tests/gate/serialLoopPeer.c) wait for the banner, send a probe and # expect it echoed. That exercises the chain: m68k-amigaos-gcc -> AmigaDOS -> # serial.device -> JoeyLib serial HAL -> Paula's UART -> host. # @@ -60,7 +60,7 @@ HOME="$work/home" DISPLAY="$display" SDL_AUDIODRIVER=dummy ALSOFT_DRIVERS=null " > "$work/fsuae.log" 2>&1 & fsuaePid=$! -result=$(python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-client "$port" "$wall") +result=$("$(joeyTool serialLoopPeer)" tcp-client "$port" "$wall") rc=$? echo "$result" if [ "$rc" -eq 0 ]; then diff --git a/scripts/verify-atarist-audio.sh b/scripts/verify-atarist-audio.sh index 55de8ad..8a86650 100755 --- a/scripts/verify-atarist-audio.sh +++ b/scripts/verify-atarist-audio.sh @@ -81,6 +81,7 @@ repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) source "$repo/scripts/lib/joeyEnv.sh" joeyNeed HATARI hatari joeyNeed EMUTOS_IMG emutos +stAudioGrade=$(joeyTool stAudioGrade) || exit 1 machine=st if [ "${1:-}" = "--machine" ]; then machine=${2:-} @@ -198,222 +199,4 @@ 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" "$machine" <<'PY' -import re -import sys - -# ym write data reg=0x8 val=0xc video_cyc=8696 60@17 pc=e1197e instr_cycle 16 -LINE = re.compile(r'^ym write data reg=0x([0-9a-f]+) val=0x([0-9a-f]+) ' - r'video_cyc=(\d+).*pc=([0-9a-f]+)') - -PUMP_PER_FRAME_MIN = 100 # tracker writes <= 1; the pump writes 205-246 -PUMP_RATE_LO = 150 # 12288/60 = 205, 12288/50 = 246. Below this the -PUMP_RATE_HI = 280 # Timer A divider is wrong, not the sound. -STRAY_FRAME_PCT_MAX = 5.0 # start/stop transitions only; a fighting - # tracker touches reg 8 EVERY frame. -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: 0x01 mode=stereo freq=12517 ... pc=22eec" -DMA_WRITE = re.compile(r'^DMA snd (?:mode|control) write: 0x([0-9a-f]+) .*pc=([0-9a-f]+)') -DMA_MODE_STEREO_12K = 0x01 # bit 7 clear stereo, 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 half scale, the mix's unit (61 measured) -UNSIGNED_PCT_MAX = 5.0 - - -def perFrameWrites(path): - """{frame: {reg: [pc, ...]}} for APPLICATION writes only.""" - out = {} - frame = 0 - prev = -1 - for line in open(path, errors='ignore'): - m = LINE.match(line) - if m is None: - continue - reg = int(m.group(1), 16) - cyc = int(m.group(3)) - pc = m.group(4) - if cyc < prev: # video_cyc restarts every frame - frame += 1 - prev = cyc - if pc.startswith('e'): # TOS, not us - continue - out.setdefault(frame, {}).setdefault(reg, []).append(pc) - return out - - -def countReg(frames, table, regs): - return sum(len(table[f].get(r, ())) for f in frames for r in regs) - - -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) - -frames = sorted(full) -fails = [] - -# 1. The tracker ran before any SFX overlapped it. -baseFrames = sorted(base) -baseTone = countReg(baseFrames, base, TONE_B_REGS) -baseMixer = countReg(baseFrames, base, (REG_MIXER,)) -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") - - -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_STEREO_12K for m in modes): - fails.append(f"DMA mode writes {[hex(m) for m in modes]}, want {hex(DMA_MODE_STEREO_12K)} " - f"(stereo, 12517 Hz) -- the mix is made for that rate and layout") - 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" -summary = gradeDma(summary) if machine == 'ste' else gradePump(summary) - -print(f"verify-atarist-audio: {summary}") -for f in fails: - print(f"verify-atarist-audio: {f}", file=sys.stderr) -if fails: - print("verify-atarist-audio: FAIL", file=sys.stderr) - sys.exit(1) -print("verify-atarist-audio: PASS") -PY +"$stAudioGrade" "$work/ym.log" "$work/baseline.log" "$prog" "$taps" "$machine" diff --git a/scripts/verify-atarist-input.sh b/scripts/verify-atarist-input.sh index f75e324..e55c740 100755 --- a/scripts/verify-atarist-input.sh +++ b/scripts/verify-atarist-input.sh @@ -44,6 +44,7 @@ source "$repo/scripts/lib/keysGate.sh" joeyNeed HATARI hatari joeyNeed EMUTOS_IMG emutos joeyNeed EMUTOS_DE_IMG emutos +stScreenDump=$(joeyTool stScreenDump) || exit 1 bin_dir=$repo/build/atarist/bin label=verify-atarist-input @@ -122,43 +123,7 @@ rowCheck() { sleep 3 send "hatari-debug m \$$(printf '%x' "$rowAddr") 160" sleep 2 - python3 - "$work/hatari.log" "$rowAddr" "$1" <<'PY' -import re -import sys - -log, rowAddr, target = sys.argv[1], int(sys.argv[2]), sys.argv[3] -data = open(log, "rb").read().decode(errors="replace") -lines = {} -# Keep only the LAST dump of each address (earlier sentinel probes also match). -for m in re.finditer(r"^(00[0-9A-F]{6}): ((?:[0-9a-f]{2} ){1,16})", data, re.M): - lines[int(m.group(1), 16)] = m.group(2) -raw = b"" -for off in range(0, 160, 16): - if rowAddr + off not in lines: - print(f"verify-atarist-input: FAIL - missing dump line at +{off}") - sys.exit(1) - raw += bytes.fromhex(lines[rowAddr + off].replace(" ", "")) - -def group(words): - p = [int.from_bytes(words[i * 2:i * 2 + 2], "big") for i in range(4)] - return [((p[0] >> b) & 1) | (((p[1] >> b) & 1) << 1) | - (((p[2] >> b) & 1) << 2) | (((p[3] >> b) & 1) << 3) - for b in range(15, -1, -1)] - -pix = [] -for g in range(len(raw) // 8): - pix += group(raw[g * 8:g * 8 + 8]) - -if pix[0:4] != [0xA, 0x5, 0xA, 0x5]: - print(f"verify-atarist-input: FAIL - sentinel wrong: {pix[0:4]}") - sys.exit(1) -count = (pix[4] << 4) | pix[5] -chars = bytes((pix[8 + 2 * i] << 4) | pix[8 + 2 * i + 1] for i in range(count)) -print(f"VERIFY-ST-INPUT count={count} chars={chars!r}") -if chars.decode("ascii", errors="replace") != target: - print(f'verify-atarist-input: FAIL - expected "{target}"') - sys.exit(1) -PY + "$stScreenDump" keys "$work/hatari.log" "$rowAddr" "$1" } @@ -195,28 +160,7 @@ keysRelease() { keysRead() { send "hatari-debug m \$$(printf '%x' "$cellAddr") 160" sleep 1 - python3 - "$work/hatari.log" "$cellAddr" <<'PY' -import re -import sys - -log, addr = sys.argv[1], int(sys.argv[2]) -GROUPS = (5, 7, 9, 11, 13, 15, 17) -data = open(log, "rb").read().decode(errors="replace") -lines = {} -for m in re.finditer(r"^(00[0-9A-F]{6}): ((?:[0-9a-f]{2} ){1,16})", data, re.M): - lines[int(m.group(1), 16)] = m.group(2) -raw = b"" -for off in range(0, 160, 16): - raw += bytes.fromhex(lines.get(addr + off, "00 " * 16).replace(" ", "")) -digits = "" -for g in GROUPS: - colour = 0 - for plane in range(4): - word = int.from_bytes(raw[g * 8 + plane * 2:g * 8 + plane * 2 + 2], "big") - colour |= ((word >> 15) & 1) << plane - digits += "1" if colour == 2 else ("0" if colour == 1 else "?") -print(digits) -PY + "$stScreenDump" cells "$work/hatari.log" "$cellAddr" } KEYS_SETTLE_SECONDS=1 keysRunSteps kp8 kp2 kp4 kp6 kpenter diff --git a/scripts/verify-atarist-joy.sh b/scripts/verify-atarist-joy.sh index 0c8c839..d14a9c6 100755 --- a/scripts/verify-atarist-joy.sh +++ b/scripts/verify-atarist-joy.sh @@ -28,13 +28,13 @@ # 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 +# go through tests/gate/joyScreen.c (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; +# Requires: Hatari, EmuTOS, Xvfb, xdotool; # build//bin/JOY.PRG (make atarist / make ste). set -uo pipefail @@ -46,7 +46,7 @@ joeyNeed HATARI hatari joeyNeed EMUTOS_IMG emutos build="${1:-atarist}" -# The ST build writes 3-bit colours on any machine (joyScreen.py --st). +# The ST build writes 3-bit colours on any machine (joyScreen --st). case "$build" in atarist) machine="${2:-st}"; JOY_SCREEN_ARGS=(--st) ;; ste) machine="${2:-ste}" ;; @@ -184,7 +184,7 @@ joyShot() { # 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 + joyShot "$JOY_WORK/probe.png" && "$(joeyTool joyScreen)" "$JOY_WORK/probe.png" "${JOY_SCREEN_ARGS[@]}" > /dev/null 2>&1 } diff --git a/scripts/verify-atarist-palette.sh b/scripts/verify-atarist-palette.sh index 7e85cb2..c89f6fd 100755 --- a/scripts/verify-atarist-palette.sh +++ b/scripts/verify-atarist-palette.sh @@ -71,58 +71,79 @@ for machine in $machines; do --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 + awk -v machine="$machine" -v build="$build" ' + # One 4-bit channel as that build writes the shifter nibble. + function channel(v) { + return build == "atarist" ? rshift(v, 1) : or(rshift(v, 1), lshift(and(v, 1), 3)) + } + # A set of colour words as Python prints a sorted list of their hex. + function hexList(set, count, names, i, n, out) { + n = 0 + for (i = 0; i < count; i++) { + names[++n] = sprintf("0x%x", set[i]) + } + asort(names) + out = "" + for (i = 1; i <= n; i++) { + out = out (i > 1 ? ", " : "") "\x27" names[i] "\x27" + } + return "[" out "]" + } + BEGIN { + # PATTERN'"'"'s eight palettes: which of red, green, and blue carry + # the gradient. + split("111 100 110 010 011 001 101 111", gradients, " ") + } + # write col addr=ff825e col=70 video_cyc_w=55892 ... pc=1f00e + match($0, /^write col addr=ff82([45][0-9a-f]) col=([0-9a-f]+) /, m) { + register = int((strtonum("0x" m[1]) - 64) / 2) + value = strtonum("0x" m[2]) + if (!((register, value) in seen)) { + seen[register, value] = 1 + got[register, seenCount[register]++] = value + } + writes++ + } + END { + bits = build == "atarist" ? "3-bit" : "4-bit" + if (writes == 0) { + printf "verify-atarist-palette: %s build on %s FAIL - PATTERN never wrote the palette\n", build, machine > "/dev/stderr" + exit 1 + } + badCount = 0 + for (i = 0; i < 16; i++) { + delete want + delete wantList + wantCount = 0 + v = i ? channel(i) : 0 + for (g = 1; g <= 8; g++) { + word = (substr(gradients[g], 1, 1) == "1" ? lshift(v, 8) : 0) + (substr(gradients[g], 2, 1) == "1" ? lshift(v, 4) : 0) + (substr(gradients[g], 3, 1) == "1" ? v : 0) + if (!(word in want)) { + want[word] = 1 + wantList[wantCount++] = word + } + } + same = wantCount == seenCount[i] + for (word in want) { + same = same && ((i, word + 0) in seen) + } + if (!same) { + delete gotList + for (k = 0; k < seenCount[i]; k++) { + gotList[k] = got[i, k] + } + bad[++badCount] = sprintf("register %d: wrote %s, want %s", i, hexList(gotList, seenCount[i]), hexList(wantList, wantCount)) + } + } + if (badCount > 0) { + printf "verify-atarist-palette: %s build on %s FAIL - not the %s encoding of PATTERN'"'"'s palettes:\n", build, machine, bits > "/dev/stderr" + for (b = 1; b <= badCount && b <= 4; b++) { + printf "verify-atarist-palette: %s\n", bad[b] > "/dev/stderr" + } + exit 1 + } + printf "verify-atarist-palette: %s build on %s OK - %d palette writes, all 16 registers exactly the %s encoding of the 8 band palettes\n", build, machine, writes, bits + }' "$work/$machine.log" || fails=$((fails + 1)) done if [ "$fails" -gt 0 ]; then diff --git a/scripts/verify-atarist-serial.sh b/scripts/verify-atarist-serial.sh index 9605be1..7896b8f 100755 --- a/scripts/verify-atarist-serial.sh +++ b/scripts/verify-atarist-serial.sh @@ -7,7 +7,7 @@ # # Runs SERTEST.PRG (examples/sertest: banner, then echo) under Hatari and # EmuTOS with the Modem port on a pseudo-terminal whose other end is the host -# peer (scripts/lib/serialLoopPeer.py): it waits for the banner, sends a +# peer (tests/gate/serialLoopPeer.c): it waits for the banner, sends a # probe and expects it echoed. That exercises the chain: m68k-atari-mint-gcc # -> the BIOS Aux channel -> JoeyLib serial HAL -> the MFP's USART -> host. # @@ -36,7 +36,8 @@ trap cleanup EXIT mkdir -p "$work/c" cp "$prg" "$work/c/SERTEST.PRG" -python3 "$repo/scripts/lib/serialLoopPeer.py" pty "$work/pty" "$wall" > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" pty "$work/pty" "$wall" > "$work/peer.log" 2>&1 & peerPid=$! for _ in $(seq 1 50); do [ -f "$work/pty" ] && break diff --git a/scripts/verify-audio-conformance.sh b/scripts/verify-audio-conformance.sh index 3da06c3..e76961e 100755 --- a/scripts/verify-audio-conformance.sh +++ b/scripts/verify-audio-conformance.sh @@ -8,7 +8,7 @@ # voice and noise steps, sound effects, a module's end and tick rates, # logged as it plays) on one port, records what the port played, and grades # the recording against the audio contract with -# tools/gradeAudioConformance.py: pitch, 2 dB attenuation steps, noise rate, +# tests/gate/audioGate.c: pitch, 2 dB attenuation steps, noise rate, # effect rates and pans, the module ending and saying so, tick counts, and # silence where there should be silence. # @@ -64,7 +64,7 @@ repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) source "$repo/scripts/lib/joeyEnv.sh" port=${1:-} label="verify-audio-conformance ($port)" -grader=$repo/tools/gradeAudioConformance.py +grader=$(joeyTool audioGate) || exit 1 PORT_STEREO="amiga dos dvx ste sdl3 web win32 psp haiku haiku32 beos" DOS_SBTYPE=${AUDPAR_DOS_SBTYPE:-} if [ -n "$DOS_SBTYPE" ] && [ "$DOS_SBTYPE" != sb16 ]; then @@ -127,16 +127,7 @@ cannot() { # Raw signed 16-bit stereo at RECORD_RATE -> out.wav. rawToWav() { - python3 - "$1" "$work/out.wav" "$RECORD_RATE" <<'PY' -import sys -import wave -raw = open(sys.argv[1], 'rb').read() -with wave.open(sys.argv[2], 'wb') as out: - out.setnchannels(2) - out.setsampwidth(2) - out.setframerate(int(sys.argv[3])) - out.writeframes(raw[:len(raw) // 4 * 4]) -PY + "$grader" wav "$1" "$work/out.wav" "$RECORD_RATE" } xvfbStart() { @@ -550,9 +541,9 @@ esac case "$port" in atarist) flags+=(--level-tolerance 1.75) ;; ste) flags+=(--level-tolerance 1.95) ;; - amiga) flags+=(--skip "tone centred" "voice 0 centred" "voice 1 centred" "voice 2 centred") ;; + amiga) flags+=(--skip "tone centred" --skip "voice 0 centred" --skip "voice 1 centred" --skip "voice 2 centred") ;; esac -python3 "$grader" "$port" "$work/log.txt" "$work/out.wav" "${flags[@]}" > "$work/grade.txt" +"$grader" conformance "$port" "$work/log.txt" "$work/out.wav" "${flags[@]}" > "$work/grade.txt" status=$? grep -v '^PASS' "$work/grade.txt" summary=$(tail -1 "$work/grade.txt") diff --git a/scripts/verify-be-icon.sh b/scripts/verify-be-icon.sh index 1f604e4..82fd435 100755 --- a/scripts/verify-be-icon.sh +++ b/scripts/verify-be-icon.sh @@ -6,7 +6,7 @@ # verify-be-icon.sh - a Be API program's icon as Tracker shows it. # # make puts every Be API program's icons into its resources (make/be.mk: -# tools/iconBake.py be, 32x32 and 16x16 in the BeOS palette, attached by +# tools/iconBake be, 32x32 and 16x16 in the BeOS palette, attached by # xres as BEOS:L:STD_ICON and BEOS:M:STD_ICON). Tracker shows a file by the # icons in its attributes, which a program built here does not have; the # backend copies them over from its resources when it starts @@ -20,8 +20,8 @@ # Usage: scripts/verify-be-icon.sh [haiku|haiku32|beos] (default haiku) # Env: BE_ICON_KEEP keep the last screendump as build/PORT/be-icon.png # Exit: 0 PASS, 1 FAIL, 2 SKIP (the VM not installed, or DRAW not built) -# Requires: QEMU and the port's VM (./install.sh install emulator:PORT), -# python3 + PIL + numpy; build/PORT/bin/DRAW and its icon +# Requires: QEMU and the port's VM (./install.sh install emulator:PORT); +# build/PORT/bin/DRAW and its icon # (make PORT). set -uo pipefail @@ -61,55 +61,7 @@ trap cleanup EXIT # (Tracker's icon view shows the 32x32, its list view the 16x16); prints the # verdict. iconShown() { - python3 - "$1" "$iconDir" "$repo/tools" "$label" <<'PY' -import os -import sys - -import numpy -from PIL import Image - -png, iconDir, tools, label = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] -sys.path.insert(0, tools) -import iconBake # noqa: E402 - -# BeOS R5's 16-bit screen keeps 5 or 6 bits a gun. -TOLERANCE = 8 -screen = numpy.asarray(Image.open(png).convert("RGB"), dtype=numpy.int16) -sh, sw = screen.shape[:2] -palette = iconBake.BE_PALETTE - - -def matches(colour): - return numpy.all(numpy.abs(screen - colour) <= TOLERANCE, axis=2) - - -def find(size, raw): - """Where the screen holds the baked SIZE x SIZE icon raw, or None.""" - indices = open(raw, "rb").read() - opaque = [(i // size, i % size) for i, index in enumerate(indices) if index != iconBake.BE_TRANSPARENT] - colours = numpy.array([palette[indices[y * size + x] * 3:indices[y * size + x] * 3 + 3] for y, x in opaque], dtype=numpy.int16) - opaque = numpy.array(opaque) - counts = [int(matches(c).sum()) for c in colours] - anchor = min(range(len(opaque)), key=lambda i: counts[i]) - ay, ax = opaque[anchor] - for y, x in numpy.argwhere(matches(colours[anchor])): - top, left = y - ay, x - ax - if top < 0 or left < 0 or top + size > sh or left + size > sw: - continue - shown = screen[top + opaque[:, 0], left + opaque[:, 1]] - if numpy.all(numpy.abs(shown - colours) <= TOLERANCE): - return left, top, len(opaque) - return None - - -for size, name in ((iconBake.BE_LARGE, "icon32.raw"), (iconBake.BE_SMALL, "icon16.raw")): - at = find(size, os.path.join(iconDir, name)) - if at is not None: - print(f"{label}: PASS (Tracker shows DRAW by its icon at {at[0]},{at[1]}: all {at[2]} opaque pixels of the baked {size}x{size})") - sys.exit(0) -print(f"{label}: FAIL - neither of DRAW's icons is on the screen") -sys.exit(1) -PY + "$(joeyTool iconFind)" be "$1" "$iconDir" "$repo/tools/iconBake/bePalette.h" "$label" } diff --git a/scripts/verify-be-input.sh b/scripts/verify-be-input.sh index 9f2a4b1..a26bdd5 100755 --- a/scripts/verify-be-input.sh +++ b/scripts/verify-be-input.sh @@ -10,19 +10,19 @@ # 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 +# verification scanline (row 198) with tests/gate/keysRow.c, 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. Then the named keys a second key gives (scripts/lib/keysGate.sh): # the keypad's arrows and Enter, and the right Ctrl and Alt, held through -# QMP and read off KEYS's cells (scripts/lib/keysCells.py). Escape then ends +# QMP and read off KEYS's cells (tests/gate/keysCells.c). 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). +# Requires: QEMU and the port's VM (./install.sh install emulator:PORT); +# build/PORT/bin/KEYS (make PORT). set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -55,7 +55,7 @@ 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 + if "$(joeyTool keysRow)" "$work/keys.png" "$1" --scale 1 > "$work/row.txt"; then cat "$work/row.txt" return 0 fi @@ -100,7 +100,7 @@ if [ "$passed" = 1 ] && [[ "$(cat "$work/row.txt")" =~ row\ at\ \(([0-9]+),([0-9 } keysRead() { beQmp "$work" screendump "$work/cells.png" - python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" --at "$stageAt" + "$(joeyTool keysCells)" "$work/cells.png" --at "$stageAt" } keysRunSteps kp8 kp2 kp4 kp6 kpenter rctrl ralt [ -z "$KEYS_FAILS" ] || passed=0 diff --git a/scripts/verify-be-serial.sh b/scripts/verify-be-serial.sh index b47a7df..653e8ad 100755 --- a/scripts/verify-be-serial.sh +++ b/scripts/verify-be-serial.sh @@ -7,7 +7,7 @@ # # Runs SERTEST (examples/sertest: banner, then echo) in the port's VM # (scripts/lib/beGate.sh) with the machine's second serial port joined to -# the host peer (scripts/lib/serialLoopPeer.py) over TCP: the peer listens, +# the host peer (tests/gate/serialLoopPeer.c) over TCP: the peer listens, # QEMU's COM2 connects to it, and JOEYLIB_SERIAL names COM2's device to the # program (Haiku's /dev/ports/pc_serial1; COM1 is the VM's console). The # peer waits for the banner, sends a probe and expects it echoed, so the @@ -20,7 +20,7 @@ # in the fail-safe video mode), so the machine has none. # # Usage: scripts/verify-be-serial.sh [haiku|haiku32|beos] -# Requires: the port's VM (component haikuVm, haikuVm32 or beosVm), python3; +# Requires: the port's VM (component haikuVm, haikuVm32 or beosVm); # build/PORT/bin/SERTEST (make PORT). set -uo pipefail @@ -63,7 +63,8 @@ cp "$program" "$work/job/SERTEST/SERTEST" beJobDisk "$work/job.img" "$work/job" peerPort=$((peerPortMin + RANDOM % peerPortSpan)) -python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$peerPort" "$((bootSeconds + runSeconds))" > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" tcp-server "$peerPort" "$((bootSeconds + runSeconds))" > "$work/peer.log" 2>&1 & peerPid=$! sleep 1 beStart "$work" "$work/job.img" -serial "tcp:127.0.0.1:$peerPort" diff --git a/scripts/verify-dos-audio.sh b/scripts/verify-dos-audio.sh index 861eb20..30e8429 100755 --- a/scripts/verify-dos-audio.sh +++ b/scripts/verify-dos-audio.sh @@ -10,7 +10,7 @@ # script -- no register trace, no debugger this harness can drive -- so # unlike the IIgs and ST gates (which read the DOC and the YM2149) this # one records what actually came out and grades it. The Amiga gate works -# the same way for the same reason, and both share tools/gradeAudioPhases.py. +# the same way for the same reason, and both share tests/gate/audioGate.c. # # Capture path: DOSBox plays through SDL, SDL is pointed at PulseAudio, # and PULSE_SINK sends this ONE process to a null sink created for the @@ -45,7 +45,7 @@ # Env: DOS_AUDIO_KEEP set to keep the work dir and the WAV # CYCLES override the conf's 386SX-16 cpu cap # Requires: DOSBox Staging (install.sh install emulator:dos), Xvfb, xdotool, parec/pactl, -# python3, build/dos/bin/.EXE +# build/dos/bin/.EXE set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -54,16 +54,15 @@ joeyNeed DOSBOX dosbox prog=${1:-AUDIO} binDir=$repo/build/dos/bin conf=$repo/scripts/dosbox-386sx16.conf -grader=$repo/tools/gradeAudioPhases.py +grader=$(joeyTool audioGate) || exit 1 pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-dos-audio -[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; } [ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; } # shellcheck source=scripts/pulseRecordLib.sh . "$pulseLib" [ -f "$conf" ] || { echo "$label: missing $conf" >&2; exit 2; } -for tool in Xvfb xdotool parec pactl python3; do +for tool in Xvfb xdotool parec pactl; 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 (tried XDG_RUNTIME_DIR and /run/user/$(id -u)/pulse/native)" >&2; exit 2; } @@ -178,7 +177,7 @@ parecPid="" # Thresholds sit at about half the measured levels (music ~1590, sfx # ~108; paused ~17 under its ceiling) so ordinary mixing jitter cannot # flip the verdict. -python3 "$grader" "$label" "$work/out.wav" \ +"$grader" phases "$label" "$work/out.wav" \ "music:1:5:600:" \ "paused:8:13::40" \ "sfx:16:22:54:" \ diff --git a/scripts/verify-dos-input.sh b/scripts/verify-dos-input.sh index 6197ed2..4bc7ca8 100755 --- a/scripts/verify-dos-input.sh +++ b/scripts/verify-dos-input.sh @@ -17,7 +17,7 @@ # would hand KEYS a Ctrl press. # # Usage: scripts/verify-dos-input.sh -# Requires: DOSBox Staging (install.sh install emulator:dos), Xvfb, xdotool, python3 + PIL; +# Requires: DOSBox Staging (install.sh install emulator:dos), Xvfb, xdotool; # build/dos/bin/KEYS.EXE (make dos). set -euo pipefail @@ -76,57 +76,10 @@ if ! dosboxShot ":$display_num" "$win" "$work/shots" "$png"; then exit 1 fi -python3 - "$png" "$target" <<'PY' -import sys -from PIL import Image - -png, target = sys.argv[1], sys.argv[2] -img = Image.open(png).convert("RGB") -w, h = img.size -# Raw capture is native 320x200; tolerate an integer upscale. -sx, sy = w // 320, h // 200 -if sx < 1 or sy < 1 or w % 320 or h % 200: - print(f"verify-dos-input: FAIL - unexpected screenshot size {w}x{h}") - sys.exit(1) - -def dac8(c4): - v6 = ((c4 << 2) | (c4 >> 2)) & 0x3F - return (v6 << 2) | (v6 >> 4) - -# KEYS palette 0 (see buildPalette): gray ramp with the four demo -# colors and red at 15. 12-bit 0x0RGB -> exact 8-bit via the port's -# 6-bit DAC expansion. -pal12 = [(i << 8) | (i << 4) | i for i in range(16)] -pal12[0], pal12[1], pal12[2], pal12[3] = 0x000, 0x333, 0x0F0, 0xFFF -pal12[15] = 0xF00 -# Nearest-color match: emulators differ in how they scale the 6-bit -# DAC to 8-bit (bit-replication vs rounding), but the palette entries -# sit ~17 units apart per channel, so nearest is unambiguous. -palRgb = [(dac8((v >> 8) & 0xF), dac8((v >> 4) & 0xF), dac8(v & 0xF)) for v in pal12] - -def pix(px): - r, g, b = img.getpixel((px * sx, 198 * sy)) - best, bestDist = 0, 1 << 30 - for n, (pr, pg, pb) in enumerate(palRgb): - d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2 - if d < bestDist: - best, bestDist = n, d - if bestDist > 3 * 8 ** 2: - print(f"verify-dos-input: FAIL - pixel {px} color {(r, g, b)} too far from any palette entry") - sys.exit(1) - return best - -row = bytes((pix(2 * i) << 4) | pix(2 * i + 1) for i in range(40)) -expect = bytes([0xA5, 0xA5, len(target), 0x00]) + target.encode("ascii") -got = row[: len(expect)] -print("VERIFY-DOS-INPUT row=" + row.hex().upper()) -if got != expect: - print(f"verify-dos-input: FAIL\n expected: {expect.hex().upper()}\n got: {got.hex().upper()}") - sys.exit(1) -PY +"$(joeyTool dosRow)" input "$png" "$target" # The stage in the raw screenshot: KEYS's row 198 found where it is. -found=$(python3 "$repo/scripts/lib/keysRow.py" "$png" "$target") || true +found=$("$(joeyTool keysRow)" "$png" "$target") || true if [[ ! "$found" =~ row\ at\ \(([0-9]+),([0-9]+)\)\ at\ ([0-9]+)x ]]; then echo "verify-dos-input: FAIL - KEYS's stage is not in the screenshot: $found" >&2 exit 1 @@ -144,7 +97,7 @@ keysRelease() { } keysRead() { if dosboxShot ":$display_num" "$win" "$work/shots" "$work/cells.png"; then - python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" --at "$stageAt" --scale "$scale" + "$(joeyTool keysCells)" "$work/cells.png" --at "$stageAt" --scale "$scale" else echo "no screenshot" fi diff --git a/scripts/verify-dos-joy.sh b/scripts/verify-dos-joy.sh index 655d2b8..c76ce73 100755 --- a/scripts/verify-dos-joy.sh +++ b/scripts/verify-dos-joy.sh @@ -13,14 +13,14 @@ # 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 +# tests/gate/joyScreen.c (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; +# Requires: DOSBox Staging, Xvfb, xdotool; # build/dos/bin/JOY.EXE (make dos). set -uo pipefail diff --git a/scripts/verify-dos-mouse.sh b/scripts/verify-dos-mouse.sh index 56c3296..9b13357 100755 --- a/scripts/verify-dos-mouse.sh +++ b/scripts/verify-dos-mouse.sh @@ -20,7 +20,7 @@ # 640-wide virtual screen, which the port halves, so every column counts. # # Usage: scripts/verify-dos-mouse.sh -# Requires: DOSBox Staging (install.sh install emulator:dos), Xvfb, xdotool, python3 + PIL; +# Requires: DOSBox Staging (install.sh install emulator:dos), Xvfb, xdotool; # build/dos/bin/KEYS.EXE (make dos). set -uo pipefail @@ -73,36 +73,7 @@ DISPLAY=":$display_num" xdotool windowactivate --sync "$win" 2>/dev/null || true readRow() { local png="$work/$1.png" dosboxShot ":$display_num" "$win" "$work/shots" "$png" || return 1 - python3 - "$png" <<'PY' -import sys -from PIL import Image - -img = Image.open(sys.argv[1]).convert("RGB") -w, h = img.size -sx, sy = w // 320, h // 200 -if sx < 1 or sy < 1 or w % 320 or h % 200: - sys.exit(1) - -def dac8(c4): - v6 = ((c4 << 2) | (c4 >> 2)) & 0x3F - return (v6 << 2) | (v6 >> 4) - -# KEYS palette 0 (as verify-dos-input.sh decodes it). -pal12 = [(i << 8) | (i << 4) | i for i in range(16)] -pal12[0], pal12[1], pal12[2], pal12[3] = 0x000, 0x333, 0x0F0, 0xFFF -pal12[15] = 0xF00 -palRgb = [(dac8((v >> 8) & 0xF), dac8((v >> 4) & 0xF), dac8(v & 0xF)) for v in pal12] - -def pix(px): - r, g, b = img.getpixel((px * sx, 199 * sy)) - return min(range(16), key=lambda n: (r - palRgb[n][0]) ** 2 + (g - palRgb[n][1]) ** 2 + (b - palRgb[n][2]) ** 2) - -nib = [pix(i) for i in range(16)] -words = [(nib[4 * k] << 12) | (nib[4 * k + 1] << 8) | (nib[4 * k + 2] << 4) | nib[4 * k + 3] for k in range(4)] -if words[0] != 0x5A5A: - sys.exit(1) -print("%04X %d %d" % (words[1], words[2], words[3])) -PY + "$(joeyTool dosRow)" mouse "$png" } fails=() diff --git a/scripts/verify-dos-serial.sh b/scripts/verify-dos-serial.sh index 892236d..84a8a59 100755 --- a/scripts/verify-dos-serial.sh +++ b/scripts/verify-dos-serial.sh @@ -7,7 +7,7 @@ # # Runs SERTEST.EXE (examples/sertest: banner, then echo) in DOSBox Staging # with COM1 as a nullmodem connecting to the host peer -# (scripts/lib/serialLoopPeer.py), which waits for the banner, sends a probe +# (tests/gate/serialLoopPeer.c), which waits for the banner, sends a probe # and expects it echoed. That exercises the chain: DJGPP -> DPMI -> JoeyLib's # interrupt-driven 8250/16550 HAL -> DOSBox's UART -> host. # @@ -37,7 +37,8 @@ trap cleanup EXIT mkdir -p "$work/c" "$work/xdg" cp "$exe" "$work/c/SERTEST.EXE" -python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$port" "$wall" > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" tcp-server "$port" "$wall" > "$work/peer.log" 2>&1 & peerPid=$! sleep 1 diff --git a/scripts/verify-dvx-audio.sh b/scripts/verify-dvx-audio.sh index 1d66c41..bf1c708 100755 --- a/scripts/verify-dvx-audio.sh +++ b/scripts/verify-dvx-audio.sh @@ -9,7 +9,7 @@ # Sound Blaster driver and mixer) and music library (src/dvx/audio.c). # Nothing in DOSBox can be tapped register by register, so, like the DOS and # Amiga gates, this records what came out and grades it with -# tools/gradeAudioPhases.py, through a PulseAudio null sink made for this run +# tests/gate/audioGate.c, through a PulseAudio null sink made for this run # (scripts/pulseRecordLib.sh: the host's own sound is never touched). # # AUDIO (examples/audio) is the desktop of a scratch DVX in DOSBox Staging @@ -28,7 +28,7 @@ # # Usage: scripts/verify-dvx-audio.sh # Env: DVX_AUDIO_KEEP keep the work folder and the recording -# Requires: DOSBox Staging, Xvfb, xdotool, parec/pactl, python3, the dvx +# Requires: DOSBox Staging, Xvfb, xdotool, parec/pactl, the dvx # component; the audio program (make dvx). set -uo pipefail @@ -37,7 +37,7 @@ source "$repo/scripts/lib/joeyEnv.sh" source "$repo/scripts/lib/dvxGate.sh" joeyNeed DOSBOX dosbox joeyNeed DVX_ROOT dvx -grader=$repo/tools/gradeAudioPhases.py +grader=$(joeyTool audioGate) || exit 1 pulseLib=$repo/scripts/pulseRecordLib.sh label=verify-dvx-audio program=audio @@ -47,7 +47,7 @@ settleSeconds=4 # then AUDIO opening its window and starting the song # shellcheck source=scripts/pulseRecordLib.sh . "$pulseLib" -for tool in Xvfb xdotool parec pactl python3; do +for tool in Xvfb xdotool parec pactl; 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; } @@ -148,11 +148,11 @@ dvxDvxLog "$work/c" 200 | grep -E "^Audio:" | tail -3 [ -s "$work/out.wav" ] || { echo "$label: FAIL - recorded no audio at all" >&2; exit 1; } -# Measured under DOSBox Staging 0.83 (SB16, DVX mixing at 22,050 Hz): music -# ~1260, paused 0, sfx ~670, resumed ~1245 -- the DOS port's levels, near -# enough, so the DOS gate's bounds (about half of each) apply unchanged. -python3 "$grader" "$label" "$work/out.wav" \ - "music:1:5:600:" \ +# Measured under DOSBox Staging 0.83 (SB16, DVX mixing at 22,050 Hz), after +# the 2026-10-16 mix: music ~3030, paused 0, sfx ~170, resumed ~3040; the +# bounds are about half of each. +"$grader" phases "$label" "$work/out.wav" \ + "music:1:5:1500:" \ "paused:8:13::200" \ - "sfx:16:22:330:" \ - "resumed:27:32:600:" + "sfx:16:22:80:" \ + "resumed:27:32:1500:" diff --git a/scripts/verify-dvx-fullscreen.sh b/scripts/verify-dvx-fullscreen.sh index b3202c4..7cfd782 100755 --- a/scripts/verify-dvx-fullscreen.sh +++ b/scripts/verify-dvx-fullscreen.sh @@ -17,7 +17,7 @@ # wherever it is shown. # 2. KEYS on the whole screen: the typed string must reach jlInputGetChar # (its log), and its verification row must read back off the 320x200 -# screen at exactly the stage's own (0,198) (scripts/lib/keysRow.py): +# screen at exactly the stage's own (0,198) (tests/gate/keysRow.c): # the full-screen present, its palette and its placement. Alt+keypad # Enter (DOSBox keeps Alt+Enter for its own full screen) takes KEYS back # to its window, where the row must be found on the desktop; the same @@ -26,7 +26,7 @@ # # Usage: scripts/verify-dvx-fullscreen.sh # Env: DVX_FULLSCREEN_SHOTS also copy the screenshots into this folder -# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL, the dvx component; +# Requires: DOSBox Staging, Xvfb, xdotool, the dvx component; # the uber and keys programs (make dvx). set -uo pipefail @@ -138,7 +138,7 @@ shot() { png=$(ls "$work/$1/shots/"*.png 2> /dev/null | head -1) if [ -n "$png" ]; then mv "$png" "$work/$2.png" - size=$(python3 -c 'import sys; from PIL import Image; print("%dx%d" % Image.open(sys.argv[1]).size)' "$work/$2.png") + size=$("$(joeyTool pngProbe)" size "$work/$2.png") echo "verify-dvx-fullscreen: $2: $size" [ "$size" = "$3" ] || fail "$2 is $size, want $3" return 0 @@ -181,7 +181,7 @@ if [ -z "$log" ]; then fail "UBER wrote no \"$uberDone\" within ${uberSeconds}s" else tr -d '\r' < "$log" > "$work/uber.txt" - if hashes=$("$repo/tools/diff-uber-hashes" "$repo/tests/goldens/uber/dos.txt" "$work/uber.txt"); then + if hashes=$("$(joeyTool diffUberHashes)" "$repo/tests/goldens/uber/dos.txt" "$work/uber.txt"); then echo "verify-dvx-fullscreen: UBER hashes: $(printf '%s\n' "$hashes" | tail -1)" else printf '%s\n' "$hashes" @@ -198,13 +198,13 @@ fi DISPLAY="$display" xdotool type --window "$win" --delay 150 -- "$target" sleep "$stepSeconds" shot keys keysFull "$fullSize" -python3 "$repo/scripts/lib/keysRow.py" "$work/keysFull.png" "$target" --at "$rowAt" || fail "KEYS's row on the whole screen" +"$(joeyTool keysRow)" "$work/keysFull.png" "$target" --at "$rowAt" || fail "KEYS's row on the whole screen" hold alt KP_Enter shot keys keysWindow "$desktopSize" -python3 "$repo/scripts/lib/keysRow.py" "$work/keysWindow.png" "$target" || fail "KEYS's row in its window after leaving the whole screen" +"$(joeyTool keysRow)" "$work/keysWindow.png" "$target" || fail "KEYS's row in its window after leaving the whole screen" hold alt KP_Enter shot keys keysFullAgain "$fullSize" -python3 "$repo/scripts/lib/keysRow.py" "$work/keysFullAgain.png" "$target" --at "$rowAt" || fail "KEYS's row on the whole screen again" +"$(joeyTool keysRow)" "$work/keysFullAgain.png" "$target" --at "$rowAt" || fail "KEYS's row on the whole screen again" hold Escape log=$(dvxAwaitLog "$work/keys/c" keys '^typed=' "$logSeconds" "$guard") sets=$(fullScreenSets keys) diff --git a/scripts/verify-dvx-icon.sh b/scripts/verify-dvx-icon.sh index 934e3c5..4b50f44 100755 --- a/scripts/verify-dvx-icon.sh +++ b/scripts/verify-dvx-icon.sh @@ -5,7 +5,7 @@ # # verify-dvx-icon.sh - a DVX program's icon as Program Manager shows it. # -# make gives every DVX program its icon (make/dvx.mk: tools/iconBake.py bmp, +# make gives every DVX program its icon (make/dvx.mk: tools/iconBake bmp, # a 32x32 24-bit BMP on DVX's grey, appended by dvxres as the icon32 # resource). DVX is staged with DRAW (examples/draw) installed and Program # Manager, DVX's own, as the desktop (scripts/lib/dvxGate.sh), and boots in @@ -18,7 +18,7 @@ # Env: DVX_ICON_KEEP keep the last screenshot as build/dvx/dvx-icon.png # Exit: 0 PASS, 1 FAIL, 2 SKIP (DOSBox or DVX not installed, or DRAW not # built) -# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL + numpy, the dvx +# Requires: DOSBox Staging, Xvfb, xdotool, the dvx # component; DRAW (make dvx). set -uo pipefail @@ -54,44 +54,7 @@ trap cleanup EXIT # iconShown PNG -- true when PNG holds the icon; prints the verdict. iconShown() { - python3 - "$1" "$icon" "$repo/tools" "$label" <<'PY' -import sys - -import numpy -from PIL import Image - -png, bmp, tools, label = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] -sys.path.insert(0, tools) -import iconBake # noqa: E402 - -# A 16-bit desktop keeps 5 or 6 bits a gun. -TOLERANCE = 8 -icon = numpy.asarray(Image.open(bmp).convert("RGB"), dtype=numpy.int16) -opaque = numpy.argwhere(numpy.any(icon != numpy.array(iconBake.DVX_GREY), axis=2)) -colours = icon[opaque[:, 0], opaque[:, 1]] -screen = numpy.asarray(Image.open(png).convert("RGB"), dtype=numpy.int16) -ih, iw = icon.shape[:2] -sh, sw = screen.shape[:2] - - -def matches(colour): - return numpy.all(numpy.abs(screen - colour) <= TOLERANCE, axis=2) - - -counts = [int(matches(c).sum()) for c in colours] -anchor = min(range(len(opaque)), key=lambda i: counts[i]) -ay, ax = opaque[anchor] -for y, x in numpy.argwhere(matches(colours[anchor])): - top, left = y - ay, x - ax - if top < 0 or left < 0 or top + ih > sh or left + iw > sw: - continue - shown = screen[top + opaque[:, 0], left + opaque[:, 1]] - if numpy.all(numpy.abs(shown - colours) <= TOLERANCE): - print(f"{label}: PASS (Program Manager shows DRAW by its icon at {left},{top}: all {len(opaque)} pixels of the baked {iw}x{ih} that are not the grey)") - sys.exit(0) -print(f"{label}: FAIL - DRAW's icon is not on the screen ({len(opaque)} pixels looked for)") -sys.exit(1) -PY + "$(joeyTool iconFind)" dvx "$1" "$icon" "$label" } diff --git a/scripts/verify-dvx-input.sh b/scripts/verify-dvx-input.sh index d564b49..70f824e 100755 --- a/scripts/verify-dvx-input.sh +++ b/scripts/verify-dvx-input.sh @@ -17,7 +17,7 @@ # expanded from the stage through the port's palette tables into the # desktop's pixel format at the window's scale -- decoding to exactly # the typed bytes, every stage pixel a whole scale x scale block -# (scripts/lib/keysRow.py); +# (tests/gate/keysRow.c); # 3. the typed history: after Escape, KEYS logs every received character # as hex to its joeylog.txt (in its settings folder), which must equal # the typed ASCII. @@ -28,7 +28,7 @@ # # Usage: scripts/verify-dvx-input.sh [WxHxBPP:SCALE[:square]]... # e.g. scripts/verify-dvx-input.sh 800x600x8:2 1024x768x32:3 -# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL, the dvx component; +# Requires: DOSBox Staging, Xvfb, xdotool, the dvx component; # the keys program (make dvx). set -uo pipefail @@ -184,7 +184,7 @@ run() { if [ -z "$png" ]; then fail "$spec" "no screenshot captured" else - python3 "$repo/scripts/lib/keysRow.py" "$png" "$target" --scale "$scale" || fail "$spec" "the present" + "$(joeyTool keysRow)" "$png" "$target" --scale "$scale" || fail "$spec" "the present" fi if [ -z "$log" ]; then fail "$spec" "KEYS logged no typed line within ${logSeconds}s of Escape" diff --git a/scripts/verify-dvx-joy.sh b/scripts/verify-dvx-joy.sh index 2879f95..be258a9 100755 --- a/scripts/verify-dvx-joy.sh +++ b/scripts/verify-dvx-joy.sh @@ -24,7 +24,7 @@ # grey; green when moved), two button squares and a patch that says whether # the port reports a stick. It runs with square pixels (dvxSettings): the # 4:3 window repeats some rows, and the dots are read pixel for pixel. -# scripts/lib/joyScreen.py reads the WHOLE state of +# tests/gate/joyScreen.c reads the WHOLE state of # 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 @@ -42,7 +42,7 @@ # # Usage: scripts/verify-dvx-joy.sh # Env: JOY_SHOTS also keep the screenshots in this folder -# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL, the dvx component; +# Requires: DOSBox Staging, Xvfb, xdotool, the dvx component; # the joy program (make dvx). set -uo pipefail diff --git a/scripts/verify-dvx-serial.sh b/scripts/verify-dvx-serial.sh index d9dde52..7c340c1 100755 --- a/scripts/verify-dvx-serial.sh +++ b/scripts/verify-dvx-serial.sh @@ -7,7 +7,7 @@ # # Runs SERTEST (examples/sertest: banner, then echo) as the desktop of a # scratch DVX in DOSBox Staging, with COM1 a nullmodem connecting to the -# host peer (scripts/lib/serialLoopPeer.py), which waits for the banner, +# host peer (tests/gate/serialLoopPeer.c), which waits for the banner, # sends a probe and expects it echoed. That exercises the chain: JoeyLib's # serial layer -> the DVX port (src/dvx/serial.c) -> DVX's rs232 library and # its interrupt -> DOSBox's UART -> host. @@ -21,7 +21,7 @@ # # Usage: scripts/verify-dvx-serial.sh # Env: DVX_SERIAL_PORT TCP port the peer listens on (default 6803) -# Requires: DOSBox Staging, Xvfb, python3, the dvx component; the sertest +# Requires: DOSBox Staging, Xvfb, the dvx component; the sertest # program (make dvx). set -uo pipefail @@ -55,7 +55,8 @@ dvxDosboxConf "$work/dosbox.conf" "$work/c" printf '\n[serial]\nserial1 = nullmodem server:127.0.0.1 port:%s transparent:0\n' "$port" >> "$work/dosbox.conf" mkdir -p "$work/xdg" -python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$port" "$peerWall" --dtr-check > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" tcp-server "$port" "$peerWall" --dtr-check > "$work/peer.log" 2>&1 & peerPid=$! sleep 1 diff --git a/scripts/verify-iigs-icon.sh b/scripts/verify-iigs-icon.sh index 20987db..788cddb 100755 --- a/scripts/verify-iigs-icon.sh +++ b/scripts/verify-iigs-icon.sh @@ -6,7 +6,7 @@ # verify-iigs-icon.sh - IIgs programs' icons as the Finder shows them. # # make bakes every IIgs program a Finder icon file (make/iigs.mk: -# tools/iconBake.py iigs, type $CA: an icon record for a GS/OS application of +# tools/iconBake iigs, type $CA: an icon record for a GS/OS application of # the program's name, black and white), and make-iigs-disk.sh puts it in the # JOEYLIB volume's Icons folder, which the Finder reads when it starts. GS/OS # boots under MAME into its Finder with joey.2mg mounted; the gate opens the @@ -17,14 +17,14 @@ # opaque, as the two screen pixels QuickDraw draws a 4-bit icon pixel as in # 640 mode, the first its high two bits and the second its low two. The # screen's own 2-bit values are compared, so the check does not lean on the -# palette; the icon's colours are the Finder's palette's (tools/iconBake.py). +# palette; the icon's colours are the Finder's palette's (tools/iconBake). # # Usage: scripts/verify-iigs-icon.sh # Env: IIGS_ICON_KEEP keep the screen as build/iigs/iigs-icon.png # MAME_READ_FRAME the frame the screen is read at (default 5400) # Exit: 0 PASS, 1 FAIL, 2 SKIP (MAME or the IIgs firmware not installed, # or joey.2mg not built) -# Requires: ./install.sh install emulator:iigs, python3 + PIL + numpy; +# Requires: ./install.sh install emulator:iigs; # build/iigs/bin/joey.2mg (make iigs-disk). set -euo pipefail @@ -92,80 +92,4 @@ LUA -autoboot_script "$work/icon.lua" < /dev/null > "$work/mame.log" 2>&1) || true [ -s "$work/shr.bin" ] || { echo "$label: FAIL - MAME never reached frame $readFrame" >&2; tail -5 "$work/mame.log" >&2; exit 1; } -python3 - "$work/shr.bin" "$icon" "$label" "${IIGS_ICON_KEEP:+$repo/build/iigs/iigs-icon.png}" <<'PY' -import struct -import sys - -import numpy -from PIL import Image - -shrPath, iconPath, label, keep = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] -WIDTH_640 = 640 -WIDTH_320 = 320 -HEIGHT = 200 -ROW_BYTES = 160 -SCB_AT = 0x7D00 -PALETTES_AT = 0x7E00 -SCB_640 = 0x80 -# A 640-mode screen pixel is 2 bits; so is each half of an icon pixel. -HALF_BITS = 2 -HALF_MASK = 3 -shr = open(shrPath, "rb").read() - - -def colour(word): - """A $0RGB palette word as 8-bit RGB.""" - return (((word >> 8) & 0xF) * 17, ((word >> 4) & 0xF) * 17, (word & 0xF) * 17) - - -# The screen at 640 pixels a line: a 640-mode line's pixel takes its colour -# from the palette quarter its place in the byte gives it (bits 7-6 from -# entries 8-11, 5-4 from 12-15, 3-2 from 0-3, 1-0 from 4-7); a 320-mode -# line's pixel is shown twice. -# Each 640-mode screen pixel's own 2-bit value goes in values (-1 on a -# 320-mode line). -screen = numpy.zeros((HEIGHT, WIDTH_640, 3), dtype=numpy.uint8) -values = numpy.full((HEIGHT, WIDTH_640), -1, dtype=numpy.int8) -for y in range(HEIGHT): - scb = shr[SCB_AT + y] - palette = [colour(struct.unpack_from("> (6 - 2 * p)) & HALF_MASK - screen[y, i * 4 + p] = palette[base + values[y, i * 4 + p]] - else: - for p, value in enumerate((byte >> 4, byte & 0xF)): - screen[y, i * 4 + p * 2] = palette[value] - screen[y, i * 4 + p * 2 + 1] = palette[value] -if keep: - Image.fromarray(screen).resize((WIDTH_640, HEIGHT * 2), Image.NEAREST).save(keep) - -# The big icon of the file's one record: its 4-bit pixels and mask, the even -# pixel in the high nibble. -record = 26 + 86 -iconType, size, h, w = struct.unpack_from("> shift) & 0xF: - want.append((y, x, (data[y * rowBytes + x // 2] >> shift) & 0xF)) -# In 640 mode an icon pixel is two screen pixels: its high half, then its -# low half. -ys = numpy.array([p[0] for p in want for half in (0, 1)]) -xs = numpy.array([p[1] * 2 + half for p in want for half in (0, 1)]) -halves = numpy.array([(p[2] >> HALF_BITS) if half == 0 else (p[2] & HALF_MASK) for p in want for half in (0, 1)], dtype=numpy.int8) -found = [] -for top in range(HEIGHT - h + 1): - for left in range(WIDTH_640 - w * 2 + 1): - if numpy.array_equal(values[top + ys, left + xs], halves): - found.append((left, top)) -if not found: - print(f"{label}: FAIL - no program's icon is in the JOEYLIB window ({len(want)} opaque pixels looked for)") - sys.exit(1) -print(f"{label}: PASS (the Finder shows {len(found)} programs by the baked {w}x{h} colour icon, the first at {found[0][0]},{found[0][1]}: all {len(want)} opaque pixels, each its two screen pixels' values)") -PY +"$(joeyTool iigsIcon)" "$work/shr.bin" "$icon" "$label" "${IIGS_ICON_KEEP:+$repo/build/iigs/iigs-icon.png}" diff --git a/scripts/verify-iigs-joy.sh b/scripts/verify-iigs-joy.sh index 5c123a3..1c6130c 100755 --- a/scripts/verify-iigs-joy.sh +++ b/scripts/verify-iigs-joy.sh @@ -14,7 +14,7 @@ # 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 +# double-width pixels), then go through tests/gate/joyScreen.c # (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 @@ -24,7 +24,7 @@ # 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 +# scripts), ImageMagick; build/iigs/bin/JOY # (make iigs-disk). set -uo pipefail @@ -230,7 +230,7 @@ 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 +if joyShot "$JOY_WORK/after.png" && "$(joeyTool joyScreen)" "$JOY_WORK/after.png" > /dev/null 2>&1; then joyFail "Escape did not end JOY" fi diff --git a/scripts/verify-iigs-serial-loss.sh b/scripts/verify-iigs-serial-loss.sh index 971b19b..ba054a5 100755 --- a/scripts/verify-iigs-serial-loss.sh +++ b/scripts/verify-iigs-serial-loss.sh @@ -109,40 +109,8 @@ fi # Peer: listen (MAME connects out), wait for the probe's READY, stream the # pattern, then keep draining whatever the probe sends until MAME goes away. -cat > "$work/peer.py" <<'PY' -import socket, sys, time - -port = int(sys.argv[1]) -total = int(sys.argv[2]) -log = open(sys.argv[3], "w") -srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -srv.bind(("127.0.0.1", port)) -srv.listen(1) -srv.settimeout(600) -conn, _ = srv.accept() -conn.settimeout(1.0) -seen = b"" -sent = False -while True: - try: - chunk = conn.recv(256) - except socket.timeout: - continue - except OSError: - break - if not chunk: - break - seen += chunk - if not sent and b"READY" in seen: - conn.sendall(bytes(k & 0xFF for k in range(total))) - sent = True - log.write("peer: READY seen, sent %d bytes\n" % total) - log.flush() -log.write("peer: received %r\n" % seen[:64]) -log.close() -PY -python3 "$work/peer.py" "$PORT" "$total" "$work/peer.log" & +peer=$(joeyTool iigsSerialPeer) || exit 1 +"$peer" loss "$PORT" "$total" "$work/peer.log" & peer_pid=$! sleep 1 diff --git a/scripts/verify-iigs-serial.sh b/scripts/verify-iigs-serial.sh index d71a621..73620bc 100755 --- a/scripts/verify-iigs-serial.sh +++ b/scripts/verify-iigs-serial.sh @@ -55,40 +55,8 @@ JOEY_DISK_EXAMPLES="SCCPROBE" bash "$repo/scripts/make-iigs-disk.sh" "$work/scc. cp "$sys_disk" "$work/boot.po" # Serial peer: listen, then log every byte the IIgs sends. -cat > "$work/peer.py" <<'PY' -import socket, sys, time - -port = int(sys.argv[1]) -out = sys.argv[2] -srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -srv.bind(("127.0.0.1", port)) -srv.listen(1) -srv.settimeout(300) -try: - conn, _ = srv.accept() -except socket.timeout: - open(out, "wb").write(b"") - sys.exit(0) -conn.settimeout(1.0) -buf = b"" -deadline = time.time() + 280 -while time.time() < deadline: - try: - chunk = conn.recv(256) - except socket.timeout: - continue - except OSError: - break - if not chunk: - break - buf += chunk - with open(out, "wb") as f: - f.write(buf) -with open(out, "wb") as f: - f.write(buf) -PY -python3 "$work/peer.py" "$PORT" "$work/rx.bin" & +peer=$(joeyTool iigsSerialPeer) || exit 1 +"$peer" capture "$PORT" "$work/rx.bin" & peer_pid=$! sleep 1 diff --git a/scripts/verify-psp-audio.sh b/scripts/verify-psp-audio.sh index a3d9aa0..31e2128 100755 --- a/scripts/verify-psp-audio.sh +++ b/scripts/verify-psp-audio.sh @@ -15,7 +15,7 @@ # 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 +# also ends the run and the recording. tests/gate/audioGate.c 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. @@ -23,7 +23,7 @@ # 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. +# Requires: PPSSPPHeadless; AUDIOPRB built for the PSP. set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -58,4 +58,4 @@ if grep -q '^TIMEOUT' "$work/ppsspp.log"; then 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 }')" +"$(joeyTool audioGate)" psp "$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 index d23eaed..6f9bad8 100755 --- a/scripts/verify-psp-input.sh +++ b/scripts/verify-psp-input.sh @@ -12,7 +12,7 @@ # nub, cross as button 0, circle as button 1), with Start as Return and # Select as Escape, and Square opening a keyboard drawn over the screen # (src/hosted/psp/pspKeyboard.c) whose d-pad moves and cross presses are -# worked out from its own source by scripts/lib/pspKeyboardPath.py. Three +# worked out from its own source by tests/gate/pspKeyboardPath.c. Three # 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 @@ -24,12 +24,12 @@ # the d-pad and cross pressed (the stick must rest: they are the # keyboard's), the keyboard closed and the d-pad pressed again (the # stick's once more). After each, a snapshot of the PSP's screen, -# whose stage scripts/lib/joyScreen.py reads both sticks off (stick +# whose stage tests/gate/joyScreen.c 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 +# row of the characters it received (tests/gate/keysRow.c). Then # Select: KEYS must end by itself. # KEYS again through the keyboard: Square shows it at the bottom of the # screen and L moves it to the top; F5 held lights KEYS's F5 cell @@ -38,7 +38,7 @@ # KEYS must have received the string, nothing more. Then Select. # # Usage: scripts/verify-psp-input.sh -# Requires: PPSSPPHeadless, python3 with PIL; build/psp/bin/JOY and +# Requires: PPSSPPHeadless; build/psp/bin/JOY and # build/psp/bin/KEYS (make psp). set -uo pipefail @@ -53,7 +53,6 @@ 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. @@ -81,10 +80,10 @@ typedWant=$'\r' rest=10000 unplugged=00000 # A stick at rest under the keyboard, which covers the patch that says -# whether it is there (joyScreen.py reads the stage where the backend puts +# whether it is there (joyScreen reads the stage where the backend puts # it, so it needs no patch to find it). covered=?0000 -# name, input script action, stick A's state (joyScreen.py's characters: +# name, input script action, stick A's state (joyScreen's characters: # connected, X, Y, button 0, button 1), and stick B's (unplugged unless # given). joySteps=( @@ -182,7 +181,7 @@ for step in "${joySteps[@]}"; do fail "JOY $name: no snapshot" continue fi - got=$(python3 "$repo/scripts/lib/joyScreen.py" "$png" --scale 1 --at "$stageX,$stageY") + got=$("$(joeyTool joyScreen)" "$png" --scale 1 --at "$stageX,$stageY") echo "$label: JOY $name: $got" [ "${got% scale=*}" = "A=$want B=$wantB" ] || fail "JOY $name: read ${got% scale=*}, want A=$want B=$wantB" done @@ -204,10 +203,10 @@ for shot in "start|$returnLit" "release|$returnUnlit"; do 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") + got=$("$(joeyTool pngProbe)" pixel "$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" + typed=$("$(joeyTool keysRow)" "$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 @@ -251,7 +250,7 @@ while read -r step what; do at=$((at + kbdStepMs)) ;; esac -done < <(python3 "$repo/scripts/lib/pspKeyboardPath.py" "$kbdSource" key:F5 "type:$kbdTarget") +done < <("$(joeyTool pspKeyboardPath)" "$kbdSource" key:F5 "type:$kbdTarget") kbd "$at" "buttons square" kbd "$((at + kbdPressMs))" "buttons none" kbd "$((at + kbdHoldMs))" "snapshot $kbdShots/closed.png" @@ -266,7 +265,7 @@ pixelIs() { fail "keyboard $1: no snapshot" return 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]))))" "$kbdShots/$1.png" "$2" "$3") + got=$("$(joeyTool pngProbe)" pixel "$kbdShots/$1.png" "$2" "$3") echo "$label: keyboard $1: $5 $got" [ "$got" = "$4" ] || fail "keyboard $1: $5 is $got, want $4" } @@ -280,7 +279,7 @@ pixelIs f5Up "$f5X" "$f5Y" "$returnUnlit" "KEYS's F5 cell" pixelIs shift "$shiftX" "$shiftY" "$returnLit" "KEYS's Shift cell" pixelIs closed "$kbdX" "$kbdTopY" "$kbdNone" "the top of the screen" if [ -f "$kbdShots/closed.png" ]; then - typed=$(python3 "$repo/scripts/lib/keysRow.py" "$kbdShots/closed.png" "$kbdTarget" --size "$screenSize" --at "$stageX,$typedRow" --scale 1) || fail "keyboard: KEYS did not receive \"$kbdTarget\"" + typed=$("$(joeyTool keysRow)" "$kbdShots/closed.png" "$kbdTarget" --size "$screenSize" --at "$stageX,$typedRow" --scale 1) || fail "keyboard: KEYS did not receive \"$kbdTarget\"" echo "$label: keyboard typed: ${typed//$'\n'/; }" fi diff --git a/scripts/verify-psp-save.sh b/scripts/verify-psp-save.sh index 70f33c6..5a1129d 100755 --- a/scripts/verify-psp-save.sh +++ b/scripts/verify-psp-save.sh @@ -42,7 +42,7 @@ for run in 1 2; do 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 2; } -for tool in Xvfb xdotool python3; do +for tool in Xvfb xdotool; do command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; } done @@ -98,28 +98,18 @@ 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 +"$grader" wav "$work/out.raw" "$work/out.wav" 44100 -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:" \ +# Thresholds sit at about half the measured levels (music ~6150, paused 0, +# sfx ~310, noise ~3260, mod ~3990, measured after the 2026-10-16 mix; the +# grader averages the two channels) so the verdict has room either way. +"$grader" phases "$label" "$work/out.wav" \ + "music:1:5:3000:" \ "paused:8:13::100" \ - "sfx:15:22:600:" \ - "resumed:25:29:1500:" \ - "noise:31.5:34:800:" \ - "mod:37:43:4000:" + "sfx:15:22:150:" \ + "resumed:25:29:3000:" \ + "noise:31.5:34:1600:" \ + "mod:37:43:2000:" status=$? if [ -n "${SDL3_AUDIO_KEEP:-}" ]; then cp "$work/out.wav" "$repo/build/sdl3/headless-audio.wav" diff --git a/scripts/verify-sdl3-icon.sh b/scripts/verify-sdl3-icon.sh index 526cb26..8bc2aec 100755 --- a/scripts/verify-sdl3-icon.sh +++ b/scripts/verify-sdl3-icon.sh @@ -5,16 +5,16 @@ # # verify-sdl3-icon.sh - an SDL3 program's icon on its window. # -# make bakes every SDL3 program's icon into it (make/sdl3.mk: tools/iconBake.py +# make bakes every SDL3 program's icon into it (make/sdl3.mk: tools/iconBake # c, SDL3_ICON_SIZE pixels square), and the backend gives it # SDL_SetWindowIcon (HOSTED_WINDOW_ICON). DRAW (examples/draw) runs on a # private Xvfb display through SDL's X11 driver, which hands the icon to X # as the window's _NET_WM_ICON; that must be the program's picture as -# iconBake.py fits it (scripts/lib/netWmIcon.py). +# iconBake fits it (tests/gate/netWmIcon.c). # # Usage: scripts/verify-sdl3-icon.sh # Exit: 0 PASS, 1 FAIL, 2 SKIP (DRAW not built) -# Requires: Xvfb, xdotool, xprop, python3 + PIL; build/sdl3/bin/DRAW +# Requires: Xvfb, xdotool, xprop; build/sdl3/bin/DRAW # (make sdl3). set -euo pipefail @@ -36,7 +36,7 @@ cleanup() { } trap cleanup EXIT -python3 "$repo/tools/iconBake.py" png "$picture" "$work/want.png" "${size}x${size}" +"$(joeyTool iconBake)" png "$picture" "$work/want.png" "${size}x${size}" Xvfb ":$displayNum" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 & xvfbPid=$! sleep 1 @@ -51,4 +51,4 @@ if [ -z "$win" ]; then exit 1 fi DISPLAY=":$displayNum" xprop -id "$win" -f _NET_WM_ICON 32c ' = $0+' _NET_WM_ICON > "$work/icon.txt" 2>&1 -python3 "$repo/scripts/lib/netWmIcon.py" "$work/icon.txt" "$work/want.png" "$size" "$label" +"$(joeyTool netWmIcon)" "$work/icon.txt" "$work/want.png" "$size" "$label" diff --git a/scripts/verify-sdl3-input.sh b/scripts/verify-sdl3-input.sh index 0782652..609f2b9 100755 --- a/scripts/verify-sdl3-input.sh +++ b/scripts/verify-sdl3-input.sh @@ -10,15 +10,15 @@ # 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. Then the named keys a second +# tests/gate/keysRow.c, as the DVX gates do. Then the named keys a second # physical key gives: the keypad's 8, 2, 4, and 6 as the arrows, its Enter # as Return, and the right Ctrl and Alt as Ctrl and Alt, each held alone, # and Up and the keypad's 8 held together with either let go first, which # must leave Up down -- read off KEYS's cells (scripts/lib/keysGate.sh, -# scripts/lib/keysCells.py). +# tests/gate/keysCells.c). # # Usage: scripts/verify-sdl3-input.sh -# Requires: Xvfb, xdotool, ImageMagick, python3 + PIL; build/sdl3/bin/KEYS +# Requires: Xvfb, xdotool, ImageMagick; build/sdl3/bin/KEYS # (make sdl3). set -euo pipefail @@ -60,7 +60,7 @@ 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 +if ! "$(joeyTool keysRow)" "$work/keys.png" "$target" --size 320x200 --at 0,198 --scale 1; then echo "verify-sdl3-input: FAIL" >&2 exit 1 fi @@ -76,7 +76,7 @@ keysRelease() { } keysRead() { DISPLAY=":$displayNum" import -window "$win" "$work/cells.png" - python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" + "$(joeyTool keysCells)" "$work/cells.png" } keysRunSteps kp8 kp2 kp4 kp6 kpenter rctrl ralt if [ -n "$KEYS_FAILS" ]; then diff --git a/scripts/verify-sdl3-joy.sh b/scripts/verify-sdl3-joy.sh index 2547e08..84983f1 100755 --- a/scripts/verify-sdl3-joy.sh +++ b/scripts/verify-sdl3-joy.sh @@ -18,13 +18,13 @@ # first pass and as two-button joysticks in the second, 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 +# tests/gate/joyScreen.c (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). +# Requires: write access to /dev/uinput, Xvfb, xdotool, ImageMagick; +# build/sdl3/bin/JOY (make sdl3). set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) diff --git a/scripts/verify-sdl3-save.sh b/scripts/verify-sdl3-save.sh index c7e3fed..b8b5a04 100755 --- a/scripts/verify-sdl3-save.sh +++ b/scripts/verify-sdl3-save.sh @@ -33,7 +33,7 @@ for run in 1 2; do (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(' termios -> the pty -> the host. Before the run the # guest end is set to 4800 baud with HUPCL on; SERTEST opens it at 9600. The @@ -19,7 +19,7 @@ # port had and leaves DTR as it was. # # Usage: scripts/verify-sdl3-serial.sh -# Requires: python3, stty; build/sdl3/bin/SERTEST (make sdl3). +# Requires: stty; build/sdl3/bin/SERTEST (make sdl3). set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -43,7 +43,8 @@ cleanup() { } trap cleanup EXIT -python3 "$repo/scripts/lib/serialLoopPeer.py" pty "$work/pty.path" "$wall" --close-hold "$work/release" > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" pty "$work/pty.path" "$wall" --close-hold "$work/release" > "$work/peer.log" 2>&1 & peerPid=$! for ((tries = 0; tries < ptyWaitTries; tries++)); do [ -s "$work/pty.path" ] && break diff --git a/scripts/verify-web-audio.sh b/scripts/verify-web-audio.sh index 123028b..a250108 100755 --- a/scripts/verify-web-audio.sh +++ b/scripts/verify-web-audio.sh @@ -8,7 +8,7 @@ # 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 +# copy of every buffer the node fills, as a WAV. tests/gate/audioGate.c # 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 @@ -19,14 +19,14 @@ # # 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). +# Requires: Chromium, emsdk's node; 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 +grader=$(joeyTool audioGate) || exit 1 label=verify-web-audio dir=$repo/build/web/bin/AUDIO @@ -59,15 +59,16 @@ if ! webDrive "AUDIO.html" --seconds 120 --audio "$work/out.wav" \ 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:" \ +# Thresholds sit at about half the measured levels (music ~6150, paused 0, +# sfx ~310, noise ~2860, mod ~3990, measured after the 2026-10-16 mix, the +# grader averaging the two channels). +"$grader" phases "$label" "$work/out.wav" \ + "music:1:5:3000:" \ "paused:8:13::100" \ - "sfx:15:22:600:" \ - "resumed:25:29:1500:" \ - "noise:31.5:34:700:" \ - "mod:37:43:4000:" + "sfx:15:22:150:" \ + "resumed:25:29:3000:" \ + "noise:31.5:34:1400:" \ + "mod:37:43:2000:" status=$? if [ -n "${WEB_AUDIO_KEEP:-}" ]; then cp "$work/out.wav" "$repo/build/web/headless-audio.wav" diff --git a/scripts/verify-web-input.sh b/scripts/verify-web-input.sh index 8c14cce..ebcf5fb 100755 --- a/scripts/verify-web-input.sh +++ b/scripts/verify-web-input.sh @@ -8,11 +8,11 @@ # (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 +# verification scanline (row 198) with tests/gate/keysRow.c, 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). +# Requires: Chromium, emsdk's node; build/web/bin/KEYS (make web). set -euo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -34,7 +34,7 @@ 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 +if "$(joeyTool keysRow)" "$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 diff --git a/scripts/verify-web-joy.sh b/scripts/verify-web-joy.sh index b65db96..a3378df 100755 --- a/scripts/verify-web-joy.sh +++ b/scripts/verify-web-joy.sh @@ -16,12 +16,12 @@ # 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) +# shots then go through tests/gate/joyScreen.c (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; +# Requires: Chromium and Node (install emulator:web); # build/web/bin/JOY (make web). set -uo pipefail diff --git a/scripts/verify-web-save.sh b/scripts/verify-web-save.sh index 037f10e..d036dfc 100755 --- a/scripts/verify-web-save.sh +++ b/scripts/verify-web-save.sh @@ -42,7 +42,7 @@ cp -r "$dir" "$work/site" cp "$work/site/SAVE.html" "$work/site/OTHER.html" webServe "$work/site" webDrive "OTHER.html" --step wait:8 --step open:SAVE.html --step wait:8 --step reload --step wait:8 --file "/SAVE/SAVES/runs.dat:$work/runs.dat" -count=$(python3 -c "import struct, sys; print(struct.unpack('&2; exit 2; } -for tool in Xvfb xdotool parec pactl python3; do +for tool in Xvfb xdotool parec pactl; 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; } @@ -73,16 +73,7 @@ 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 + "$grader" onset "$work/out.raw" "$SOUND_THRESHOLD" } start="" @@ -123,26 +114,18 @@ 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 +"$grader" wav "$work/out.raw" "$work/out.wav" 44100 "$start" -# 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:" \ +# Thresholds sit at about half the measured levels (music ~3000, paused 0, +# sfx ~160, resumed ~3010, noise ~1410, mod ~3960, measured after the +# 2026-10-16 mix; DOSBox's Sound Blaster plays about half as loud as Wine's +# output). +"$grader" phases "$label" "$work/out.wav" \ + "music:1:5:1500:" \ "paused:8:13::100" \ - "sfx:15:22:300:" \ - "resumed:25:29:750:" \ - "noise:31.5:34:380:" \ + "sfx:15:22:75:" \ + "resumed:25:29:1500:" \ + "noise:31.5:34:700:" \ "mod:37:43:2000:" status=$? if [ -n "${WIN16_AUDIO_KEEP:-}" ]; then diff --git a/scripts/verify-win16-icon.sh b/scripts/verify-win16-icon.sh index efb5001..7f90e7c 100755 --- a/scripts/verify-win16-icon.sh +++ b/scripts/verify-win16-icon.sh @@ -6,7 +6,7 @@ # verify-win16-icon.sh - a Win16 program's icon as Windows 3.1 shows it. # # make binds every Win16 program's icon into its executable (make/win16.mk: -# tools/iconBake.py ico16, 32x32 in the 16 VGA colours, resource +# tools/iconBake ico16, 32x32 in the 16 VGA colours, resource # HOSTED_ICON_ID), and the backend gives its window class that icon. Here # Program Manager is Windows' shell and WIN.INI's load= starts DRAW # (examples/draw) minimised, so Windows draws the class's icon on the @@ -18,7 +18,7 @@ # Env: WIN16_ICON_KEEP keep the screenshot as build/win16/win16-icon.png # Exit: 0 PASS, 1 FAIL, 2 SKIP (Windows or DOSBox not installed, or DRAW # not built) -# Requires: DOSBox Staging, Xvfb, xdotool, python3 + PIL + numpy, the win311 +# Requires: DOSBox Staging, Xvfb, xdotool, the win311 # component; DRAW.EXE and its icon (make win16). set -euo pipefail @@ -39,49 +39,7 @@ shotSeconds=10 # iconShown PNG: true when PNG holds the icon; says where, or that it does # not. iconShown() { - python3 - "$1" "$icon" "$label" <<'PY' -import sys - -import numpy -from PIL import Image - -png, ico, label = sys.argv[1], sys.argv[2], sys.argv[3] -# The 6-bit DAC behind DOSBox's capture moves a colour by a few levels. -TOLERANCE = 8 -screen = numpy.asarray(Image.open(png).convert("RGB"), dtype=numpy.int16) -icon = numpy.asarray(Image.open(ico).convert("RGBA"), dtype=numpy.int16) -opaque = numpy.argwhere(icon[:, :, 3] >= 128) -if len(opaque) == 0: - print(f"{label}: FAIL - the icon file has no opaque pixel") - sys.exit(1) -colours = icon[opaque[:, 0], opaque[:, 1], :3] -ih, iw = icon.shape[:2] -sh, sw = screen.shape[:2] - - -def matches(colour): - return numpy.all(numpy.abs(screen - colour) <= TOLERANCE, axis=2) - - -# Anchor on the opaque pixel whose colour the screen holds least, then try -# the icon at every place that pixel's colour is. -counts = [int(matches(c).sum()) for c in colours] -anchor = min(range(len(opaque)), key=lambda i: counts[i]) -ay, ax = opaque[anchor] -found = None -for y, x in numpy.argwhere(matches(colours[anchor])): - top, left = y - ay, x - ax - if top < 0 or left < 0 or top + ih > sh or left + iw > sw: - continue - shown = screen[top + opaque[:, 0], left + opaque[:, 1]] - if numpy.all(numpy.abs(shown - colours) <= TOLERANCE): - found = (left, top) - break -if found is None: - print(f"{label}: FAIL - the program's icon is not on the screen ({len(opaque)} opaque pixels looked for)") - sys.exit(1) -print(f"{label}: PASS (DRAW minimised shows its bound icon at {found[0]},{found[1]}: all {len(opaque)} opaque pixels of the baked 32x32 in place)") -PY + "$(joeyTool iconFind)" win16 "$1" "$icon" "$label" } work=$(mktemp -d -t joeylib-win16-icon.XXXXXX) @@ -94,8 +52,8 @@ trap cleanup EXIT mkdir -p "$work/program" "$work/shots" cp "$program" "$work/program/DRAW.EXE" win16Stage "$work" "$work/program" DRAW.EXE -python3 "$repo/tools/winIni.py" "$work/c/WINDOWS/SYSTEM.INI" set boot shell progman.exe -python3 "$repo/tools/winIni.py" "$work/c/WINDOWS/WIN.INI" set windows load 'D:\DRAW.EXE' +"$(joeyTool winIni)" "$work/c/WINDOWS/SYSTEM.INI" set boot shell progman.exe +"$(joeyTool winIni)" "$work/c/WINDOWS/WIN.INI" set windows load 'D:\DRAW.EXE' WIN16_DOSBOX_ARGS=(--set "capture_dir=$work/shots" --set "default_image_capture_formats=raw") win16Start "$work" win="" diff --git a/scripts/verify-win16-input.sh b/scripts/verify-win16-input.sh index 19e3f1f..e049af5 100755 --- a/scripts/verify-win16-input.sh +++ b/scripts/verify-win16-input.sh @@ -10,7 +10,7 @@ # 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 +# verification scanline (row 198) with tests/gate/keysRow.c, wherever # Windows put the window. Then the named keys a second key gives # (scripts/lib/keysGate.sh): the keypad's arrows and Enter, and the right # Ctrl and Alt (the key messages' extended bit). A mapper file binds just @@ -19,7 +19,7 @@ # # 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). +# xdotool; build/win16/bin/KEYS.EXE (make win16). set -euo pipefail @@ -57,7 +57,7 @@ DISPLAY="$WIN16_DISPLAY" xdotool type --window "$win" --delay 150 -- "$target" sleep 2 png="$work/typed.png" dosboxShot "$WIN16_DISPLAY" "$win" "$work/shots" "$png" || { echo "$label: FAIL - DOSBox took no screenshot" >&2; exit 1; } -found=$(python3 "$repo/scripts/lib/keysRow.py" "$png" "$target" --scale 1) || { echo "$found"; echo "$label: FAIL" >&2; exit 1; } +found=$("$(joeyTool keysRow)" "$png" "$target" --scale 1) || { echo "$found"; echo "$label: FAIL" >&2; exit 1; } echo "$found" [[ "$found" =~ row\ at\ \(([0-9]+),([0-9]+)\) ]] stageAt="${BASH_REMATCH[1]},$((BASH_REMATCH[2] - 198))" @@ -73,7 +73,7 @@ keysRelease() { } keysRead() { if dosboxShot "$WIN16_DISPLAY" "$win" "$work/shots" "$work/cells.png"; then - python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" --at "$stageAt" + "$(joeyTool keysCells)" "$work/cells.png" --at "$stageAt" else echo "no screenshot" fi diff --git a/scripts/verify-win16-joy.sh b/scripts/verify-win16-joy.sh index ae6daff..b906ea8 100755 --- a/scripts/verify-win16-joy.sh +++ b/scripts/verify-win16-joy.sh @@ -27,7 +27,7 @@ # 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 +# tests/gate/joyScreen.c 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. # @@ -36,7 +36,7 @@ # # 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 +# Requires: DOSBox Staging, Xvfb, xdotool, the win311 and # kpjoy components; JOY.EXE (make win16). set -uo pipefail @@ -87,7 +87,7 @@ boot() { [ -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 + joyShot "$dir/up.png" && "$(joeyTool joyScreen)" "$dir/up.png" --scale "$JOY_SCALE" > /dev/null 2>&1 && return 0 joeyAlive "$WIN16_PID" || return 1 sleep 1 done diff --git a/scripts/verify-win16-save.sh b/scripts/verify-win16-save.sh index f76f8b4..0b336db 100755 --- a/scripts/verify-win16-save.sh +++ b/scripts/verify-win16-save.sh @@ -41,7 +41,7 @@ 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(' "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" tcp-server "$port" "$wall" --stream "$streamBytes" --rate "$baud" > "$work/peer.log" 2>&1 & peerPid=$! sleep 1 mkdir -p "$work/program" diff --git a/scripts/verify-win16-serial.sh b/scripts/verify-win16-serial.sh index 721710e..e238638 100755 --- a/scripts/verify-win16-serial.sh +++ b/scripts/verify-win16-serial.sh @@ -7,7 +7,7 @@ # # 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 +# connecting to the host peer (tests/gate/serialLoopPeer.c), 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 @@ -22,7 +22,7 @@ # # 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; +# Requires: Windows on DOSBox (install.sh install emulator:win16); # build/win16/bin/SERTEST.EXE (make win16). set -uo pipefail @@ -48,7 +48,8 @@ cleanup() { } trap cleanup EXIT -python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-server "$port" "$wall" --dtr-check > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" tcp-server "$port" "$wall" --dtr-check > "$work/peer.log" 2>&1 & peerPid=$! sleep 1 mkdir -p "$work/program" diff --git a/scripts/verify-win32-audio.sh b/scripts/verify-win32-audio.sh index 600cb1a..be7ff48 100755 --- a/scripts/verify-win32-audio.sh +++ b/scripts/verify-win32-audio.sh @@ -9,7 +9,7 @@ # 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 +# tests/gate/audioGate.c 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 @@ -21,7 +21,7 @@ # 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; +# Requires: Wine, Xvfb, xdotool, parec/pactl; # build/win32/bin/AUDIO.EXE (make win32). set -uo pipefail @@ -30,12 +30,12 @@ source "$repo/scripts/lib/joeyEnv.sh" source "$repo/scripts/lib/wineGate.sh" source "$repo/scripts/pulseRecordLib.sh" wineNeed -grader=$repo/tools/gradeAudioPhases.py +grader=$(joeyTool audioGate) || exit 1 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 +for tool in Xvfb xdotool parec pactl; 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; } @@ -71,17 +71,7 @@ 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 + "$grader" onset "$work/out.raw" "$SOUND_THRESHOLD" } SOUND_THRESHOLD=256 @@ -122,28 +112,19 @@ 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 +"$grader" wav "$work/out.raw" "$work/out.wav" 44100 "$start" -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:" \ +# Thresholds sit at about half the measured levels (music ~6150, paused 0, +# sfx ~310, resumed ~6040, noise ~3160, mod ~3980, measured after the +# 2026-10-16 mix; Wine's runs spread, so a little under half of the low +# ends, the grader averaging the two channels). +"$grader" phases "$label" "$work/out.wav" \ + "music:1:5:3000:" \ "paused:8:13::100" \ - "sfx:15:22:700:" \ - "resumed:25:29:1400:" \ - "noise:31.5:34:650:" \ - "mod:37:43:3700:" + "sfx:15:22:150:" \ + "resumed:25:29:2800:" \ + "noise:31.5:34:1200:" \ + "mod:37:43:1850:" status=$? if [ -n "${WIN32_AUDIO_KEEP:-}" ]; then cp "$work/out.wav" "$repo/build/win32/headless-audio.wav" diff --git a/scripts/verify-win32-icon.sh b/scripts/verify-win32-icon.sh index a7b5bb3..b12b5b0 100755 --- a/scripts/verify-win32-icon.sh +++ b/scripts/verify-win32-icon.sh @@ -6,15 +6,15 @@ # verify-win32-icon.sh - a Win32 program's icon on its window. # # make links every Win32 program's icon into it (make/win32.mk: -# tools/iconBake.py ico, 16, 32 and 48 pixels in 256 colours and in 32-bit, +# tools/iconBake ico, 16, 32 and 48 pixels in 256 colours and in 32-bit, # resource HOSTED_ICON_ID), and the backend gives its window class that # icon. DRAW (examples/draw) runs under Wine (scripts/lib/wineGate.sh), which # hands a window's class icon to X as its _NET_WM_ICON; that must be the -# baked 32x32 32-bit picture (scripts/lib/netWmIcon.py). +# baked 32x32 32-bit picture (tests/gate/netWmIcon.c). # # Usage: scripts/verify-win32-icon.sh # Exit: 0 PASS, 1 FAIL, 2 SKIP (Wine not installed, or DRAW not built) -# Requires: Wine, Xvfb, xdotool, xprop, python3 + PIL; DRAW.EXE and its icon +# Requires: Wine, Xvfb, xdotool, xprop; DRAW.EXE and its icon # (make win32). set -uo pipefail @@ -45,4 +45,4 @@ win=$(wineWindow DRAW) || { echo "$label: FAIL - DRAW's window never appeared" > # Wine sets the property as the window is mapped; a moment for it. sleep 2 xprop -id "$win" -f _NET_WM_ICON 32c ' = $0+' _NET_WM_ICON > "$work/icon.txt" 2>&1 -python3 "$repo/scripts/lib/netWmIcon.py" "$work/icon.txt" "$icon" "$ICON_SIZE" "$label" +"$(joeyTool netWmIcon)" "$work/icon.txt" "$icon" "$ICON_SIZE" "$label" diff --git a/scripts/verify-win32-input.sh b/scripts/verify-win32-input.sh index b80691f..51399d5 100755 --- a/scripts/verify-win32-input.sh +++ b/scripts/verify-win32-input.sh @@ -8,14 +8,14 @@ # 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. +# the example's verification scanline (row 198) with tests/gate/keysRow.c. # Then the named keys a second key gives (scripts/lib/keysGate.sh): the # keypad's arrows and Enter, and the right Ctrl and Alt (the key messages' -# extended bit), read off KEYS's cells (scripts/lib/keysCells.py). +# extended bit), read off KEYS's cells (tests/gate/keysCells.c). # # Usage: scripts/verify-win32-input.sh # Requires: Wine (install.sh install emulator:win32), Xvfb, xdotool, -# ImageMagick, python3 + PIL; build/win32/bin/KEYS.EXE +# ImageMagick; build/win32/bin/KEYS.EXE # (make win32). set -euo pipefail @@ -47,7 +47,7 @@ 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. -found=$(python3 "$repo/scripts/lib/keysRow.py" "$work/keys.png" "$target" --scale 1) || { echo "$found"; echo "$label: FAIL" >&2; exit 1; } +found=$("$(joeyTool keysRow)" "$work/keys.png" "$target" --scale 1) || { echo "$found"; echo "$label: FAIL" >&2; exit 1; } echo "$found" [[ "$found" =~ row\ at\ \(([0-9]+),([0-9]+)\) ]] stageAt="${BASH_REMATCH[1]},$((BASH_REMATCH[2] - 198))" @@ -63,7 +63,7 @@ keysRelease() { } keysRead() { import -window "$win" "$work/cells.png" - python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" --at "$stageAt" + "$(joeyTool keysCells)" "$work/cells.png" --at "$stageAt" } keysRunSteps kp8 kp2 kp4 kp6 kpenter rctrl ralt if [ -n "$KEYS_FAILS" ]; then diff --git a/scripts/verify-win32-joy.sh b/scripts/verify-win32-joy.sh index 4f2a261..0cd89aa 100755 --- a/scripts/verify-win32-joy.sh +++ b/scripts/verify-win32-joy.sh @@ -15,13 +15,13 @@ # 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 +# tests/gate/joyScreen.c (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). +# Requires: Wine, write access to /dev/uinput, Xvfb, xdotool, ImageMagick; +# build/win32/bin/JOY.EXE (make win32). set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) diff --git a/scripts/verify-win32-save.sh b/scripts/verify-win32-save.sh index fa8f7f7..7a568e8 100755 --- a/scripts/verify-win32-save.sh +++ b/scripts/verify-win32-save.sh @@ -42,7 +42,7 @@ for run in 1 2; do 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(' Wine -> the pty -> the host. # # Usage: scripts/verify-win32-serial.sh -# Requires: Wine, python3; build/win32/bin/SERTEST.EXE (make win32). +# Requires: Wine; build/win32/bin/SERTEST.EXE (make win32). set -uo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) @@ -37,7 +37,8 @@ cleanup() { trap cleanup EXIT wineStart "$work" -python3 "$repo/scripts/lib/serialLoopPeer.py" pty "$work/pty.path" "$wall" > "$work/peer.log" 2>&1 & +peer=$(joeyTool serialLoopPeer) || exit 1 +"$peer" pty "$work/pty.path" "$wall" > "$work/peer.log" 2>&1 & peerPid=$! for ((tries = 0; tries < ptyWaitTries; tries++)); do [ -s "$work/pty.path" ] && break diff --git a/scripts/verify-x68000-audio.sh b/scripts/verify-x68000-audio.sh index 05d0aea..adfd21d 100755 --- a/scripts/verify-x68000-audio.sh +++ b/scripts/verify-x68000-audio.sh @@ -61,17 +61,18 @@ source=$repo/src/x68000/audio.c # The noise codes, from the port's own constants so the two cannot drift # apart: OPM_NOISE_PERIODS - pitch, pitch 1 .. NOISE_PITCH_MAX. -table=$(python3 - "$source" <<'PY' -import re, sys -src = open(sys.argv[1]).read() -def const(name): - m = re.search(r'#define\s+' + name + r'\s+(\d+)u?', src) - if not m: - sys.exit(name + " not found") - return int(m.group(1)) -periods = const("OPM_NOISE_PERIODS") -print(",".join(str(periods - p) for p in range(1, const("NOISE_PITCH_MAX") + 1))) -PY +table=$(awk ' + $1 == "#define" && $2 == "OPM_NOISE_PERIODS" && periods == "" { periods = $3 + 0 } + $1 == "#define" && $2 == "NOISE_PITCH_MAX" && pitches == "" { pitches = $3 + 0 } + END { + if (periods == "" || pitches == "") { + exit 1 + } + for (p = 1; p <= pitches; p++) { + printf "%s%d", (p > 1 ? "," : ""), periods - p + } + print "" + }' "$source" ) || { echo "verify-x68000-audio: cannot read the noise constants from $source" >&2; exit 2; } work=$(mktemp -d -t joey-x68audio.XXXXXX) @@ -161,67 +162,93 @@ LUA [[ -f $work/audio.txt ]] || { echo "verify-x68000-audio: FAIL (MAME produced no tap output)" >&2; exit 1; } -python3 - "$work/audio.txt" "$table" "$PROG" <<'PY' -import sys -path, table, prog = sys.argv[1], sys.argv[2], sys.argv[3] -allowed = {int(v) for v in table.split(",")} -noise, portc, ct, rate, done = [], [], [], None, False -for line in open(path): - p = line.split() - if not p: - continue - if p[0] == "NOISE": - noise.append(int(p[1], 16)) - elif p[0] == "PORTC": - portc.append(int(p[1], 16)) - elif p[0] == "CT": - ct.append(int(p[1], 16)) - elif p[0] == "PCMRATE": - rate = float(p[1]) - elif p[0] == "DONE": - done = True - -fails = [] -if not done: - fails.append("the run never reached its time limit") - -# 1. The ADPCM rate and channels: the last values written win; the ROM's -# own boot value of port C (0x0B) does not count as the port's. -ours = [v for v in portc if v != 0x0B] -if not ours: - fails.append("PPI port C was never programmed, so the ADPCM never played") -else: - got = ours[-1] & 0x0F - clock = 4000000 if ct and ct[-1] & 0x80 else 8000000 - divider = (1024, 768, 512, 512)[(got >> 2) & 3] - hz = clock // divider - if hz != 7812 or got & 3: - fails.append(f"port C low nibble {got:#04x} with a {clock // 1000000} MHz clock: " - f"{hz} Hz, channels {got & 3}; want 7812 Hz on both (0)") - -# 2. Every enabled noise write must be one of the port's codes. -on = {v & 0x1F for v in noise if v & 0x80} -if not on: - fails.append("no noise writes seen at all") -stray = sorted(on - allowed) -if stray: - fails.append(f"noise codes no pitch makes: {stray}") - -# 3. Byte feed rate: two samples per byte at 7812 Hz. -if rate is None: - fails.append("the ADPCM channel was never fed") -elif not (3500.0 <= rate <= 4300.0): - fails.append(f"ADPCM in-burst feed {rate:.0f} bytes/s, want ~3906 " - f"({'half rate, the divider is wrong' if rate < 2600 else 'off rate'})") - -portcText = "/".join(f"{v:02X}" for v in ours[-2:]) if ours else "never set" -rateText = f"{rate:.0f} bytes/s in-burst" if rate is not None else "silent" -print(f"verify-x68000-audio: {prog} noise writes {len(noise)} " - f"({len(on)} distinct codes, {len(allowed - on)} of the port's unused), " - f"port C {portcText}, CT writes {len(ct)}, ADPCM {rateText}") -if fails: - for f in fails: - print(f"verify-x68000-audio: FAIL ({f})", file=sys.stderr) - sys.exit(1) -print("verify-x68000-audio: PASS") -PY +awk -v table="$table" -v prog="$PROG" ' + BEGIN { + tableCount = split(table, codes, ",") + for (i = 1; i <= tableCount; i++) { + allowed[codes[i] + 0] = 1 + } + } + $1 == "NOISE" { + noise[++noiseCount] = strtonum("0x" $2) + } + $1 == "PORTC" { + portc[++portcCount] = strtonum("0x" $2) + } + $1 == "CT" { + ct[++ctCount] = strtonum("0x" $2) + } + $1 == "PCMRATE" { + rate = $2 + 0.0 + rateSeen = 1 + } + $1 == "DONE" { + done = 1 + } + END { + if (!done) { + fails[++failCount] = "the run never reached its time limit" + } + # 1. The ADPCM rate and channels: the last values written win; the + # ROM'"'"'s own boot value of port C (0x0B) does not count as the port'"'"'s. + for (i = 1; i <= portcCount; i++) { + if (portc[i] != 11) { + ours[++oursCount] = portc[i] + } + } + if (oursCount == 0) { + fails[++failCount] = "PPI port C was never programmed, so the ADPCM never played" + } else { + got = and(ours[oursCount], 15) + clock = ctCount > 0 && and(ct[ctCount], 128) ? 4000000 : 8000000 + split("1024 768 512 512", dividers, " ") + hz = int(clock / dividers[and(rshift(got, 2), 3) + 1]) + if (hz != 7812 || and(got, 3)) { + fails[++failCount] = sprintf("port C low nibble 0x%02x with a %d MHz clock: %d Hz, channels %d; want 7812 Hz on both (0)", got, int(clock / 1000000), hz, and(got, 3)) + } + } + # 2. Every enabled noise write must be one of the port'"'"'s codes. + for (i = 1; i <= noiseCount; i++) { + if (and(noise[i], 128) && !(and(noise[i], 31) in on)) { + on[and(noise[i], 31)] = 1 + onCount++ + } + } + if (onCount == 0) { + fails[++failCount] = "no noise writes seen at all" + } + stray = "" + for (code = 0; code < 32; code++) { + if ((code in on) && !(code in allowed)) { + stray = stray (stray == "" ? "" : ", ") code + } + } + if (stray != "") { + fails[++failCount] = "noise codes no pitch makes: [" stray "]" + } + unused = 0 + for (code in allowed) { + if (!(code in on)) { + unused++ + } + } + # 3. Byte feed rate: two samples per byte at 7812 Hz. + if (!rateSeen) { + fails[++failCount] = "the ADPCM channel was never fed" + } else if (!(rate >= 3500.0 && rate <= 4300.0)) { + fails[++failCount] = sprintf("ADPCM in-burst feed %.0f bytes/s, want ~3906 (%s)", rate, rate < 2600 ? "half rate, the divider is wrong" : "off rate") + } + portcText = "never set" + if (oursCount > 0) { + portcText = (oursCount > 1 ? sprintf("%02X/", ours[oursCount - 1]) : "") sprintf("%02X", ours[oursCount]) + } + rateText = rateSeen ? sprintf("%.0f bytes/s in-burst", rate) : "silent" + printf "verify-x68000-audio: %s noise writes %d (%d distinct codes, %d of the port'"'"'s unused), port C %s, CT writes %d, ADPCM %s\n", prog, noiseCount, onCount, unused, portcText, ctCount, rateText + if (failCount > 0) { + for (i = 1; i <= failCount; i++) { + printf "verify-x68000-audio: FAIL (%s)\n", fails[i] > "/dev/stderr" + } + exit 1 + } + print "verify-x68000-audio: PASS" + }' "$work/audio.txt" diff --git a/scripts/verify-x68000-golden.sh b/scripts/verify-x68000-golden.sh index 265a546..5b952b4 100755 --- a/scripts/verify-x68000-golden.sh +++ b/scripts/verify-x68000-golden.sh @@ -185,4 +185,4 @@ x68kXdf extract "$work/data.xdf" joeylog.txt "$work/x68.txt" || { } tr -d '\r' < "$work/x68.txt" > "$work/x68.clean" cp "$work/x68.clean" "$repo/build/x68000/joeylog.txt" -"$repo/tools/diff-uber-hashes" "$GOLDEN" "$work/x68.clean" +"$(joeyTool diffUberHashes)" "$GOLDEN" "$work/x68.clean" diff --git a/scripts/verify-x68000-serial-mouse.sh b/scripts/verify-x68000-serial-mouse.sh index 0c56f98..ba74028 100755 --- a/scripts/verify-x68000-serial-mouse.sh +++ b/scripts/verify-x68000-serial-mouse.sh @@ -21,7 +21,7 @@ # bytes here as it would on the machine. Then, all at once: # serial the guest streams byte k = streamByte(k, 0xA5) and checks the # peer's stream, byte k = streamByte(k, 0x00), in order; the host -# peer (scripts/lib/serialStreamPeer.py) checks the guest's and +# peer (tests/gate/serialStreamPeer.c) checks the guest's and # answers byte for byte. The guest also raises DTR 16 times a loop, # the add-on's own select-then-write of channel A's WR5, so that # sequence runs under both channels' interrupts all the time. @@ -516,7 +516,8 @@ end) LUA } > "$work/sermouse.lua" -python3 "$repo/scripts/lib/serialStreamPeer.py" "$PORT" "$((WALL + PEER_MARGIN))" "$MIN_BYTES" \ +peerTool=$(joeyTool serialStreamPeer) || exit 1 +"$peerTool" "$PORT" "$((WALL + PEER_MARGIN))" "$MIN_BYTES" \ > "$work/peer.out" 2>&1 & peer=$! # The peer must be listening before MAME connects out to it. @@ -532,7 +533,7 @@ done cd "$work" || exit 1 if [ "$BAUD" != "$DEFAULT_BAUD" ]; then mkdir -p "$work/cfg" - python3 "$repo/scripts/lib/mameNullModemCfg.py" x68000 :rs232c:null_modem "$BAUD" \ + "$repo/scripts/lib/mameNullModemCfg.sh" x68000 :rs232c:null_modem "$BAUD" \ > "$work/cfg/x68000.cfg" || exit 2 fi JOEY_Z80SCC_RX_FIFO=$FIFO_DEPTH timeout -s KILL "$WALL" "$MAME" x68000 -bios ipl10 \ diff --git a/scripts/verify-x68000-serial.sh b/scripts/verify-x68000-serial.sh index 94ad59f..515c6d0 100755 --- a/scripts/verify-x68000-serial.sh +++ b/scripts/verify-x68000-serial.sh @@ -55,7 +55,8 @@ x68kBootDisk "$work/serial.xdf" "A:\\$PROG.X" || exit 1 x68kXdf add "$work/serial.xdf" "$repo/build/x68000/bin/$PROG.X" "$PROG.X" >/dev/null || exit 1 # Host end of the link. MAME's null_modem socket form CONNECTS, so we listen. -python3 "$repo/scripts/x68kSerialPeer.py" \ +peerTool=$(joeyTool x68kSerialPeer) || exit 1 +"$peerTool" \ --port "$PORT" --probe "$PROBE" --timeout "$MAXWAIT" \ > "$work/peer.out" 2>&1 & peer=$! @@ -66,7 +67,7 @@ sleep 2 cd "$work" || exit 1 if [ -n "$LINE" ]; then mkdir -p "$work/cfg" - python3 "$repo/scripts/lib/mameNullModemCfg.py" x68000 :rs232c:null_modem "$LINE" \ + "$repo/scripts/lib/mameNullModemCfg.sh" x68000 :rs232c:null_modem "$LINE" \ > "$work/cfg/x68000.cfg" || exit 2 fi # MAME runs until the peer has its verdict and is then stopped by PID: waiting diff --git a/scripts/x68kSerialPeer.py b/scripts/x68kSerialPeer.py deleted file mode 100755 index f2aaabe..0000000 --- a/scripts/x68kSerialPeer.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/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 -guest's SERIAL.X is up it echoes every byte it receives, so sending a probe -string and reading it back proves both directions of the link. - -The guest takes a while to appear: Human68k boots, then COMMAND.X runs -AUTOEXEC.BAT, then SERIAL.X initialises. Rather than guess, this retransmits -the probe periodically until the echo comes back or the deadline passes. -""" - -import argparse -import socket -import sys -import time - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--port", type=int, default=6800) - ap.add_argument("--probe", default="JOEYLIB-X68K") - ap.add_argument("--timeout", type=float, default=420.0) - ap.add_argument("--until", default="", - help="listen-only: keep collecting until this text arrives") - ap.add_argument("--listen-only", action="store_true", - help="never transmit; pass if ANY bytes arrive. Isolates the\n guest TX path (e.g. keyboard -> jlSerialWrite) from echo.") - args = ap.parse_args() - - probe = args.probe.encode("ascii") - deadline = time.time() + args.timeout - - srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - srv.bind(("127.0.0.1", args.port)) - srv.listen(1) - srv.settimeout(max(1.0, args.timeout)) - print(f"X68SER listening on 127.0.0.1:{args.port}") - - try: - conn, peer = srv.accept() - except socket.timeout: - print("X68SER FAIL: MAME never connected") - return 1 - print(f"X68SER connected from {peer}") - conn.settimeout(2.0) - - received = bytearray() - sent_total = 0 - last_send = 0.0 - - while time.time() < deadline: - # Retransmit periodically: the guest is not listening until SERIAL.X - # has booted and opened the port, and we cannot see when that happens. - if not args.listen_only and time.time() - last_send > 5.0: - try: - conn.sendall(probe) - sent_total += len(probe) - last_send = time.time() - except OSError as exc: - print(f"X68SER FAIL: send error {exc}") - return 1 - try: - chunk = conn.recv(256) - except socket.timeout: - continue - except OSError as exc: - print(f"X68SER FAIL: recv error {exc}") - return 1 - if not chunk: - print("X68SER FAIL: link closed by MAME") - return 1 - received += chunk - print(f"X68SER rx {len(chunk)} bytes: {chunk!r}") - if args.listen_only and args.until and args.until.encode() in received: - print(f"X68SER PASS: guest transmitted {len(received)} bytes " - f"unprompted: {bytes(received)!r}") - return 0 - if probe in received: - elapsed = args.timeout - (deadline - time.time()) - print(f"X68SER PASS: probe echoed back after {elapsed:.1f}s " - f"(sent {sent_total} bytes, received {len(received)})") - return 0 - - if args.listen_only and received: - print(f"X68SER COLLECTED {len(received)} bytes: {bytes(received)!r}") - return 0 - print(f"X68SER FAIL: timeout. sent={sent_total} received={len(received)} " - f"bytes: {bytes(received[:64])!r}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/amiga/audio.c b/src/amiga/audio.c index 768081c..b736ada 100644 --- a/src/amiga/audio.c +++ b/src/amiga/audio.c @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -42,18 +43,15 @@ extern UBYTE mt_E8Trigger; // ----- Constants ----- -// Paula's NTSC PAL period for middle-C-ish playback. PTPlayer's SFX -// API takes a hardware period (cycles per sample) rather than a -// frequency. To convert from a sample's authored Hz: period = clock / -// rate, where clock is ~3546895 (PAL) or ~3579545 (NTSC). We default -// PAL since that's what our screen mode requests, and clamp to legal -// Paula period limits. -#define PAULA_CLOCK_PAL 3546895UL +// Paula plays a sample at a period (colour clocks a sample), not a rate: +// period = clock / rate, clamped to Paula's legal periods. The clock is the +// machine's crystal, 3546895 Hz on a PAL Amiga and 3579545 Hz on an NTSC +// one, whatever its display shows; it is five times the E-clock exec +// measures at boot (paulaClock). +#define ECLOCKS_PER_COLOUR 5UL #define PAULA_PERIOD_MIN 124 // Paula HW limit #define PAULA_PERIOD_MAX 65535 -// The fastest rate Paula plays: an effect faster than this plays decimated -// (jlAudioDecimate), at its pitch. -#define PAULA_MAX_RATE ((uint16_t)(PAULA_CLOCK_PAL / PAULA_PERIOD_MIN)) +#define PERIOD_DEFAULT 428 // ~middle C, for a rate of 0 // A one-shot's head, in Chip RAM before it starts: the rest is copied while // Paula plays it (jlpAudioPlaySfx). #define SFX_HEAD_BYTES 512u @@ -68,7 +66,7 @@ extern UBYTE mt_E8Trigger; // channel (DMA auto-reloads the location/length latches at length // exhaustion, so a started voice sustains with zero CPU -- the sticky // contract falls straight out of the hardware). Pitch is the period -// register: toneHz = PAULA_CLOCK_PAL / (period * 2). Loudness is the +// register: toneHz = paulaClock() / (period * 2). Loudness is the // channel's 0..64 volume. The square must live in chip RAM; it is // AllocMem'd on first use because jlAudioVoice works whether or not the // engine started (jlpAudioShutdown frees it independently of gInstalled). @@ -156,17 +154,12 @@ static bool gWantLoopOnce = false; // honor loop=false vi static BYTE *gVoiceChip = NULL; // chip-RAM square shared by all voices static BYTE *gNoiseChip = NULL; // chip-RAM LFSR noise for channel 3 -// Paula periods for noise pitch 0..31: the sample plays at the rate every -// port gives that pitch, the YM2149's 125000 / pitch Hz (pitch 0 as 1), so -// period = 3546895 * pitch / 125000 (PAL colour clock), rounded. Pitches 0 -// to 4 ask for more than Paula's fastest rate (period 124, 28.6 kHz) and -// play there. -static const UWORD kNoisePeriod[NOISE_PITCH_MAX + 1] = { - 124, 124, 124, 124, 124, 142, 170, 199, - 227, 255, 284, 312, 341, 369, 397, 426, - 454, 482, 511, 539, 568, 596, 624, 653, - 681, 709, 738, 766, 795, 823, 851, 880 -}; +// Paula periods for noise pitch 0..31, made with the noise sample: the +// sample plays at the rate every port gives that pitch, the YM2149's +// 125000 / pitch Hz (pitch 0 as 1), so period = clock * pitch / 125000, +// rounded. Pitches 0 to 4 ask for more than Paula's fastest rate (period +// 124: 28.6 kHz on PAL, 28.9 kHz on NTSC) and play there. +static UWORD gNoisePeriod[NOISE_PITCH_MAX + 1]; typedef struct { UWORD vol; // Paula volume, 0..64 @@ -206,7 +199,9 @@ static volatile bool gTickPending = false; // a request is out static BYTE channelFree(const BYTE *candidates); static bool channelHeld(BYTE cha); static void maskNoiseChannel(void); -static UWORD periodForRate(uint16_t rateHz); +static ULONG paulaClock(void); +static UWORD paulaMaxRate(void); +static UWORD periodForRate(ULONG rateHz); static void sfxPlay(uint8_t slot, SfxStructure *sfx); static void slotSilence(uint8_t slot); static bool streamChunk(uint8_t slot); @@ -270,13 +265,27 @@ static void maskNoiseChannel(void) { } -static UWORD periodForRate(uint16_t rateHz) { - unsigned long period; +// Paula's clock (colour clocks a second), from the E-clock: exec reads it +// at boot, 709379 Hz on a PAL machine and 715909 Hz on an NTSC one. +static ULONG paulaClock(void) { + return SysBase->ex_EClockFrequency * ECLOCKS_PER_COLOUR; +} + + +// The fastest rate Paula plays: an effect faster than this plays decimated +// (jlAudioDecimate), at its pitch. +static UWORD paulaMaxRate(void) { + return (UWORD)(paulaClock() / PAULA_PERIOD_MIN); +} + + +static UWORD periodForRate(ULONG rateHz) { + ULONG period; if (rateHz == 0) { - return 428; // ~middle C, sane default + return PERIOD_DEFAULT; } - period = PAULA_CLOCK_PAL / (unsigned long)rateHz; + period = paulaClock() / rateHz; if (period < PAULA_PERIOD_MIN) { period = PAULA_PERIOD_MIN; } @@ -601,7 +610,7 @@ void jlpAudioPlaySfx(uint8_t slot, const uint8_t *sample, uint32_t length, uint1 return; } // Faster than Paula plays: every factor-th sample, at its pitch. - factor = jlAudioDecimationFactor(rateHz, PAULA_MAX_RATE); + factor = jlAudioDecimationFactor(rateHz, paulaMaxRate()); kept = (length + factor - 1u) / factor; if (kept < 2 || kept > 0x1FFFEUL) { return; // Paula sample length is 16 bits of words = 128KB max @@ -668,7 +677,7 @@ void jlpAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill, void *ctx, uin } } // Faster than Paula plays: every decimate-th sample, at its pitch. - s->decimate = jlAudioDecimationFactor(rateHz, PAULA_MAX_RATE); + s->decimate = jlAudioDecimationFactor(rateHz, paulaMaxRate()); s->phase = 0u; s->fill = fill; s->ctx = ctx; @@ -697,6 +706,8 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { int i; BYTE high; BYTE low; + ULONG clock; + ULONG period; gNoiseChip = (BYTE *)AllocMem(NOISE_BYTES * VOICE_SAMPLE_COPIES, MEMF_CHIP); if (gNoiseChip == NULL) { @@ -710,6 +721,11 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { gNoiseChip[NOISE_BYTES + i] = low; lfsr = (uint16_t)((lfsr >> 1) ^ ((lfsr & 1u) ? NOISE_LFSR_TAPS : 0u)); } + clock = paulaClock(); + for (i = 0; i <= NOISE_PITCH_MAX; i++) { + period = (clock * (ULONG)(i == 0 ? 1 : i) + AUDIO_NOISE_CLOCK_HZ / 2u) / AUDIO_NOISE_CLOCK_HZ; + gNoisePeriod[i] = (UWORD)(period < PAULA_PERIOD_MIN ? PAULA_PERIOD_MIN : period); + } } if (pitch > NOISE_PITCH_MAX) { @@ -721,7 +737,7 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { // redundant DMA set is harmless. gCust->aud[NOISE_CHANNEL].ac_ptr = (UWORD *)(gNoiseChip + (kAttenVolume[atten].quarter ? NOISE_BYTES : 0)); gCust->aud[NOISE_CHANNEL].ac_len = NOISE_BYTES / 2; // Paula length is in words - gCust->aud[NOISE_CHANNEL].ac_per = kNoisePeriod[pitch]; + gCust->aud[NOISE_CHANNEL].ac_per = gNoisePeriod[pitch]; gCust->aud[NOISE_CHANNEL].ac_vol = kAttenVolume[atten].vol; gCust->dmacon = (UWORD)(DMAF_SETCLR | DMAF_MASTER | DMAF_AUD3); } @@ -737,8 +753,6 @@ void jlpAudioNoise(uint8_t pitch, uint8_t atten) { // first, sfxPlay); prefer the tracker/voice layer OR the MOD/SFX layer // per scene, not both. void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { - ULONG period; - if (voice >= JL_AUDIO_VOICES) { return; } @@ -762,17 +776,10 @@ void jlpAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten) { gVoiceChip[3] = SWING_QUARTER_LOW; } - period = PAULA_CLOCK_PAL / ((ULONG)freqHz * 2UL); - if (period < PAULA_PERIOD_MIN) { - period = PAULA_PERIOD_MIN; - } - if (period > PAULA_PERIOD_MAX) { - period = PAULA_PERIOD_MAX; - } - + // The square is two samples a cycle. gCust->aud[voice].ac_ptr = (UWORD *)(gVoiceChip + (kAttenVolume[atten].quarter ? VOICE_SQUARE_BYTES : 0)); gCust->aud[voice].ac_len = VOICE_SQUARE_BYTES / 2; // Paula length is in words - gCust->aud[voice].ac_per = (UWORD)period; + gCust->aud[voice].ac_per = periodForRate((ULONG)freqHz * VOICE_SQUARE_BYTES); gCust->aud[voice].ac_vol = kAttenVolume[atten].vol; gCust->dmacon = (UWORD)(DMAF_SETCLR | DMAF_MASTER | (DMAF_AUD0 << voice)); } diff --git a/src/amiga/hal.c b/src/amiga/hal.c index c117f27..6da809a 100644 --- a/src/amiga/hal.c +++ b/src/amiga/hal.c @@ -864,6 +864,7 @@ void jlAmigaProbePresentPageSums(const jlSurfaceT *src, uint32_t *frontSum, uint bool jlpInit(const jlConfigT *config) { uint16_t i; + ULONG displayId; (void)config; @@ -914,15 +915,19 @@ bool jlpInit(const jlConfigT *config) { gPrevStageMaxWord[i] = (uint8_t)(SURFACE_WORDS_PER_ROW - 1u); } - // SA_DisplayID pins us to OCS PAL low-res so Intuition opens a - // real planar screen rather than an RTG substitute. SA_BitMap - // makes Intuition use OUR pre-allocated planes; CloseScreen will - // not free them -- our jlpShutdown does. - jlgScreen = OpenScreenTags(NULL, (ULONG)SA_Width, (ULONG)JL_SURFACE_WIDTH, (ULONG)SA_Height, (ULONG)JL_SURFACE_HEIGHT, (ULONG)SA_Depth, (ULONG)AMIGA_BITPLANES, (ULONG)SA_DisplayID, (ULONG)(PAL_MONITOR_ID | LORES_KEY), (ULONG)SA_DetailPen, (ULONG)0, (ULONG)SA_BlockPen, (ULONG)1, (ULONG)SA_Title, (ULONG)"JoeyLib", (ULONG)SA_Type, (ULONG)CUSTOMSCREEN, (ULONG)SA_Quiet, (ULONG)TRUE, (ULONG)SA_BitMap, (ULONG)gBitMap, TAG_DONE); + // SA_DisplayID pins us to chipset low-res in the machine's own + // standard (PAL or NTSC, as it booted), so Intuition opens a real + // planar screen rather than an RTG substitute or a promoted mode. An + // NTSC machine has no PAL modes unless its PAL monitor driver is + // mounted (and the other way round), so the other standard would fail + // to open. SA_BitMap makes Intuition use OUR pre-allocated planes; + // CloseScreen will not free them -- our jlpShutdown does. + displayId = (ULONG)(((GfxBase->DisplayFlags & PAL) != 0u ? PAL_MONITOR_ID : NTSC_MONITOR_ID) | LORES_KEY); + jlgScreen = OpenScreenTags(NULL, (ULONG)SA_Width, (ULONG)JL_SURFACE_WIDTH, (ULONG)SA_Height, (ULONG)JL_SURFACE_HEIGHT, (ULONG)SA_Depth, (ULONG)AMIGA_BITPLANES, (ULONG)SA_DisplayID, displayId, (ULONG)SA_DetailPen, (ULONG)0, (ULONG)SA_BlockPen, (ULONG)1, (ULONG)SA_Title, (ULONG)"JoeyLib", (ULONG)SA_Type, (ULONG)CUSTOMSCREEN, (ULONG)SA_Quiet, (ULONG)TRUE, (ULONG)SA_BitMap, (ULONG)gBitMap, TAG_DONE); if (jlgScreen == NULL) { screenPlanesFree(); - jlCoreSetError("Intuition could not open the 320x200 PAL screen"); + jlCoreSetError("Intuition could not open the 320x200 screen"); return false; } // Force COLOR00 to black so the overscan/border region around the @@ -1250,10 +1255,10 @@ static volatile uint16_t gFrameCount = 0; static struct Interrupt gVblIntServer; static bool gVblInstalled = false; -// The display's refresh (jlpFrameHz), measured at jlpInit: the screen asks -// for PAL, but an NTSC machine whose chips cannot do PAL scans at 60 Hz all -// the same. A PAL beam counts 312 lines a frame and an NTSC one 262, so the -// highest line the beam reaches in two frames says which. +// The display's refresh (jlpFrameHz), measured at jlpInit from the beam +// the screen really got (a monitor driver or the boot menu can change it): +// a PAL beam counts 312 lines a frame and an NTSC one 262, so the highest +// line the beam reaches in two frames says which. #define MS_PER_SECOND 1000u #define BEAM_PAL_LINES 300u #define BEAM_FRAMES 2u diff --git a/src/core/assetLoad.c b/src/core/assetLoad.c index 9ffe794..408a4c1 100644 --- a/src/core/assetLoad.c +++ b/src/core/assetLoad.c @@ -3,7 +3,7 @@ // 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 +// tools/assetbake/assetbake.c. Both formats are documented in detail // in that tool; the short version: // // .spr cross-target chunky 4bpp sprite cels, which each port turns diff --git a/src/core/spritePrecompiled.h b/src/core/spritePrecompiled.h index 266150e..40f6e1c 100644 --- a/src/core/spritePrecompiled.h +++ b/src/core/spritePrecompiled.h @@ -38,7 +38,7 @@ // -- NOT 0: pre-compiled code is machine-specific, so a .spc is validated // against the build platform exactly like a .tbk. #define SPC_HEADER_BYTES 44 -#define SPC_OFF_TARGET 4 // 1B per-machine target (see TARGET_* in assetLoad.c/assetbake.py) +#define SPC_OFF_TARGET 4 // 1B per-machine target (see TARGET_* in assetLoad.c/assetbake.c) #define SPC_OFF_HAS_PALETTE 5 // 1B #define SPC_OFF_WIDTH_TILES 6 // 1B #define SPC_OFF_HEIGHT_TILES 7 // 1B diff --git a/src/iigs/audio_full.c b/src/iigs/audio_full.c index 1b8d196..d3c1568 100644 --- a/src/iigs/audio_full.c +++ b/src/iigs/audio_full.c @@ -49,13 +49,13 @@ typedef void *Pointer; #define NTP_ENTRY_STOP 6 #define NTP_ENTRY_STREAMSOUND 24 // The word JoeyLib's build adds right after the jump table (make/support/ -// iigs/ntpIrqWindow.py). Nonzero bounds NTP's two long interrupts-off +// iigs/ntpIrqWindow.patch). Nonzero bounds NTP's two long interrupts-off // stretches: NTPstreamsound takes interrupts between the two 256-byte // halves of its first DOC fill (~2.4 ms masked otherwise), and the DOC // interrupt refills one streaming oscillator per interrupt instead of // every one that is due (~1 ms apiece). #define NTP_IRQ_WINDOW 27 -// ntpIrqWindow.py's other word: the replayer's address of its playing flag, +// ntpIrqWindow.patch's other word: the replayer's address of its playing flag, // which its stop_playing clears when a song played once ends. NTP is loaded // at the start of a bank (a 64 KB handle that cannot cross one) and // assembled for one, so that address is an offset from gNTPBase. @@ -626,7 +626,7 @@ static bool loadNTP(void) { // A song played once has ended when NTP's own playing flag (published by -// ntpIrqWindow.py at NTP_PLAYING_ADDR) is 0 -- which gNTPPlaying, set at +// ntpIrqWindow.patch at NTP_PLAYING_ADDR) is 0 -- which gNTPPlaying, set at // the start, never sees; the flag is also 0 if NTPprepare refused the // module. NTP leaves the last note's oscillators sounding when it ends a // song, so the replayer is stopped as jlAudioStopMod stops it, and armed diff --git a/tests/gate/amigaCheck.c b/tests/gate/amigaCheck.c new file mode 100644 index 0000000..4c4ea4a --- /dev/null +++ b/tests/gate/amigaCheck.c @@ -0,0 +1,559 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// amigaCheck.c - the Amiga gates' checks of a screenshot and the probes' +// logs (scripts/verify-amiga-icon.sh and verify-amiga-palette.sh). +// +// amigaCheck icon WORK STACK WBTOOL LABEL +// amigaCheck palette PNG LOG MINRATE LABEL +// +// icon: WORK holds WBLAUNCH's log (wblaunch.txt), Prog/Palprobe.info, and +// the screenshot (screen.png). Every line of the log with FAIL in it fails; +// the log must say icon.library read the icon as a tool's (type WBTOOL) of +// STACK bytes of stack and the file's image size and depth; and the +// screenshot must show the icon. The screen is Workbench's grey and the +// icon's coloured pixels lie inside it: the file's coloured pixels (index +// not 0) and the screenshot's (not grey) must have the same bounds, scaled, +// and each of the file's pixels is looked at in the middle of its cell. +// Only the grey's upper half is searched: FS-UAE prints its notices over +// the bottom. Then Palprobe's log must be in Prog/ with its last line, and +// no log in WORK: it ran in its own folder. +// +// palette: LOG must have PALPROBE's two rates (palette and SCB changes +// presented a second; the last of each the log has), each at least +// MINRATE, and the colour bands down the screenshot's middle column, top to +// bottom, black (the border) dropped, must be red, green, yellow, and red. +// +// Prints LABEL: PASS or LABEL: FAIL - the reasons, as the gate's verdict; +// exits 0 or 1, or 2 on a bad command line. + +#include +#include +#include +#include + +#include "gateArg.h" +#include "gateImage.h" +#include "gateText.h" +#include "toolBuf.h" +#include "toolFile.h" + +#define TOOL_NAME "amigaCheck" +#define FAIL_EXIT 1 +#define PATH_BYTES 4096u +#define NAME_BYTES 32u +#define SEPARATOR "; " +#define LIST_SEPARATOR ", " +// A pattern: its text, '#' standing for a run of digits (a number that +// fits a long, so of NUMBER_DIGITS_MAX digits at most). +#define PATTERN_NUMBER '#' +#define PATTERN_VALUES_MAX 8u +#define NUMBER_DIGITS_MAX 19u + +// The icon file: a 78-byte DiskObject and the struct Image's first 4 +// bytes, then its width, height, and depth (big-endian words); its planes +// follow the 20-byte struct Image, rows of whole 16-bit words. +#define INFO_SIZE_AT 82u +#define INFO_PLANES_AT 98u +#define INFO_DEPTH_MAX 8 +#define WORD_PIXELS 16 +#define WORD_BYTES 2 +#define BYTE_BITS 8 +#define TOP_BIT 0x80u +#define AMIGA_COLOURS 4u +#define GREY_STEP 2 +#define HALF 0.5 +#define ICON_PATTERN "icon type=# stack=# gadget=#x# image=#x#x#" +#define ICON_TYPE 0u +#define ICON_STACK 1u +#define ICON_IMAGE_W 4u +#define ICON_IMAGE_H 5u +#define ICON_IMAGE_DEPTH 6u +#define PALPROBE_DONE "PALPROBE: done" +#define FAIL_TEXT "FAIL" + +// The palette probe: its two rates (the third number of its line), and +// the bands it draws. +#define RATE_KINDS 2u +#define RATE_VALUE 2u +#define BAND_COUNT 4u +#define BAND_NAMES 3u +#define BAND_OTHER BAND_NAMES + +// The reasons a gate fails, "; " between them. +typedef struct FailsT { + ToolBufT text; + uint32_t count; +} FailsT; + +// A box round some pixels, and whether there are any. +typedef struct BoundsT { + long x0; + long x1; + long y0; + long y1; + bool any; +} BoundsT; + +// A Workbench icon's image: its size and depth as the file gives them, and +// each pixel's colour index (no pixels when the size is not positive). +typedef struct IconT { + long width; + long height; + long depth; + long cols; + long rows; + uint8_t *indices; +} IconT; + +static long be16(const uint8_t *p); +static void boundsAdd(BoundsT *bounds, long x, long y); +static int checkIcon(char **args); +static int checkPalette(char **args); +static void failAdd(FailsT *fails, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +static void failLine(FailsT *fails, const uint8_t *line, size_t len); +static void failSeparate(FailsT *fails); +static bool fileExists(const char *path); +static bool findPattern(const uint8_t *text, size_t len, size_t *at, const char *pattern, long *values); +static void iconRead(const char *path, IconT *icon); +static void iconShown(const char *path, const IconT *icon, const char *label, FailsT *fails); +static bool matchAt(const uint8_t *text, size_t len, size_t at, const char *pattern, long *values); +static int printVerdict(const FailsT *fails, const char *label); +static uint8_t *readText(const char *path, size_t *len); + +static const GateModeT kModes[] = { + { "icon", 4, checkIcon }, + { "palette", 4, checkPalette } +}; + +// Workbench's grey, black, white, and blue. +static const uint8_t kWorkbench[AMIGA_COLOURS][GATE_RGB_BYTES] = { { 170, 170, 170 }, { 0, 0, 0 }, { 255, 255, 255 }, { 102, 136, 187 } }; + +static const char *const kRateKinds[RATE_KINDS] = { "palette", "scb" }; +static const char *const kRatePatterns[RATE_KINDS] = { "PALPROBE: palette+present: # in # ms = #/s", "PALPROBE: scb+present: # in # ms = #/s" }; + +static const uint8_t kBandColours[BAND_NAMES][GATE_RGB_BYTES] = { { 255, 0, 0 }, { 0, 255, 0 }, { 255, 255, 0 } }; +static const char *const kBandNames[BAND_NAMES] = { "red", "green", "yellow" }; +static const uint32_t kWantBands[BAND_COUNT] = { 0u, 1u, 2u, 0u }; +static const uint8_t kBlack[GATE_RGB_BYTES] = { 0, 0, 0 }; + +static const char *const kUsage[] = { + "usage:", + " amigaCheck icon WORK STACK WBTOOL LABEL WBLAUNCH read and drew the", + " icon; Palprobe ran in Prog/", + " amigaCheck palette PNG LOG MINRATE LABEL PALPROBE's rates and bands" +}; + + +// A signed big-endian 16-bit number. +static long be16(const uint8_t *p) { + return (int16_t)(((uint32_t)p[0] << BYTE_BITS) | p[1]); +} + + +static void boundsAdd(BoundsT *bounds, long x, long y) { + bounds->x0 = (!bounds->any || x < bounds->x0) ? x : bounds->x0; + bounds->x1 = (!bounds->any || x > bounds->x1) ? x : bounds->x1; + bounds->y0 = (!bounds->any || y < bounds->y0) ? y : bounds->y0; + bounds->y1 = (!bounds->any || y > bounds->y1) ? y : bounds->y1; + bounds->any = true; +} + + +static int checkIcon(char **args) { + FailsT fails; + IconT icon; + char path[PATH_BYTES]; + uint8_t *log; + uint8_t *palLog; + long got[PATTERN_VALUES_MAX]; + size_t logLen; + size_t palLogLen; + size_t at; + size_t end; + size_t lineLen; + long wantStack; + long wbTool; + + memset(&fails, 0, sizeof(fails)); + wantStack = gateArgLong("stack", args[1]); + wbTool = gateArgLong("tool type", args[2]); + snprintf(path, sizeof(path), "%s/wblaunch.txt", args[0]); + log = readText(path, &logLen); + // Each line (a \r before its \n dropped) with FAIL in it. + for (at = 0; at < logLen; at = end + 1u) { + end = gateTextFind(log, logLen, at, "\n", 1u); + end = (end == GATE_TEXT_NOT_FOUND) ? logLen : end; + lineLen = (end > at && log[end - 1u] == '\r') ? end - at - 1u : end - at; + if (gateTextFind(log + at, lineLen, 0u, FAIL_TEXT, strlen(FAIL_TEXT)) != GATE_TEXT_NOT_FOUND) { + failLine(&fails, log + at, lineLen); + } + } + + snprintf(path, sizeof(path), "%s/Prog/Palprobe.info", args[0]); + iconRead(path, &icon); + at = 0u; + if (!findPattern(log, logLen, &at, ICON_PATTERN, got)) { + failAdd(&fails, "icon.library never read the icon"); + } else { + if (got[ICON_TYPE] != wbTool) { + failAdd(&fails, "icon type %ld, want WBTOOL (%ld)", got[ICON_TYPE], wbTool); + } + if (got[ICON_STACK] != wantStack) { + failAdd(&fails, "icon stack %ld, want %ld", got[ICON_STACK], wantStack); + } + if (got[ICON_IMAGE_W] != icon.width || got[ICON_IMAGE_H] != icon.height || got[ICON_IMAGE_DEPTH] != icon.depth) { + failAdd(&fails, "icon.library's image %ldx%ldx%ld, the file's %ldx%ldx%ld", got[ICON_IMAGE_W], got[ICON_IMAGE_H], got[ICON_IMAGE_DEPTH], icon.width, icon.height, icon.depth); + } + } + snprintf(path, sizeof(path), "%s/screen.png", args[0]); + iconShown(path, &icon, args[3], &fails); + + snprintf(path, sizeof(path), "%s/Prog/joeylog.txt", args[0]); + palLog = fileExists(path) ? readText(path, &palLogLen) : NULL; + if (palLog == NULL || gateTextFind(palLog, palLogLen, 0u, PALPROBE_DONE, strlen(PALPROBE_DONE)) == GATE_TEXT_NOT_FOUND) { + failAdd(&fails, "Palprobe's log is not in Prog/ with its last line: it did not run in its own folder"); + } + snprintf(path, sizeof(path), "%s/joeylog.txt", args[0]); + if (fileExists(path)) { + failAdd(&fails, "a joeylog.txt is in the drive's root: Palprobe ran outside its folder"); + } + if (fails.count != 0u) { + return printVerdict(&fails, args[3]); + } + printf("%s: PASS (icon.library reads a tool's icon, %ld bytes of stack, %ldx%ldx%ld, and draws every pixel of it; started from it as Workbench does, Palprobe ran in its own folder and its message came back)\n", args[3], wantStack, icon.width, icon.height, icon.depth); + return 0; +} + + +static int checkPalette(char **args) { + GateImageT img; + FailsT fails; + ToolBufT bands; + uint8_t *log; + const uint8_t *rgb; + long values[PATTERN_VALUES_MAX]; + long rates[RATE_KINDS]; + bool seen[RATE_KINDS]; + uint32_t bandIds[BAND_COUNT]; + uint32_t bandCount; + uint32_t band; + uint32_t k; + uint32_t y; + size_t logLen; + size_t at; + long minRate; + bool matched; + char name[NAME_BYTES]; + char last[NAME_BYTES]; + + memset(&fails, 0, sizeof(fails)); + memset(&bands, 0, sizeof(bands)); + minRate = gateArgLong("minimum rate", args[2]); + log = readText(args[1], &logLen); + // Each kind's rate: the last the log has. + for (k = 0; k < RATE_KINDS; k++) { + seen[k] = false; + rates[k] = 0; + for (at = 0u; findPattern(log, logLen, &at, kRatePatterns[k], values); at++) { + seen[k] = true; + rates[k] = values[RATE_VALUE]; + } + } + if (!seen[0] || !seen[1]) { + printf("%s: FAIL - the log does not have both rates (", args[3]); + for (k = 0; k < RATE_KINDS; k++) { + printf("%s%s ", (k == 0u) ? "" : LIST_SEPARATOR, kRateKinds[k]); + if (seen[k]) { + printf("%ld/s", rates[k]); + } else { + printf("missing"); + } + } + printf(")\n"); + return FAIL_EXIT; + } + + // The bands down the middle column, top to bottom, black (the border) + // dropped: each run of one colour is a band. + gateImageLoad(args[0], &img); + bandCount = 0u; + for (y = 0; y < img.height; y++) { + rgb = gateImagePixel(&img, img.width / 2u, y); + if (memcmp(rgb, kBlack, GATE_RGB_BYTES) == 0) { + continue; + } + band = BAND_OTHER; + for (k = 0; k < BAND_NAMES && band == BAND_OTHER; k++) { + band = (memcmp(rgb, kBandColours[k], GATE_RGB_BYTES) == 0) ? k : BAND_OTHER; + } + if (band != BAND_OTHER) { + snprintf(name, sizeof(name), "%s", kBandNames[band]); + } else { + snprintf(name, sizeof(name), "other (%u, %u, %u)", (unsigned)rgb[0], (unsigned)rgb[1], (unsigned)rgb[2]); + } + if (bandCount > 0u && strcmp(name, last) == 0) { + continue; + } + gateTextAdd(&bands, "%s%s", (bandCount == 0u) ? "" : LIST_SEPARATOR, name); + if (bandCount < BAND_COUNT) { + bandIds[bandCount] = band; + } + bandCount++; + memcpy(last, name, sizeof(last)); + } + for (k = 0; k < RATE_KINDS; k++) { + if (rates[k] < minRate) { + failAdd(&fails, "%s changes presented %ld/s, want at least %ld", kRateKinds[k], rates[k], minRate); + } + } + matched = bandCount == BAND_COUNT; + for (k = 0; k < BAND_COUNT && matched; k++) { + matched = bandIds[k] == kWantBands[k]; + } + if (!matched) { + failAdd(&fails, "bands %s, want red, green, yellow, red", (bandCount == 0u) ? "none" : gateTextString(&bands)); + } + if (fails.count != 0u) { + return printVerdict(&fails, args[3]); + } + printf("%s: PASS (palette changes %ld/s, SCB changes %ld/s; bands red, green, yellow, red after a palette change and a same-layout SCB change)\n", args[3], rates[0], rates[1]); + return 0; +} + + +static void failAdd(FailsT *fails, const char *fmt, ...) { + va_list args; + + failSeparate(fails); + va_start(args, fmt); + gateTextAddV(&fails->text, fmt, args); + va_end(args); +} + + +// A line of a log as a reason, its bytes as they are. +static void failLine(FailsT *fails, const uint8_t *line, size_t len) { + failSeparate(fails); + toolBufAdd(&fails->text, line, len); +} + + +// "; " before every reason but the first. +static void failSeparate(FailsT *fails) { + if (fails->count > 0u) { + toolBufAdd(&fails->text, SEPARATOR, strlen(SEPARATOR)); + } + fails->count++; +} + + +static bool fileExists(const char *path) { + FILE *f; + + f = fopen(path, "rb"); + if (f == NULL) { + return false; + } + fclose(f); + return true; +} + + +// The first place at or after *at that the pattern matches: there, in *at, +// and its numbers in values. +static bool findPattern(const uint8_t *text, size_t len, size_t *at, const char *pattern, long *values) { + for (; *at < len; (*at)++) { + if (matchAt(text, len, *at, pattern, values)) { + return true; + } + } + return false; +} + + +// The image of the icon file at path: its size and depth, and its pixels' +// colour indices, a bit of each from each plane. +static void iconRead(const char *path, IconT *icon) { + uint8_t *info; + size_t infoLen; + size_t byteAt; + long rowBytes; + long plane; + long x; + long y; + + info = readText(path, &infoLen); + if (infoLen < INFO_PLANES_AT) { + toolFail("%s is too short for an icon", path); + } + icon->width = be16(info + INFO_SIZE_AT); + icon->height = be16(info + INFO_SIZE_AT + WORD_BYTES); + icon->depth = be16(info + INFO_SIZE_AT + 2u * WORD_BYTES); + if (icon->depth > INFO_DEPTH_MAX) { + toolFail("%s has %ld planes", path, icon->depth); + } + icon->cols = (icon->width > 0 && icon->height > 0) ? icon->width : 0; + icon->rows = (icon->cols > 0) ? icon->height : 0; + icon->indices = (uint8_t *)calloc((size_t)(icon->cols * icon->rows) + 1u, 1u); + if (icon->indices == NULL) { + toolFail("out of memory"); + } + rowBytes = (icon->width + WORD_PIXELS - 1) / WORD_PIXELS * WORD_BYTES; + for (plane = 0; plane < icon->depth; plane++) { + for (y = 0; y < icon->rows; y++) { + for (x = 0; x < icon->cols; x++) { + byteAt = INFO_PLANES_AT + (size_t)(plane * rowBytes * icon->rows + y * rowBytes + x / BYTE_BITS); + if (byteAt >= infoLen) { + toolFail("%s's planes are truncated", path); + } + if (info[byteAt] & (TOP_BIT >> (x % BYTE_BITS))) { + icon->indices[y * icon->cols + x] |= (uint8_t)(1u << plane); + } + } + } + } + free(info); +} + + +// Whether the screenshot at path shows the icon: the bounds of the grey +// (looked for at every other pixel), the non-grey inside its upper half, +// and each of the icon's pixels in the middle of its cell there. Prints +// where the icon is and how many of its pixels are right; adds what is +// wrong to fails. +static void iconShown(const char *path, const IconT *icon, const char *label, FailsT *fails) { + GateImageT shot; + BoundsT grey; + BoundsT shown; + BoundsT held; + const uint8_t *pixel; + long x; + long y; + long cells; + long wrong; + uint8_t index; + double scaleX; + double scaleY; + + gateImageLoad(path, &shot); + memset(&grey, 0, sizeof(grey)); + for (y = 0; y < (long)shot.height; y += GREY_STEP) { + for (x = 0; x < (long)shot.width; x += GREY_STEP) { + if (memcmp(gateImagePixel(&shot, (uint32_t)x, (uint32_t)y), kWorkbench[0], GATE_RGB_BYTES) == 0) { + boundsAdd(&grey, x, y); + } + } + } + if (!grey.any) { + failAdd(fails, "no Workbench grey on the screenshot"); + return; + } + memset(&shown, 0, sizeof(shown)); + for (y = grey.y0; y <= (grey.y0 + grey.y1) / 2; y++) { + for (x = grey.x0; x <= grey.x1; x++) { + if (memcmp(gateImagePixel(&shot, (uint32_t)x, (uint32_t)y), kWorkbench[0], GATE_RGB_BYTES) != 0) { + boundsAdd(&shown, x, y); + } + } + } + memset(&held, 0, sizeof(held)); + for (y = 0; y < icon->rows; y++) { + for (x = 0; x < icon->cols; x++) { + if (icon->indices[y * icon->cols + x] != 0u) { + boundsAdd(&held, x, y); + } + } + } + if (!shown.any || !held.any) { + failAdd(fails, held.any ? "no icon on the screenshot" : "the icon file is empty"); + return; + } + scaleX = (double)(shown.x1 + 1 - shown.x0) / (double)(held.x1 + 1 - held.x0); + scaleY = (double)(shown.y1 + 1 - shown.y0) / (double)(held.y1 + 1 - held.y0); + wrong = 0; + for (y = held.y0; y <= held.y1; y++) { + for (x = held.x0; x <= held.x1; x++) { + index = icon->indices[y * icon->cols + x]; + if (index >= AMIGA_COLOURS) { + toolFail("the icon's pixel %ld,%ld is colour %u, past Workbench's four", x, y, (unsigned)index); + } + pixel = gateImagePixel(&shot, (uint32_t)(shown.x0 + (x - held.x0 + HALF) * scaleX), (uint32_t)(shown.y0 + (y - held.y0 + HALF) * scaleY)); + if (memcmp(pixel, kWorkbench[index], GATE_RGB_BYTES) != 0) { + wrong++; + } + } + } + cells = (held.x1 + 1 - held.x0) * (held.y1 + 1 - held.y0); + printf("%s: icon shown at %ld,%ld scaled %.2fx%.2f: %ld/%ld pixels right\n", label, shown.x0, shown.y0, scaleX, scaleY, cells - wrong, cells); + if (wrong != 0) { + failAdd(fails, "%ld of the icon's %ld pixels wrong on the screen", wrong, cells); + } + gateImageFree(&shot); +} + + +// Whether the pattern matches text at at: its characters as they are, a +// PATTERN_NUMBER a run of digits that fits a long, each number in values. +static bool matchAt(const uint8_t *text, size_t len, size_t at, const char *pattern, long *values) { + char digits[NUMBER_DIGITS_MAX + 1u]; + uint32_t count; + size_t n; + + count = 0u; + for (; *pattern != '\0'; pattern++) { + if (*pattern != PATTERN_NUMBER) { + if (at >= len || text[at] != (uint8_t)*pattern) { + return false; + } + at++; + continue; + } + n = 0u; + while (at + n < len && text[at + n] >= '0' && text[at + n] <= '9') { + n++; + } + if (n == 0u || n > NUMBER_DIGITS_MAX) { + return false; + } + memcpy(digits, text + at, n); + digits[n] = '\0'; + if (!gateArgParseLong(digits, &values[count])) { + return false; + } + count++; + at += n; + } + return true; +} + + +static int printVerdict(const FailsT *fails, const char *label) { + printf("%s: FAIL - ", label); + fwrite(fails->text.data, 1u, fails->text.len, stdout); + printf("\n"); + return FAIL_EXIT; +} + + +// A whole file; one that cannot be read is fatal. +static uint8_t *readText(const char *path, size_t *len) { + uint8_t *data; + + if (!toolReadFile(path, &data, len)) { + toolFail("cannot read %s", path); + } + return data; +} + + +int main(int argc, char **argv) { + int exitCode; + + toolFileName(TOOL_NAME); + if (!gateModeRun(kModes, GATE_COUNT_OF(kModes), argc, argv, &exitCode)) { + return gateUsage(kUsage, GATE_COUNT_OF(kUsage)); + } + return exitCode; +} diff --git a/tests/gate/audioGate.c b/tests/gate/audioGate.c new file mode 100644 index 0000000..6c87085 --- /dev/null +++ b/tests/gate/audioGate.c @@ -0,0 +1,2228 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// audioGate.c - grade the audio the audio gates record, and turn the raw +// PCM their recorders leave into WAV files. +// +// audioGate phases LABEL WAV PHASE [PHASE ...] +// audioGate conformance PORT LOG WAV [--stereo] [--skip NAME ...] [--level-tolerance DB] +// audioGate psp LABEL WAV QUIT_SECONDS +// audioGate wav RAW OUT.WAV RATE [SKIP] +// audioGate onset RAW THRESHOLD +// +// phases grades the AUDIO example through phases of known loudness +// (scripts/verify-{amiga,dos,dvx,sdl3,web,win16,win32}-audio.sh): each +// PHASE is name:startSec:endSec:minRms:maxRms, either bound left empty to +// skip it; exit 0 when every phase is inside its bounds, 1 when one is not +// or the file is unusable, 2 on a usage error. conformance grades a +// recording of AUDPAR against the audio contract +// (scripts/verify-audio-conformance.sh; the comment above confRun says +// how). psp grades what AUDIOPRB played on the PSP +// (scripts/verify-psp-audio.sh; the comment above pspRun says how). wav +// writes raw signed 16-bit stereo PCM, from byte SKIP on and cut to whole +// frames, as a WAV at RATE; onset prints the byte offset of the first +// stereo frame of raw PCM holding a sample louder than THRESHOLD, or +// nothing. +// +// The graders replace tools/gradeAudioPhases.py, +// tools/gradeAudioConformance.py, and scripts/lib/pspAudioGrade.py, and +// measure as they did, so a recording grades as it always has: numpy's +// sums, Hann window, and bin frequencies are computed in numpy's order +// (gateFft), and scipy's Welch estimate in scipy's. + +#define _DEFAULT_SOURCE + +#include +#include +#include +#include +#include +#include + +#include "gateArg.h" +#include "gateFft.h" +#include "gateText.h" +#include "gateWav.h" +#include "toolBuf.h" +#include "toolFile.h" + +#define TOOL_NAME "audioGate" +#define FAIL_EXIT 1 +#define COMMAND_ARG 1 +#define FIRST_ARG 2 + +// phases: LABEL WAV PHASE..., each phase name:start:end:minRms:maxRms. +#define PHASES_ARGS_MIN 5 +#define PHASE_FIELDS 5u +#define PHASE_NAME 0u +#define PHASE_START 1u +#define PHASE_END 2u +#define PHASE_MIN 3u +#define PHASE_MAX 4u +#define RECORDING_MIN_S 1.0 + +// psp: examples/audio/audio.c's probe timetable and streamed tone. +#define PSP_ARGS 5 +#define FRAME_HZ 60 +#define PROBE_STREAM_FIRST 30 +#define PROBE_STREAM_FRAMES 90 +#define STREAM_RATE_HZ 8000 +#define STREAM_BLOCK 256 +#define STREAM_BLOCKS 48 +#define STREAM_START_HZ 330 +#define STREAM_DROP_HZ 220 +#define STREAM_TAKE_HZ 55 +#define TAKES 3 +// The music starts when its first sample rises above ONSET_LEVEL. +#define ONSET_LEVEL 200.0 +#define MUSIC_WINDOW_START 0.05 +#define MUSIC_WINDOW_END 0.40 +#define MUSIC_RMS_MIN 1000 +#define MUSIC_RMS_MAX 8000 +// A take is measured from TAKE_WINDOW_START seconds after it is due: far +// enough in to miss the audio queue's latency, early in its fall. +#define TAKE_WINDOW_START 0.10 +#define TAKE_WINDOW_END 0.20 +#define TAKE_MIN_RMS 15000 +#define TAKE_PITCH_TOLERANCE 0.10 +#define END_SLACK 0.5 +#define CROSSINGS_PER_CYCLE 2.0 + +// conformance: gradeAudioConformance.py's constants, by its names. +#define SYNC_HZ 1000.0 +#define SYNC_BAND_HZ 60.0 +#define FRAME_S 0.010 +#define WINDOW_S 0.040 +#define SYNC_SHARE 0.5 +#define SYNC_MIN_DBFS -45.0 +#define SYNC_RUN_FRAMES 10 +#define SYNC_LAST_SHARE 0.8 +#define SYNC_SCALE_TOLERANCE 0.05 +#define MARGIN_S 0.060 +#define PITCH_LOW_HZ 40.0 +#define PITCH_HIGH_HZ 8000.0 +#define PITCH_MIN_SAMPLES 256u +#define PITCH_PAD_SHIFT 2 +#define PITCH_TOLERANCE 0.01 +#define LOG_FLOOR 1e-12 +#define LEVEL_STEP_DB 2.0 +#define LEVEL_TOLERANCE 1.0 +#define NOISE_LEVEL_TOLERANCE 1.5 +#define VOICE_MATCH_DB 3.0 +#define TONE_MATCH_DB 4.0 +#define VOICE_PITCH_ATTEN_MAX 8 +#define VOICE_ATTEN_MAX 14 +#define VOICE_OTHER_FIRST 1 +#define VOICE_OTHER_LAST 2 +#define NOISE_RATE_HZ 125000.0 +#define NOISE_TOLERANCE 0.15 +#define NOISE_FIT_LIMIT 0.45 +#define NULL_SEARCH_LOW 0.6 +#define NULL_SEARCH_HIGH 1.6 +#define NULL_LEVEL_LOW 0.2 +#define NULL_DEPTH_DB 6.0 +#define NULL_SMOOTH 5 +#define NULL_SEARCH_MIN 3u +#define NOISE_GRADED_HZ 4100.0 +#define SILENCE_DBFS -55.0 +#define SOUND_DBFS -45.0 +#define PAN_APART_DB 12.0 +#define PAN_CENTRE_DB 3.0 +#define CENTRE_CHECK_HZ 440.0 +#define NOISE_LOUDNESS_PITCH 31 +#define MOD_END_SLACK_S 0.6 +#define MOD_STOP_LOOK_S 0.2 +#define MOD_END_MAX_S 11.5 +#define MOD_LISTEN_S 1.0 +#define SFX_LATENCY_MAX 0.050 +#define ONSET_SEARCH_S 1.0 +#define PURE_BAND 0.10 +#define RAW_PURE_SHARE 0.75 +#define ONSET_BAND 0.08 +#define ONSET_SHARE 0.5 +#define MOD_LEAD_S 0.1 +#define TICK_SHARE 0.03 +#define TICK_SLACK 0.25 +#define TICK_MIN 1.0 +#define FULL_SCALE 32768.0 +#define RMS_FLOOR 1e-9 +#define LEVEL_FRAME_S 0.020 +#define SFX_SAMPLES 4000 +#define SAMPLE_FULL_SCALE 127.0 +#define BALANCE_RATE "8000" +#define MOD_VOICE_DB 0.0 +#define MOD_LEVEL_S 2.0 +#define DB_PER_DECADE 20.0 +#define POWER_DB_PER_DECADE 10.0 +#define WELCH_SEGMENT_MAX 2048u +#define MS_PER_SECOND 1000.0 +#define PERCENT 100.0 +#define PAN_LEFT 0 +#define PAN_RIGHT 2 +// Where each AUDPAR line's values are: "tone HZ", "sync HZ", +// "voice N HZ ATTEN", "noise PITCH ATTEN", "sfx N RATE HZ PAN [SAMPLES]", +// "stream N RATE HZ PAN RAW [SAMPLES]", "mod start|end|stop|loop [PLAYING]", +// "tick HZ OK CALLS MS". +#define TONE_HZ_AT 0u +#define VOICE_NUMBER_AT 0u +#define VOICE_HZ_AT 1u +#define VOICE_ATTEN_AT 2u +#define NOISE_PITCH_AT 0u +#define NOISE_ATTEN_AT 1u +#define SAMPLE_RATE_AT 1u +#define SAMPLE_HZ_AT 2u +#define SAMPLE_PAN_AT 3u +#define SFX_SAMPLES_AT 4u +#define STREAM_RAW_AT 4u +#define STREAM_SAMPLES_AT 5u +#define MOD_WHAT_AT 0u +#define MOD_PLAYING_AT 1u +#define TICK_HZ_AT 0u +#define TICK_OK_AT 1u +#define TICK_CALLS_AT 2u +#define TICK_MS_AT 3u +#define CHANNEL_MIX (-1) +#define CHANNEL_LEFT 0 +#define CHANNEL_RIGHT 1 +#define CONF_POSITIONALS 3u +// The lowest rate graded: a FRAME_S hop of at least one sample. +#define MIN_RATE_HZ 100u +#define LOG_TAG "AUDPAR " +#define LIST_FIRST_CAP 16u + +// raw PCM: signed 16-bit stereo. +#define RAW_CHANNELS 2u +#define SAMPLE_BYTES 2u +#define RAW_FRAME_BYTES (RAW_CHANNELS * SAMPLE_BYTES) +#define WAV_ARGS_MIN 5 +#define WAV_ARGS_MAX 6 +#define ONSET_ARGS 4 +#define BYTE_BITS 8u + +#define TEXT_MAX 40u + +// Samples cut out of a recording (malloc'd). +typedef struct SamplesT { + double *x; + size_t n; +} SamplesT; + +// An AUDPAR log line: "AUDPAR ". +typedef struct EventT { + double time; + char *kind; + char **values; + size_t valueCount; + size_t valueCap; +} EventT; + +typedef struct EventsT { + EventT *items; + size_t count; + size_t cap; +} EventsT; + +// A list of (first, second, value), appended to, or used as a dictionary +// keyed by (first, second) that keeps its keys in the order they came. +typedef struct EntryT { + long first; + long second; + double value; +} EntryT; + +typedef struct EntriesT { + EntryT *items; + size_t count; + size_t cap; +} EntriesT; + +// A conformance grading: the arguments, the recording, the timeline's map +// onto it, the transforms (the window's, shared by the sync and onset +// searches, and the last of each other length), and what the steps have +// measured so far. +typedef struct ConfT { + const char *port; + const char *logPath; + const char *wavPath; + const char **skips; + size_t skipCount; + size_t skipCap; + double levelTolerance; + bool stereo; + uint32_t passed; + uint32_t graded; + uint32_t failed; + ToolBufT name; + double *data; + double *mono; + size_t frames; + uint32_t channels; + uint32_t rate; + EventsT events; + double t0; + double scale; + size_t window; + size_t hop; + GateFftT windowFft; + GateFftT pitchFft; + GateFftT purityFft; + GateFftT welchFft; + double *taper; + double *freqs; + double *bins; + double *scratch; + double lag; + bool havePlainPure; + double plainPure; + EntriesT toneLevels; + EntriesT voiceLevels; + EntriesT noiseLevels; + EntriesT noiseCentroids; + bool haveBalanceVoice; + double balanceVoice; + EntriesT balanceSfx; + bool haveBalanceMod; + double balanceMod; +} ConfT; + +typedef int (*CommandFnT)(int argc, char **argv); + +// A subcommand. +typedef struct CommandT { + const char *name; + CommandFnT run; +} CommandT; + +static void *arrayRoom(void *items, size_t count, size_t *cap, size_t size); +static void bandOf(const double *freqs, size_t bins, double low, double high, bool inclusive, size_t *lo, size_t *hi); +static void confArgs(int argc, char **argv, ConfT *c); +static void confCentred(ConfT *c, double start, double end, const char *what); +static double confCentroid(ConfT *c, const double *x, size_t n); +static void confCheck(ConfT *c, bool ok, const char *fmt, ...) __attribute__((format(printf, 3, 4))); +static SamplesT confCut(const ConfT *c, double start, double end, int channel, double shift); +static double confDbfs(const double *x, size_t n); +static void confEventLine(const char *line, size_t len, EventsT *events); +static void confEvents(const char *text, size_t len, EventsT *events); +static double confFloat(const ConfT *c, const EventT *e, size_t at); +static void confHann(double *window, size_t n); +static long confInt(const ConfT *c, const EventT *e, size_t at); +static double confLoudest(const ConfT *c, double start, double end, bool steady, double shift); +static double confMeasure(const ConfT *c, double start, double end, int channel, double shift, bool steady); +static double confMedian(const double *values, size_t n); +static void confMod(ConfT *c, double start); +static void confModStop(ConfT *c, double start); +static void confName(ConfT *c, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +static void confNoise(ConfT *c, double start, double end, const EventT *e); +static void confNoiseLevels(ConfT *c); +static bool confNoiseRate(ConfT *c, const double *x, size_t n, double expected, double *got); +static bool confOnset(ConfT *c, double start, double hz, double *found); +static double confPitch(ConfT *c, const double *x, size_t n); +static double confPurity(ConfT *c, const double *x, size_t n, double hz); +static int confRun(int argc, char **argv); +static void confSample(ConfT *c, double start, const EventT *e); +static void confSilence(ConfT *c, double start, double end); +static double confSteady(const ConfT *c, const double *x, size_t n); +static void confSummary(ConfT *c); +static bool *confSyncFlags(ConfT *c, size_t *count); +static bool confSyncOnset(const bool *flags, size_t count, long from, long *at); +static void confTick(ConfT *c, const EventT *e); +static void confTone(ConfT *c, double start, double end, const EventT *e); +static const char *confValue(const ConfT *c, const EventT *e, size_t at); +static void confVoice(ConfT *c, double start, double end, const EventT *e); +static size_t confWelch(ConfT *c, const double *x, size_t n, double **freqs, double **psd); +static void entriesAdd(EntriesT *list, long first, long second, double value); +static EntryT *entriesFind(EntriesT *list, long first, long second); +static void entriesSet(EntriesT *list, long first, long second, double value); +static void fftFor(GateFftT *fft, size_t n); +static double *freqsOf(size_t n, uint32_t rate); +static void lineAdd(ToolBufT *lines, const char *label, const char *fmt, ...) __attribute__((format(printf, 3, 4))); +static void *memoryFor(size_t count, size_t size); +static bool monoRead(const char *path, double **samples, size_t *count, uint32_t *rate, ToolBufT *problem); +static bool monoRms(const double *samples, size_t count, uint32_t rate, double startSec, double endSec, double *level); +static int onsetRun(int argc, char **argv); +static bool optionValue(int argc, char **argv, int *a, const char *name, const char **value); +static int phasesRun(int argc, char **argv); +static double pspCrossings(const double *samples, size_t count, uint32_t rate, double start, double end); +static int pspRun(int argc, char **argv); +static double pspTakeHz(int take, double seconds); +static uint8_t *rawRead(const char *path, size_t *len); +static void spanOf(int64_t a, int64_t b, size_t len, size_t *lo, size_t *hi); +static int64_t truncated(double value); +static void usageFail(const char *fmt, ...) __attribute__((noreturn, format(printf, 1, 2))); +static int wavRun(int argc, char **argv); + +// AUDPAR's kSineCycle: its sound effects' sine, a cycle of 8 samples. +static const int32_t kSineCycle[] = { 0, 71, 100, 71, 0, -71, -100, -71 }; + +// The pans an effect is played at, by number. +static const char *const kPanNames[] = { "left", "centre", "right" }; + +static const CommandT kCommands[] = { + { "phases", phasesRun }, + { "conformance", confRun }, + { "psp", pspRun }, + { "wav", wavRun }, + { "onset", onsetRun } +}; + +static const char kUsage[] = "usage: audioGate phases LABEL WAV PHASE... (PHASE: name:startSec:endSec:minRms:maxRms)\n audioGate conformance PORT LOG WAV [--stereo] [--skip NAME]... [--level-tolerance DB]\n audioGate psp LABEL WAV QUIT_SECONDS\n audioGate wav RAW OUT.WAV RATE [SKIP]\n audioGate onset RAW THRESHOLD\n"; + + + +// Room for one more item in a growing array: the array, reallocated when +// it is full. +static void *arrayRoom(void *items, size_t count, size_t *cap, size_t size) { + void *grown; + size_t want; + + if (count < *cap) { + return items; + } + want = *cap == 0u ? LIST_FIRST_CAP : *cap * 2u; + grown = realloc(items, want * size); + if (grown == NULL) { + toolFail("out of memory"); + } + *cap = want; + return grown; +} + + +// The bins a numpy mask over ascending frequencies picks, between low and +// high (inclusive or not): [lo, hi). +static void bandOf(const double *freqs, size_t bins, double low, double high, bool inclusive, size_t *lo, size_t *hi) { + size_t k; + bool inside; + bool found; + + *lo = 0u; + *hi = 0u; + found = false; + for (k = 0u; k < bins; k++) { + inside = inclusive ? freqs[k] >= low && freqs[k] <= high : freqs[k] > low && freqs[k] < high; + if (inside) { + *lo = found ? *lo : k; + *hi = k + 1u; + found = true; + } + } +} + + +// PORT LOG WAV, with the options anywhere among them: --stereo, --skip +// NAME (once for each name), --level-tolerance DB (both also as +// --name=VALUE), -h or --help. Anything else is a usage error. +static void confArgs(int argc, char **argv, ConfT *c) { + const char *positionals[CONF_POSITIONALS]; + const char *arg; + const char *value; + size_t positionalCount; + int a; + + positionalCount = 0u; + c->levelTolerance = LEVEL_TOLERANCE; + for (a = FIRST_ARG; a < argc; a++) { + arg = argv[a]; + if (strcmp(arg, "-h") == 0 || strcmp(arg, "--help") == 0) { + fputs(kUsage, stdout); + exit(0); + } + if (strcmp(arg, "--stereo") == 0) { + c->stereo = true; + } else if (optionValue(argc, argv, &a, "--skip", &value)) { + c->skips = (const char **)arrayRoom((void *)c->skips, c->skipCount, &c->skipCap, sizeof(const char *)); + c->skips[c->skipCount++] = value; + } else if (optionValue(argc, argv, &a, "--level-tolerance", &value)) { + c->levelTolerance = gateArgDouble("level tolerance", value); + } else if (arg[0] == '-') { + usageFail("unknown option %s", arg); + } else if (positionalCount < CONF_POSITIONALS) { + positionals[positionalCount++] = arg; + } else { + usageFail("one argument too many: %s", arg); + } + } + if (positionalCount < CONF_POSITIONALS) { + usageFail("conformance needs PORT LOG WAV"); + } + c->port = positionals[0]; + c->logPath = positionals[1]; + c->wavPath = positionals[2]; +} + + +// Each channel's level for a step: within PAN_CENTRE_DB of each other. +static void confCentred(ConfT *c, double start, double end, const char *what) { + double left; + double right; + + left = confMeasure(c, start, end, CHANNEL_LEFT, 0.0, false); + right = confMeasure(c, start, end, CHANNEL_RIGHT, 0.0, false); + confName(c, "%s centred", what); + confCheck(c, fabs(left - right) <= PAN_CENTRE_DB, "left %.1f dBFS, right %.1f dBFS", left, right); +} + + +// The spectrum's centroid (Welch). +static double confCentroid(ConfT *c, const double *x, size_t n) { + double *freqs; + double *psd; + double *weighted; + double total; + double centroid; + size_t bins; + size_t k; + + bins = confWelch(c, x, n, &freqs, &psd); + weighted = (double *)memoryFor(bins, sizeof(double)); + for (k = 0u; k < bins; k++) { + weighted[k] = freqs[k] * psd[k]; + } + total = gateSum(psd, bins); + centroid = gateSum(weighted, bins) / (total > LOG_FLOOR ? total : LOG_FLOOR); + free(weighted); + free(freqs); + free(psd); + return centroid; +} + + +// The check confName named: PASS or FAIL with its detail, or SKIP when the +// port skips it (a --skip NAME its name starts with). +static void confCheck(ConfT *c, bool ok, const char *fmt, ...) { + va_list args; + const char *name; + const char *verdict; + size_t i; + + name = gateTextString(&c->name); + verdict = NULL; + for (i = 0u; i < c->skipCount && verdict == NULL; i++) { + if (strncmp(name, c->skips[i], strlen(c->skips[i])) == 0) { + verdict = "SKIP"; + } + } + if (verdict == NULL) { + c->graded++; + c->passed += ok ? 1u : 0u; + c->failed += ok ? 0u : 1u; + verdict = ok ? "PASS" : "FAIL"; + } + printf("%s %s: ", verdict, name); + va_start(args, fmt); + vprintf(fmt, args); + va_end(args); + putchar('\n'); +} + + +// A step's samples on the recording (the mix, or one channel), +// MARGIN_S in from each end, shifted by shift seconds. +static SamplesT confCut(const ConfT *c, double start, double end, int channel, double shift) { + SamplesT out; + int64_t a; + int64_t b; + size_t lo; + size_t hi; + size_t i; + + if (channel != CHANNEL_MIX && (uint32_t)channel >= c->channels) { + toolFail("%s: --stereo, but it has %u channel", c->wavPath, (unsigned)c->channels); + } + a = truncated((c->t0 + start * c->scale + shift + MARGIN_S) * (double)c->rate); + b = truncated((c->t0 + end * c->scale + shift - MARGIN_S) * (double)c->rate); + spanOf(a, b, c->frames, &lo, &hi); + out.n = hi - lo; + out.x = (double *)memoryFor(out.n, sizeof(double)); + for (i = 0u; i < out.n; i++) { + out.x[i] = channel == CHANNEL_MIX ? c->mono[lo + i] : c->data[(lo + i) * c->channels + (uint32_t)channel]; + } + return out; +} + + +// A level in dBFS without DC: a DAC resting off centre (the ST's YM) is +// silence. +static double confDbfs(const double *x, size_t n) { + double *squares; + double mean; + double rms; + double d; + size_t i; + + rms = 0.0; + if (n > 0u) { + squares = (double *)memoryFor(n, sizeof(double)); + mean = gateMean(x, n); + for (i = 0u; i < n; i++) { + d = x[i] - mean; + squares[i] = d * d; + } + rms = sqrt(gateMean(squares, n)); + free(squares); + } + return DB_PER_DECADE * log10((rms > RMS_FLOOR ? rms : RMS_FLOOR) / FULL_SCALE); +} + + +// An AUDPAR line, "AUDPAR ": the first "AUDPAR " that +// digits, a space, and a word follow; the rest of the line split on white +// space. +static void confEventLine(const char *line, size_t len, EventsT *events) { + EventT *e; + char *digits; + char *value; + size_t tagLen; + size_t at; + size_t numberEnd; + size_t wordEnd; + size_t valueEnd; + + tagLen = strlen(LOG_TAG); + for (at = 0u; at + tagLen <= len; at++) { + if (memcmp(line + at, LOG_TAG, tagLen) != 0) { + continue; + } + numberEnd = at + tagLen; + while (numberEnd < len && isdigit((unsigned char)line[numberEnd])) { + numberEnd++; + } + if (numberEnd == at + tagLen || numberEnd >= len || line[numberEnd] != ' ') { + continue; + } + wordEnd = numberEnd + 1u; + while (wordEnd < len && (isalnum((unsigned char)line[wordEnd]) || line[wordEnd] == '_')) { + wordEnd++; + } + if (wordEnd == numberEnd + 1u) { + continue; + } + events->items = (EventT *)arrayRoom(events->items, events->count, &events->cap, sizeof(EventT)); + e = &events->items[events->count++]; + memset(e, 0, sizeof(*e)); + digits = strndup(line + at + tagLen, numberEnd - at - tagLen); + e->kind = strndup(line + numberEnd + 1u, wordEnd - numberEnd - 1u); + if (digits == NULL || e->kind == NULL) { + toolFail("out of memory"); + } + e->time = strtod(digits, NULL) / MS_PER_SECOND; + free(digits); + for (at = wordEnd; at < len; at = valueEnd) { + if (isspace((unsigned char)line[at])) { + valueEnd = at + 1u; + continue; + } + valueEnd = at; + while (valueEnd < len && !isspace((unsigned char)line[valueEnd])) { + valueEnd++; + } + value = strndup(line + at, valueEnd - at); + if (value == NULL) { + toolFail("out of memory"); + } + e->values = (char **)arrayRoom(e->values, e->valueCount, &e->valueCap, sizeof(char *)); + e->values[e->valueCount++] = value; + } + return; + } +} + + +// Every AUDPAR line of the log. +static void confEvents(const char *text, size_t len, EventsT *events) { + size_t at; + size_t end; + + for (at = 0u; at < len; at = end + 1u) { + end = at; + while (end < len && text[end] != '\n') { + end++; + } + confEventLine(text + at, end - at, events); + } +} + + +// A log value as a number. +static double confFloat(const ConfT *c, const EventT *e, size_t at) { + double value; + + if (!gateArgParseDouble(confValue(c, e, at), &value)) { + toolFail("%s: AUDPAR %s at %.3f s: '%s' is not a number", c->logPath, e->kind, e->time, e->values[at]); + } + return value; +} + + +// scipy's get_window('hann', n): periodic, 0.5 + 0.5 cos over +// linspace(-pi, pi, n + 1) with its last point dropped. +static void confHann(double *window, size_t n) { + double step; + size_t i; + + if (n == 1u) { + window[0] = 1.0; + return; + } + step = (M_PI - -M_PI) / (double)n; + for (i = 0u; i < n; i++) { + window[i] = 0.5 + 0.5 * cos((double)i * step + -M_PI); + } +} + + +// A log value as a whole number. +static long confInt(const ConfT *c, const EventT *e, size_t at) { + long value; + + if (!gateArgParseLong(confValue(c, e, at), &value)) { + toolFail("%s: AUDPAR %s at %.3f s: '%s' is not a whole number", c->logPath, e->kind, e->time, e->values[at]); + } + return value; +} + + +// A step's level in its louder channel on a stereo port, else +// the mix's. +static double confLoudest(const ConfT *c, double start, double end, bool steady, double shift) { + double left; + double right; + + if (!c->stereo) { + return confMeasure(c, start, end, CHANNEL_MIX, shift, steady); + } + left = confMeasure(c, start, end, CHANNEL_LEFT, shift, steady); + right = confMeasure(c, start, end, CHANNEL_RIGHT, shift, steady); + return right > left ? right : left; +} + + +// The level of a cut: dbfs(), or steadyDbfs(). +static double confMeasure(const ConfT *c, double start, double end, int channel, double shift, bool steady) { + SamplesT x; + double level; + + x = confCut(c, start, end, channel, shift); + level = steady ? confSteady(c, x.x, x.n) : confDbfs(x.x, x.n); + free(x.x); + return level; +} + + +// numpy.median. +static double confMedian(const double *values, size_t n) { + double *sorted; + double middle; + double swap; + size_t i; + size_t j; + + sorted = (double *)memoryFor(n, sizeof(double)); + memcpy(sorted, values, n * sizeof(double)); + for (i = 1u; i < n; i++) { + for (j = i; j > 0u && sorted[j - 1u] > sorted[j]; j--) { + swap = sorted[j]; + sorted[j] = sorted[j - 1u]; + sorted[j - 1u] = swap; + } + } + middle = n % 2u != 0u ? sorted[n / 2u] : (sorted[n / 2u - 1u] + sorted[n / 2u]) / 2.0; + free(sorted); + return middle; +} + + +// A module that does not loop: it sounds, ends, and is reported ended +// within MOD_END_SLACK_S of its sound stopping; its level weighed against +// a voice's. +static void confMod(ConfT *c, double start) { + const EventT *endEvent; + const EventT *e; + SamplesT x; + double endAt; + double until; + double lastLoud; + double soundEnd; + size_t loudCount; + size_t lastIndex; + size_t i; + bool stillPlaying; + bool sounded; + + endEvent = NULL; + for (i = 0u; i < c->events.count && endEvent == NULL; i++) { + e = &c->events.items[i]; + if (strcmp(e->kind, "mod") == 0 && e->valueCount > MOD_WHAT_AT && strcmp(e->values[MOD_WHAT_AT], "end") == 0) { + endEvent = e; + } + } + if (endEvent == NULL) { + confName(c, "mod"); + confCheck(c, false, "no mod end in the log"); + return; + } + endAt = endEvent->time; + c->balanceMod = confLoudest(c, start, start + MOD_LEVEL_S < endAt ? start + MOD_LEVEL_S : endAt, true, 0.0); + c->haveBalanceMod = true; + stillPlaying = endEvent->valueCount > MOD_PLAYING_AT && strcmp(endEvent->values[MOD_PLAYING_AT], "0") != 0; + // Listened to a second past the end, or up to whatever sounds next. + until = endAt + MOD_LISTEN_S; + for (i = 0u; i < c->events.count; i++) { + e = &c->events.items[i]; + if (e->time > endAt && strcmp(e->kind, "silence") != 0 && e->time < until) { + until = e->time; + } + } + x = confCut(c, start, until, CHANNEL_MIX, 0.0); + loudCount = 0u; + lastIndex = 0u; + sounded = false; + for (i = 0u; i + c->hop < x.n; i += c->hop) { + if (confDbfs(x.x + i, c->hop) > SOUND_DBFS) { + loudCount++; + lastIndex = i / c->hop; + sounded = true; + } + } + free(x.x); + lastLoud = sounded ? (double)(lastIndex + 1u) * FRAME_S : 0.0; + soundEnd = start + (MARGIN_S + lastLoud) / c->scale; + confName(c, "mod sounds"); + confCheck(c, sounded, "%.1f s of it above %.0f dBFS", (double)loudCount * FRAME_S, SOUND_DBFS); + confName(c, "mod ends"); + confCheck(c, !stillPlaying && endAt - start < MOD_END_MAX_S, "jlAudioIsPlayingMod false %.2f s after the start", endAt - start); + confName(c, "mod end reported"); + confCheck(c, -MOD_LEAD_S <= endAt - soundEnd && endAt - soundEnd <= MOD_END_SLACK_S, "its sound stopped at %.2f s, reported at %.2f s", soundEnd - start, endAt - start); +} + + +// A looping module: it sounds until jlAudioStopMod, and is silent from +// SFX_LATENCY_MAX after. +static void confModStop(ConfT *c, double start) { + double before; + double after; + + before = confMeasure(c, start - MOD_STOP_LOOK_S, start, CHANNEL_MIX, 0.0, false); + after = confMeasure(c, start + SFX_LATENCY_MAX, start + SFX_LATENCY_MAX + MOD_STOP_LOOK_S, CHANNEL_MIX, 0.0, true); + confName(c, "mod sounds until its stop"); + confCheck(c, before > SOUND_DBFS, "%.1f dBFS before jlAudioStopMod", before); + confName(c, "mod stops"); + confCheck(c, after < SILENCE_DBFS, "%.1f dBFS from %.0f ms after jlAudioStopMod", after, SFX_LATENCY_MAX * MS_PER_SECOND); +} + + +// The name of the next check. +static void confName(ConfT *c, const char *fmt, ...) { + va_list args; + + c->name.len = 0u; + va_start(args, fmt); + gateTextAddV(&c->name, fmt, args); + va_end(args); +} + + +// A noise step: the first at a pitch is heard (its rate read off its +// spectral null where that is graded, its centroid kept for the darkening); +// every one's level kept for the attenuation steps. +static void confNoise(ConfT *c, double start, double end, const EventT *e) { + SamplesT x; + long pitch; + long atten; + double level; + double expected; + double got; + + pitch = confInt(c, e, NOISE_PITCH_AT); + atten = confInt(c, e, NOISE_ATTEN_AT); + x = confCut(c, start, end, CHANNEL_MIX, 0.0); + level = confSteady(c, x.x, x.n); + if (atten == 0 && entriesFind(&c->noiseLevels, pitch, 0) == NULL) { + expected = NOISE_RATE_HZ / (double)(pitch > 1 ? pitch : 1); + entriesAdd(&c->noiseCentroids, pitch, 0, confCentroid(c, x.x, x.n)); + if (level <= SOUND_DBFS) { + confName(c, "noise %ld sounds", pitch); + confCheck(c, false, "%.1f dBFS: it does not hold for the step", level); + } else if (expected <= NOISE_GRADED_HZ) { + confName(c, "noise %ld rate", pitch); + if (confNoiseRate(c, x.x, x.n, expected, &got)) { + confCheck(c, fabs(got / expected - 1.0) <= NOISE_TOLERANCE, "%.0f Hz, %.0f asked, at %.1f dBFS", got, expected, level); + } else { + confCheck(c, false, "no null near %.0f Hz, at %.1f dBFS", expected, level); + } + } else { + confName(c, "noise %ld sounds", pitch); + confCheck(c, true, "%.1f dBFS (its rate, %.0f Hz, is graded by the darkening below)", level, expected); + } + } + entriesSet(&c->noiseLevels, pitch, atten, level); + free(x.x); +} + + +// The noise's attenuation steps, at the last pitch that had any: each +// 2 dB a step below attenuation 0, in rising order. +static void confNoiseLevels(ConfT *c) { + EntryT *base; + EntryT *step; + long *attens; + long pitch; + long swap; + size_t count; + size_t i; + size_t j; + bool found; + double drop; + double tolerance; + + pitch = 0; + found = false; + for (i = 0u; i < c->noiseLevels.count; i++) { + if (c->noiseLevels.items[i].second > 0) { + pitch = c->noiseLevels.items[i].first; + found = true; + } + } + base = found ? entriesFind(&c->noiseLevels, pitch, 0) : NULL; + if (base == NULL) { + return; + } + attens = (long *)memoryFor(c->noiseLevels.count, sizeof(long)); + count = 0u; + for (i = 0u; i < c->noiseLevels.count; i++) { + if (c->noiseLevels.items[i].first == pitch && c->noiseLevels.items[i].second > 0) { + attens[count++] = c->noiseLevels.items[i].second; + } + } + for (i = 1u; i < count; i++) { + for (j = i; j > 0u && attens[j - 1u] > attens[j]; j--) { + swap = attens[j]; + attens[j] = attens[j - 1u]; + attens[j - 1u] = swap; + } + } + tolerance = NOISE_LEVEL_TOLERANCE > c->levelTolerance ? NOISE_LEVEL_TOLERANCE : c->levelTolerance; + for (i = 0u; i < count; i++) { + step = entriesFind(&c->noiseLevels, pitch, attens[i]); + drop = base->value - step->value; + confName(c, "noise level %ld", attens[i]); + confCheck(c, fabs(drop - LEVEL_STEP_DB * (double)attens[i]) <= tolerance, "%.1f dB below attenuation 0, %.0f asked", drop, LEVEL_STEP_DB * (double)attens[i]); + } + free(attens); +} + + +// The noise's sample-and-hold rate, read off its first +// spectral null: noise held at R Hz has a sinc^2 spectrum that first falls +// to nothing at R. Looked for between NULL_SEARCH_LOW and NULL_SEARCH_HIGH +// of the rate asked, and only a real dip counts -- NULL_DEPTH_DB below the +// spectrum's level under it -- so a wrong rate shows as a null in the wrong +// place or as none. (Fitting the whole spectrum was thrown by the images a +// chip's own output rate leaves above it: the IIgs's DOC runs at +// 26.3 kHz.) False when there is no such null. +static bool confNoiseRate(ConfT *c, const double *x, size_t n, double expected, double *got) { + double *freqs; + double *psd; + double *level; + double sum; + double highest; + double top; + size_t bins; + size_t searchLo; + size_t searchHi; + size_t belowLo; + size_t belowHi; + size_t dip; + size_t k; + long j; + long at; + bool found; + + bins = confWelch(c, x, n, &freqs, &psd); + level = (double *)memoryFor(bins, sizeof(double)); + // numpy.convolve(psd, ones(5) / 5, 'same'): each bin the products + // summed in order, the kernel's ends hanging off the edges. + for (k = 0u; k < bins; k++) { + sum = 0.0; + for (j = 0; j < NULL_SMOOTH; j++) { + at = (long)k - NULL_SMOOTH / 2 + j; + if (at >= 0 && (size_t)at < bins) { + sum += psd[at] * (1.0 / (double)NULL_SMOOTH); + } + } + level[k] = POWER_DB_PER_DECADE * log10(sum + LOG_FLOOR); + } + top = NULL_SEARCH_HIGH * expected < NOISE_FIT_LIMIT * (double)c->rate ? NULL_SEARCH_HIGH * expected : NOISE_FIT_LIMIT * (double)c->rate; + bandOf(freqs, bins, NULL_SEARCH_LOW * expected, top, true, &searchLo, &searchHi); + bandOf(freqs, bins, NULL_LEVEL_LOW * expected, NULL_SEARCH_LOW * expected, true, &belowLo, &belowHi); + found = false; + if (searchHi - searchLo >= NULL_SEARCH_MIN && belowHi > belowLo) { + dip = searchLo; + for (k = searchLo + 1u; k < searchHi; k++) { + dip = level[k] < level[dip] ? k : dip; + } + highest = level[belowLo]; + for (k = belowLo + 1u; k < belowHi; k++) { + highest = level[k] > highest ? level[k] : highest; + } + if (dip != searchLo && dip != searchHi - 1u && highest - level[dip] >= NULL_DEPTH_DB) { + *got = freqs[dip]; + found = true; + } + } + free(level); + free(freqs); + free(psd); + return found; +} + + +// Seconds from a step's log time to a sound at hz (its band +// holding ONSET_SHARE of a loud window's power); false when none comes in +// ONSET_SEARCH_S. The tail of whatever came before is a different pitch +// and does not count. +static bool confOnset(ConfT *c, double start, double hz, double *found) { + const double *frame; + int64_t a; + int64_t limit; + int64_t i; + double mean; + double total; + size_t lo; + size_t hi; + size_t bins; + size_t k; + + bins = c->window / 2u + 1u; + bandOf(c->freqs, bins, hz * (1.0 - ONSET_BAND), hz * (1.0 + ONSET_BAND), false, &lo, &hi); + a = truncated((c->t0 + start * c->scale) * (double)c->rate); + limit = a + truncated(ONSET_SEARCH_S * (double)c->rate); + limit = limit < (int64_t)c->frames ? limit : (int64_t)c->frames; + for (i = a; i < limit - (int64_t)c->window; i += (int64_t)c->hop) { + frame = c->mono + i; + mean = gateMean(frame, c->window); + for (k = 0u; k < c->window; k++) { + c->scratch[k] = (frame[k] - mean) * c->taper[k]; + } + gateFftAbs(&c->windowFft, c->scratch, c->window, c->bins); + for (k = 0u; k < bins; k++) { + c->bins[k] *= c->bins[k]; + } + total = gateSum(c->bins, bins); + if (confDbfs(frame, c->window) > SOUND_DBFS && gateSum(c->bins + lo, hi - lo) > ONSET_SHARE * total) { + *found = (double)(i - a) / (double)c->rate; + return true; + } + } + return false; +} + + +// The strongest frequency in PITCH_LOW_HZ..PITCH_HIGH_HZ, +// refined between bins. +static double confPitch(ConfT *c, const double *x, size_t n) { + double *shaped; + double *spectrum; + double *freqs; + double mean; + double a; + double b; + double d; + double offset; + size_t size; + size_t bins; + size_t lo; + size_t hi; + size_t peak; + size_t k; + + if (n < PITCH_MIN_SAMPLES) { + return 0.0; + } + size = (size_t)1u << ((int)log2((double)n) + PITCH_PAD_SHIFT); + bins = size / 2u + 1u; + shaped = (double *)memoryFor(n, sizeof(double)); + spectrum = (double *)memoryFor(bins, sizeof(double)); + freqs = freqsOf(size, c->rate); + gateHanning(shaped, n); + mean = gateMean(x, n); + for (k = 0u; k < n; k++) { + shaped[k] = (x[k] - mean) * shaped[k]; + } + fftFor(&c->pitchFft, size); + gateFftAbs(&c->pitchFft, shaped, n, spectrum); + // The rate is at least MIN_RATE_HZ (confRun): bins fall in the band. + bandOf(freqs, bins, PITCH_LOW_HZ, PITCH_HIGH_HZ, true, &lo, &hi); + peak = lo; + for (k = lo + 1u; k < hi; k++) { + peak = spectrum[k] > spectrum[peak] ? k : peak; + } + offset = 0.0; + if (peak > 0u && peak < bins - 1u) { + a = log(spectrum[peak - 1u] + LOG_FLOOR); + b = log(spectrum[peak] + LOG_FLOOR); + d = log(spectrum[peak + 1u] + LOG_FLOOR); + if (a - 2.0 * b + d != 0.0) { + offset = 0.5 * (a - d) / (a - 2.0 * b + d); + } + } + free(shaped); + free(spectrum); + free(freqs); + return ((double)peak + offset) * (double)c->rate / (double)size; +} + + +// The share of a tone's power within PURE_BAND of its pitch: a +// sine played as it was made keeps nearly all of it; one whose bytes were +// taken in the wrong format (signed for unsigned) spreads it into +// harmonics and a DC step. +static double confPurity(ConfT *c, const double *x, size_t n, double hz) { + double *shaped; + double *spectrum; + double *freqs; + double mean; + double total; + double share; + size_t bins; + size_t lo; + size_t hi; + size_t k; + + if (n < PITCH_MIN_SAMPLES) { + return 0.0; + } + bins = n / 2u + 1u; + shaped = (double *)memoryFor(n, sizeof(double)); + spectrum = (double *)memoryFor(bins, sizeof(double)); + freqs = freqsOf(n, c->rate); + gateHanning(shaped, n); + mean = gateMean(x, n); + for (k = 0u; k < n; k++) { + shaped[k] = (x[k] - mean) * shaped[k]; + } + fftFor(&c->purityFft, n); + gateFftAbs(&c->purityFft, shaped, n, spectrum); + for (k = 0u; k < bins; k++) { + spectrum[k] *= spectrum[k]; + } + bandOf(freqs, bins, hz * (1.0 - PURE_BAND), hz * (1.0 + PURE_BAND), true, &lo, &hi); + total = gateSum(spectrum, bins); + share = gateSum(spectrum + lo, hi - lo) / (total > LOG_FLOOR ? total : LOG_FLOOR); + free(shaped); + free(spectrum); + free(freqs); + return share; +} + + +// conformance: AUDPAR plays one timeline and logs when each step started +// ("AUDPAR "). This finds the timeline in the +// recording by its two sync tones (1 kHz, at the start and the end, so an +// emulator whose clock runs a little fast or slow is mapped straight), cuts +// each step out of it, and measures it: +// +// tone, voice the fundamental within PITCH_TOLERANCE of the asked Hz +// voice levels voice 0's attenuation steps 2 dB apart, LEVEL_TOLERANCE +// voices 1, 2 their pitch, and as loud as voice 0 (VOICE_MATCH_DB) +// noise the generator's rate, 125000 / pitch Hz: read off the +// first spectral null where every machine's output passes +// it (NOISE_GRADED_HZ, within NOISE_TOLERANCE), darker as +// the pitch rises everywhere, and its steps 2 dB apart +// sfx a sine of 8 samples a cycle sounds rate / 8 Hz, starting +// within SFX_LATENCY_MAX of the call; panned left or right +// it is in one channel (--stereo ports); and the mix: a +// full-scale sample peaks where a voice at attenuation 0 +// does, so the sine sounds its own level under full scale +// (kSineCycle's) against voice 0 (VOICE_MATCH_DB), each by +// its louder channel on a --stereo port (the Amiga's +// voices play on one side), the sine's level the median +// of the BALANCE_RATE effects (centre, left, right): one +// sound an emulator spoils does not decide it (MAME 0.264 +// clocks the X68000's MSM6258 at 4 MHz, not the 8 its CT1 +// pin says, until the first write to the OPM's CT1, so the +// first ADPCM sound after boot decodes every other nibble) +// stream the same sine streamed, plain and raw (in the port's raw +// format), likewise; the raw one as clean as the plain one +// (RAW_PURE_SHARE of its power at the pitch) +// mod stop a module that loops sounds until jlAudioStopMod, and is +// silent from SFX_LATENCY_MAX after it +// mod a module that does not loop sounds, then ends, and +// jlAudioIsPlayingMod goes false within MOD_END_SLACK_S of +// its sound stopping; and the mix: AUDPAR's module plays +// one channel of a full-scale square at full volume, as +// loud as voice 0 at attenuation 0 (MOD_VOICE_DB, +// VOICE_MATCH_DB), by the louder channel on a --stereo port +// tick the callbacks that came, against hz * time +// silence every gap is quiet: nothing left sounding +// +// --stereo: the port has stereo output: grade the pans. --skip NAME: a +// check this port cannot pass (a documented hardware limit), by its name's +// start, reported as SKIP; once for each. --level-tolerance DB: how far an +// attenuation step may land from 2 dB a step (a chip whose own levels are +// coarser, the YM2149's, gets its own figure). Prints one line per check, then "AUDPAR +// PORT passed/graded". Exit 0 when every graded check passes, 1 when one +// fails, 2 on a usage or input error. +static int confRun(int argc, char **argv) { + ConfT c; + GateWavT wav; + ToolBufT problem; + const EventT *e; + const EventT *firstSync; + const EventT *lastSync; + uint8_t *logText; + bool *flags; + size_t logLen; + size_t syncCount; + size_t doneCount; + size_t flagCount; + size_t frame; + size_t i; + uint32_t channel; + long first; + long last; + bool haveFirst; + bool haveLast; + double span; + double start; + double end; + + memset(&c, 0, sizeof(c)); + memset(&problem, 0, sizeof(problem)); + confArgs(argc, argv, &c); + if (!gateWavRead(c.wavPath, &wav, &problem) || !gateTextReadFile(c.logPath, &logText, &logLen, &problem)) { + printf("AUDPAR %s: %s\n", c.port, gateTextString(&problem)); + return GATE_USAGE_EXIT; + } + confEvents((const char *)logText, logLen, &c.events); + free(logText); + syncCount = 0u; + doneCount = 0u; + firstSync = NULL; + lastSync = NULL; + for (i = 0u; i < c.events.count; i++) { + e = &c.events.items[i]; + if (strcmp(e->kind, "sync") == 0) { + if (syncCount == 0u) { + firstSync = e; + } else if (syncCount == 1u) { + lastSync = e; + } + syncCount++; + } else if (strcmp(e->kind, "done") == 0) { + doneCount++; + } + } + if (syncCount != 2u || doneCount == 0u) { + printf("AUDPAR %s: the log does not hold a whole run (syncs %zu, done %zu)\n", c.port, syncCount, doneCount); + return GATE_USAGE_EXIT; + } + c.channels = wav.channels; + c.rate = wav.rate; + c.frames = gateWavFrames(&wav); + c.data = (double *)memoryFor(c.frames * c.channels, sizeof(double)); + for (frame = 0u; frame < c.frames; frame++) { + for (channel = 0u; channel < c.channels; channel++) { + c.data[frame * c.channels + channel] = gateWavSample(&wav, frame, channel); + } + } + c.mono = gateWavMono(&wav); + gateWavFree(&wav); + if (c.rate < MIN_RATE_HZ) { + toolFail("%s: %u Hz is too low a rate to grade", c.wavPath, (unsigned)c.rate); + } + c.window = (size_t)truncated(WINDOW_S * (double)c.rate); + c.hop = (size_t)truncated(FRAME_S * (double)c.rate); + c.taper = (double *)memoryFor(c.window, sizeof(double)); + c.scratch = (double *)memoryFor(c.window, sizeof(double)); + c.bins = (double *)memoryFor(c.window / 2u + 1u, sizeof(double)); + c.freqs = freqsOf(c.window, c.rate); + gateHanning(c.taper, c.window); + gateFftInit(&c.windowFft, c.window); + + // Align: the two sync onsets map log seconds onto the recording. + flags = confSyncFlags(&c, &flagCount); + haveFirst = confSyncOnset(flags, flagCount, 0, &first); + span = lastSync->time - firstSync->time; + if (span <= 0.0) { + toolFail("%s: the closing sync is logged no later than the opening one", c.logPath); + } + haveLast = haveFirst && confSyncOnset(flags, flagCount, first + (long)truncated(SYNC_LAST_SHARE * span / FRAME_S), &last); + free(flags); + if (!haveFirst || !haveLast) { + confName(&c, "sync"); + confCheck(&c, false, "sync tones found: %s%s", haveFirst ? "start" : "none", haveLast ? ", end" : ""); + printf("AUDPAR %s %u/%u\n", c.port, (unsigned)c.passed, (unsigned)c.graded); + return FAIL_EXIT; + } + c.t0 = (double)first * FRAME_S; + c.scale = (double)(last - first) * FRAME_S / span; + confName(&c, "sync"); + confCheck(&c, fabs(c.scale - 1.0) < SYNC_SCALE_TOLERANCE, "start %.3f s, the timeline runs at %.4f of the program's clock", c.t0, c.scale); + + for (i = 0u; i < c.events.count; i++) { + e = &c.events.items[i]; + start = e->time; + end = i + 1u < c.events.count ? c.events.items[i + 1u].time : e->time; + if (strcmp(e->kind, "tone") == 0 || strcmp(e->kind, "sync") == 0) { + confTone(&c, start, end, e); + } else if (strcmp(e->kind, "voice") == 0) { + confVoice(&c, start, end, e); + } else if (strcmp(e->kind, "noise") == 0) { + confNoise(&c, start, end, e); + } else if (strcmp(e->kind, "sfx") == 0 || strcmp(e->kind, "stream") == 0) { + confSample(&c, start, e); + } else if (strcmp(e->kind, "silence") == 0) { + confSilence(&c, start, end); + } else if (strcmp(e->kind, "mod") == 0 && e->valueCount > MOD_WHAT_AT && strcmp(e->values[MOD_WHAT_AT], "start") == 0) { + confMod(&c, start); + } else if (strcmp(e->kind, "mod") == 0 && e->valueCount > MOD_WHAT_AT && strcmp(e->values[MOD_WHAT_AT], "stop") == 0) { + confModStop(&c, start); + } else if (strcmp(e->kind, "tick") == 0) { + confTick(&c, e); + } + } + confSummary(&c); + printf("AUDPAR %s %u/%u\n", c.port, (unsigned)c.passed, (unsigned)c.graded); + return c.failed == 0u ? 0 : FAIL_EXIT; +} + + +// An sfx or stream step: it starts in time, at its pitch, cleanly, at its +// pan; the BALANCE_RATE effects weighed against a voice. +static void confSample(ConfT *c, double start, const EventT *e) { + SamplesT x; + const char *what; + const char *panName; + char panText[TEXT_MAX]; + size_t samplesAt; + long pan; + long samples; + double hz; + double length; + double found; + double got; + double level; + double pure; + double left; + double right; + double apart; + bool started; + bool ok; + + hz = confFloat(c, e, SAMPLE_HZ_AT); + pan = confInt(c, e, SAMPLE_PAN_AT); + // The sine's samples follow the pan (a stream's after its raw flag), + // when the log gives them. + samplesAt = strcmp(e->kind, "sfx") == 0 ? SFX_SAMPLES_AT : STREAM_SAMPLES_AT; + samples = e->valueCount > samplesAt ? confInt(c, e, samplesAt) : SFX_SAMPLES; + length = (double)samples / confFloat(c, e, SAMPLE_RATE_AT); + panName = panText; + if (pan >= 0 && (size_t)pan < GATE_COUNT_OF(kPanNames)) { + panName = kPanNames[pan]; + } else { + snprintf(panText, sizeof(panText), "%ld", pan); + } + // A stream's name says so, and whether it went raw. + what = "sfx"; + if (strcmp(e->kind, "sfx") != 0) { + what = e->valueCount > STREAM_RAW_AT && strcmp(e->values[STREAM_RAW_AT], "1") == 0 ? "raw stream" : "stream"; + } + started = confOnset(c, start, hz, &found); + confName(c, "%s %s Hz %s starts", what, e->values[SAMPLE_RATE_AT], panName); + if (started) { + confCheck(c, found <= SFX_LATENCY_MAX, "%.0f ms after the call", found * MS_PER_SECOND); + } else { + confCheck(c, false, "never"); + } + c->lag = started ? found : 0.0; + x = confCut(c, start, start + length, CHANNEL_MIX, c->lag); + got = confPitch(c, x.x, x.n); + level = confDbfs(x.x, x.n); + pure = confPurity(c, x.x, x.n, hz); + free(x.x); + confName(c, "%s %s Hz %s", what, e->values[SAMPLE_RATE_AT], panName); + confCheck(c, fabs(got / hz - 1.0) <= PITCH_TOLERANCE && level > SOUND_DBFS, "%.1f Hz (%.0f asked) at %.1f dBFS, %.1f%% at its pitch", got, hz, level, pure * PERCENT); + // The raw stream's bytes in the wrong format sound at the same pitch, + // but spread into harmonics: it must be about as clean as the plain + // stream through the same path. + if (strcmp(what, "stream") == 0) { + c->plainPure = pure; + c->havePlainPure = true; + } else if (strcmp(what, "raw stream") == 0 && c->havePlainPure) { + confName(c, "raw stream as clean as the plain one"); + confCheck(c, pure >= c->plainPure * RAW_PURE_SHARE, "%.1f%% at its pitch, the plain stream %.1f%%", pure * PERCENT, c->plainPure * PERCENT); + } + if (strcmp(what, "sfx") == 0 && strcmp(e->values[SAMPLE_RATE_AT], BALANCE_RATE) == 0) { + entriesAdd(&c->balanceSfx, 0, 0, confLoudest(c, start, start + length, false, c->lag)); + } + if (c->channels >= RAW_CHANNELS) { + left = confMeasure(c, start, start + length, CHANNEL_LEFT, c->lag, false); + right = confMeasure(c, start, start + length, CHANNEL_RIGHT, c->lag, false); + apart = left - right; + if (pan == PAN_LEFT) { + ok = apart >= PAN_APART_DB; + } else if (pan == PAN_RIGHT) { + ok = -apart >= PAN_APART_DB; + } else { + ok = fabs(apart) <= PAN_CENTRE_DB; + } + if (c->stereo) { + confName(c, "pan %s", panName); + confCheck(c, ok, "left %.1f dBFS, right %.1f dBFS", left, right); + } + } +} + + +// A gap: quiet. After a sample-path sound, its tail is late by the latency +// the sfx check graded: the silence is judged after it. +static void confSilence(ConfT *c, double start, double end) { + double level; + + level = confMeasure(c, start, end, CHANNEL_MIX, c->lag, true); + confName(c, "silence at %.2f s", start); + confCheck(c, level < SILENCE_DBFS, "%.1f dBFS", level); +} + + +// The median level of LEVEL_FRAME_S frames: a step's own +// level, which a glitch of the recording path (a real-time emulator's sound +// server dropping or repeating a block) cannot move the way it moves a mean. +static double confSteady(const ConfT *c, const double *x, size_t n) { + double *levels; + double level; + size_t frame; + size_t count; + size_t i; + + frame = (size_t)truncated(LEVEL_FRAME_S * (double)c->rate); + if (n < frame) { + return confDbfs(x, n); + } + count = n / frame; + levels = (double *)memoryFor(count, sizeof(double)); + for (i = 0u; i < count; i++) { + levels[i] = confDbfs(x + i * frame, frame); + } + level = confMedian(levels, count); + free(levels); + return level; +} + + +// The checks across steps: the tones' levels even, voice 0's attenuation +// steps, the voices and the noise as loud as voice 0, the mix (effects and +// module against a voice), the noise's attenuation steps, and its +// darkening as its pitch rises. +static void confSummary(ConfT *c) { + ToolBufT text; + EntryT *base; + EntryT *entry; + EntryT *now; + double *values; + double spread; + double drop; + double apart; + double power; + double sfxVoiceDb; + size_t i; + long atten; + long voice; + bool darker; + + memset(&text, 0, sizeof(text)); + if (c->toneLevels.count > 0u) { + spread = 0.0; + for (i = 0u; i < c->toneLevels.count; i++) { + entry = &c->toneLevels.items[i]; + spread = fabs(entry->value - c->toneLevels.items[0].value) > spread ? fabs(entry->value - c->toneLevels.items[0].value) : spread; + gateTextAdd(&text, "%s%ld Hz %.1f", i > 0u ? ", " : "", entry->first, entry->value); + } + confName(c, "tone levels even"); + confCheck(c, spread <= TONE_MATCH_DB, "%s", gateTextString(&text)); + } + base = entriesFind(&c->voiceLevels, 0, 0); + if (base != NULL) { + for (atten = 1; atten <= VOICE_ATTEN_MAX; atten++) { + entry = entriesFind(&c->voiceLevels, 0, atten); + if (entry != NULL) { + drop = base->value - entry->value; + confName(c, "voice level %ld", atten); + confCheck(c, fabs(drop - LEVEL_STEP_DB * (double)atten) <= c->levelTolerance, "%.1f dB below attenuation 0, %.0f asked", drop, LEVEL_STEP_DB * (double)atten); + } + } + for (voice = VOICE_OTHER_FIRST; voice <= VOICE_OTHER_LAST; voice++) { + entry = entriesFind(&c->voiceLevels, voice, 0); + if (entry != NULL) { + confName(c, "voice %ld as loud as voice 0", voice); + confCheck(c, fabs(entry->value - base->value) <= VOICE_MATCH_DB, "%.1f against %.1f dBFS", entry->value, base->value); + } + } + // Full-swing noise and a full-swing square carry the same power, as + // on the PSG every port's voices are modelled on. + entry = entriesFind(&c->noiseLevels, NOISE_LOUDNESS_PITCH, 0); + if (entry != NULL) { + confName(c, "noise as loud as a voice"); + confCheck(c, fabs(entry->value - base->value) <= VOICE_MATCH_DB, "%.1f against %.1f dBFS", entry->value, base->value); + } + } + if (c->haveBalanceVoice && c->balanceSfx.count > 0u) { + // The canonical mix: AUDPAR's sine, peaking under full scale, sounds + // this far under a full-swing square of the same peak (a voice at + // attenuation 0). + power = 0.0; + for (i = 0u; i < GATE_COUNT_OF(kSineCycle); i++) { + power += (double)(kSineCycle[i] * kSineCycle[i]); + } + sfxVoiceDb = DB_PER_DECADE * log10(sqrt(power / (double)GATE_COUNT_OF(kSineCycle)) / SAMPLE_FULL_SCALE); + values = (double *)memoryFor(c->balanceSfx.count, sizeof(double)); + for (i = 0u; i < c->balanceSfx.count; i++) { + values[i] = c->balanceSfx.items[i].value; + } + apart = confMedian(values, c->balanceSfx.count) - c->balanceVoice; + free(values); + confName(c, "sfx as loud as a voice"); + confCheck(c, fabs(apart - sfxVoiceDb) <= VOICE_MATCH_DB, "%+.1f dB against voice 0, %+.1f asked (a full-scale sample peaks where the voice does)", apart, sfxVoiceDb); + } + if (c->haveBalanceVoice && c->haveBalanceMod) { + apart = c->balanceMod - c->balanceVoice; + confName(c, "mod as loud as a voice"); + confCheck(c, fabs(apart - MOD_VOICE_DB) <= VOICE_MATCH_DB, "%+.1f dB against voice 0, %+.1f asked (a module channel at full volume peaks where the voice does)", apart, MOD_VOICE_DB); + } + confNoiseLevels(c); + // Each pitch darker than the one before, where the later one's rate is + // inside the recording's band: rates past half the sample rate all + // record as near-white noise and cannot be told apart. + if (c->noiseCentroids.count > 1u) { + darker = true; + text.len = 0u; + for (i = 0u; i < c->noiseCentroids.count; i++) { + now = &c->noiseCentroids.items[i]; + gateTextAdd(&text, "%s%ld: %.0f Hz", i > 0u ? ", " : "", now->first, now->value); + if (i > 0u && NOISE_RATE_HZ / (double)(now->first > 1 ? now->first : 1) < (double)c->rate / 2.0 && !(now->value < c->noiseCentroids.items[i - 1u].value)) { + darker = false; + } + } + confName(c, "noise darkens"); + confCheck(c, darker, "%s", gateTextString(&text)); + } + toolBufFree(&text); +} + + +// Per FRAME_S hop, whether the 1 kHz sync tone dominates (a +// loud window holding SYNC_SHARE of its power in the sync band). +static bool *confSyncFlags(ConfT *c, size_t *count) { + bool *flags; + double total; + size_t bins; + size_t lo; + size_t hi; + size_t start; + size_t k; + size_t i; + bool loud; + + bins = c->window / 2u + 1u; + *count = c->frames > c->window ? (c->frames - c->window + c->hop - 1u) / c->hop : 0u; + flags = (bool *)memoryFor(*count, sizeof(bool)); + bandOf(c->freqs, bins, SYNC_HZ - SYNC_BAND_HZ, SYNC_HZ + SYNC_BAND_HZ, false, &lo, &hi); + for (i = 0u; i < *count; i++) { + start = i * c->hop; + for (k = 0u; k < c->window; k++) { + c->scratch[k] = c->mono[start + k] * c->taper[k]; + } + gateFftAbs(&c->windowFft, c->scratch, c->window, c->bins); + for (k = 0u; k < bins; k++) { + c->bins[k] *= c->bins[k]; + } + total = gateSum(c->bins, bins); + loud = confDbfs(c->mono + start, c->window) > SYNC_MIN_DBFS; + flags[i] = loud && total > 0.0 && gateSum(c->bins + lo, hi - lo) / total > SYNC_SHARE; + } + return flags; +} + + +// The first frame at or after from that starts +// SYNC_RUN_FRAMES of sync; false when none does. +static bool confSyncOnset(const bool *flags, size_t count, long from, long *at) { + long run; + long i; + + run = 0; + for (i = from; i < (long)count; i++) { + run = flags[i] ? run + 1 : 0; + if (run == SYNC_RUN_FRAMES) { + *at = i - SYNC_RUN_FRAMES + 1; + return true; + } + } + return false; +} + + +// A tick step: the callbacks that came, against hz * time. +static void confTick(ConfT *c, const EventT *e) { + long hz; + long calls; + long ms; + double expected; + double slack; + bool registered; + + hz = confInt(c, e, TICK_HZ_AT); + registered = strcmp(confValue(c, e, TICK_OK_AT), "1") == 0; + calls = confInt(c, e, TICK_CALLS_AT); + ms = confInt(c, e, TICK_MS_AT); + expected = (double)(hz * ms) / MS_PER_SECOND; + slack = TICK_SHARE * expected > TICK_MIN ? TICK_SHARE * expected : TICK_MIN; + confName(c, "tick %ld Hz", hz); + confCheck(c, registered && fabs((double)calls - expected) <= slack + TICK_SLACK, "registered %s, %ld calls in %ld ms, %.0f expected", registered ? "yes" : "no", calls, ms, expected); +} + + +// A tone or sync step: at its pitch, and sounding; a tone's level kept for +// the evenness check, the 440 Hz one's pan graded. +static void confTone(ConfT *c, double start, double end, const EventT *e) { + SamplesT x; + double hz; + double got; + double level; + + hz = confFloat(c, e, TONE_HZ_AT); + x = confCut(c, start, end, CHANNEL_MIX, 0.0); + got = confPitch(c, x.x, x.n); + level = confDbfs(x.x, x.n); + free(x.x); + confName(c, "%s %lld Hz", e->kind, (long long)truncated(hz)); + confCheck(c, fabs(got / hz - 1.0) <= PITCH_TOLERANCE && level > SOUND_DBFS, "%.1f Hz at %.1f dBFS", got, level); + if (strcmp(e->kind, "tone") == 0) { + entriesAdd(&c->toneLevels, (long)truncated(hz), 0, level); + if (c->stereo && hz == CENTRE_CHECK_HZ) { + confCentred(c, start, end, "tone"); + } + } +} + + +// A log value, which must be there. +static const char *confValue(const ConfT *c, const EventT *e, size_t at) { + if (at >= e->valueCount) { + toolFail("%s: AUDPAR %s at %.3f s has %zu values, not %zu", c->logPath, e->kind, e->time, e->valueCount, at + 1u); + } + return e->values[at]; +} + + +// A voice step: its level kept for the attenuation and loudness checks, +// its pan graded at attenuation 0, its pitch while it is loud enough to +// read. +static void confVoice(ConfT *c, double start, double end, const EventT *e) { + SamplesT x; + long voice; + long atten; + double hz; + double level; + double got; + char what[TEXT_MAX]; + + voice = confInt(c, e, VOICE_NUMBER_AT); + hz = confFloat(c, e, VOICE_HZ_AT); + atten = confInt(c, e, VOICE_ATTEN_AT); + x = confCut(c, start, end, CHANNEL_MIX, 0.0); + level = confSteady(c, x.x, x.n); + if (c->stereo && atten == 0) { + snprintf(what, sizeof(what), "voice %ld", voice); + confCentred(c, start, end, what); + } + entriesSet(&c->voiceLevels, voice, atten, level); + if (voice == 0 && atten == 0) { + c->balanceVoice = confLoudest(c, start, end, true, 0.0); + c->haveBalanceVoice = true; + } + if (atten <= VOICE_PITCH_ATTEN_MAX) { + got = confPitch(c, x.x, x.n); + confName(c, "voice %ld %lld Hz atten %ld", voice, (long long)truncated(hz), atten); + confCheck(c, fabs(got / hz - 1.0) <= PITCH_TOLERANCE, "%.1f Hz at %.1f dBFS", got, level); + } + free(x.x); +} + + +// scipy.signal.welch(x - mean, rate, nperseg=min(2048, n)): Hann-windowed +// segments half overlapped, each made zero mean, their one-sided power +// densities averaged in turn. The bins' frequencies and densities, in +// malloc'd arrays; how many. +static size_t confWelch(ConfT *c, const double *x, size_t n, double **freqs, double **psd) { + double *centred; + double *window; + double *squares; + double *segment; + double *re; + double *im; + double scale; + double mean; + double power; + size_t perSegment; + size_t step; + size_t segments; + size_t bins; + size_t s; + size_t k; + + perSegment = n < WELCH_SEGMENT_MAX ? n : WELCH_SEGMENT_MAX; + if (perSegment == 0u) { + toolFail("%s: a step too short to measure", c->logPath); + } + step = perSegment - perSegment / 2u; + segments = (n - perSegment / 2u) / step; + bins = perSegment / 2u + 1u; + centred = (double *)memoryFor(n, sizeof(double)); + window = (double *)memoryFor(perSegment, sizeof(double)); + squares = (double *)memoryFor(perSegment, sizeof(double)); + segment = (double *)memoryFor(perSegment, sizeof(double)); + re = (double *)memoryFor(bins, sizeof(double)); + im = (double *)memoryFor(bins, sizeof(double)); + *psd = (double *)memoryFor(bins, sizeof(double)); + *freqs = freqsOf(perSegment, c->rate); + mean = gateMean(x, n); + for (k = 0u; k < n; k++) { + centred[k] = x[k] - mean; + } + confHann(window, perSegment); + for (k = 0u; k < perSegment; k++) { + squares[k] = window[k] * window[k]; + } + scale = 1.0 / ((double)c->rate * gateSum(squares, perSegment)); + fftFor(&c->welchFft, perSegment); + for (s = 0u; s < segments; s++) { + mean = gateMean(centred + s * step, perSegment); + for (k = 0u; k < perSegment; k++) { + segment[k] = window[k] * (centred[s * step + k] - mean); + } + gateFftReal(&c->welchFft, segment, perSegment, re, im); + for (k = 0u; k < bins; k++) { + power = (re[k] * re[k] + im[k] * im[k]) * scale; + // One-sided: every bin but DC (and Nyquist, for an even length) + // carries its negative twin too. + if (k > 0u && (perSegment % 2u != 0u || k < bins - 1u)) { + power *= 2.0; + } + (*psd)[k] += power; + } + } + for (k = 0u; k < bins; k++) { + (*psd)[k] /= (double)segments; + } + free(centred); + free(window); + free(squares); + free(segment); + free(re); + free(im); + return bins; +} + + +static void entriesAdd(EntriesT *list, long first, long second, double value) { + list->items = (EntryT *)arrayRoom(list->items, list->count, &list->cap, sizeof(EntryT)); + list->items[list->count].first = first; + list->items[list->count].second = second; + list->items[list->count].value = value; + list->count++; +} + + +static EntryT *entriesFind(EntriesT *list, long first, long second) { + size_t i; + + for (i = 0u; i < list->count; i++) { + if (list->items[i].first == first && list->items[i].second == second) { + return &list->items[i]; + } + } + return NULL; +} + + +// The value at (first, second): replaced where the key is, else added at +// the end. +static void entriesSet(EntriesT *list, long first, long second, double value) { + EntryT *entry; + + entry = entriesFind(list, first, second); + if (entry == NULL) { + entriesAdd(list, first, second, value); + return; + } + entry->value = value; +} + + +// A transform of n points: fft as it is when it is that long already. +static void fftFor(GateFftT *fft, size_t n) { + if (fft->n == n) { + return; + } + if (fft->n != 0u) { + gateFftFree(fft); + } + gateFftInit(fft, n); +} + + +// numpy.fft.rfftfreq(n, 1.0 / rate), malloc'd. +static double *freqsOf(size_t n, uint32_t rate) { + double *freqs; + size_t k; + + freqs = (double *)memoryFor(n / 2u + 1u, sizeof(double)); + for (k = 0u; k <= n / 2u; k++) { + freqs[k] = gateRfftFreq(k, n, rate); + } + return freqs; +} + + +// "label: text\n" appended to lines. +static void lineAdd(ToolBufT *lines, const char *label, const char *fmt, ...) { + va_list args; + + gateTextAdd(lines, "%s: ", label); + va_start(args, fmt); + gateTextAddV(lines, fmt, args); + va_end(args); + toolBufByte(lines, '\n'); +} + + +// count items of size bytes, zeroed (at least one, so none is not NULL); +// running out is fatal. +static void *memoryFor(size_t count, size_t size) { + void *memory; + + memory = calloc(count > 0u ? count : 1u, size); + if (memory == NULL) { + toolFail("out of memory"); + } + return memory; +} + + +// A 16-bit WAV's samples averaged over its channels; false, with what is +// wrong in problem, when it cannot be read. +static bool monoRead(const char *path, double **samples, size_t *count, uint32_t *rate, ToolBufT *problem) { + GateWavT wav; + + if (!gateWavRead(path, &wav, problem)) { + return false; + } + if (wav.rate == 0u) { + gateTextAdd(problem, "%s: a rate of 0 Hz", path); + gateWavFree(&wav); + return false; + } + *samples = gateWavMono(&wav); + *count = gateWavFrames(&wav); + *rate = wav.rate; + gateWavFree(&wav); + return true; +} + + +// The level from startSec to endSec, summed in order; false when +// the span is empty. +static bool monoRms(const double *samples, size_t count, uint32_t rate, double startSec, double endSec, double *level) { + size_t a; + size_t b; + size_t i; + double total; + + spanOf(truncated(startSec * (double)rate), truncated(endSec * (double)rate), count, &a, &b); + if (b <= a) { + return false; + } + total = 0.0; + for (i = a; i < b; i++) { + total += samples[i] * samples[i]; + } + *level = sqrt(total / (double)(b - a)); + return true; +} + + +// onset: the byte offset of the first stereo frame of raw PCM holding a +// sample louder than THRESHOLD, or nothing. +static int onsetRun(int argc, char **argv) { + uint8_t *raw; + size_t len; + size_t i; + long threshold; + int16_t sample; + + if (argc != ONSET_ARGS) { + usageFail("onset needs RAW THRESHOLD"); + } + threshold = gateArgLong("threshold", argv[FIRST_ARG + 1]); + raw = rawRead(argv[FIRST_ARG], &len); + len -= len % RAW_FRAME_BYTES; + for (i = 0u; i < len / SAMPLE_BYTES; i++) { + sample = (int16_t)((uint32_t)raw[i * SAMPLE_BYTES] | ((uint32_t)raw[i * SAMPLE_BYTES + 1u] << BYTE_BITS)); + if (labs((long)sample) > threshold) { + printf("%zu\n", (i / RAW_CHANNELS) * RAW_FRAME_BYTES); + break; + } + } + free(raw); + return 0; +} + + +// Whether argv[*a] is the option name, as "name VALUE" (the next argument +// taken) or "name=VALUE"; its value at *value. A name at the end, without +// its value, is a usage error. +static bool optionValue(int argc, char **argv, int *a, const char *name, const char **value) { + size_t len; + + len = strlen(name); + if (strncmp(argv[*a], name, len) != 0) { + return false; + } + if (argv[*a][len] == '=') { + *value = argv[*a] + len + 1u; + return true; + } + if (argv[*a][len] != '\0') { + return false; + } + if (*a + 1 >= argc) { + usageFail("%s needs a value", name); + } + (*a)++; + *value = argv[*a]; + return true; +} + + +// phases: each phase's RMS within its bounds. +static int phasesRun(int argc, char **argv) { + const char *label; + const char *path; + char *fields[PHASE_FIELDS]; + char *copy; + char *field; + char *colon; + double *samples; + double duration; + double level; + double bounds[PHASE_FIELDS]; + ToolBufT problem; + ToolBufT parts; + ToolBufT fails; + size_t count; + size_t fieldCount; + size_t f; + uint32_t rate; + bool good; + int a; + + if (argc < PHASES_ARGS_MIN) { + usageFail("phases needs LABEL WAV PHASE..."); + } + label = argv[FIRST_ARG]; + path = argv[FIRST_ARG + 1]; + memset(&problem, 0, sizeof(problem)); + memset(&parts, 0, sizeof(parts)); + memset(&fails, 0, sizeof(fails)); + if (!monoRead(path, &samples, &count, &rate, &problem)) { + fprintf(stderr, "%s: FAIL - %s\n", label, gateTextString(&problem)); + return FAIL_EXIT; + } + duration = (double)count / (double)rate; + if (duration < RECORDING_MIN_S) { + fprintf(stderr, "%s: FAIL - recorded only %.2fs of audio; the capture never attached to the emulator's output\n", label, duration); + return FAIL_EXIT; + } + for (a = FIRST_ARG + 2; a < argc; a++) { + copy = strdup(argv[a]); + if (copy == NULL) { + toolFail("out of memory"); + } + // name:start:end:low:high, the times numbers, the bounds numbers or + // empty. + fieldCount = 0u; + for (field = copy; field != NULL; field = colon != NULL ? colon + 1 : NULL) { + colon = strchr(field, ':'); + if (colon != NULL) { + *colon = '\0'; + } + if (fieldCount < PHASE_FIELDS) { + fields[fieldCount] = field; + } + fieldCount++; + } + good = fieldCount == PHASE_FIELDS; + for (f = PHASE_START; good && f < PHASE_FIELDS; f++) { + good = (f >= PHASE_MIN && fields[f][0] == '\0') || gateArgParseDouble(fields[f], &bounds[f]); + } + if (!good) { + fprintf(stderr, "%s: bad phase spec '%s'\n", label, argv[a]); + return GATE_USAGE_EXIT; + } + if (!monoRms(samples, count, rate, bounds[PHASE_START], bounds[PHASE_END], &level)) { + lineAdd(&fails, label, "phase '%s' (%s-%ss) is past the end of a %.1fs recording", fields[PHASE_NAME], fields[PHASE_START], fields[PHASE_END], duration); + } else { + gateTextAdd(&parts, "%s%s=%.0f", parts.len > 0u ? " " : "", fields[PHASE_NAME], level); + if (fields[PHASE_MIN][0] != '\0' && level < bounds[PHASE_MIN]) { + lineAdd(&fails, label, "phase '%s' RMS %.0f is below %s: expected sound here and got near-silence", fields[PHASE_NAME], level, fields[PHASE_MIN]); + } + if (fields[PHASE_MAX][0] != '\0' && level > bounds[PHASE_MAX]) { + lineAdd(&fails, label, "phase '%s' RMS %.0f is above %s: expected near-silence here and got sound", fields[PHASE_NAME], level, fields[PHASE_MAX]); + } + } + free(copy); + } + free(samples); + printf("%s: %.1fs recorded, RMS %s\n", label, duration, parts.len > 0u ? gateTextString(&parts) : ""); + if (fails.len > 0u) { + fwrite(fails.data, 1u, fails.len, stderr); + fprintf(stderr, "%s: FAIL\n", label); + return FAIL_EXIT; + } + printf("%s: PASS\n", label); + return 0; +} + + +// Zero crossings a second from start to end. +static double pspCrossings(const double *samples, size_t count, uint32_t rate, double start, double end) { + size_t a; + size_t b; + size_t i; + size_t crossings; + + spanOf(truncated(start * (double)rate), truncated(end * (double)rate), count, &a, &b); + crossings = 0u; + for (i = a + 1u; i < b; i++) { + if ((samples[i - 1u] < 0.0) != (samples[i] < 0.0)) { + crossings++; + } + } + return (double)crossings / ((double)(b - a) / (double)rate); +} + + +// psp: 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 +// +// 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. +static int pspRun(int argc, char **argv) { + const char *label; + const char *path; + double *samples; + double quitSeconds; + double duration; + double onset; + double level; + double due; + double start; + double end; + double hz; + double want; + ToolBufT problem; + ToolBufT fails; + size_t count; + size_t i; + uint32_t rate; + int take; + + if (argc != PSP_ARGS) { + usageFail("psp needs LABEL WAV QUIT_SECONDS"); + } + label = argv[FIRST_ARG]; + path = argv[FIRST_ARG + 1]; + quitSeconds = gateArgDouble("quit seconds", argv[FIRST_ARG + 2]); + memset(&problem, 0, sizeof(problem)); + memset(&fails, 0, sizeof(fails)); + if (!monoRead(path, &samples, &count, &rate, &problem)) { + fprintf(stderr, "%s: FAIL - %s\n", label, gateTextString(&problem)); + return FAIL_EXIT; + } + duration = (double)count / (double)rate; + i = 0u; + while (i < count && fabs(samples[i]) <= ONSET_LEVEL) { + i++; + } + if (i == count) { + fprintf(stderr, "%s: FAIL - %.1fs recorded and all of it silent\n", label, duration); + return FAIL_EXIT; + } + onset = (double)i / (double)rate; + if (!monoRms(samples, count, rate, onset + MUSIC_WINDOW_START, onset + MUSIC_WINDOW_END, &level)) { + fprintf(stderr, "%s: FAIL - the music starts at %.2fs, too near the end of a %.1fs recording to measure\n", label, onset, duration); + return FAIL_EXIT; + } + printf("%s: music from %.2fs, RMS %.0f\n", label, onset, level); + if (!(MUSIC_RMS_MIN <= level && level <= MUSIC_RMS_MAX)) { + lineAdd(&fails, label, "FAIL - music RMS %.0f, want %d-%d", level, MUSIC_RMS_MIN, MUSIC_RMS_MAX); + } + for (take = 0; take < TAKES; take++) { + due = onset + (double)(PROBE_STREAM_FIRST + PROBE_STREAM_FRAMES * take) / (double)FRAME_HZ; + start = due + TAKE_WINDOW_START; + end = due + TAKE_WINDOW_END; + if (!monoRms(samples, count, rate, start, end, &level)) { + lineAdd(&fails, label, "FAIL - take %d is past the end of the recording", take + 1); + continue; + } + hz = pspCrossings(samples, count, rate, start, end) / CROSSINGS_PER_CYCLE; + want = pspTakeHz(take, (TAKE_WINDOW_START + TAKE_WINDOW_END) / 2.0); + printf("%s: take %d at %.2fs: RMS %.0f, %.0f Hz (want %.0f)\n", label, take + 1, start, level, hz, want); + if (level < TAKE_MIN_RMS) { + lineAdd(&fails, label, "FAIL - take %d RMS %.0f, want at least %d", take + 1, level, TAKE_MIN_RMS); + } + if (fabs(hz - want) > want * TAKE_PITCH_TOLERANCE) { + lineAdd(&fails, label, "FAIL - take %d at %.0f Hz, want %.0f", take + 1, hz, want); + } + } + printf("%s: the recording ends at %.2fs, Select at %.2fs\n", label, duration, quitSeconds); + if (fabs(duration - quitSeconds) > END_SLACK) { + lineAdd(&fails, label, "FAIL - the recording ends at %.2fs, not within %.1fs of Select at %.2fs", duration, END_SLACK, quitSeconds); + } + free(samples); + if (fails.len > 0u) { + fwrite(fails.data, 1u, fails.len, stderr); + return FAIL_EXIT; + } + printf("%s: PASS\n", label); + return 0; +} + + +// The pitch a take is at, seconds after it starts. +static double pspTakeHz(int take, double seconds) { + long block; + + block = (long)truncated(seconds * (double)STREAM_RATE_HZ) / STREAM_BLOCK; + return (double)(STREAM_START_HZ + STREAM_TAKE_HZ * take) - (double)(STREAM_DROP_HZ * block) / (double)STREAM_BLOCKS; +} + + +// A raw PCM file, whole; it is fatal when it cannot be read. +static uint8_t *rawRead(const char *path, size_t *len) { + ToolBufT problem; + uint8_t *raw; + + memset(&problem, 0, sizeof(problem)); + if (!gateTextReadFile(path, &raw, len, &problem)) { + toolFail("%s", gateTextString(&problem)); + } + return raw; +} + + +// [a, b) on len items, clamped to them: [lo, hi), empty when b <= a. +static void spanOf(int64_t a, int64_t b, size_t len, size_t *lo, size_t *hi) { + int64_t n; + + n = (int64_t)len; + a = a < 0 ? 0 : (a > n ? n : a); + b = b < 0 ? 0 : (b > n ? n : b); + *lo = (size_t)a; + *hi = (size_t)(b > a ? b : a); +} + + +// value toward zero, as a sample index (clamped to int64_t's range; NaN is +// 0). +static int64_t truncated(double value) { + if (isnan(value)) { + return 0; + } + if (value >= (double)INT64_MAX) { + return INT64_MAX; + } + if (value <= (double)INT64_MIN) { + return INT64_MIN; + } + return (int64_t)value; +} + + +// A usage error: what is wrong, then the usage; exit 2. +static void usageFail(const char *fmt, ...) { + va_list args; + + fprintf(stderr, "%s: ", TOOL_NAME); + va_start(args, fmt); + vfprintf(stderr, fmt, args); + va_end(args); + fputc('\n', stderr); + fputs(kUsage, stderr); + exit(GATE_USAGE_EXIT); +} + + +// wav: raw signed 16-bit stereo PCM, from byte SKIP on and cut to whole +// frames, as a WAV at RATE. +static int wavRun(int argc, char **argv) { + uint8_t *raw; + size_t len; + size_t from; + long rate; + long skip; + + if (argc < WAV_ARGS_MIN || argc > WAV_ARGS_MAX) { + usageFail("wav needs RAW OUT.WAV RATE [SKIP]"); + } + rate = gateArgLong("rate", argv[FIRST_ARG + 2]); + skip = argc == WAV_ARGS_MAX ? gateArgLong("skip", argv[FIRST_ARG + 3]) : 0; + if (rate <= 0 || rate > (long)UINT32_MAX || skip < 0) { + usageFail("RATE must be above 0 and SKIP not below it"); + } + raw = rawRead(argv[FIRST_ARG], &len); + from = (size_t)skip < len ? (size_t)skip : len; + if (!gateWavWrite(argv[FIRST_ARG + 1], RAW_CHANNELS, (uint32_t)rate, raw + from, (len - from) / RAW_FRAME_BYTES * RAW_FRAME_BYTES)) { + toolFail("cannot write %s: %s", argv[FIRST_ARG + 1], strerror(errno)); + } + free(raw); + return 0; +} + + +int main(int argc, char **argv) { + size_t i; + + toolFileName(TOOL_NAME); + if (argc > COMMAND_ARG && (strcmp(argv[COMMAND_ARG], "-h") == 0 || strcmp(argv[COMMAND_ARG], "--help") == 0)) { + fputs(kUsage, stdout); + return 0; + } + for (i = 0u; argc > COMMAND_ARG && i < GATE_COUNT_OF(kCommands); i++) { + if (strcmp(argv[COMMAND_ARG], kCommands[i].name) == 0) { + return kCommands[i].run(argc, argv); + } + } + if (argc > COMMAND_ARG) { + usageFail("unknown command %s", argv[COMMAND_ARG]); + } + usageFail("no command"); +} diff --git a/tests/gate/checkDocsCode.c b/tests/gate/checkDocsCode.c new file mode 100644 index 0000000..f138a46 --- /dev/null +++ b/tests/gate/checkDocsCode.c @@ -0,0 +1,322 @@ +// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. +// See LICENSE.md for license information. +// SPDX-License-Identifier: MIT +// +// checkDocsCode.c - fail when the built manual shows source code with a +// character that is not ASCII. +// +// checkDocsCode MANUAL.html +// +// Asciidoctor's replacements turn `->` into an arrow, `...` into an +// ellipsis, and a backtick next to an apostrophe into a curly quote, even +// inside a backtick code span, so code the reader copies out of the manual +// would not compile. Write such a span as `+text+`, which Asciidoctor +// leaves as it is. +// +// Every and
 element is read with its tags dropped and its
+// character references decoded (numeric ones, and amp, lt, gt, quot, apos,
+// and nbsp; any other named reference counts as a character that is not
+// ASCII). Exit 0 when every one is ASCII, 1 otherwise (each listed, with
+// the start of its text), 2 on bad arguments.
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "toolFile.h"
+
+#define TOOL_NAME     "checkDocsCode"
+#define SAMPLE_CHARS  70u
+#define ASCII_MAX     0x7Fu
+#define CONTROL_END   0x20u
+#define BYTE_MAX      0xFFu
+#define WIDE_MAX      0xFFFFu
+#define TWO_BYTE_MAX  0x7FFu
+#define NBSP          0xA0u
+#define GLYPHS_MAX    64u
+#define TEXT_START    256u
+#define UTF8_CONT     0x80u
+#define UTF8_CONT_MSK 0xC0u
+#define UTF8_TWO      0xC0u
+#define UTF8_THREE    0xE0u
+#define UTF8_FOUR     0xF0u
+#define UTF8_BITS     6u
+#define UTF8_PAYLOAD  0x3Fu
+#define DECIMAL       10
+#define HEX           16
+// What an unknown named reference is counted as: not ASCII.
+#define NAMED_GLYPH   0xFFFFFFFFu
+
+// One code span or block's text: its characters, as code points.
+typedef struct TextT {
+    uint32_t *chars;
+    size_t    len;
+    size_t    cap;
+} TextT;
+
+// The elements whose text is code.
+static const char *kElements[] = { "code", "pre" };
+
+static size_t      decodeRef(const char *at, const char *end, uint32_t *out);
+static size_t      decodeUtf8(const char *at, const char *end, uint32_t *out);
+static const char *elementEnd(const char *at, const char *end, const char *name, size_t nameLen);
+static bool        isNameChar(char c);
+static void        report(const TextT *text, const char *name);
+static void        textAdd(TextT *text, uint32_t c);
+static void        textOf(const char *at, const char *end, TextT *text);
+static void        utf8Put(uint32_t c);
+
+
+// The character reference at "&": its code point in out and its length,
+// or 0 when it is not one (the "&" is then itself).
+static size_t decodeRef(const char *at, const char *end, uint32_t *out) {
+    static const struct {
+        const char *name;
+        uint32_t    c;
+    } kNamed[] = {
+        { "amp", '&' },
+        { "lt", '<' },
+        { "gt", '>' },
+        { "quot", '"' },
+        { "apos", '\'' },
+        { "nbsp", NBSP }
+    };
+    const char *p;
+    const char *semi;
+    char       *stop;
+    size_t      i;
+    int         base;
+
+    semi = memchr(at, ';', (size_t)(end - at));
+    if (semi == NULL || semi == at + 1) {
+        return 0;
+    }
+    p = at + 1;
+    if (*p == '#') {
+        p++;
+        base = (*p == 'x' || *p == 'X') ? HEX : DECIMAL;
+        p   += base == HEX ? 1 : 0;
+        *out = (uint32_t)strtoul(p, &stop, base);
+        return stop == semi && stop != p ? (size_t)(semi - at) + 1u : 0u;
+    }
+    for (i = 0; i < sizeof(kNamed) / sizeof(kNamed[0]); i++) {
+        if ((size_t)(semi - p) == strlen(kNamed[i].name) && memcmp(p, kNamed[i].name, (size_t)(semi - p)) == 0) {
+            *out = kNamed[i].c;
+            return (size_t)(semi - at) + 1u;
+        }
+    }
+    for (; p < semi; p++) {
+        if (!isNameChar(*p)) {
+            return 0;
+        }
+    }
+    *out = NAMED_GLYPH;
+    return (size_t)(semi - at) + 1u;
+}
+
+
+// The UTF-8 character at at: its code point in out and its byte length.
+static size_t decodeUtf8(const char *at, const char *end, uint32_t *out) {
+    uint8_t first;
+    size_t  len;
+    size_t  i;
+
+    first = (uint8_t)*at;
+    if (first <= ASCII_MAX) {
+        *out = first;
+        return 1;
+    }
+    len  = first >= UTF8_FOUR ? 4u : (first >= UTF8_THREE ? 3u : (first >= UTF8_TWO ? 2u : 1u));
+    *out = first & (BYTE_MAX >> (len + 1u));
+    for (i = 1; i < len && at + i < end && ((uint8_t)at[i] & UTF8_CONT_MSK) == UTF8_CONT; i++) {
+        *out = (*out << UTF8_BITS) | ((uint8_t)at[i] & UTF8_PAYLOAD);
+    }
+    return i;
+}
+
+
+// Where the element whose content starts at at ends: its closing tag, or
+// NULL when it has none.
+static const char *elementEnd(const char *at, const char *end, const char *name, size_t nameLen) {
+    const char *p;
+
+    for (p = at; p + 2 + nameLen < end; p++) {
+        if (p[0] == '<' && p[1] == '/' && memcmp(p + 2, name, nameLen) == 0 && p[2 + nameLen] == '>') {
+            return p;
+        }
+    }
+    return NULL;
+}
+
+
+static bool isNameChar(char c) {
+    return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
+}
+
+
+// One element's line: its characters that are not ASCII, and the start of
+// its text quoted.
+static void report(const TextT *text, const char *name) {
+    uint32_t glyphs[GLYPHS_MAX];
+    size_t   count;
+    size_t   i;
+    size_t   j;
+    size_t   k;
+    uint32_t c;
+
+    count = 0;
+    for (i = 0; i < text->len; i++) {
+        c = text->chars[i];
+        if (c <= ASCII_MAX) {
+            continue;
+        }
+        for (j = 0; j < count && glyphs[j] < c; j++) {
+        }
+        if ((j < count && glyphs[j] == c) || count == GLYPHS_MAX) {
+            continue;
+        }
+        for (k = count; k > j; k--) {
+            glyphs[k] = glyphs[k - 1u];
+        }
+        glyphs[j] = c;
+        count++;
+    }
+    fprintf(stderr, "%s:", TOOL_NAME);
+    for (i = 0; i < count; i++) {
+        if (glyphs[i] == NAMED_GLYPH) {
+            fprintf(stderr, " (a named reference)");
+        } else {
+            fprintf(stderr, " U+%04X", (unsigned)glyphs[i]);
+        }
+    }
+    fprintf(stderr, " in <%s>: \"", name);
+    for (i = 0; i < text->len && i < SAMPLE_CHARS; i++) {
+        c = text->chars[i];
+        if (c == '\n') {
+            fputs("\\n", stderr);
+        } else if (c < CONTROL_END) {
+            fprintf(stderr, "\\x%02x", (unsigned)c);
+        } else {
+            utf8Put(c);
+        }
+    }
+    fputs("\"\n", stderr);
+}
+
+
+static void textAdd(TextT *text, uint32_t c) {
+    if (text->len == text->cap) {
+        text->cap   = text->cap == 0 ? TEXT_START : text->cap * 2u;
+        text->chars = (uint32_t *)realloc(text->chars, text->cap * sizeof(uint32_t));
+        if (text->chars == NULL) {
+            toolFail("out of memory");
+        }
+    }
+    text->chars[text->len++] = c;
+}
+
+
+// An element's content as text: its tags dropped, its references and UTF-8
+// decoded.
+static void textOf(const char *at, const char *end, TextT *text) {
+    const char *close;
+    uint32_t    c;
+    size_t      used;
+
+    text->len = 0;
+    while (at < end) {
+        if (*at == '<' && (close = memchr(at, '>', (size_t)(end - at))) != NULL && close > at + 1) {
+            at = close + 1;
+            continue;
+        }
+        used = *at == '&' ? decodeRef(at, end, &c) : 0u;
+        if (used == 0) {
+            used = decodeUtf8(at, end, &c);
+        }
+        textAdd(text, c);
+        at += used;
+    }
+}
+
+
+// A code point to stderr as UTF-8 (an unknown named reference as "?").
+static void utf8Put(uint32_t c) {
+    if (c == NAMED_GLYPH) {
+        fputc('?', stderr);
+    } else if (c <= ASCII_MAX) {
+        fputc((int)c, stderr);
+    } else if (c <= TWO_BYTE_MAX) {
+        fputc((int)(UTF8_TWO | (c >> UTF8_BITS)), stderr);
+        fputc((int)(UTF8_CONT | (c & UTF8_PAYLOAD)), stderr);
+    } else if (c <= WIDE_MAX) {
+        fputc((int)(UTF8_THREE | (c >> (2u * UTF8_BITS))), stderr);
+        fputc((int)(UTF8_CONT | ((c >> UTF8_BITS) & UTF8_PAYLOAD)), stderr);
+        fputc((int)(UTF8_CONT | (c & UTF8_PAYLOAD)), stderr);
+    } else {
+        fputc((int)(UTF8_FOUR | (c >> (3u * UTF8_BITS))), stderr);
+        fputc((int)(UTF8_CONT | ((c >> (2u * UTF8_BITS)) & UTF8_PAYLOAD)), stderr);
+        fputc((int)(UTF8_CONT | ((c >> UTF8_BITS) & UTF8_PAYLOAD)), stderr);
+        fputc((int)(UTF8_CONT | (c & UTF8_PAYLOAD)), stderr);
+    }
+}
+
+
+int main(int argc, char **argv) {
+    uint8_t    *file;
+    size_t      len;
+    const char *page;
+    const char *end;
+    const char *at;
+    const char *open;
+    const char *close;
+    const char *name;
+    size_t      nameLen;
+    size_t      i;
+    size_t      j;
+    uint32_t    bad;
+    TextT       text;
+
+    toolFileName(TOOL_NAME);
+    if (argc != 2) {
+        fprintf(stderr, "Usage:   %s MANUAL.html\nExit:    0 every code span and block is ASCII; 1 otherwise (each one listed).\n", TOOL_NAME);
+        return GATE_USAGE_EXIT;
+    }
+    if (!toolReadFile(argv[1], &file, &len)) {
+        toolFail("cannot read %s", argv[1]);
+    }
+    memset(&text, 0, sizeof(text));
+    page = (const char *)file;
+    end  = page + len;
+    bad  = 0;
+    for (at = page; at < end; at++) {
+        if (*at != '<') {
+            continue;
+        }
+        name    = NULL;
+        nameLen = 0;
+        for (i = 0; i < sizeof(kElements) / sizeof(kElements[0]); i++) {
+            j = strlen(kElements[i]);
+            if (at + 1 + j < end && memcmp(at + 1, kElements[i], j) == 0 && !isNameChar(at[1 + j]) && at[1 + j] != '_') {
+                name    = kElements[i];
+                nameLen = j;
+            }
+        }
+        if (name == NULL || (open = memchr(at, '>', (size_t)(end - at))) == NULL || (close = elementEnd(open + 1, end, name, nameLen)) == NULL) {
+            continue;
+        }
+        textOf(open + 1, close, &text);
+        for (i = 0; i < text.len && text.chars[i] <= ASCII_MAX; i++) {
+        }
+        if (i < text.len) {
+            bad++;
+            report(&text, name);
+        }
+        at = close + 2 + nameLen;
+    }
+    if (bad > 0u) {
+        fprintf(stderr, "%s: %u code span(s) or block(s) not ASCII; write them as `+text+`\n", TOOL_NAME, (unsigned)bad);
+        return 1;
+    }
+    return 0;
+}
diff --git a/tests/gate/common/gateArg.c b/tests/gate/common/gateArg.c
new file mode 100644
index 0000000..5dc2ae7
--- /dev/null
+++ b/tests/gate/common/gateArg.c
@@ -0,0 +1,125 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateArg.c - command lines for the gate tools (gateArg.h).
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "toolFile.h"
+
+#define DECIMAL  10
+#define PORT_MAX 65535L
+
+
+void gateArgBad(const char *name, const char *text) {
+    toolFailCode(GATE_USAGE_EXIT, "bad %s: %s", name, text);
+}
+
+
+double gateArgDouble(const char *name, const char *text) {
+    double value;
+
+    if (!gateArgParseDouble(text, &value)) {
+        gateArgBad(name, text);
+    }
+    return value;
+}
+
+
+long gateArgLong(const char *name, const char *text) {
+    long value;
+
+    if (!gateArgParseLong(text, &value)) {
+        gateArgBad(name, text);
+    }
+    return value;
+}
+
+
+void gateArgPair(const char *name, const char *text, char separator, long *first, long *second) {
+    char  *copy;
+    char  *split;
+    size_t len;
+    bool   good;
+
+    len  = strlen(text);
+    copy = (char *)malloc(len + 1u);
+    if (copy == NULL) {
+        toolFail("out of memory");
+    }
+    memcpy(copy, text, len + 1u);
+    split = strchr(copy, separator);
+    good  = split != NULL && strchr(split + 1, separator) == NULL;
+    if (good) {
+        *split = '\0';
+        good   = gateArgParseLong(copy, first) && gateArgParseLong(split + 1, second);
+    }
+    free(copy);
+    if (!good) {
+        gateArgBad(name, text);
+    }
+}
+
+
+bool gateArgParseDouble(const char *text, double *out) {
+    char *end;
+
+    if (text[0] == '\0' || isspace((unsigned char)text[0])) {
+        return false;
+    }
+    *out = strtod(text, &end);
+    return *end == '\0' && isfinite(*out);
+}
+
+
+bool gateArgParseLong(const char *text, long *out) {
+    char *end;
+
+    if (text[0] == '\0' || isspace((unsigned char)text[0])) {
+        return false;
+    }
+    errno = 0;
+    *out  = strtol(text, &end, DECIMAL);
+    return *end == '\0' && errno != ERANGE;
+}
+
+
+uint16_t gateArgPort(const char *text) {
+    long port;
+
+    port = gateArgLong("port", text);
+    if (port < 0 || port > PORT_MAX) {
+        gateArgBad("port", text);
+    }
+    return (uint16_t)port;
+}
+
+
+bool gateModeRun(const GateModeT *modes, size_t count, int argc, char **argv, int *exitCode) {
+    size_t m;
+
+    for (m = 0; argc >= 2 && m < count; m++) {
+        if (strcmp(argv[1], modes[m].name) == 0 && argc == modes[m].argCount + 2) {
+            *exitCode = modes[m].run(argv + 2);
+            return true;
+        }
+    }
+    return false;
+}
+
+
+int gateUsage(const char *const *lines, size_t count) {
+    size_t i;
+
+    for (i = 0; i < count; i++) {
+        fprintf(stderr, "%s\n", lines[i]);
+    }
+    return GATE_USAGE_EXIT;
+}
diff --git a/tests/gate/common/gateArg.h b/tests/gate/common/gateArg.h
new file mode 100644
index 0000000..f4daf59
--- /dev/null
+++ b/tests/gate/common/gateArg.h
@@ -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
+//
+// gateArg.h - command lines for the gate tools: numbers, pairs of numbers,
+// and ports, each read whole (one that is not is the tool's usage error);
+// tools of several modes; and usage text.
+
+#ifndef GATE_ARG_H
+#define GATE_ARG_H
+
+#include 
+#include 
+#include 
+
+// What a gate tool exits with when its command line is wrong.
+#define GATE_USAGE_EXIT 2
+
+// How many entries an array has.
+#define GATE_COUNT_OF(a) (sizeof(a) / sizeof((a)[0]))
+
+// A tool's mode: its name, how many arguments follow the name, and what it
+// does with them (returning the tool's exit code).
+typedef int (*GateModeFnT)(char **args);
+
+typedef struct GateModeT {
+    const char *name;
+    int         argCount;
+    GateModeFnT run;
+} GateModeT;
+
+// Print ": bad NAME: TEXT" to stderr and exit GATE_USAGE_EXIT.
+void gateArgBad(const char *name, const char *text) __attribute__((noreturn));
+
+// A finite number, all of text (strtod's form); anything else is
+// gateArgBad.
+double gateArgDouble(const char *name, const char *text);
+
+// A whole number, all of text, decimal, in a long's range; anything else is
+// gateArgBad.
+long gateArgLong(const char *name, const char *text);
+
+// Two whole numbers, as gateArgLong reads them, separated by separator
+// ("X,Y" or "WxH"); anything else is gateArgBad.
+void gateArgPair(const char *name, const char *text, char separator, long *first, long *second);
+
+// Whether text is all one finite number, or one whole number as
+// gateArgLong takes them (no white space round it); its value in out.
+bool gateArgParseDouble(const char *text, double *out);
+bool gateArgParseLong(const char *text, long *out);
+
+// A TCP port: a whole number from 0 to 65535; anything else is gateArgBad.
+uint16_t gateArgPort(const char *text);
+
+// Run the mode argv[1] names, given the arguments after it; false (and
+// nothing run) when no mode has that name and that many arguments.
+bool gateModeRun(const GateModeT *modes, size_t count, int argc, char **argv, int *exitCode);
+
+// Print a tool's usage, a line a string, to stderr; returns
+// GATE_USAGE_EXIT.
+int gateUsage(const char *const *lines, size_t count);
+
+#endif
diff --git a/tests/gate/common/gateFft.c b/tests/gate/common/gateFft.c
new file mode 100644
index 0000000..bf4085c
--- /dev/null
+++ b/tests/gate/common/gateFft.c
@@ -0,0 +1,413 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateFft.c - numpy's spectral arithmetic for the gate tools (gateFft.h).
+//
+// The transform is decimation in time over the length's prime factors, each
+// stage's butterfly a direct DFT of its radix (two by sums and differences),
+// with the roots of unity taken from the first octant by symmetry so that
+// the quarter turns are exact. A length with a prime factor past
+// FFT_RADIX_MAX goes through Bluestein's chirp instead: the transform as a
+// convolution, done on a power-of-two transform at least twice as long.
+// numpy's own transform (pocketfft) differs from this one in the last bits
+// of each bin only; nothing a grade prints can tell them apart.
+//
+// numpy's sum is not a running total: it adds a block of up to 8192 values
+// (its buffer) pairwise, and the blocks' sums in turn. gateSum does the
+// same, so means and levels come out to the bit.
+
+#define _DEFAULT_SOURCE
+
+#include 
+#include 
+#include 
+
+#include "gateFft.h"
+#include "toolFile.h"
+
+// The largest prime a stage takes directly; a length with a larger factor
+// is transformed by Bluestein's chirp.
+#define FFT_RADIX_MAX      61u
+#define FFT_RADIX_TWO      2u
+#define FFT_FIRST_ODD      3u
+#define OCTANTS            8u
+#define HALF_TURN_OCTANTS  4u
+#define QUARTER_OCTANTS    2u
+// Bluestein's chirp angles are k^2 over twice the length.
+#define CHIRP_SPAN         2u
+// numpy's sum: blocks of its buffer size, pairwise within them below a
+// block of PAIRWISE_LEAF values unrolled eight ways.
+#define SUM_BLOCK          8192u
+#define PAIRWISE_LEAF      128u
+#define PAIRWISE_WAYS      8u
+
+static void   fftAlloc(double **re, double **im, size_t count);
+static void   fftLoad(GateFftT *fft, const double *in, size_t inLen);
+static void   fftStage(const GateFftT *fft, const double *inRe, const double *inIm, size_t inStride, double *outRe, double *outIm, size_t n, size_t factorAt);
+static void   fftTransform(GateFftT *fft);
+static double pairwiseSum(const double *x, size_t n);
+static void   unitRoot(uint64_t k, uint64_t n, double *re, double *im);
+
+
+// Two arrays of count doubles, zeroed.
+static void fftAlloc(double **re, double **im, size_t count) {
+    *re = (double *)calloc(count, sizeof(double));
+    *im = (double *)calloc(count, sizeof(double));
+    if (*re == NULL || *im == NULL) {
+        toolFail("out of memory");
+    }
+}
+
+
+// numpy.fft.rfft's input: in[0..inLen) zero-padded (or cut) to n real
+// points, transformed into fft->out.
+static void fftLoad(GateFftT *fft, const double *in, size_t inLen) {
+    size_t count;
+
+    count = inLen < fft->n ? inLen : fft->n;
+    memcpy(fft->inRe, in, count * sizeof(double));
+    memset(fft->inRe + count, 0, (fft->n - count) * sizeof(double));
+    memset(fft->inIm, 0, fft->n * sizeof(double));
+    fftTransform(fft);
+}
+
+
+// One decimation-in-time stage: the n points inStride apart from in, as
+// factors[factorAt] interleaved transforms of n / p points (each by the
+// stages after this one), then combined by radix-p butterflies into out.
+static void fftStage(const GateFftT *fft, const double *inRe, const double *inIm, size_t inStride, double *outRe, double *outIm, size_t n, size_t factorAt) {
+    size_t p;
+    size_t m;
+    size_t q;
+    size_t k;
+    size_t r;
+    size_t rootStride;
+    size_t radixStride;
+    size_t root;
+    double re;
+    double im;
+    double sumRe;
+    double sumIm;
+
+    p           = fft->factors[factorAt];
+    m           = n / p;
+    rootStride  = fft->n / n;
+    radixStride = fft->n / p;
+    for (q = 0u; q < p; q++) {
+        if (m == 1u) {
+            outRe[q] = inRe[q * inStride];
+            outIm[q] = inIm[q * inStride];
+        } else {
+            fftStage(fft, inRe + q * inStride, inIm + q * inStride, inStride * p, outRe + q * m, outIm + q * m, m, factorAt + 1u);
+        }
+    }
+    for (k = 0u; k < m; k++) {
+        for (q = 0u; q < p; q++) {
+            re = outRe[q * m + k];
+            im = outIm[q * m + k];
+            if (q == 0u || k == 0u) {
+                fft->spokeRe[q] = re;
+                fft->spokeIm[q] = im;
+            } else {
+                root            = q * k * rootStride;
+                fft->spokeRe[q] = re * fft->rootRe[root] - im * fft->rootIm[root];
+                fft->spokeIm[q] = re * fft->rootIm[root] + im * fft->rootRe[root];
+            }
+        }
+        if (p == FFT_RADIX_TWO) {
+            outRe[k]     = fft->spokeRe[0] + fft->spokeRe[1];
+            outIm[k]     = fft->spokeIm[0] + fft->spokeIm[1];
+            outRe[k + m] = fft->spokeRe[0] - fft->spokeRe[1];
+            outIm[k + m] = fft->spokeIm[0] - fft->spokeIm[1];
+            continue;
+        }
+        for (r = 0u; r < p; r++) {
+            sumRe = fft->spokeRe[0];
+            sumIm = fft->spokeIm[0];
+            for (q = 1u; q < p; q++) {
+                root   = ((q * r) % p) * radixStride;
+                sumRe += fft->spokeRe[q] * fft->rootRe[root] - fft->spokeIm[q] * fft->rootIm[root];
+                sumIm += fft->spokeRe[q] * fft->rootIm[root] + fft->spokeIm[q] * fft->rootRe[root];
+            }
+            outRe[r * m + k] = sumRe;
+            outIm[r * m + k] = sumIm;
+        }
+    }
+}
+
+
+// fft->in into fft->out.
+static void fftTransform(GateFftT *fft) {
+    GateFftT *inner;
+    size_t    k;
+    size_t    m;
+    double    re;
+    double    im;
+
+    if (fft->inner == NULL) {
+        fftStage(fft, fft->inRe, fft->inIm, 1u, fft->outRe, fft->outIm, fft->n, 0u);
+        return;
+    }
+    // Bluestein: X[j] = w[j] * sum(x[k] w[k] conj(w[j - k])), the sum a
+    // convolution with the filter; the inverse transform is the forward
+    // one of the conjugate, conjugated and scaled.
+    inner = fft->inner;
+    m     = inner->n;
+    for (k = 0u; k < m; k++) {
+        if (k < fft->n) {
+            inner->inRe[k] = fft->inRe[k] * fft->chirpRe[k] - fft->inIm[k] * fft->chirpIm[k];
+            inner->inIm[k] = fft->inRe[k] * fft->chirpIm[k] + fft->inIm[k] * fft->chirpRe[k];
+        } else {
+            inner->inRe[k] = 0.0;
+            inner->inIm[k] = 0.0;
+        }
+    }
+    fftTransform(inner);
+    for (k = 0u; k < m; k++) {
+        re             = inner->outRe[k] * fft->filterRe[k] - inner->outIm[k] * fft->filterIm[k];
+        im             = inner->outRe[k] * fft->filterIm[k] + inner->outIm[k] * fft->filterRe[k];
+        inner->inRe[k] = re;
+        inner->inIm[k] = -im;
+    }
+    fftTransform(inner);
+    for (k = 0u; k < fft->n; k++) {
+        re            = inner->outRe[k] / (double)m;
+        im            = -inner->outIm[k] / (double)m;
+        fft->outRe[k] = re * fft->chirpRe[k] - im * fft->chirpIm[k];
+        fft->outIm[k] = re * fft->chirpIm[k] + im * fft->chirpRe[k];
+    }
+}
+
+
+void gateFftAbs(GateFftT *fft, const double *in, size_t inLen, double *magnitude) {
+    size_t k;
+
+    fftLoad(fft, in, inLen);
+    for (k = 0u; k <= fft->n / 2u; k++) {
+        magnitude[k] = hypot(fft->outRe[k], fft->outIm[k]);
+    }
+}
+
+
+void gateFftFree(GateFftT *fft) {
+    if (fft->inner != NULL) {
+        gateFftFree(fft->inner);
+        free(fft->inner);
+    }
+    free(fft->rootRe);
+    free(fft->rootIm);
+    free(fft->inRe);
+    free(fft->inIm);
+    free(fft->outRe);
+    free(fft->outIm);
+    free(fft->spokeRe);
+    free(fft->spokeIm);
+    free(fft->chirpRe);
+    free(fft->chirpIm);
+    free(fft->filterRe);
+    free(fft->filterIm);
+    memset(fft, 0, sizeof(*fft));
+}
+
+
+void gateFftInit(GateFftT *fft, size_t n) {
+    GateFftT *inner;
+    size_t    rest;
+    size_t    p;
+    size_t    k;
+    size_t    m;
+    size_t    largest;
+
+    memset(fft, 0, sizeof(*fft));
+    fft->n = n;
+    rest   = n;
+    // The prime factors, rising (one factor of 1 for a single point).
+    while (rest > 1u && rest % FFT_RADIX_TWO == 0u) {
+        fft->factors[fft->factorCount++] = FFT_RADIX_TWO;
+        rest /= FFT_RADIX_TWO;
+    }
+    for (p = FFT_FIRST_ODD; p * p <= rest; p += FFT_RADIX_TWO) {
+        while (rest % p == 0u) {
+            fft->factors[fft->factorCount++] = p;
+            rest /= p;
+        }
+    }
+    if (rest > 1u || fft->factorCount == 0u) {
+        fft->factors[fft->factorCount++] = rest;
+    }
+    largest = fft->factors[fft->factorCount - 1u];
+    fftAlloc(&fft->inRe, &fft->inIm, n);
+    fftAlloc(&fft->outRe, &fft->outIm, n);
+    if (largest <= FFT_RADIX_MAX) {
+        fftAlloc(&fft->rootRe, &fft->rootIm, n);
+        fftAlloc(&fft->spokeRe, &fft->spokeIm, largest);
+        for (k = 0u; k < n; k++) {
+            unitRoot(k, n, &fft->rootRe[k], &fft->rootIm[k]);
+        }
+        return;
+    }
+    // Bluestein: the chirp w[k] = exp(-i pi k^2 / n), and the filter, the
+    // transform of conj(w) laid out circularly over a power of two m of at
+    // least 2n - 1 points.
+    m = 1u;
+    while (m < CHIRP_SPAN * n - 1u) {
+        m *= FFT_RADIX_TWO;
+    }
+    inner = (GateFftT *)malloc(sizeof(GateFftT));
+    if (inner == NULL) {
+        toolFail("out of memory");
+    }
+    gateFftInit(inner, m);
+    fft->inner = inner;
+    fftAlloc(&fft->chirpRe, &fft->chirpIm, n);
+    fftAlloc(&fft->filterRe, &fft->filterIm, m);
+    for (k = 0u; k < n; k++) {
+        unitRoot(((uint64_t)k * k) % ((uint64_t)CHIRP_SPAN * n), (uint64_t)CHIRP_SPAN * n, &fft->chirpRe[k], &fft->chirpIm[k]);
+    }
+    for (k = 0u; k < n; k++) {
+        inner->inRe[k] = fft->chirpRe[k];
+        inner->inIm[k] = -fft->chirpIm[k];
+        if (k > 0u) {
+            inner->inRe[m - k] = fft->chirpRe[k];
+            inner->inIm[m - k] = -fft->chirpIm[k];
+        }
+    }
+    fftTransform(inner);
+    memcpy(fft->filterRe, inner->outRe, m * sizeof(double));
+    memcpy(fft->filterIm, inner->outIm, m * sizeof(double));
+}
+
+
+void gateFftReal(GateFftT *fft, const double *in, size_t inLen, double *re, double *im) {
+    fftLoad(fft, in, inLen);
+    memcpy(re, fft->outRe, (fft->n / 2u + 1u) * sizeof(double));
+    memcpy(im, fft->outIm, (fft->n / 2u + 1u) * sizeof(double));
+}
+
+
+void gateHanning(double *window, size_t n) {
+    size_t k;
+    double size;
+
+    if (n == 1u) {
+        window[0] = 1.0;
+        return;
+    }
+    // 0.5 + 0.5 cos(pi m / (n - 1)) for m = 1 - n, 3 - n, ... n - 1.
+    size = (double)n;
+    for (k = 0u; k < n; k++) {
+        window[k] = 0.5 + 0.5 * cos(M_PI * ((1.0 - size) + 2.0 * (double)k) / (size - 1.0));
+    }
+}
+
+
+double gateMean(const double *x, size_t n) {
+    return gateSum(x, n) / (double)n;
+}
+
+
+double gateRfftFreq(size_t bin, size_t n, uint32_t rate) {
+    double spacing;
+
+    spacing = 1.0 / ((double)n * (1.0 / (double)rate));
+    return (double)bin * spacing;
+}
+
+
+double gateSum(const double *x, size_t n) {
+    double total;
+    size_t at;
+    size_t count;
+
+    total = 0.0;
+    for (at = 0u; at < n; at += SUM_BLOCK) {
+        count  = n - at < SUM_BLOCK ? n - at : SUM_BLOCK;
+        total += pairwiseSum(x + at, count);
+    }
+    return total;
+}
+
+
+// numpy's pairwise_sum (loops_utils.h).
+static double pairwiseSum(const double *x, size_t n) {
+    double sums[PAIRWISE_WAYS];
+    double total;
+    size_t i;
+    size_t j;
+    size_t half;
+
+    if (n < PAIRWISE_WAYS) {
+        total = 0.0;
+        for (i = 0u; i < n; i++) {
+            total += x[i];
+        }
+        return total;
+    }
+    if (n <= PAIRWISE_LEAF) {
+        for (j = 0u; j < PAIRWISE_WAYS; j++) {
+            sums[j] = x[j];
+        }
+        for (i = PAIRWISE_WAYS; i < n - n % PAIRWISE_WAYS; i += PAIRWISE_WAYS) {
+            for (j = 0u; j < PAIRWISE_WAYS; j++) {
+                sums[j] += x[i + j];
+            }
+        }
+        total = ((sums[0] + sums[1]) + (sums[2] + sums[3])) + ((sums[4] + sums[5]) + (sums[6] + sums[7]));
+        for (; i < n; i++) {
+            total += x[i];
+        }
+        return total;
+    }
+    half  = n / 2u;
+    half -= half % PAIRWISE_WAYS;
+    return pairwiseSum(x, half) + pairwiseSum(x + half, n - half);
+}
+
+
+// exp(-2 pi i k / n) for k < n, from the first octant: the angle k / n of a
+// turn is reflected (as eighths: 8k against n) into [0, 1/8], so the
+// quarter turns come out exact and both halves of a turn agree.
+static void unitRoot(uint64_t k, uint64_t n, double *re, double *im) {
+    uint64_t eighths;
+    double   angle;
+    double   c;
+    double   s;
+    double   swap;
+    bool     negSin;
+    bool     negCos;
+    bool     swapped;
+
+    eighths = k * OCTANTS;
+    negSin  = false;
+    negCos  = false;
+    swapped = false;
+    if (eighths > HALF_TURN_OCTANTS * n) {
+        eighths = OCTANTS * n - eighths;
+        negSin  = true;
+    }
+    if (eighths > QUARTER_OCTANTS * n) {
+        eighths = HALF_TURN_OCTANTS * n - eighths;
+        negCos  = true;
+    }
+    if (eighths > n) {
+        eighths = QUARTER_OCTANTS * n - eighths;
+        swapped = true;
+    }
+    angle = 2.0 * M_PI * (double)eighths / (double)(OCTANTS * n);
+    c     = cos(angle);
+    s     = sin(angle);
+    if (swapped) {
+        swap = c;
+        c    = s;
+        s    = swap;
+    }
+    if (negCos) {
+        c = -c;
+    }
+    if (negSin) {
+        s = -s;
+    }
+    *re = c;
+    *im = -s;
+}
diff --git a/tests/gate/common/gateFft.h b/tests/gate/common/gateFft.h
new file mode 100644
index 0000000..88fe5fb
--- /dev/null
+++ b/tests/gate/common/gateFft.h
@@ -0,0 +1,70 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateFft.h - numpy's spectral arithmetic for the gate tools: the discrete
+// Fourier transform of any length, and the pieces of numpy around it that
+// the graders lean on (its sum, its Hann window, its bin frequencies), each
+// computed in the order numpy computes it so a grade reads as it did.
+
+#ifndef GATE_FFT_H
+#define GATE_FFT_H
+
+#include 
+#include 
+#include 
+
+// Prime factors a length can have (2^64 needs 64).
+#define GATE_FFT_FACTORS_MAX 64u
+
+// A transform of n points: mixed radix over its prime factors (the roots
+// of unity, and a butterfly's twiddled inputs, its spokes), or, when one of
+// them is too large for a direct butterfly, Bluestein's chirp through a
+// power-of-two transform (inner).
+typedef struct GateFftT {
+    size_t           n;
+    size_t           factorCount;
+    size_t           factors[GATE_FFT_FACTORS_MAX];
+    double          *rootRe;
+    double          *rootIm;
+    double          *inRe;
+    double          *inIm;
+    double          *outRe;
+    double          *outIm;
+    double          *spokeRe;
+    double          *spokeIm;
+    struct GateFftT *inner;
+    double          *chirpRe;
+    double          *chirpIm;
+    double          *filterRe;
+    double          *filterIm;
+} GateFftT;
+
+// numpy.abs(numpy.fft.rfft(in[0..inLen), fft->n)): the n / 2 + 1 bins'
+// magnitudes (hypot, as numpy takes them).
+void gateFftAbs(GateFftT *fft, const double *in, size_t inLen, double *magnitude);
+
+void gateFftFree(GateFftT *fft);
+
+// A plan for transforms of n (at least 1) points. Running out of memory is
+// fatal (toolFail).
+void gateFftInit(GateFftT *fft, size_t n);
+
+// numpy.fft.rfft(in[0..inLen), fft->n): the n / 2 + 1 bins, the input
+// zero-padded (or cut) to n points.
+void gateFftReal(GateFftT *fft, const double *in, size_t inLen, double *re, double *im);
+
+// numpy.hanning(n): the symmetric Hann window.
+void gateHanning(double *window, size_t n);
+
+// numpy.mean of n doubles: gateSum / n.
+double gateMean(const double *x, size_t n);
+
+// numpy.fft.rfftfreq(n, 1.0 / rate)[bin].
+double gateRfftFreq(size_t bin, size_t n, uint32_t rate);
+
+// numpy.sum of n doubles: pairwise (eight running sums, halved past 128),
+// a buffer (8192) at a time, the buffers' sums added in turn.
+double gateSum(const double *x, size_t n);
+
+#endif
diff --git a/tests/gate/common/gateImage.c b/tests/gate/common/gateImage.c
new file mode 100644
index 0000000..af17ff4
--- /dev/null
+++ b/tests/gate/common/gateImage.c
@@ -0,0 +1,682 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateImage.c - screenshots and icons for the gate tools (gateImage.h).
+//
+// The rules the pictures are read by (checked once against the Python
+// gates these tools replaced):
+//   - A PNG goes through toolPng to RGBA: grey spread to RGB, 16-bit
+//     samples cut to their high byte, palette entries looked up.
+//   - A BMP is an uncompressed DIB: 1, 4, or 8 bits through its colour
+//     table (an index past the table is black), or 24 or 32 bits of BGR; it
+//     is opaque.
+//   - An ICO's directory is sorted largest picture first and, among equals,
+//     fewest colours first (a stable sort); its default picture is the first.
+//     A picture that is a PNG is decoded as one. A DIB picture is its XOR
+//     bitmap (half the height its header gives), its alpha the 32-bit
+//     picture's fourth bytes or else its AND mask (a set bit transparent),
+//     which is the last bytes of the picture as the directory sizes it.
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+#include "toolPng.h"
+
+#define BYTE_BITS          8u
+#define LE16_BYTES         2u
+#define SAMPLE_MAX         255u
+#define RED_SHIFT          16u
+#define GREEN_SHIFT        8u
+#define NIBBLE_MASK        0xFu
+#define NIBBLE_BITS        4u
+// A 4-bit gun widened to 8 bits: 0xF x 17 is 0xFF.
+#define NIBBLE_WIDEN       17u
+#define PIXELS_INITIAL     256u
+
+// KEYS's palette 0: a grey ramp (n in each gun), then black, dark grey,
+// green, and white at 0 to 3 and red at 15 (examples/keys buildPalette).
+#define KEYS_BLACK         0x000u
+#define KEYS_UNLIT         0x333u
+#define KEYS_LIT           0x0F0u
+#define KEYS_CURSOR        0xFFFu
+#define KEYS_RED           0xF00u
+#define KEYS_RED_INDEX     15u
+#define KEYS_SENTINEL      0xA5u
+#define KEYS_COUNT_MAX     255u
+#define ASCII_MAX          0x7Fu
+
+// File magic.
+#define PNG_MAGIC          "\x89PNG\r\n\x1a\n"
+#define PNG_MAGIC_BYTES    8u
+#define BMP_MAGIC          "BM"
+#define BMP_MAGIC_BYTES    2u
+#define ICO_MAGIC          "\0\0\1\0"
+#define ICO_MAGIC_BYTES    4u
+
+// BMP: the file header, and where its pixels start.
+#define BMP_FILE_HEADER    14u
+#define BMP_PIXELS_AT      10u
+
+// A DIB's BITMAPINFOHEADER and colour table.
+#define DIB_HEADER_MIN     40u
+#define DIB_WIDTH_AT       4u
+#define DIB_HEIGHT_AT      8u
+#define DIB_BITS_AT        14u
+#define DIB_COMPRESSION_AT 16u
+#define DIB_COLOURS_AT     32u
+#define DIB_RGB            0u
+#define DIB_ENTRY_BYTES    4u
+#define DIB_ROW_BITS       32u
+#define DIB_BLUE           0u
+#define DIB_GREEN          1u
+#define DIB_RED            2u
+#define DIB_PALETTE_MAX    256u
+#define BITS_1             1u
+#define BITS_4             4u
+#define BITS_8             8u
+#define BITS_24            24u
+#define BITS_32            32u
+#define TOP_BIT            0x80u
+
+// ICO: the directory and its entries (a 0 side means 256).
+#define ICO_COUNT_AT       4u
+#define ICO_DIR_BYTES      6u
+#define ICO_ENTRY_BYTES    16u
+#define ICO_ENTRY_W        0u
+#define ICO_ENTRY_H        1u
+#define ICO_ENTRY_COLOURS  2u
+#define ICO_ENTRY_BPP      6u
+#define ICO_ENTRY_SIZE     8u
+#define ICO_ENTRY_OFFSET   12u
+#define ICO_SIDE_ZERO      256u
+#define ICO_DEPTH_NONE     256u
+// gateImageLoad's ICO picture: the default, the sorted directory's first;
+// and a picture of any depth.
+#define ICO_DEFAULT_SIDE   0u
+#define ICO_ANY_BPP        0u
+
+// A DIB's layout: the picture's size (the XOR half of an icon's), its bits
+// a pixel, row order, and where its rows and its colour table are.
+typedef struct DibT {
+    uint32_t width;
+    uint32_t height;
+    uint32_t bits;
+    bool     bottomUp;
+    size_t   paletteAt;
+    uint32_t paletteCount;
+    size_t   pixelsAt;
+    size_t   stride;
+} DibT;
+
+// An ICO directory entry: its picture's size and depth, and where it is.
+typedef struct IcoEntryT {
+    uint32_t width;
+    uint32_t height;
+    uint32_t colourDepth;
+    uint32_t bpp;
+    uint32_t size;
+    uint32_t offset;
+} IcoEntryT;
+
+static void     bmpDecode(const uint8_t *data, size_t len, const char *path, GateImageT *img);
+static void     dibDecode(const uint8_t *data, size_t len, const DibT *dib, const char *path, GateImageT *img);
+static void     dibRead(const uint8_t *data, size_t len, size_t headerAt, size_t pixelsAt, bool icon, const char *path, DibT *dib);
+static void     icoAndMask(const uint8_t *data, size_t len, const IcoEntryT *entry, const char *path, GateImageT *img);
+static void     icoDecode(const uint8_t *data, size_t len, uint32_t side, uint32_t bpp, const char *path, GateImageT *img);
+static uint32_t icoDepth(uint32_t bpp, uint32_t colours);
+static bool     icoEntryBefore(const IcoEntryT *a, const IcoEntryT *b);
+static uint32_t iconAnchor(const GateImageT *screen, const GateIconT *icon, uint32_t tolerance);
+static bool     iconAt(const GateImageT *screen, const GateIconT *icon, uint32_t tolerance, uint32_t left, uint32_t top);
+static void     imageAlloc(GateImageT *img, uint32_t width, uint32_t height);
+static bool     magic(const uint8_t *data, size_t len, const char *bytes, size_t count);
+static int      packedCompare(const void *a, const void *b);
+static void     pngDecode(const uint8_t *data, size_t len, const char *path, GateImageT *img);
+static bool     within(const uint8_t *a, const uint8_t *b, uint32_t tolerance);
+
+
+// A BMP file: its DIB, whose rows start where the file header says.
+static void bmpDecode(const uint8_t *data, size_t len, const char *path, GateImageT *img) {
+    DibT dib;
+
+    if (len < BMP_FILE_HEADER) {
+        toolFail("%s: truncated BMP", path);
+    }
+    dibRead(data, len, BMP_FILE_HEADER, gateLe32(data + BMP_PIXELS_AT), false, path, &dib);
+    dibDecode(data, len, &dib, path, img);
+}
+
+
+// A DIB's rows as RGBA, opaque.
+static void dibDecode(const uint8_t *data, size_t len, const DibT *dib, const char *path, GateImageT *img) {
+    const uint8_t *row;
+    const uint8_t *entry;
+    uint8_t       *out;
+    uint32_t       x;
+    uint32_t       y;
+    uint32_t       index;
+    uint32_t       shift;
+
+    if (dib->pixelsAt > len || dib->stride * dib->height > len - dib->pixelsAt) {
+        toolFail("%s: truncated picture", path);
+    }
+    imageAlloc(img, dib->width, dib->height);
+    for (y = 0; y < dib->height; y++) {
+        row = data + dib->pixelsAt + dib->stride * (dib->bottomUp ? dib->height - 1u - y : y);
+        for (x = 0; x < dib->width; x++) {
+            out = img->rgba + ((size_t)y * dib->width + x) * GATE_RGBA_BYTES;
+            if (dib->bits >= BITS_24) {
+                entry = row + (size_t)x * (dib->bits / BYTE_BITS);
+            } else {
+                shift = BYTE_BITS - dib->bits - (x * dib->bits) % BYTE_BITS;
+                index = (row[(x * dib->bits) / BYTE_BITS] >> shift) & ((1u << dib->bits) - 1u);
+                entry = (index < dib->paletteCount) ? data + dib->paletteAt + (size_t)index * DIB_ENTRY_BYTES : NULL;
+            }
+            if (entry != NULL) {
+                out[0] = entry[DIB_RED];
+                out[1] = entry[DIB_GREEN];
+                out[2] = entry[DIB_BLUE];
+            }
+            out[GATE_ALPHA] = SAMPLE_MAX;
+        }
+    }
+}
+
+
+// A DIB's BITMAPINFOHEADER at headerAt: an icon's picture is the upper
+// half of the height it gives, and its rows follow its colour table
+// (pixelsAt 0); a BMP file says where its rows are.
+static void dibRead(const uint8_t *data, size_t len, size_t headerAt, size_t pixelsAt, bool icon, const char *path, DibT *dib) {
+    const uint8_t *h;
+    uint32_t       headerBytes;
+    int32_t        height;
+    uint32_t       colours;
+
+    if (headerAt > len || len - headerAt < DIB_HEADER_MIN) {
+        toolFail("%s: truncated bitmap header", path);
+    }
+    h           = data + headerAt;
+    headerBytes = gateLe32(h);
+    height      = (int32_t)gateLe32(h + DIB_HEIGHT_AT);
+    dib->width  = gateLe32(h + DIB_WIDTH_AT);
+    dib->bits   = gateLe16(h + DIB_BITS_AT);
+    colours     = gateLe32(h + DIB_COLOURS_AT);
+    if (headerBytes < DIB_HEADER_MIN || gateLe32(h + DIB_COMPRESSION_AT) != DIB_RGB || (int32_t)dib->width <= 0 || height == 0) {
+        toolFail("%s: not an uncompressed bitmap", path);
+    }
+    if (dib->bits != BITS_1 && dib->bits != BITS_4 && dib->bits != BITS_8 && dib->bits != BITS_24 && dib->bits != BITS_32) {
+        toolFail("%s: %u bits a pixel is not read", path, (unsigned)dib->bits);
+    }
+    dib->bottomUp     = height > 0;
+    dib->height       = (uint32_t)(height > 0 ? height : -height);
+    dib->paletteAt    = headerAt + headerBytes;
+    dib->paletteCount = 0u;
+    if (dib->bits <= BITS_8) {
+        dib->paletteCount = (colours != 0u) ? colours : (1u << dib->bits);
+        if (dib->paletteCount > DIB_PALETTE_MAX || dib->paletteAt > len || (size_t)dib->paletteCount * DIB_ENTRY_BYTES > len - dib->paletteAt) {
+            toolFail("%s: bad colour table", path);
+        }
+    }
+    if (icon) {
+        dib->height /= 2u;
+    }
+    dib->pixelsAt = (pixelsAt != 0u) ? pixelsAt : dib->paletteAt + (size_t)dib->paletteCount * DIB_ENTRY_BYTES;
+    dib->stride   = (((size_t)dib->width * dib->bits + DIB_ROW_BITS - 1u) / DIB_ROW_BITS) * (DIB_ROW_BITS / BYTE_BITS);
+}
+
+
+uint32_t gateDistance(const uint8_t *a, const uint8_t *b) {
+    uint32_t sum;
+    int32_t  d;
+    uint32_t i;
+
+    sum = 0u;
+    for (i = 0; i < GATE_RGB_BYTES; i++) {
+        d    = (int32_t)a[i] - (int32_t)b[i];
+        sum += (uint32_t)(d * d);
+    }
+    return sum;
+}
+
+
+void gateHexPrint(const uint8_t *data, size_t len) {
+    size_t i;
+
+    for (i = 0; i < len; i++) {
+        printf("%02X", (unsigned)data[i]);
+    }
+}
+
+
+void gateIconAdd(GateIconT *icon, uint32_t x, uint32_t y, const uint8_t *rgb) {
+    GateIconPixelT *grown;
+
+    if (icon->count == icon->cap) {
+        icon->cap = (icon->cap == 0u) ? PIXELS_INITIAL : icon->cap * 2u;
+        grown     = (GateIconPixelT *)realloc(icon->pixels, (size_t)icon->cap * sizeof(GateIconPixelT));
+        if (grown == NULL) {
+            toolFail("out of memory");
+        }
+        icon->pixels = grown;
+    }
+    icon->pixels[icon->count].x = x;
+    icon->pixels[icon->count].y = y;
+    memcpy(icon->pixels[icon->count].rgb, rgb, GATE_RGB_BYTES);
+    icon->count++;
+}
+
+
+bool gateIconFind(const GateImageT *screen, const GateIconT *icon, uint32_t tolerance, uint32_t *left, uint32_t *top) {
+    const GateIconPixelT *a;
+    uint32_t              x;
+    uint32_t              y;
+
+    if (icon->count == 0u) {
+        toolFail("the icon has no opaque pixel");
+    }
+    a = &icon->pixels[iconAnchor(screen, icon, tolerance)];
+    for (y = a->y; y < screen->height; y++) {
+        for (x = a->x; x < screen->width; x++) {
+            if (within(gateImagePixel(screen, x, y), a->rgb, tolerance) && iconAt(screen, icon, tolerance, x - a->x, y - a->y)) {
+                *left = x - a->x;
+                *top  = y - a->y;
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+
+void gateIconFree(GateIconT *icon) {
+    free(icon->pixels);
+    icon->pixels = NULL;
+    icon->count  = 0u;
+    icon->cap    = 0u;
+}
+
+
+void gateImageFree(GateImageT *img) {
+    free(img->rgba);
+    img->rgba = NULL;
+}
+
+
+bool gateImageHas(const GateImageT *img, long x, long y) {
+    return x >= 0 && y >= 0 && x < (long)img->width && y < (long)img->height;
+}
+
+
+void gateImageLoad(const char *path, GateImageT *img) {
+    gateImageLoadIcon(path, ICO_DEFAULT_SIDE, ICO_ANY_BPP, img);
+}
+
+
+// A picture file by its magic: PNG, BMP, or ICO (side and bpp choose an
+// ICO's picture).
+void gateImageLoadIcon(const char *path, uint32_t side, uint32_t bpp, GateImageT *img) {
+    uint8_t *data;
+    size_t   len;
+
+    if (!toolReadFile(path, &data, &len)) {
+        toolFail("cannot read %s", path);
+    }
+    if (magic(data, len, PNG_MAGIC, PNG_MAGIC_BYTES)) {
+        pngDecode(data, len, path, img);
+    } else if (magic(data, len, BMP_MAGIC, BMP_MAGIC_BYTES)) {
+        bmpDecode(data, len, path, img);
+    } else if (magic(data, len, ICO_MAGIC, ICO_MAGIC_BYTES)) {
+        icoDecode(data, len, side, bpp, path, img);
+    } else {
+        toolFail("cannot identify image file %s", path);
+    }
+    free(data);
+}
+
+
+const uint8_t *gateImagePixel(const GateImageT *img, uint32_t x, uint32_t y) {
+    return img->rgba + ((size_t)y * img->width + x) * GATE_RGBA_BYTES;
+}
+
+
+void gateKeysExpect(const char *target, ToolBufT *expect) {
+    size_t len;
+    size_t i;
+    bool   ascii;
+
+    len   = strlen(target);
+    ascii = true;
+    for (i = 0; i < len; i++) {
+        ascii = ascii && (uint8_t)target[i] <= ASCII_MAX;
+    }
+    if (!ascii || len > KEYS_COUNT_MAX) {
+        gateArgBad("target", target);
+    }
+    toolBufByte(expect, KEYS_SENTINEL);
+    toolBufByte(expect, KEYS_SENTINEL);
+    toolBufByte(expect, (uint32_t)len);
+    toolBufByte(expect, 0u);
+    toolBufAdd(expect, target, len);
+}
+
+
+void gateKeysPalette(uint16_t words[GATE_KEYS_COLOURS]) {
+    uint32_t i;
+
+    for (i = 0; i < GATE_KEYS_COLOURS; i++) {
+        words[i] = (uint16_t)((i << (2u * NIBBLE_BITS)) | (i << NIBBLE_BITS) | i);
+    }
+    words[0]              = KEYS_BLACK;
+    words[1]              = KEYS_UNLIT;
+    words[2]              = KEYS_LIT;
+    words[3]              = KEYS_CURSOR;
+    words[KEYS_RED_INDEX] = KEYS_RED;
+}
+
+
+uint32_t gateLe16(const uint8_t *p) {
+    return (uint32_t)p[0] | ((uint32_t)p[1] << BYTE_BITS);
+}
+
+
+uint32_t gateLe32(const uint8_t *p) {
+    return gateLe16(p) | (gateLe16(p + LE16_BYTES) << (LE16_BYTES * BYTE_BITS));
+}
+
+
+uint32_t gateNearest(const uint8_t *rgb, const uint8_t (*palette)[GATE_RGB_BYTES], uint32_t count, uint32_t *distance) {
+    uint32_t best;
+    uint32_t bestDistance;
+    uint32_t d;
+    uint32_t i;
+
+    best         = 0u;
+    bestDistance = 0u;
+    for (i = 0; i < count; i++) {
+        d = gateDistance(rgb, palette[i]);
+        if (i == 0u || d < bestDistance) {
+            best         = i;
+            bestDistance = d;
+        }
+    }
+    *distance = bestDistance;
+    return best;
+}
+
+
+void gateRgb12(uint32_t word, uint8_t *rgb) {
+    rgb[0] = (uint8_t)(((word >> (2u * NIBBLE_BITS)) & NIBBLE_MASK) * NIBBLE_WIDEN);
+    rgb[1] = (uint8_t)(((word >> NIBBLE_BITS) & NIBBLE_MASK) * NIBBLE_WIDEN);
+    rgb[2] = (uint8_t)((word & NIBBLE_MASK) * NIBBLE_WIDEN);
+}
+
+
+// The alpha of an icon's picture of fewer than 32 bits, from its AND mask:
+// the last bytes of the picture's, rows padded to 32 bits, bottom row
+// first; a set bit is transparent.
+static void icoAndMask(const uint8_t *data, size_t len, const IcoEntryT *entry, const char *path, GateImageT *img) {
+    uint8_t *out;
+    size_t   rowBytes;
+    size_t   total;
+    size_t   maskAt;
+    uint32_t x;
+    uint32_t y;
+    uint8_t  bits;
+
+    rowBytes = (((size_t)img->width + DIB_ROW_BITS - 1u) / DIB_ROW_BITS) * (DIB_ROW_BITS / BYTE_BITS);
+    total    = rowBytes * img->height;
+    if ((size_t)entry->offset + entry->size > len || total > (size_t)entry->offset + entry->size) {
+        toolFail("%s: truncated icon mask", path);
+    }
+    maskAt = (size_t)entry->offset + entry->size - total;
+    for (y = 0; y < img->height; y++) {
+        for (x = 0; x < img->width; x++) {
+            bits            = data[maskAt + rowBytes * (img->height - 1u - y) + x / BYTE_BITS];
+            out             = img->rgba + ((size_t)y * img->width + x) * GATE_RGBA_BYTES;
+            out[GATE_ALPHA] = (bits & (TOP_BIT >> (x % BYTE_BITS))) ? 0u : SAMPLE_MAX;
+        }
+    }
+}
+
+
+// An ICO's picture: the first in the sorted directory side x side at bpp
+// bits (any bits for ICO_ANY_BPP), else (or for side ICO_DEFAULT_SIDE) the
+// first of all.
+static void icoDecode(const uint8_t *data, size_t len, uint32_t side, uint32_t bpp, const char *path, GateImageT *img) {
+    IcoEntryT     *entries;
+    IcoEntryT      held;
+    const uint8_t *e;
+    uint32_t       count;
+    uint32_t       i;
+    uint32_t       j;
+    uint32_t       chosen;
+    uint32_t       x;
+    uint32_t       y;
+    DibT           dib;
+
+    count = gateLe16(data + ICO_COUNT_AT);
+    if (count == 0u || len < ICO_DIR_BYTES + (size_t)count * ICO_ENTRY_BYTES) {
+        toolFail("%s: truncated icon directory", path);
+    }
+    entries = (IcoEntryT *)malloc((size_t)count * sizeof(IcoEntryT));
+    if (entries == NULL) {
+        toolFail("out of memory");
+    }
+    for (i = 0; i < count; i++) {
+        e                      = data + ICO_DIR_BYTES + (size_t)i * ICO_ENTRY_BYTES;
+        entries[i].width       = (e[ICO_ENTRY_W] != 0u) ? e[ICO_ENTRY_W] : ICO_SIDE_ZERO;
+        entries[i].height      = (e[ICO_ENTRY_H] != 0u) ? e[ICO_ENTRY_H] : ICO_SIDE_ZERO;
+        entries[i].bpp         = gateLe16(e + ICO_ENTRY_BPP);
+        entries[i].colourDepth = icoDepth(entries[i].bpp, e[ICO_ENTRY_COLOURS]);
+        entries[i].size        = gateLe32(e + ICO_ENTRY_SIZE);
+        entries[i].offset      = gateLe32(e + ICO_ENTRY_OFFSET);
+        // An insertion sort, which keeps equals in the directory's order.
+        held = entries[i];
+        for (j = i; j > 0u && icoEntryBefore(&held, &entries[j - 1u]); j--) {
+            entries[j] = entries[j - 1u];
+        }
+        entries[j] = held;
+    }
+    chosen = 0u;
+    for (i = 0; i < count && side != ICO_DEFAULT_SIDE; i++) {
+        if (entries[i].width == side && entries[i].height == side && (bpp == ICO_ANY_BPP || bpp == entries[i].colourDepth)) {
+            chosen = i;
+            break;
+        }
+    }
+    held = entries[chosen];
+    free(entries);
+    if (held.offset >= len) {
+        toolFail("%s: truncated icon", path);
+    }
+    if (magic(data + held.offset, len - held.offset, PNG_MAGIC, PNG_MAGIC_BYTES)) {
+        pngDecode(data + held.offset, len - held.offset, path, img);
+        return;
+    }
+    dibRead(data, len, held.offset, 0u, true, path, &dib);
+    dibDecode(data, len, &dib, path, img);
+    if (held.bpp != BITS_32) {
+        icoAndMask(data, len, &held, path, img);
+        return;
+    }
+    for (y = 0; y < img->height; y++) {
+        for (x = 0; x < img->width; x++) {
+            img->rgba[((size_t)y * img->width + x) * GATE_RGBA_BYTES + GATE_ALPHA] = data[dib.pixelsAt + ((size_t)(img->height - 1u - y) * img->width + x) * GATE_RGBA_BYTES + GATE_ALPHA];
+        }
+    }
+}
+
+
+// An ICO entry's colour depth: its bits a pixel, or else the bits its
+// colour count needs, or else ICO_DEPTH_NONE.
+static uint32_t icoDepth(uint32_t bpp, uint32_t colours) {
+    uint32_t depth;
+
+    if (bpp != 0u) {
+        return bpp;
+    }
+    depth = 0u;
+    while (depth < BITS_32 && (1u << depth) < colours) {
+        depth++;
+    }
+    return (depth != 0u) ? depth : ICO_DEPTH_NONE;
+}
+
+
+// Whether entry a goes before b: bigger first, then fewer colours.
+static bool icoEntryBefore(const IcoEntryT *a, const IcoEntryT *b) {
+    uint32_t squareA;
+    uint32_t squareB;
+
+    squareA = a->width * a->height;
+    squareB = b->width * b->height;
+    if (squareA != squareB) {
+        return squareA > squareB;
+    }
+    return a->colourDepth < b->colourDepth;
+}
+
+
+// The icon's opaque pixel whose colour (within tolerance) the screen holds
+// least, the first of equals: the screen's colours are counted once, sorted.
+static uint32_t iconAnchor(const GateImageT *screen, const GateIconT *icon, uint32_t tolerance) {
+    uint32_t      *packed;
+    uint32_t      *counts;
+    const uint8_t *pixel;
+    uint8_t        rgb[GATE_RGB_BYTES];
+    size_t         pixelCount;
+    size_t         i;
+    uint32_t       unique;
+    uint32_t       u;
+    uint32_t       p;
+    uint32_t       count;
+    uint32_t       bestCount;
+    uint32_t       anchor;
+
+    pixelCount = (size_t)screen->width * screen->height;
+    packed     = (uint32_t *)malloc((pixelCount + 1u) * sizeof(uint32_t));
+    counts     = (uint32_t *)malloc((pixelCount + 1u) * sizeof(uint32_t));
+    if (packed == NULL || counts == NULL) {
+        toolFail("out of memory");
+    }
+    for (i = 0; i < pixelCount; i++) {
+        pixel     = screen->rgba + i * GATE_RGBA_BYTES;
+        packed[i] = ((uint32_t)pixel[0] << RED_SHIFT) | ((uint32_t)pixel[1] << GREEN_SHIFT) | pixel[2];
+    }
+    qsort(packed, pixelCount, sizeof(uint32_t), packedCompare);
+    unique = 0u;
+    for (i = 0; i < pixelCount; i++) {
+        if (unique > 0u && packed[unique - 1u] == packed[i]) {
+            counts[unique - 1u]++;
+        } else {
+            packed[unique] = packed[i];
+            counts[unique] = 1u;
+            unique++;
+        }
+    }
+    anchor    = 0u;
+    bestCount = 0u;
+    for (p = 0; p < icon->count; p++) {
+        count = 0u;
+        for (u = 0; u < unique; u++) {
+            rgb[0] = (uint8_t)(packed[u] >> RED_SHIFT);
+            rgb[1] = (uint8_t)(packed[u] >> GREEN_SHIFT);
+            rgb[2] = (uint8_t)packed[u];
+            if (within(rgb, icon->pixels[p].rgb, tolerance)) {
+                count += counts[u];
+            }
+        }
+        if (p == 0u || count < bestCount) {
+            anchor    = p;
+            bestCount = count;
+        }
+    }
+    free(packed);
+    free(counts);
+    return anchor;
+}
+
+
+// Whether the screen shows the icon with its top left at (left, top): it
+// lies wholly on the screen and every opaque pixel is within tolerance.
+static bool iconAt(const GateImageT *screen, const GateIconT *icon, uint32_t tolerance, uint32_t left, uint32_t top) {
+    const GateIconPixelT *q;
+    uint32_t              p;
+    uint32_t              x;
+    uint32_t              y;
+
+    if ((uint64_t)top + icon->height > screen->height || (uint64_t)left + icon->width > screen->width) {
+        return false;
+    }
+    for (p = 0; p < icon->count; p++) {
+        q = &icon->pixels[p];
+        x = left + q->x;
+        y = top + q->y;
+        if (x >= screen->width || y >= screen->height || !within(gateImagePixel(screen, x, y), q->rgb, tolerance)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+
+static void imageAlloc(GateImageT *img, uint32_t width, uint32_t height) {
+    img->width  = width;
+    img->height = height;
+    img->rgba   = (uint8_t *)calloc((size_t)width * height + 1u, GATE_RGBA_BYTES);
+    if (img->rgba == NULL) {
+        toolFail("out of memory");
+    }
+}
+
+
+static bool magic(const uint8_t *data, size_t len, const char *bytes, size_t count) {
+    return len >= count && memcmp(data, bytes, count) == 0;
+}
+
+
+static int packedCompare(const void *a, const void *b) {
+    uint32_t x;
+    uint32_t y;
+
+    x = *(const uint32_t *)a;
+    y = *(const uint32_t *)b;
+    return (x > y) - (x < y);
+}
+
+
+// A PNG through toolPng, made RGBA.
+static void pngDecode(const uint8_t *data, size_t len, const char *path, GateImageT *img) {
+    ToolImageT decoded;
+    ToolImageT rgba;
+    char       err[TOOL_PNG_ERROR_BYTES];
+
+    if (!toolPngDecode(data, len, &decoded, err)) {
+        toolFail("%s: %s", path, err);
+    }
+    if (!toolImageToRgba(&decoded, &rgba)) {
+        toolFail("out of memory");
+    }
+    toolImageFree(&decoded);
+    img->width  = rgba.width;
+    img->height = rgba.height;
+    img->rgba   = rgba.pixels;
+}
+
+
+// Whether every gun of a is within tolerance of b's.
+static bool within(const uint8_t *a, const uint8_t *b, uint32_t tolerance) {
+    uint32_t i;
+
+    for (i = 0; i < GATE_RGB_BYTES; i++) {
+        if ((uint32_t)abs((int32_t)a[i] - (int32_t)b[i]) > tolerance) {
+            return false;
+        }
+    }
+    return true;
+}
+
diff --git a/tests/gate/common/gateImage.h b/tests/gate/common/gateImage.h
new file mode 100644
index 0000000..db53a8b
--- /dev/null
+++ b/tests/gate/common/gateImage.h
@@ -0,0 +1,107 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateImage.h - screenshots and icons for the gate tools.
+//
+// A gate's screenshot (a PNG of any colour type and bit depth) or an icon
+// (a BMP, or an ICO's picture) is read as RGBA, four bytes a pixel. Beside
+// that: the tolerant search for an icon on a screen, the nearest of a
+// palette's colours, and KEYS's palette and the row it stamps
+// (examples/keys).
+
+#ifndef GATE_IMAGE_H
+#define GATE_IMAGE_H
+
+#include 
+#include 
+#include 
+
+#include "toolBuf.h"
+
+#define GATE_RGB_BYTES    3u
+#define GATE_RGBA_BYTES   4u
+#define GATE_ALPHA        3u
+#define GATE_KEYS_COLOURS 16u
+
+// A picture: RGBA, four bytes a pixel, row after row.
+typedef struct GateImageT {
+    uint32_t width;
+    uint32_t height;
+    uint8_t *rgba;
+} GateImageT;
+
+// One opaque pixel of an icon: where it is in the icon, and its colour.
+typedef struct GateIconPixelT {
+    uint32_t x;
+    uint32_t y;
+    uint8_t  rgb[GATE_RGB_BYTES];
+} GateIconPixelT;
+
+// An icon to look for: its size (it must lie wholly on the screen) and its
+// opaque pixels, in the order they were added.
+typedef struct GateIconT {
+    uint32_t        width;
+    uint32_t        height;
+    uint32_t        count;
+    uint32_t        cap;
+    GateIconPixelT *pixels;
+} GateIconT;
+
+// The squared distance between two RGB colours.
+uint32_t gateDistance(const uint8_t *a, const uint8_t *b);
+
+// Print bytes as upper-case hex, two digits a byte, to stdout.
+void gateHexPrint(const uint8_t *data, size_t len);
+
+// Add an opaque pixel to an icon (running out of memory is fatal).
+void gateIconAdd(GateIconT *icon, uint32_t x, uint32_t y, const uint8_t *rgb);
+
+// Where the screen shows the icon: every opaque pixel within tolerance a
+// gun of its colour. The search anchors on the opaque pixel whose colour
+// the screen holds least (the first of equals) and tries the icon at each
+// place that colour is, top to bottom, left to right; the first place the
+// whole icon fits and matches is the answer. An icon with no opaque pixel
+// is fatal.
+bool gateIconFind(const GateImageT *screen, const GateIconT *icon, uint32_t tolerance, uint32_t *left, uint32_t *top);
+
+// Free an icon's pixels (the struct is the caller's).
+void gateIconFree(GateIconT *icon);
+
+// Free a picture's pixels (the struct is the caller's).
+void gateImageFree(GateImageT *img);
+
+// Whether (x, y) is on the picture.
+bool gateImageHas(const GateImageT *img, long x, long y);
+
+// Load a PNG, BMP, or ICO (its default picture: the largest, and of those
+// the one of fewest colours). Anything unreadable is fatal.
+void gateImageLoad(const char *path, GateImageT *img);
+
+// As gateImageLoad, but an ICO gives its side x side picture of bpp bits
+// (any bits when bpp is 0), else its default picture.
+void gateImageLoadIcon(const char *path, uint32_t side, uint32_t bpp, GateImageT *img);
+
+// The pixel at (x, y), which must be on the picture.
+const uint8_t *gateImagePixel(const GateImageT *img, uint32_t x, uint32_t y);
+
+// KEYS's verification row for target as bytes: the sentinel A5 A5, the
+// count, a zero, then the characters. A target that is not ASCII, or is
+// longer than a count byte holds, is a bad argument (gateArgBad).
+void gateKeysExpect(const char *target, ToolBufT *expect);
+
+// KEYS's palette 0 (examples/keys buildPalette) as 12-bit $0RGB words.
+void gateKeysPalette(uint16_t words[GATE_KEYS_COLOURS]);
+
+// Little-endian 16- and 32-bit numbers at p.
+uint32_t gateLe16(const uint8_t *p);
+uint32_t gateLe32(const uint8_t *p);
+
+// The palette entry nearest rgb (squared distance, the first of equals),
+// and that distance.
+uint32_t gateNearest(const uint8_t *rgb, const uint8_t (*palette)[GATE_RGB_BYTES], uint32_t count, uint32_t *distance);
+
+// A $0RGB word as 8-bit RGB, each 4-bit gun widened x17.
+void gateRgb12(uint32_t word, uint8_t *rgb);
+
+#endif
diff --git a/tests/gate/common/gateNet.c b/tests/gate/common/gateNet.c
new file mode 100644
index 0000000..c385e70
--- /dev/null
+++ b/tests/gate/common/gateNet.c
@@ -0,0 +1,189 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateNet.c - sockets, waits, and the clock shared by the gates' serial
+// and network peers, and received bytes shown printably (gateNet.h).
+
+#define _DEFAULT_SOURCE
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateNet.h"
+#include "toolFile.h"
+
+#define MS_PER_SECOND  1000.0
+#define NS_PER_SECOND  1000000000.0
+#define WAIT_FOREVER   (-1)
+// Bytes from SHOW_PRINTABLE up to (not including) SHOW_DELETE show as
+// themselves.
+#define SHOW_PRINTABLE 0x20u
+#define SHOW_DELETE    0x7Fu
+
+
+int gateNetAccept(int listener, double seconds, struct sockaddr_in *from) {
+    struct sockaddr_in peer;
+    socklen_t          peerLen;
+    int                conn;
+
+    if (!gateNetWait(listener, POLLIN, seconds)) {
+        return -1;
+    }
+    peerLen = sizeof(peer);
+    conn    = accept(listener, (struct sockaddr *)&peer, &peerLen);
+    if (conn < 0) {
+        toolFail("accept: %s", strerror(errno));
+    }
+    if (from != NULL) {
+        *from = peer;
+    }
+    return conn;
+}
+
+
+int gateNetAcceptOne(uint16_t port, double seconds) {
+    int listener;
+    int conn;
+
+    listener = gateNetListen(port, GATE_NET_ONE_PEER);
+    conn     = gateNetAccept(listener, seconds, NULL);
+    close(listener);
+    return conn;
+}
+
+
+int gateNetListen(uint16_t port, int backlog) {
+    struct sockaddr_in addr;
+    int                fd;
+    int                on;
+
+    memset(&addr, 0, sizeof(addr));
+    addr.sin_family      = AF_INET;
+    addr.sin_port        = htons(port);
+    addr.sin_addr.s_addr = inet_addr(GATE_NET_HOST);
+    on                   = 1;
+    fd                   = socket(AF_INET, SOCK_STREAM, 0);
+    if (fd < 0 || setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) != 0 || bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0 || listen(fd, backlog) != 0) {
+        toolFail("cannot listen on %s:%u: %s", GATE_NET_HOST, (unsigned)port, strerror(errno));
+    }
+    return fd;
+}
+
+
+double gateNetNow(void) {
+    struct timespec now;
+
+    clock_gettime(CLOCK_MONOTONIC, &now);
+    return (double)now.tv_sec + (double)now.tv_nsec / NS_PER_SECOND;
+}
+
+
+int32_t gateNetRecv(int fd, uint8_t *buf, size_t cap, double seconds) {
+    ssize_t got;
+
+    if (!gateNetWait(fd, POLLIN, seconds)) {
+        return GATE_NET_IDLE;
+    }
+    got = read(fd, buf, cap);
+    if (got == 0) {
+        errno = 0;
+    }
+    return got > 0 ? (int32_t)got : GATE_NET_CLOSED;
+}
+
+
+bool gateNetSendAll(int fd, const void *data, size_t len) {
+    const uint8_t *at;
+    ssize_t        put;
+
+    at = (const uint8_t *)data;
+    while (len > 0u) {
+        put = write(fd, at, len);
+        if (put < 0 && errno == EINTR) {
+            continue;
+        }
+        if (put <= 0) {
+            return false;
+        }
+        at  += put;
+        len -= (size_t)put;
+    }
+    return true;
+}
+
+
+void gateNetShowBytes(FILE *out, const void *data, size_t len) {
+    const uint8_t *bytes;
+    size_t         i;
+    uint8_t        c;
+
+    bytes = (const uint8_t *)data;
+    fputc('"', out);
+    for (i = 0; i < len; i++) {
+        c = bytes[i];
+        if (c == '"' || c == '\\') {
+            fprintf(out, "\\%c", c);
+        } else if (c == '\n') {
+            fputs("\\n", out);
+        } else if (c == '\r') {
+            fputs("\\r", out);
+        } else if (c == '\t') {
+            fputs("\\t", out);
+        } else if (c < SHOW_PRINTABLE || c >= SHOW_DELETE) {
+            fprintf(out, "\\x%02x", c);
+        } else {
+            fputc(c, out);
+        }
+    }
+    fputc('"', out);
+}
+
+
+void gateNetSleep(double seconds) {
+    struct timespec left;
+
+    if (seconds <= 0.0) {
+        return;
+    }
+    left.tv_sec  = (time_t)seconds;
+    left.tv_nsec = (long)((seconds - (double)left.tv_sec) * NS_PER_SECOND);
+    while (nanosleep(&left, &left) != 0 && errno == EINTR) {
+        // A signal cut the sleep short: sleep what is left of it.
+    }
+}
+
+
+void gateNetStart(const char *toolName) {
+    toolFileName(toolName);
+    setvbuf(stdout, NULL, _IOLBF, 0);
+    signal(SIGPIPE, SIG_IGN);
+}
+
+
+bool gateNetWait(int fd, short events, double seconds) {
+    struct pollfd watch;
+    double        ms;
+    int           timeoutMs;
+    int           ready;
+
+    // Whole milliseconds, rounded up: never a shorter wait than asked.
+    ms           = ceil(seconds * MS_PER_SECOND);
+    timeoutMs    = ms >= (double)INT_MAX ? WAIT_FOREVER : (ms > 0.0 ? (int)ms : 0);
+    watch.fd     = fd;
+    watch.events = events;
+    do {
+        watch.revents = 0;
+        ready         = poll(&watch, 1, timeoutMs);
+    } while (ready < 0 && errno == EINTR);
+    // An error is "ready": the read or write that follows reports it.
+    return ready != 0;
+}
diff --git a/tests/gate/common/gateNet.h b/tests/gate/common/gateNet.h
new file mode 100644
index 0000000..8185a8d
--- /dev/null
+++ b/tests/gate/common/gateNet.h
@@ -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
+//
+// gateNet.h - sockets, waits, and the clock shared by the gates' serial and
+// network peers (tests/gate/serialLoopPeer.c and the rest), and received
+// bytes shown printably in their logs. Their command lines are gateArg's,
+// and gateTextFind finds bytes in what they receive.
+
+#ifndef GATE_NET_H
+#define GATE_NET_H
+
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+// gateNetRecv: nothing arrived in time, or the stream ended (or failed).
+#define GATE_NET_IDLE       0
+#define GATE_NET_CLOSED     (-1)
+
+// The only address the peers listen on or connect to, and the backlog a
+// peer that takes one connection listens with.
+#define GATE_NET_HOST       "127.0.0.1"
+#define GATE_NET_ONE_PEER   1
+
+// A connection on listener within seconds (its address in from, unless
+// NULL); -1 when none came. A failed accept ends the tool.
+int gateNetAccept(int listener, double seconds, struct sockaddr_in *from);
+
+// Listen on port for one connection within seconds, then stop listening;
+// -1 when none came.
+int gateNetAcceptOne(uint16_t port, double seconds);
+
+// A socket listening on GATE_NET_HOST:port (SO_REUSEADDR) with backlog
+// connections waiting at most; a port it cannot have ends the tool.
+int gateNetListen(uint16_t port, int backlog);
+
+// Seconds on a clock that never steps.
+double gateNetNow(void);
+
+// Up to cap bytes from fd, waiting at most seconds: their count,
+// GATE_NET_IDLE, or GATE_NET_CLOSED (errno 0 at the end of the stream,
+// else what failed).
+int32_t gateNetRecv(int fd, uint8_t *buf, size_t cap, double seconds);
+
+// Every byte to fd; false (errno set) when it fails.
+bool gateNetSendAll(int fd, const void *data, size_t len);
+
+// len bytes in double quotes: printable ASCII as itself, a backslash or a
+// double quote escaped with a backslash, \n \r \t, and any other byte as
+// \xNN.
+void gateNetShowBytes(FILE *out, const void *data, size_t len);
+
+// Sleep seconds (none at all for 0 or less).
+void gateNetSleep(double seconds);
+
+// Each peer's first act: the name its messages begin with, stdout written a
+// line at a time (so a killed peer's log is whole), and a closed connection
+// an error from send rather than a SIGPIPE.
+void gateNetStart(const char *toolName);
+
+// Whether fd is ready for events (poll's) within seconds.
+bool gateNetWait(int fd, short events, double seconds);
+
+#endif
diff --git a/tests/gate/common/gateText.c b/tests/gate/common/gateText.c
new file mode 100644
index 0000000..017d2cd
--- /dev/null
+++ b/tests/gate/common/gateText.c
@@ -0,0 +1,98 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateText.c - message text for the gate tools (gateText.h).
+
+#define _DEFAULT_SOURCE
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateText.h"
+#include "toolFile.h"
+
+
+void gateTextAdd(ToolBufT *buf, const char *fmt, ...) {
+    va_list args;
+
+    va_start(args, fmt);
+    gateTextAddV(buf, fmt, args);
+    va_end(args);
+}
+
+
+void gateTextAddV(ToolBufT *buf, const char *fmt, va_list args) {
+    va_list again;
+    char   *text;
+    int     len;
+
+    va_copy(again, args);
+    len = vsnprintf(NULL, 0, fmt, args);
+    if (len < 0) {
+        toolFail("bad message format");
+    }
+    text = (char *)malloc((size_t)len + 1u);
+    if (text == NULL) {
+        toolFail("out of memory");
+    }
+    vsnprintf(text, (size_t)len + 1u, fmt, again);
+    va_end(again);
+    toolBufAdd(buf, text, (size_t)len);
+    free(text);
+}
+
+
+size_t gateTextFind(const uint8_t *hay, size_t hayLen, size_t from, const void *needle, size_t needleLen) {
+    const uint8_t *first;
+    const uint8_t *at;
+    const uint8_t *last;
+
+    if (from > hayLen || hayLen - from < needleLen) {
+        return GATE_TEXT_NOT_FOUND;
+    }
+    if (needleLen == 0u) {
+        return from;
+    }
+    first = (const uint8_t *)needle;
+    last  = hay + hayLen - needleLen;
+    at    = hay + from;
+    while (at <= last) {
+        at = (const uint8_t *)memchr(at, first[0], (size_t)(last - at) + 1u);
+        if (at == NULL) {
+            return GATE_TEXT_NOT_FOUND;
+        }
+        if (memcmp(at, needle, needleLen) == 0) {
+            return (size_t)(at - hay);
+        }
+        at++;
+    }
+    return GATE_TEXT_NOT_FOUND;
+}
+
+
+bool gateTextReadFile(const char *path, uint8_t **data, size_t *len, ToolBufT *problem) {
+    struct stat info;
+
+    // A directory opens, then reads as nonsense: say what it is.
+    if (stat(path, &info) == 0 && S_ISDIR(info.st_mode)) {
+        gateTextAdd(problem, "cannot read %s: %s", path, strerror(EISDIR));
+        return false;
+    }
+    errno = 0;
+    if (!toolReadFile(path, data, len)) {
+        gateTextAdd(problem, "cannot read %s: %s", path, strerror(errno != 0 ? errno : EIO));
+        return false;
+    }
+    return true;
+}
+
+
+const char *gateTextString(ToolBufT *buf) {
+    toolBufByte(buf, 0u);
+    buf->len--;
+    return (const char *)buf->data;
+}
diff --git a/tests/gate/common/gateText.h b/tests/gate/common/gateText.h
new file mode 100644
index 0000000..a1e1079
--- /dev/null
+++ b/tests/gate/common/gateText.h
@@ -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
+//
+// gateText.h - text for the gate tools: printf into a growing buffer,
+// bytes found in a buffer, and a whole-file read that says why it could not
+// read the file.
+
+#ifndef GATE_TEXT_H
+#define GATE_TEXT_H
+
+#include 
+#include 
+#include 
+#include 
+
+#include "toolBuf.h"
+
+// gateTextFind: the bytes are not there.
+#define GATE_TEXT_NOT_FOUND SIZE_MAX
+
+// Append printf-style text to buf (no terminating zero: see gateTextString).
+void gateTextAdd(ToolBufT *buf, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
+
+// gateTextAdd with the arguments in a va_list.
+void gateTextAddV(ToolBufT *buf, const char *fmt, va_list args) __attribute__((format(printf, 2, 0)));
+
+// Where needle first is in hay at or after from; GATE_TEXT_NOT_FOUND when
+// it is not (an empty needle is at from).
+size_t gateTextFind(const uint8_t *hay, size_t hayLen, size_t from, const void *needle, size_t needleLen);
+
+// A whole file, as toolReadFile reads it; false, with "cannot read PATH:
+// " appended to problem, when it cannot be read.
+bool gateTextReadFile(const char *path, uint8_t **data, size_t *len, ToolBufT *problem);
+
+// buf's text as a C string: a zero byte kept after it, not counted in len.
+const char *gateTextString(ToolBufT *buf);
+
+#endif
diff --git a/tests/gate/common/gateWav.c b/tests/gate/common/gateWav.c
new file mode 100644
index 0000000..ff6e184
--- /dev/null
+++ b/tests/gate/common/gateWav.c
@@ -0,0 +1,344 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateWav.c - WAV files for the gate tools (gateWav.h).
+//
+// The reading walks a WAV as Python's wave module did, since the graders
+// were written against it and a recording that is not quite whole must
+// yield the same audio: the RIFF chunk's body is walked chunk by chunk,
+// every read capped at the RIFF's declared size and at the file's end, a
+// chunk of odd size followed by a pad byte; the fmt chunk before the first
+// data chunk gives the format, and the audio is that data chunk's whole
+// frames, as far as the caps let them go.
+
+#include 
+#include 
+#include 
+
+#include "gateText.h"
+#include "gateWav.h"
+#include "toolFile.h"
+
+#define RIFF_HEADER       8u
+#define CHUNK_ID_BYTES    4u
+#define CHUNK_SIZE_BYTES  4u
+#define FMT_BYTES         14u
+#define FMT_BITS_BYTES    2u
+#define FMT_EXT_BYTES     8u
+#define FMT_GUID_BYTES    16u
+#define FMT_CHANNELS_AT   2u
+#define FMT_RATE_AT       4u
+#define FORMAT_PCM        0x0001u
+#define FORMAT_EXTENSIBLE 0xFFFEu
+#define BITS_PER_BYTE     8u
+#define SAMPLE_BYTES      2u
+#define WAV_FMT_SIZE      16u
+#define WAV_RIFF_EXTRA    36u
+
+// The RIFF chunk being read: its declared size, and how much of it has
+// been read (its body starts RIFF_HEADER bytes into the file).
+typedef struct RiffT {
+    const uint8_t *file;
+    size_t         fileLen;
+    uint64_t       size;
+    uint64_t       pos;
+} RiffT;
+
+// A chunk inside it: its declared size, where its body starts in the RIFF,
+// and how much of it has been read.
+typedef struct ChunkT {
+    char     id[CHUNK_ID_BYTES];
+    uint64_t size;
+    uint64_t offset;
+    uint64_t pos;
+} ChunkT;
+
+static size_t   chunkRead(RiffT *riff, ChunkT *chunk, uint64_t n, const uint8_t **at);
+static bool     chunkSkip(RiffT *riff, ChunkT *chunk);
+static bool     fmtRead(RiffT *riff, ChunkT *chunk, const char *path, GateWavT *wav, ToolBufT *problem);
+static uint32_t le16(const uint8_t *at);
+static uint32_t le32(const uint8_t *at);
+static size_t   riffRead(RiffT *riff, uint64_t n, const uint8_t **at);
+
+// KSDATAFORMAT_SUBTYPE_PCM, the GUID of an extensible WAV's PCM samples.
+static const uint8_t kPcmGuid[FMT_GUID_BYTES] = {
+    0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71
+};
+
+
+// Up to n bytes of a chunk's body (then its pad byte, when its size is odd
+// and its end is reached): how many, at *at.
+static size_t chunkRead(RiffT *riff, ChunkT *chunk, uint64_t n, const uint8_t **at) {
+    const uint8_t *pad;
+    size_t         got;
+
+    *at = NULL;
+    if (chunk->pos >= chunk->size) {
+        return 0u;
+    }
+    if (n > chunk->size - chunk->pos) {
+        n = chunk->size - chunk->pos;
+    }
+    got         = riffRead(riff, n, at);
+    chunk->pos += got;
+    if (chunk->pos == chunk->size && (chunk->size & 1u) != 0u) {
+        chunk->pos += riffRead(riff, 1u, &pad);
+    }
+    return got;
+}
+
+
+// Past the rest of a chunk (and its pad byte); false when that would leave
+// the RIFF chunk.
+static bool chunkSkip(RiffT *riff, ChunkT *chunk) {
+    int64_t step;
+    int64_t to;
+
+    step = (int64_t)chunk->size - (int64_t)chunk->pos;
+    if ((chunk->size & 1u) != 0u) {
+        step++;
+    }
+    to = (int64_t)riff->pos + step;
+    if (to < 0 || (uint64_t)to > riff->size) {
+        return false;
+    }
+    riff->pos   = (uint64_t)to;
+    chunk->pos += (uint64_t)step;
+    return true;
+}
+
+
+// The fmt chunk: 16-bit PCM (plainly, or as the extensible format's PCM),
+// its channels and rate; false, with what is wrong in problem, when it is
+// not one this reads.
+static bool fmtRead(RiffT *riff, ChunkT *chunk, const char *path, GateWavT *wav, ToolBufT *problem) {
+    const uint8_t *at;
+    uint32_t       format;
+    uint32_t       bits;
+
+    if (chunkRead(riff, chunk, FMT_BYTES, &at) < FMT_BYTES) {
+        gateTextAdd(problem, "%s: fmt chunk cut short", path);
+        return false;
+    }
+    format        = le16(at);
+    wav->channels = le16(at + FMT_CHANNELS_AT);
+    wav->rate     = le32(at + FMT_RATE_AT);
+    if (format != FORMAT_PCM && format != FORMAT_EXTENSIBLE) {
+        gateTextAdd(problem, "%s: format %u, not PCM", path, (unsigned)format);
+        return false;
+    }
+    if (chunkRead(riff, chunk, FMT_BITS_BYTES, &at) < FMT_BITS_BYTES) {
+        gateTextAdd(problem, "%s: fmt chunk cut short", path);
+        return false;
+    }
+    bits = le16(at);
+    if (format == FORMAT_EXTENSIBLE) {
+        if (chunkRead(riff, chunk, FMT_EXT_BYTES, &at) < FMT_EXT_BYTES || chunkRead(riff, chunk, FMT_GUID_BYTES, &at) < FMT_GUID_BYTES) {
+            gateTextAdd(problem, "%s: fmt chunk cut short", path);
+            return false;
+        }
+        if (memcmp(at, kPcmGuid, FMT_GUID_BYTES) != 0) {
+            gateTextAdd(problem, "%s: extensible format, not PCM", path);
+            return false;
+        }
+    }
+    // A sample takes whole bytes: 9 to 16 bits are stored in 16.
+    if ((bits + BITS_PER_BYTE - 1u) / BITS_PER_BYTE != SAMPLE_BYTES) {
+        gateTextAdd(problem, "%s: %u-bit samples, not 16-bit", path, (unsigned)bits);
+        return false;
+    }
+    if (wav->channels == 0u) {
+        gateTextAdd(problem, "%s: no channels", path);
+        return false;
+    }
+    return true;
+}
+
+
+size_t gateWavFrames(const GateWavT *wav) {
+    return wav->byteCount / (SAMPLE_BYTES * wav->channels);
+}
+
+
+void gateWavFree(GateWavT *wav) {
+    free(wav->bytes);
+    memset(wav, 0, sizeof(*wav));
+}
+
+
+double *gateWavMono(const GateWavT *wav) {
+    double  *mono;
+    size_t   frames;
+    size_t   frame;
+    uint32_t channel;
+    double   total;
+
+    frames = gateWavFrames(wav);
+    mono   = (double *)malloc((frames > 0u ? frames : 1u) * sizeof(double));
+    if (mono == NULL) {
+        toolFail("out of memory");
+    }
+    for (frame = 0u; frame < frames; frame++) {
+        total = 0.0;
+        for (channel = 0u; channel < wav->channels; channel++) {
+            total += gateWavSample(wav, frame, channel);
+        }
+        mono[frame] = total / (double)wav->channels;
+    }
+    return mono;
+}
+
+
+bool gateWavRead(const char *path, GateWavT *wav, ToolBufT *problem) {
+    uint8_t       *file;
+    size_t         fileLen;
+    const uint8_t *at;
+    const char    *wrong;
+    RiffT          riff;
+    ChunkT         chunk;
+    ChunkT         data;
+    bool           haveFmt;
+    bool           haveData;
+    uint64_t       frames;
+
+    memset(wav, 0, sizeof(*wav));
+    memset(&data, 0, sizeof(data));
+    if (!gateTextReadFile(path, &file, &fileLen, problem)) {
+        return false;
+    }
+    riff.file    = file;
+    riff.fileLen = fileLen;
+    riff.size    = fileLen >= RIFF_HEADER ? le32(file + CHUNK_ID_BYTES) : 0u;
+    riff.pos     = 0u;
+    wrong        = NULL;
+    if (fileLen < RIFF_HEADER || memcmp(file, "RIFF", CHUNK_ID_BYTES) != 0) {
+        wrong = "not a RIFF file";
+    } else if (riffRead(&riff, CHUNK_ID_BYTES, &at) < CHUNK_ID_BYTES || memcmp(at, "WAVE", CHUNK_ID_BYTES) != 0) {
+        wrong = "not a WAVE file";
+    }
+    haveFmt  = false;
+    haveData = false;
+    while (wrong == NULL && !haveData) {
+        if (riffRead(&riff, CHUNK_ID_BYTES, &at) < CHUNK_ID_BYTES) {
+            break;
+        }
+        memcpy(chunk.id, at, CHUNK_ID_BYTES);
+        if (riffRead(&riff, CHUNK_SIZE_BYTES, &at) < CHUNK_SIZE_BYTES) {
+            break;
+        }
+        chunk.size   = le32(at);
+        chunk.offset = riff.pos;
+        chunk.pos    = 0u;
+        if (memcmp(chunk.id, "fmt ", CHUNK_ID_BYTES) == 0) {
+            if (!fmtRead(&riff, &chunk, path, wav, problem)) {
+                free(file);
+                return false;
+            }
+            haveFmt = true;
+        } else if (memcmp(chunk.id, "data", CHUNK_ID_BYTES) == 0) {
+            data     = chunk;
+            haveData = true;
+            continue;
+        }
+        if (!chunkSkip(&riff, &chunk)) {
+            wrong = "a chunk runs past the end of the RIFF";
+        }
+    }
+    if (wrong == NULL && !haveFmt) {
+        wrong = "no fmt chunk before its data";
+    } else if (wrong == NULL && !haveData) {
+        wrong = "no data chunk";
+    } else if (wrong == NULL && data.offset > riff.size) {
+        wrong = "a chunk runs past the end of the RIFF";
+    }
+    if (wrong != NULL) {
+        gateTextAdd(problem, "%s: %s", path, wrong);
+        free(file);
+        return false;
+    }
+    // The data's whole frames, as far as the RIFF and the file go.
+    riff.pos       = data.offset;
+    data.pos       = 0u;
+    frames         = data.size / ((uint64_t)wav->channels * SAMPLE_BYTES);
+    wav->byteCount = chunkRead(&riff, &data, frames * wav->channels * SAMPLE_BYTES, &at);
+    wav->bytes     = (uint8_t *)malloc(wav->byteCount > 0u ? wav->byteCount : 1u);
+    if (wav->bytes == NULL) {
+        toolFail("out of memory");
+    }
+    if (wav->byteCount > 0u) {
+        memcpy(wav->bytes, at, wav->byteCount);
+    }
+    free(file);
+    return true;
+}
+
+
+double gateWavSample(const GateWavT *wav, size_t frame, uint32_t channel) {
+    const uint8_t *at;
+
+    at = wav->bytes + (frame * wav->channels + channel) * SAMPLE_BYTES;
+    return (double)(int16_t)le16(at);
+}
+
+
+bool gateWavWrite(const char *path, uint32_t channels, uint32_t rate, const uint8_t *data, size_t len) {
+    ToolBufT out;
+    bool     ok;
+
+    memset(&out, 0, sizeof(out));
+    toolBufAdd(&out, "RIFF", CHUNK_ID_BYTES);
+    toolBufLe32(&out, (uint32_t)(WAV_RIFF_EXTRA + len));
+    toolBufAdd(&out, "WAVEfmt ", 2u * CHUNK_ID_BYTES);
+    toolBufLe32(&out, WAV_FMT_SIZE);
+    toolBufLe16(&out, FORMAT_PCM);
+    toolBufLe16(&out, channels);
+    toolBufLe32(&out, rate);
+    toolBufLe32(&out, channels * rate * SAMPLE_BYTES);
+    toolBufLe16(&out, channels * SAMPLE_BYTES);
+    toolBufLe16(&out, SAMPLE_BYTES * BITS_PER_BYTE);
+    toolBufAdd(&out, "data", CHUNK_ID_BYTES);
+    toolBufLe32(&out, (uint32_t)len);
+    toolBufAdd(&out, data, len);
+    ok = toolWriteFile(path, out.data, out.len);
+    toolBufFree(&out);
+    return ok;
+}
+
+
+static uint32_t le16(const uint8_t *at) {
+    return (uint32_t)at[0] | ((uint32_t)at[1] << BITS_PER_BYTE);
+}
+
+
+static uint32_t le32(const uint8_t *at) {
+    return le16(at) | (le16(at + SAMPLE_BYTES) << (2u * BITS_PER_BYTE));
+}
+
+
+// Up to n bytes of the RIFF chunk's body, capped at its size and the
+// file's end (then its pad byte, when its size is odd and its end is
+// reached): how many, at *at.
+static size_t riffRead(RiffT *riff, uint64_t n, const uint8_t **at) {
+    uint64_t offset;
+    uint64_t avail;
+
+    *at = NULL;
+    if (riff->pos >= riff->size) {
+        return 0u;
+    }
+    if (n > riff->size - riff->pos) {
+        n = riff->size - riff->pos;
+    }
+    offset = RIFF_HEADER + riff->pos;
+    avail  = offset < riff->fileLen ? riff->fileLen - offset : 0u;
+    if (n > avail) {
+        n = avail;
+    }
+    *at        = riff->file + offset;
+    riff->pos += n;
+    if (riff->pos == riff->size && (riff->size & 1u) != 0u && offset + n < riff->fileLen) {
+        riff->pos++;
+    }
+    return (size_t)n;
+}
diff --git a/tests/gate/common/gateWav.h b/tests/gate/common/gateWav.h
new file mode 100644
index 0000000..4f5af5b
--- /dev/null
+++ b/tests/gate/common/gateWav.h
@@ -0,0 +1,48 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// gateWav.h - 16-bit PCM WAV files for the gate tools: read with the chunk
+// walk the graders were first written against (Python's wave module's), so
+// a recording cut short or written with wrong sizes yields the same audio,
+// and written with the plain 44-byte header.
+
+#ifndef GATE_WAV_H
+#define GATE_WAV_H
+
+#include 
+#include 
+#include 
+
+#include "toolBuf.h"
+
+// A WAV's format and its audio: the data chunk's whole frames, as far as
+// the RIFF chunk and the file go.
+typedef struct GateWavT {
+    uint32_t channels;
+    uint32_t rate;
+    uint8_t *bytes;
+    size_t   byteCount;
+} GateWavT;
+
+// The frames the bytes hold.
+size_t gateWavFrames(const GateWavT *wav);
+
+void gateWavFree(GateWavT *wav);
+
+// Each frame's samples averaged over its channels: gateWavFrames of them,
+// in a malloc'd array.
+double *gateWavMono(const GateWavT *wav);
+
+// Read path as a 16-bit PCM WAV; false, with what is wrong appended to problem
+// ("cannot read PATH: ", or "PATH: "), when it
+// cannot be.
+bool gateWavRead(const char *path, GateWavT *wav, ToolBufT *problem);
+
+// A frame's 16-bit sample in one channel.
+double gateWavSample(const GateWavT *wav, size_t frame, uint32_t channel);
+
+// A 16-bit PCM WAV of len bytes of frames; false if it cannot be written.
+bool gateWavWrite(const char *path, uint32_t channels, uint32_t rate, const uint8_t *data, size_t len);
+
+#endif
diff --git a/tests/gate/dosRow.c b/tests/gate/dosRow.c
new file mode 100644
index 0000000..4332c5c
--- /dev/null
+++ b/tests/gate/dosRow.c
@@ -0,0 +1,197 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// dosRow.c - KEYS's verification rows off a DOSBox raw screenshot
+// (scripts/verify-dos-input.sh and verify-dos-mouse.sh).
+//
+//     dosRow input PNG TARGET
+//     dosRow mouse PNG
+//
+// DOSBox's raw capture is the 320x200 stage, or a whole multiple of it.
+// KEYS (examples/keys) stamps its rows as nibble pixels in its palette 0,
+// which the port's 6-bit DAC shows as each 4-bit gun widened to 6 bits and
+// then to 8 (bit replication); a pixel is the nearest entry, since
+// emulators differ in how they scale the DAC (replication or rounding) but
+// the entries sit some 17 apart a gun.
+//
+// input: row 198's first 40 bytes (two pixels a byte, high nibble first),
+// printed as "VERIFY-DOS-INPUT row=", must start with the sentinel A5
+// A5, TARGET's length, a zero, and TARGET; a pixel no entry is near, or a
+// screenshot of another size, fails. Prints the FAIL lines
+// verify-dos-input.sh shows; exits 0 or 1.
+//
+// mouse: row 199's four words (four nibbles each) must start with the
+// sentinel 5A5A; prints "  ", or nothing and exits 1 when
+// the row is not there (or the screenshot is another size).
+//
+// A bad command line exits 2.
+
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolBuf.h"
+#include "toolFile.h"
+
+#define TOOL_NAME        "dosRow"
+#define FAIL_EXIT        1
+#define STAGE_W          320u
+#define STAGE_H          200u
+#define TYPED_Y          198u
+#define TYPED_PIXELS     80u
+#define TYPED_BYTES      (TYPED_PIXELS / 2u)
+#define MOUSE_Y          199u
+#define MOUSE_WORDS      4u
+#define WORD_NIBBLES     4u
+#define MOUSE_SENTINEL   0x5A5Au
+#define MOUSE_FLAGS      1u
+#define MOUSE_X          2u
+#define MOUSE_Y_WORD     3u
+#define NIBBLE_BITS      4u
+#define NIBBLE_MASK      0xFu
+#define DAC_BITS         6u
+#define DAC_MASK         0x3Fu
+#define DAC_LOW_BITS     2u
+#define MAX_DIST         (3u * 8u * 8u)
+
+// The screenshot, its scale, and KEYS's palette as the DAC shows it.
+typedef struct StageT {
+    GateImageT img;
+    uint32_t   sx;
+    uint32_t   sy;
+    uint8_t    palette[GATE_KEYS_COLOURS][GATE_RGB_BYTES];
+} StageT;
+
+static uint8_t  dac8(uint32_t c4);
+static uint32_t pixel(const StageT *stage, uint32_t x, uint32_t y, uint32_t *distance, const uint8_t **rgb);
+static int      readInput(char **args);
+static int      readMouse(char **args);
+static bool     stageLoad(const char *path, StageT *stage);
+
+static const GateModeT kModes[] = {
+    { "input", 2, readInput },
+    { "mouse", 1, readMouse }
+};
+
+static const char *const kUsage[] = {
+    "usage:",
+    "    dosRow input PNG TARGET   KEYS's row 198 must hold TARGET",
+    "    dosRow mouse PNG          KEYS's row 199: prints \"  \""
+};
+
+
+// A 4-bit gun through the 6-bit DAC, widened to 8 bits.
+static uint8_t dac8(uint32_t c4) {
+    uint32_t v6;
+
+    v6 = ((c4 << DAC_LOW_BITS) | (c4 >> DAC_LOW_BITS)) & DAC_MASK;
+    return (uint8_t)((v6 << DAC_LOW_BITS) | (v6 >> (DAC_BITS - DAC_LOW_BITS)));
+}
+
+
+// The palette entry nearest stage pixel (x, y), and how near.
+static uint32_t pixel(const StageT *stage, uint32_t x, uint32_t y, uint32_t *distance, const uint8_t **rgb) {
+    *rgb = gateImagePixel(&stage->img, x * stage->sx, y * stage->sy);
+    return gateNearest(*rgb, (const uint8_t (*)[GATE_RGB_BYTES])stage->palette, GATE_KEYS_COLOURS, distance);
+}
+
+
+static int readInput(char **args) {
+    StageT         stage;
+    ToolBufT       expect;
+    uint8_t        row[TYPED_BYTES];
+    const uint8_t *rgb;
+    uint32_t       distance;
+    uint32_t       nibble;
+    uint32_t       p;
+    size_t         gotLen;
+
+    if (!stageLoad(args[0], &stage)) {
+        printf("verify-dos-input: FAIL - unexpected screenshot size %ux%u\n", (unsigned)stage.img.width, (unsigned)stage.img.height);
+        return FAIL_EXIT;
+    }
+    memset(row, 0, sizeof(row));
+    for (p = 0; p < TYPED_PIXELS; p++) {
+        nibble = pixel(&stage, p, TYPED_Y, &distance, &rgb);
+        if (distance > MAX_DIST) {
+            printf("verify-dos-input: FAIL - pixel %u color (%u, %u, %u) too far from any palette entry\n", (unsigned)p, (unsigned)rgb[0], (unsigned)rgb[1], (unsigned)rgb[2]);
+            return FAIL_EXIT;
+        }
+        row[p / 2u] |= (uint8_t)((p % 2u == 0u) ? nibble << NIBBLE_BITS : nibble);
+    }
+    memset(&expect, 0, sizeof(expect));
+    gateKeysExpect(args[1], &expect);
+    gotLen = (expect.len < TYPED_BYTES) ? expect.len : TYPED_BYTES;
+    printf("VERIFY-DOS-INPUT row=");
+    gateHexPrint(row, TYPED_BYTES);
+    printf("\n");
+    if (gotLen != expect.len || memcmp(row, expect.data, gotLen) != 0) {
+        printf("verify-dos-input: FAIL\n  expected: ");
+        gateHexPrint(expect.data, expect.len);
+        printf("\n  got:      ");
+        gateHexPrint(row, gotLen);
+        printf("\n");
+        return FAIL_EXIT;
+    }
+    toolBufFree(&expect);
+    gateImageFree(&stage.img);
+    return 0;
+}
+
+
+static int readMouse(char **args) {
+    StageT         stage;
+    const uint8_t *rgb;
+    uint32_t       words[MOUSE_WORDS];
+    uint32_t       distance;
+    uint32_t       w;
+    uint32_t       n;
+
+    if (!stageLoad(args[0], &stage)) {
+        return FAIL_EXIT;
+    }
+    for (w = 0; w < MOUSE_WORDS; w++) {
+        words[w] = 0u;
+        for (n = 0; n < WORD_NIBBLES; n++) {
+            words[w] = (words[w] << NIBBLE_BITS) | pixel(&stage, w * WORD_NIBBLES + n, MOUSE_Y, &distance, &rgb);
+        }
+    }
+    gateImageFree(&stage.img);
+    if (words[0] != MOUSE_SENTINEL) {
+        return FAIL_EXIT;
+    }
+    printf("%04X %u %u\n", (unsigned)words[MOUSE_FLAGS], (unsigned)words[MOUSE_X], (unsigned)words[MOUSE_Y_WORD]);
+    return 0;
+}
+
+
+// The screenshot and its scale (false when it is not a whole multiple of
+// the stage), and the palette.
+static bool stageLoad(const char *path, StageT *stage) {
+    uint16_t words[GATE_KEYS_COLOURS];
+    uint32_t i;
+
+    gateImageLoad(path, &stage->img);
+    stage->sx = stage->img.width / STAGE_W;
+    stage->sy = stage->img.height / STAGE_H;
+    gateKeysPalette(words);
+    for (i = 0; i < GATE_KEYS_COLOURS; i++) {
+        stage->palette[i][0] = dac8((words[i] >> (2u * NIBBLE_BITS)) & NIBBLE_MASK);
+        stage->palette[i][1] = dac8((words[i] >> NIBBLE_BITS) & NIBBLE_MASK);
+        stage->palette[i][2] = dac8(words[i] & NIBBLE_MASK);
+    }
+    return stage->sx >= 1u && stage->sy >= 1u && stage->img.width % STAGE_W == 0u && stage->img.height % STAGE_H == 0u;
+}
+
+
+int main(int argc, char **argv) {
+    int exitCode;
+
+    toolFileName(TOOL_NAME);
+    if (!gateModeRun(kModes, GATE_COUNT_OF(kModes), argc, argv, &exitCode)) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    return exitCode;
+}
diff --git a/tests/gate/httpServe.c b/tests/gate/httpServe.c
new file mode 100644
index 0000000..4c78e30
--- /dev/null
+++ b/tests/gate/httpServe.c
@@ -0,0 +1,359 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// httpServe.c - serve a folder over HTTP on 127.0.0.1, for the web gates'
+// browser to load a built web program from (scripts/lib/webGate.sh).
+//
+//     httpServe PORT DIR
+//
+// GET and HEAD of the files under DIR, each with its Content-Type (a .wasm
+// is application/wasm, which the browser's streaming compile needs). Of a
+// request's path the query and fragment are dropped, %XX is decoded, and
+// "." and ".." segments are resolved within DIR, never above it. A folder
+// is its index.html, and a folder's URL without its trailing slash is
+// redirected to the one with it. No file
+// is 404 (a folder without an index too: there are no listings), a request
+// that cannot be read is 400, and a method other than GET or HEAD is 501.
+//
+// Each connection is served by a child process, so a browser's parallel
+// requests are answered together; each answers one request and closes, as
+// HTTP/1.0 does. Says where it serves once listening, and runs until
+// stopped; exit 1 when the port cannot be had or DIR is no folder, 2 on bad
+// arguments.
+
+#define _DEFAULT_SOURCE
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateNet.h"
+#include "toolFile.h"
+
+#define TOOL_NAME          "httpServe"
+#define ARG_COUNT          3
+#define ARG_PORT           1
+#define ARG_DIR            2
+#define LISTEN_BACKLOG     64
+// A request's head (its line and headers) holds at most this; the files
+// go out this much at a time; a client quiet this long is dropped.
+#define HEADER_MAX         16384u
+#define SEND_CHUNK         65536u
+#define CLIENT_SECONDS     30
+#define PATH_MAX_BYTES     4096u
+#define RESPONSE_HEAD_MAX  512u
+#define HEX_BASE           16
+#define HEX_LETTER         10
+#define HEADER_END         "\r\n\r\n"
+#define HEADER_END_BARE    "\n\n"
+#define DEFAULT_TYPE       "application/octet-stream"
+#define ERROR_TYPE         "text/plain"
+#define INDEX_FILE         "index.html"
+
+#define STATUS_OK          200
+#define STATUS_MOVED       301
+#define STATUS_BAD_REQUEST 400
+#define STATUS_NOT_FOUND   404
+#define STATUS_NO_METHOD   501
+
+// A file name extension and the type it is served as.
+typedef struct TypeT {
+    const char *extension;
+    const char *type;
+} TypeT;
+
+static const char *contentType(const char *path);
+static int         hexValue(char c);
+static bool        resolve(const char *root, const char *target, char *path, size_t cap, bool *slash);
+static void        sendError(int conn, int status, const char *message, bool head);
+static void        sendFile(int conn, const char *path, bool head);
+static void        sendRedirect(int conn, const char *target);
+static void        serve(int conn, const char *root);
+
+static const TypeT kTypes[] = {
+    {".html", "text/html"},
+    {".htm", "text/html"},
+    {".js", "text/javascript"},
+    {".mjs", "text/javascript"},
+    {".wasm", "application/wasm"},
+    {".data", "application/octet-stream"},
+    {".css", "text/css"},
+    {".png", "image/png"},
+    {".json", "application/json"},
+    {".txt", "text/plain"},
+    {".ico", "image/vnd.microsoft.icon"},
+    {".svg", "image/svg+xml"},
+    {".jpg", "image/jpeg"},
+    {".gif", "image/gif"},
+    {".wav", "audio/x-wav"}
+};
+
+
+// The type a file is served as, by its name's extension (any case).
+static const char *contentType(const char *path) {
+    const char *dot;
+    size_t      i;
+
+    dot = strrchr(path, '.');
+    if (dot != NULL && strchr(dot, '/') == NULL) {
+        for (i = 0; i < sizeof(kTypes) / sizeof(kTypes[0]); i++) {
+            if (strcasecmp(dot, kTypes[i].extension) == 0) {
+                return kTypes[i].type;
+            }
+        }
+    }
+    return DEFAULT_TYPE;
+}
+
+
+// A hex digit's value, or -1.
+static int hexValue(char c) {
+    if (c >= '0' && c <= '9') {
+        return c - '0';
+    }
+    if (c >= 'a' && c <= 'f') {
+        return c - 'a' + HEX_LETTER;
+    }
+    if (c >= 'A' && c <= 'F') {
+        return c - 'A' + HEX_LETTER;
+    }
+    return -1;
+}
+
+
+// The file a request target names, under root, in path; slash says the
+// target's path ended with one. False when it can name none (too long, or
+// a NUL in it).
+static bool resolve(const char *root, const char *target, char *path, size_t cap, bool *slash) {
+    char   decoded[HEADER_MAX];
+    char  *segment;
+    char  *cut;
+    size_t rootLen;
+    size_t len;
+    size_t i;
+    int    high;
+    int    low;
+
+    // The path alone, %XX decoded (a bad escape stays as it is).
+    len = 0u;
+    for (i = 0; target[i] != '\0' && target[i] != '?' && target[i] != '#' && len + 1u < sizeof(decoded); i++) {
+        high = target[i] == '%' ? hexValue(target[i + 1u]) : -1;
+        low  = high >= 0 ? hexValue(target[i + 2u]) : -1;
+        if (low >= 0) {
+            decoded[len++] = (char)(high * HEX_BASE + low);
+            i += 2u;
+        } else {
+            decoded[len++] = target[i];
+        }
+    }
+    decoded[len] = '\0';
+    *slash       = len > 0u && target[i - 1u] == '/';
+    if (strlen(decoded) != len) {
+        return false;
+    }
+    // Segment by segment onto root: none empty or ".", and ".." takes off
+    // the one before it, never root's own.
+    rootLen = strlen(root);
+    if (rootLen >= cap) {
+        return false;
+    }
+    memcpy(path, root, rootLen + 1u);
+    for (segment = strtok(decoded, "/"); segment != NULL; segment = strtok(NULL, "/")) {
+        if (strcmp(segment, ".") == 0) {
+            continue;
+        }
+        if (strcmp(segment, "..") == 0) {
+            cut = strrchr(path + rootLen, '/');
+            if (cut != NULL) {
+                *cut = '\0';
+            }
+            continue;
+        }
+        len = strlen(path);
+        if (len + 1u + strlen(segment) >= cap) {
+            return false;
+        }
+        path[len] = '/';
+        memcpy(path + len + 1u, segment, strlen(segment) + 1u);
+    }
+    return true;
+}
+
+
+// An error: its status line and the same as the body (the head alone, for
+// HEAD).
+static void sendError(int conn, int status, const char *message, bool head) {
+    char top[RESPONSE_HEAD_MAX];
+    char body[RESPONSE_HEAD_MAX];
+    int  bodyLen;
+    int  topLen;
+
+    bodyLen = snprintf(body, sizeof(body), "%d %s\n", status, message);
+    topLen  = snprintf(top, sizeof(top), "HTTP/1.0 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", status, message, ERROR_TYPE, bodyLen);
+    if (gateNetSendAll(conn, top, (size_t)topLen) && !head) {
+        gateNetSendAll(conn, body, (size_t)bodyLen);
+    }
+}
+
+
+// A file, whole (its head alone, for HEAD); 404 when it cannot be opened.
+static void sendFile(int conn, const char *path, bool head) {
+    struct stat info;
+    uint8_t     chunk[SEND_CHUNK];
+    char        top[RESPONSE_HEAD_MAX];
+    ssize_t     got;
+    int         topLen;
+    int         fd;
+
+    fd = open(path, O_RDONLY);
+    if (fd < 0 || fstat(fd, &info) != 0 || !S_ISREG(info.st_mode)) {
+        sendError(conn, STATUS_NOT_FOUND, "File not found", head);
+        if (fd >= 0) {
+            close(fd);
+        }
+        return;
+    }
+    topLen = snprintf(top, sizeof(top), "HTTP/1.0 %d OK\r\nContent-Type: %s\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", STATUS_OK, contentType(path), (long long)info.st_size);
+    if (gateNetSendAll(conn, top, (size_t)topLen) && !head) {
+        while ((got = read(fd, chunk, sizeof(chunk))) > 0 && gateNetSendAll(conn, chunk, (size_t)got)) {
+            // Chunk by chunk to the end, or until the client goes.
+        }
+    }
+    close(fd);
+}
+
+
+// A folder's URL without its trailing slash: to the one with it (the
+// query and fragment kept).
+static void sendRedirect(int conn, const char *target) {
+    char   top[RESPONSE_HEAD_MAX + HEADER_MAX];
+    size_t pathLen;
+    int    topLen;
+
+    pathLen = strcspn(target, "?#");
+    topLen  = snprintf(top, sizeof(top), "HTTP/1.0 %d Moved Permanently\r\nLocation: %.*s/%s\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", STATUS_MOVED, (int)pathLen, target, target + pathLen);
+    gateNetSendAll(conn, top, (size_t)topLen);
+}
+
+
+// One request on conn, answered.
+static void serve(int conn, const char *root) {
+    struct stat info;
+    char        request[HEADER_MAX + 1u];
+    char        path[PATH_MAX_BYTES];
+    char        index[PATH_MAX_BYTES];
+    const char *method;
+    const char *target;
+    const char *version;
+    size_t      len;
+    ssize_t     got;
+    bool        head;
+    bool        slash;
+
+    len = 0u;
+    for (;;) {
+        request[len] = '\0';
+        if (strstr(request, HEADER_END) != NULL || strstr(request, HEADER_END_BARE) != NULL) {
+            break;
+        }
+        if (len == HEADER_MAX) {
+            sendError(conn, STATUS_BAD_REQUEST, "Request too long", false);
+            return;
+        }
+        got = read(conn, request + len, HEADER_MAX - len);
+        if (got <= 0) {
+            return;
+        }
+        len += (size_t)got;
+    }
+    // The request line alone: its method, target, and version.
+    request[strcspn(request, "\r\n")] = '\0';
+
+    method  = strtok(request, " ");
+    target  = strtok(NULL, " ");
+    version = strtok(NULL, " ");
+    if (method == NULL || target == NULL || version == NULL || strtok(NULL, " ") != NULL) {
+        sendError(conn, STATUS_BAD_REQUEST, "Bad request syntax", false);
+        return;
+    }
+    head = strcmp(method, "HEAD") == 0;
+    if (!head && strcmp(method, "GET") != 0) {
+        sendError(conn, STATUS_NO_METHOD, "Unsupported method", false);
+        return;
+    }
+    if (!resolve(root, target, path, sizeof(path), &slash)) {
+        sendError(conn, STATUS_NOT_FOUND, "File not found", head);
+        return;
+    }
+    if (stat(path, &info) != 0 || !S_ISDIR(info.st_mode)) {
+        if (slash) {
+            sendError(conn, STATUS_NOT_FOUND, "File not found", head);
+        } else {
+            sendFile(conn, path, head);
+        }
+        return;
+    }
+    if (!slash) {
+        sendRedirect(conn, target);
+        return;
+    }
+    snprintf(index, sizeof(index), "%s/%s", path, INDEX_FILE);
+    sendFile(conn, index, head);
+}
+
+
+int main(int argc, char **argv) {
+    struct timeval timeout;
+    struct stat    info;
+    const char    *root;
+    uint16_t       port;
+    pid_t          child;
+    int            listener;
+    int            conn;
+
+    gateNetStart(TOOL_NAME);
+    if (argc != ARG_COUNT) {
+        fprintf(stderr, "usage: %s PORT DIR\n", TOOL_NAME);
+        return GATE_USAGE_EXIT;
+    }
+    port = gateArgPort(argv[ARG_PORT]);
+    root = argv[ARG_DIR];
+    if (stat(root, &info) != 0 || !S_ISDIR(info.st_mode)) {
+        toolFail("%s is no folder", root);
+    }
+    listener = gateNetListen(port, LISTEN_BACKLOG);
+    printf("%s: serving %s at http://%s:%u/\n", TOOL_NAME, root, GATE_NET_HOST, (unsigned)port);
+    // Children are reaped by the system; a client quiet too long is dropped.
+    signal(SIGCHLD, SIG_IGN);
+    timeout.tv_sec  = CLIENT_SECONDS;
+    timeout.tv_usec = 0;
+    for (;;) {
+        conn = accept(listener, NULL, NULL);
+        if (conn < 0) {
+            continue;
+        }
+        setsockopt(conn, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+        setsockopt(conn, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
+        child = fork();
+        if (child == 0) {
+            close(listener);
+            serve(conn, root);
+            close(conn);
+            _exit(0);
+        }
+        // Without a process to spare, serve it here.
+        if (child < 0) {
+            serve(conn, root);
+        }
+        close(conn);
+    }
+}
diff --git a/tests/gate/iconFind.c b/tests/gate/iconFind.c
new file mode 100644
index 0000000..b8e372c
--- /dev/null
+++ b/tests/gate/iconFind.c
@@ -0,0 +1,246 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// iconFind.c - whether a screenshot shows a program's baked icon: the icon
+// gates' check (scripts/verify-be-icon.sh, verify-dvx-icon.sh, and
+// verify-win16-icon.sh).
+//
+//     iconFind be    PNG ICONDIR PALETTE.h LABEL
+//     iconFind dvx   PNG ICON.bmp LABEL
+//     iconFind win16 PNG ICON.ico LABEL
+//
+// The screenshot must hold every opaque pixel of the icon, each in its
+// colour give or take TOLERANCE a gun: BeOS R5's and DVX's 16-bit screens
+// keep 5 or 6 bits a gun, and the 6-bit DAC behind DOSBox's capture moves
+// a colour by a few levels. The search anchors on the opaque pixel whose
+// colour the screen holds least and tries the icon at every place that
+// colour is (gateIconFind).
+//
+// be: iconBake's raw icons in ICONDIR, icon32.raw then icon16.raw (32x32
+// and 16x16 indices into the BeOS palette, whose numbers are read from
+// PALETTE.h's kBePalette table; index 255 is transparent); either will do.
+// dvx: a BMP whose DVX grey (192, 192, 192) is the ground. win16: an icon
+// whose picture is opaque where its alpha is at least half.
+//
+// Prints LABEL: PASS or LABEL: FAIL as the gate's verdict; exits 0 or 1,
+// or 2 on a bad command line.
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+
+#define TOOL_NAME        "iconFind"
+#define FAIL_EXIT        1
+#define TOLERANCE        8u
+#define ALPHA_OPAQUE_MIN 128u
+#define BE_ICON_COUNT    2u
+#define BE_TRANSPARENT   255u
+#define BE_PALETTE_NAME  "kBePalette"
+#define BE_PALETTE_SIZE  256u
+#define PATH_BYTES       4096u
+#define DECIMAL_BASE     10u
+
+// Whether an icon's pixel is opaque.
+typedef bool (*OpaqueFnT)(const uint8_t *pixel);
+
+// A Be icon iconBake bakes: its side and its file.
+typedef struct BeIconT {
+    uint32_t    side;
+    const char *file;
+} BeIconT;
+
+static void bePalette(const char *path, uint8_t palette[BE_PALETTE_SIZE][GATE_RGB_BYTES]);
+static int  findBe(char **args);
+static int  findDvx(char **args);
+static int  findWin16(char **args);
+static bool halfOpaque(const uint8_t *pixel);
+static void iconFromImage(const GateImageT *img, OpaqueFnT opaque, GateIconT *icon);
+static bool notGrey(const uint8_t *pixel);
+
+static const GateModeT kModes[] = {
+    { "be",    4, findBe    },
+    { "dvx",   3, findDvx   },
+    { "win16", 3, findWin16 }
+};
+
+static const BeIconT kBeIcons[BE_ICON_COUNT] = { { 32u, "icon32.raw" }, { 16u, "icon16.raw" } };
+
+static const uint8_t kDvxGrey[GATE_RGB_BYTES] = { 192, 192, 192 };
+
+static const char *const kUsage[] = {
+    "usage:",
+    "    iconFind be    PNG ICONDIR PALETTE.h LABEL   Tracker shows icon32.raw or",
+    "                                                icon16.raw (BeOS palette)",
+    "    iconFind dvx   PNG ICON.bmp LABEL            Program Manager shows the BMP",
+    "                                                (all but DVX's grey)",
+    "    iconFind win16 PNG ICON.ico LABEL            Windows 3.1 shows the icon",
+    "                                                (its opaque pixels)"
+};
+
+
+// The BeOS palette iconBake bakes with: the numbers in the table that
+// follows kBePalette in its header, three a colour.
+static void bePalette(const char *path, uint8_t palette[BE_PALETTE_SIZE][GATE_RGB_BYTES]) {
+    uint8_t    *text;
+    size_t      len;
+    const char *at;
+    uint32_t    count;
+    uint32_t    value;
+
+    if (!toolReadFile(path, &text, &len)) {
+        toolFail("cannot read %s", path);
+    }
+    at = strstr((const char *)text, BE_PALETTE_NAME);
+    at = (at != NULL) ? strchr(at, '{') : NULL;
+    if (at == NULL) {
+        toolFail("%s has no %s table", path, BE_PALETTE_NAME);
+    }
+    count = 0u;
+    while (*at != '\0' && count < BE_PALETTE_SIZE * GATE_RGB_BYTES) {
+        if (*at < '0' || *at > '9') {
+            at++;
+            continue;
+        }
+        value = 0u;
+        while (*at >= '0' && *at <= '9') {
+            value = value * DECIMAL_BASE + (uint32_t)(*at - '0');
+            at++;
+        }
+        palette[count / GATE_RGB_BYTES][count % GATE_RGB_BYTES] = (uint8_t)value;
+        count++;
+    }
+    if (count < BE_PALETTE_SIZE * GATE_RGB_BYTES) {
+        toolFail("%s's %s table has %u numbers, not %u", path, BE_PALETTE_NAME, (unsigned)count, (unsigned)(BE_PALETTE_SIZE * GATE_RGB_BYTES));
+    }
+    free(text);
+}
+
+
+// Tracker shows DRAW by one of its baked icons, either size.
+static int findBe(char **args) {
+    GateImageT screen;
+    GateIconT  icon;
+    uint8_t    palette[BE_PALETTE_SIZE][GATE_RGB_BYTES];
+    char       path[PATH_BYTES];
+    uint8_t   *indices;
+    size_t     len;
+    size_t     i;
+    uint32_t   b;
+    uint32_t   left;
+    uint32_t   top;
+
+    gateImageLoad(args[0], &screen);
+    bePalette(args[2], palette);
+    for (b = 0; b < BE_ICON_COUNT; b++) {
+        snprintf(path, sizeof(path), "%s/%s", args[1], kBeIcons[b].file);
+        if (!toolReadFile(path, &indices, &len)) {
+            toolFail("cannot read %s", path);
+        }
+        memset(&icon, 0, sizeof(icon));
+        icon.width  = kBeIcons[b].side;
+        icon.height = kBeIcons[b].side;
+        for (i = 0; i < len; i++) {
+            if (indices[i] != BE_TRANSPARENT) {
+                gateIconAdd(&icon, (uint32_t)(i % kBeIcons[b].side), (uint32_t)(i / kBeIcons[b].side), palette[indices[i]]);
+            }
+        }
+        free(indices);
+        if (gateIconFind(&screen, &icon, TOLERANCE, &left, &top)) {
+            printf("%s: PASS (Tracker shows DRAW by its icon at %u,%u: all %u opaque pixels of the baked %ux%u)\n", args[3], (unsigned)left, (unsigned)top, (unsigned)icon.count, (unsigned)icon.width, (unsigned)icon.height);
+            return 0;
+        }
+        gateIconFree(&icon);
+    }
+    printf("%s: FAIL - neither of DRAW's icons is on the screen\n", args[3]);
+    return FAIL_EXIT;
+}
+
+
+// Program Manager shows DRAW by its icon: every pixel not the grey.
+static int findDvx(char **args) {
+    GateImageT bmp;
+    GateImageT screen;
+    GateIconT  icon;
+    uint32_t   left;
+    uint32_t   top;
+
+    gateImageLoad(args[1], &bmp);
+    iconFromImage(&bmp, notGrey, &icon);
+    gateImageLoad(args[0], &screen);
+    if (!gateIconFind(&screen, &icon, TOLERANCE, &left, &top)) {
+        printf("%s: FAIL - DRAW's icon is not on the screen (%u pixels looked for)\n", args[2], (unsigned)icon.count);
+        return FAIL_EXIT;
+    }
+    printf("%s: PASS (Program Manager shows DRAW by its icon at %u,%u: all %u pixels of the baked %ux%u that are not the grey)\n", args[2], (unsigned)left, (unsigned)top, (unsigned)icon.count, (unsigned)icon.width, (unsigned)icon.height);
+    return 0;
+}
+
+
+// DRAW minimised: Windows 3.1 shows the icon bound into it.
+static int findWin16(char **args) {
+    GateImageT ico;
+    GateImageT screen;
+    GateIconT  icon;
+    uint32_t   left;
+    uint32_t   top;
+
+    gateImageLoad(args[0], &screen);
+    gateImageLoad(args[1], &ico);
+    iconFromImage(&ico, halfOpaque, &icon);
+    if (icon.count == 0u) {
+        printf("%s: FAIL - the icon file has no opaque pixel\n", args[2]);
+        return FAIL_EXIT;
+    }
+    if (!gateIconFind(&screen, &icon, TOLERANCE, &left, &top)) {
+        printf("%s: FAIL - the program's icon is not on the screen (%u opaque pixels looked for)\n", args[2], (unsigned)icon.count);
+        return FAIL_EXIT;
+    }
+    printf("%s: PASS (DRAW minimised shows its bound icon at %u,%u: all %u opaque pixels of the baked 32x32 in place)\n", args[2], (unsigned)left, (unsigned)top, (unsigned)icon.count);
+    return 0;
+}
+
+
+static bool halfOpaque(const uint8_t *pixel) {
+    return pixel[GATE_ALPHA] >= ALPHA_OPAQUE_MIN;
+}
+
+
+// An icon from a picture: its size, and its opaque pixels row by row.
+static void iconFromImage(const GateImageT *img, OpaqueFnT opaque, GateIconT *icon) {
+    const uint8_t *pixel;
+    uint32_t       x;
+    uint32_t       y;
+
+    memset(icon, 0, sizeof(*icon));
+    icon->width  = img->width;
+    icon->height = img->height;
+    for (y = 0; y < img->height; y++) {
+        for (x = 0; x < img->width; x++) {
+            pixel = gateImagePixel(img, x, y);
+            if (opaque(pixel)) {
+                gateIconAdd(icon, x, y, pixel);
+            }
+        }
+    }
+}
+
+
+static bool notGrey(const uint8_t *pixel) {
+    return memcmp(pixel, kDvxGrey, GATE_RGB_BYTES) != 0;
+}
+
+
+int main(int argc, char **argv) {
+    int exitCode;
+
+    toolFileName(TOOL_NAME);
+    if (!gateModeRun(kModes, GATE_COUNT_OF(kModes), argc, argv, &exitCode)) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    return exitCode;
+}
diff --git a/tests/gate/iigsIcon.c b/tests/gate/iigsIcon.c
new file mode 100644
index 0000000..8163627
--- /dev/null
+++ b/tests/gate/iigsIcon.c
@@ -0,0 +1,265 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// iigsIcon.c - whether the IIgs Finder shows a program by its baked icon:
+// scripts/verify-iigs-icon.sh's check.
+//
+//     iigsIcon SHR.bin ICON LABEL [KEEP.png]
+//
+// SHR.bin is the Super Hi-Res block MAME's memory held ($E1/2000 to
+// $E1/9FFF: pixels, scan line control bytes, palettes); ICON is iconBake's
+// Finder icon file. The screen is read at 640 pixels a line: a 640-mode
+// line's pixel takes its colour from the palette quarter its place in the
+// byte gives it (bits 7-6 from entries 8-11, 5-4 from 12-15, 3-2 from 0-3,
+// 1-0 from 4-7); a 320-mode line's pixel is shown twice. Each 640-mode
+// screen pixel's own 2-bit value is kept (none on a 320-mode line), and
+// the icon is looked for by those: in 640 mode QuickDraw draws a 4-bit icon
+// pixel as two screen pixels, its high two bits then its low two, so every
+// pixel the icon's mask leaves opaque must be its two values, wherever the
+// icon is. Every place it is counts.
+//
+// KEEP, when given and not empty, gets the screen as a PNG at 640x400 (each
+// line twice), as it shows. Prints LABEL: PASS or LABEL: FAIL; exits 0 or
+// 1, or 2 on a bad command line.
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+#include "toolPng.h"
+
+#define TOOL_NAME          "iigsIcon"
+#define FAIL_EXIT          1
+#define WIDTH_640          640u
+#define HEIGHT             200u
+#define ROW_BYTES          160u
+#define SCB_AT             0x7D00u
+#define PALETTES_AT        0x7E00u
+#define SHR_BYTES          0x8000u
+#define SCB_640            0x80u
+#define SCB_PALETTE_MASK   0x0Fu
+#define PALETTE_BYTES      32u
+#define PALETTE_ENTRIES    16u
+#define ENTRY_BYTES        2u
+#define BYTE_BITS          8u
+#define PIXELS_A_BYTE_640  4u
+// A 320-mode pixel is two screen pixels at 640 a line.
+#define PIXEL_320_WIDTH    2u
+#define NIBBLE_BITS        4u
+#define NIBBLE_MASK        0xFu
+#define KEEP_SCALE_Y       2u
+#define NO_VALUE           (-1)
+// A 640-mode screen pixel is 2 bits; so is each half of an icon pixel.
+#define HALF_BITS          2u
+#define HALF_MASK          3u
+#define HALVES             2u
+// The icon file: its header (4 bytes, the file ID, 4 bytes, a 16-byte
+// name), then the record's length word, 64-byte pathname, 16-byte name,
+// file type, and aux type before its big icon: iconType, iconSize, height,
+// width, the image (4 bits a pixel, the even pixel in the high nibble),
+// and the mask, of the same shape.
+#define FILE_HEADER_BYTES  26u
+#define RECORD_HEAD_BYTES  86u
+#define ICON_AT            (FILE_HEADER_BYTES + RECORD_HEAD_BYTES)
+#define ICON_SIZE_AT       2u
+#define ICON_HEIGHT_AT     4u
+#define ICON_WIDTH_AT      6u
+#define ICON_HEADER_BYTES  8u
+#define ICON_PIXELS_A_BYTE 2u
+
+// One opaque half of an icon pixel: where it is (screen pixels) and its
+// 2-bit value.
+typedef struct HalfT {
+    uint32_t x;
+    uint32_t y;
+    int8_t   value;
+} HalfT;
+
+static void keepScreen(const char *path, const uint8_t *rgb);
+static void readScreen(const uint8_t *shr, uint8_t *rgb, int8_t *values);
+
+// The palette quarter of each 2-bit pixel of a 640-mode byte, high bits
+// first.
+static const uint32_t kQuarterBase[PIXELS_A_BYTE_640] = { 8u, 12u, 0u, 4u };
+
+static const char *const kUsage[] = {
+    "usage: iigsIcon SHR.bin ICON LABEL [KEEP.png]"
+};
+
+
+// The screen as it shows, each line twice, as a PNG.
+static void keepScreen(const char *path, const uint8_t *rgb) {
+    ToolImageT keep;
+    uint32_t   x;
+    uint32_t   y;
+    uint8_t   *out;
+
+    if (!toolImageCreate(&keep, WIDTH_640, HEIGHT * KEEP_SCALE_Y, false)) {
+        toolFail("out of memory");
+    }
+    for (y = 0; y < HEIGHT * KEEP_SCALE_Y; y++) {
+        for (x = 0; x < WIDTH_640; x++) {
+            out = keep.pixels + ((size_t)y * WIDTH_640 + x) * GATE_RGBA_BYTES;
+            memcpy(out, rgb + ((size_t)(y / KEEP_SCALE_Y) * WIDTH_640 + x) * GATE_RGB_BYTES, GATE_RGB_BYTES);
+            out[GATE_ALPHA] = UINT8_MAX;
+        }
+    }
+    if (!toolPngSave(path, &keep)) {
+        toolFail("cannot write %s", path);
+    }
+    toolImageFree(&keep);
+}
+
+
+// The screen at 640 pixels a line, as RGB and as each 640-mode pixel's
+// 2-bit value (NO_VALUE on a 320-mode line).
+static void readScreen(const uint8_t *shr, uint8_t *rgb, int8_t *values) {
+    uint8_t  palette[PALETTE_ENTRIES][GATE_RGB_BYTES];
+    uint32_t scb;
+    uint32_t x;
+    uint32_t y;
+    uint32_t i;
+    uint32_t p;
+    uint32_t value;
+    uint8_t  byte;
+    size_t   at;
+
+    for (y = 0; y < HEIGHT; y++) {
+        scb = shr[SCB_AT + y];
+        for (i = 0; i < PALETTE_ENTRIES; i++) {
+            gateRgb12(gateLe16(shr + PALETTES_AT + (scb & SCB_PALETTE_MASK) * PALETTE_BYTES + i * ENTRY_BYTES), palette[i]);
+        }
+        for (x = 0; x < ROW_BYTES; x++) {
+            byte = shr[y * ROW_BYTES + x];
+            for (p = 0; p < PIXELS_A_BYTE_640; p++) {
+                at = (size_t)y * WIDTH_640 + x * PIXELS_A_BYTE_640 + p;
+                if (scb & SCB_640) {
+                    value      = (byte >> (BYTE_BITS - HALF_BITS - HALF_BITS * p)) & HALF_MASK;
+                    values[at] = (int8_t)value;
+                    memcpy(rgb + at * GATE_RGB_BYTES, palette[kQuarterBase[p] + value], GATE_RGB_BYTES);
+                } else {
+                    value      = (byte >> ((p / PIXEL_320_WIDTH == 0u) ? NIBBLE_BITS : 0u)) & NIBBLE_MASK;
+                    values[at] = NO_VALUE;
+                    memcpy(rgb + at * GATE_RGB_BYTES, palette[value], GATE_RGB_BYTES);
+                }
+            }
+        }
+    }
+}
+
+
+int main(int argc, char **argv) {
+    uint8_t       *shr;
+    uint8_t       *icon;
+    uint8_t       *rgb;
+    int8_t        *values;
+    HalfT         *halves;
+    const uint8_t *image;
+    const char    *label;
+    size_t         shrLen;
+    size_t         iconLen;
+    uint32_t       size;
+    uint32_t       w;
+    uint32_t       h;
+    uint32_t       rowBytes;
+    uint32_t       halfCount;
+    uint32_t       found;
+    uint32_t       firstX;
+    uint32_t       firstY;
+    uint32_t       x;
+    uint32_t       y;
+    uint32_t       i;
+    uint32_t       shift;
+    uint32_t       nibble;
+    uint32_t       half;
+    bool           match;
+
+    toolFileName(TOOL_NAME);
+    if (argc != 4 && argc != 5) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    label = argv[3];
+    if (!toolReadFile(argv[1], &shr, &shrLen) || shrLen < SHR_BYTES) {
+        toolFail("cannot read %u bytes of %s", (unsigned)SHR_BYTES, argv[1]);
+    }
+    rgb    = (uint8_t *)malloc((size_t)WIDTH_640 * HEIGHT * GATE_RGB_BYTES);
+    values = (int8_t *)malloc((size_t)WIDTH_640 * HEIGHT);
+    if (rgb == NULL || values == NULL) {
+        toolFail("out of memory");
+    }
+    readScreen(shr, rgb, values);
+    if (argc == 5 && argv[4][0] != '\0') {
+        keepScreen(argv[4], rgb);
+    }
+
+    // The big icon of the file's one record: its 4-bit pixels and mask,
+    // the even pixel in the high nibble.
+    if (!toolReadFile(argv[2], &icon, &iconLen) || iconLen < ICON_AT + ICON_HEADER_BYTES) {
+        toolFail("cannot read the icon record of %s", argv[2]);
+    }
+    size     = gateLe16(icon + ICON_AT + ICON_SIZE_AT);
+    h        = gateLe16(icon + ICON_AT + ICON_HEIGHT_AT);
+    w        = gateLe16(icon + ICON_AT + ICON_WIDTH_AT);
+    image    = icon + ICON_AT + ICON_HEADER_BYTES;
+    rowBytes = (w + ICON_PIXELS_A_BYTE - 1u) / ICON_PIXELS_A_BYTE;
+    if ((size_t)size + (size_t)rowBytes * h > iconLen - ICON_AT - ICON_HEADER_BYTES) {
+        toolFail("%s's icon is truncated", argv[2]);
+    }
+    halves    = (HalfT *)malloc(((size_t)w * h + 1u) * HALVES * sizeof(HalfT));
+    halfCount = 0u;
+    if (halves == NULL) {
+        toolFail("out of memory");
+    }
+    for (y = 0; y < h; y++) {
+        for (x = 0; x < w; x++) {
+            shift = (x % ICON_PIXELS_A_BYTE == 0u) ? NIBBLE_BITS : 0u;
+            if (((image[size + y * rowBytes + x / ICON_PIXELS_A_BYTE] >> shift) & NIBBLE_MASK) == 0u) {
+                continue;
+            }
+            // In 640 mode an icon pixel is two screen pixels: its high
+            // half, then its low half.
+            nibble = (image[y * rowBytes + x / ICON_PIXELS_A_BYTE] >> shift) & NIBBLE_MASK;
+            for (half = 0; half < HALVES; half++) {
+                halves[halfCount].x     = x * HALVES + half;
+                halves[halfCount].y     = y;
+                halves[halfCount].value = (int8_t)((half == 0u) ? nibble >> HALF_BITS : nibble & HALF_MASK);
+                halfCount++;
+            }
+        }
+    }
+    if (halfCount == 0u) {
+        toolFail("%s's icon has no opaque pixel", argv[2]);
+    }
+
+    found  = 0u;
+    firstX = 0u;
+    firstY = 0u;
+    for (y = 0; y + h <= HEIGHT; y++) {
+        for (x = 0; x + w * HALVES <= WIDTH_640; x++) {
+            match = true;
+            for (i = 0; i < halfCount && match; i++) {
+                match = values[(size_t)(y + halves[i].y) * WIDTH_640 + x + halves[i].x] == halves[i].value;
+            }
+            if (match) {
+                firstX = (found == 0u) ? x : firstX;
+                firstY = (found == 0u) ? y : firstY;
+                found++;
+            }
+        }
+    }
+    if (found == 0u) {
+        printf("%s: FAIL - no program's icon is in the JOEYLIB window (%u opaque pixels looked for)\n", label, (unsigned)(halfCount / HALVES));
+        return FAIL_EXIT;
+    }
+    printf("%s: PASS (the Finder shows %u programs by the baked %ux%u colour icon, the first at %u,%u: all %u opaque pixels, each its two screen pixels' values)\n", label, (unsigned)found, (unsigned)w, (unsigned)h, (unsigned)firstX, (unsigned)firstY, (unsigned)(halfCount / HALVES));
+    free(halves);
+    free(icon);
+    free(values);
+    free(rgb);
+    free(shr);
+    return 0;
+}
diff --git a/tests/gate/iigsProfile.c b/tests/gate/iigsProfile.c
new file mode 100644
index 0000000..2873579
--- /dev/null
+++ b/tests/gate/iigsProfile.c
@@ -0,0 +1,422 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// iigsProfile.c - name the IIgs profiler's PC samples, for
+// scripts/profile-iigs.sh.
+//
+//     iigsProfile SAMPLES MAP TOP
+//
+// SAMPLES holds the MAME script's lines: "S ADDRESS COUNT" (a sampled
+// 24-bit pc and how often), "F BANK BYTES" (the first bytes of a bank as
+// it ran, in hex), and "T TOTAL". MAP is link816's map: its global and
+// local symbols by link address (a global wins an address both name);
+// beside it MAP's base name with ".json" (the segments' link banks, else
+// one segment at bank 0) and ".segN.bin" (each segment's image).
+//
+// GS/OS loads segments wherever it likes, so each running bank is matched
+// to the segment whose image its first bytes best agree with (85% at
+// least); a sample in such a bank is named by the symbol below it in that
+// segment, else as segN+OFFSET, and one elsewhere by the bank it is in
+// (ROM, the firmware and vectors, or GS/OS, the tools, and NTP). Prints
+// the matches and the TOP places most sampled.
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "toolFile.h"
+#include "toolJson.h"
+
+#define TOOL_NAME      "iigsProfile"
+#define LINE_MAX_CHARS 8192u
+#define NAME_MAX_CHARS 128u
+#define WHERE_CHARS    160u
+#define PATH_CHARS     4096u
+// Room in a path for what is added to the map's base name.
+#define SUFFIX_ROOM    32u
+#define BASE_CHARS     (PATH_CHARS - SUFFIX_ROOM)
+#define BANKS          256u
+#define BANK_SHIFT     16u
+#define OFFSET_MASK    0xFFFFu
+#define ADDRESS_DIGITS 6u
+#define MATCH_BYTES    16u
+#define MATCH_SCORE    0.85
+#define PERCENT        100.0
+#define ROM_FIRST      0xFCu
+#define FIRMWARE_FIRST 0xE0u
+#define FIRMWARE_LAST  0xE1u
+#define HEX            16
+#define START_COUNT    256u
+#define MAP_SUFFIX     ".map"
+
+typedef struct SymbolT {
+    uint32_t address;
+    char     name[NAME_MAX_CHARS];
+    bool     global;
+} SymbolT;
+
+typedef struct SegmentT {
+    uint32_t number;
+    uint32_t linkBank;
+    uint8_t *image;
+    size_t   len;
+} SegmentT;
+
+// A place samples were counted at, in the order first met.
+typedef struct PlaceT {
+    char     where[WHERE_CHARS];
+    uint32_t count;
+} PlaceT;
+
+// The samples: each address's count (the last line for it wins, in the
+// place its first line had), the banks' fingerprints, and the total.
+typedef struct SamplesT {
+    uint32_t *addresses;
+    uint32_t *counts;
+    size_t    count;
+    size_t    cap;
+    uint8_t  *prints[BANKS];
+    size_t    printLen[BANKS];
+    uint32_t  total;
+} SamplesT;
+
+// What a running bank was matched to.
+typedef struct BankT {
+    bool     matched;
+    uint32_t segment;
+    uint32_t linkBank;
+    double   score;
+} BankT;
+
+static int      bySymbol(const void *a, const void *b);
+static void     placeAdd(PlaceT **places, size_t *count, const char *where, uint32_t n);
+static void     samplesRead(const char *path, SamplesT *samples);
+static void     segmentsRead(const char *base, SegmentT **segments, size_t *count);
+static void     symbolsRead(const char *path, SymbolT **symbols, size_t *count);
+static void     whereOf(uint32_t address, const BankT *banks, const SymbolT *symbols, size_t symbolCount, const SegmentT *segments, char *where);
+static void    *xrealloc(void *p, size_t bytes);
+
+
+static int bySymbol(const void *a, const void *b) {
+    const SymbolT *x = (const SymbolT *)a;
+    const SymbolT *y = (const SymbolT *)b;
+
+    return x->address < y->address ? -1 : (x->address > y->address ? 1 : 0);
+}
+
+
+// One sample count to its place's total (a new place goes last).
+static void placeAdd(PlaceT **places, size_t *count, const char *where, uint32_t n) {
+    size_t i;
+
+    for (i = 0; i < *count && strcmp((*places)[i].where, where) != 0; i++) {
+    }
+    if (i == *count) {
+        *places = (PlaceT *)xrealloc(*places, (*count + 1u) * sizeof(PlaceT));
+        snprintf((*places)[i].where, WHERE_CHARS, "%s", where);
+        (*places)[i].count = 0;
+        (*count)++;
+    }
+    (*places)[i].count += n;
+}
+
+
+static void samplesRead(const char *path, SamplesT *samples) {
+    FILE    *f;
+    char     line[LINE_MAX_CHARS];
+    char    *at;
+    char    *stop;
+    uint32_t address;
+    uint32_t n;
+    uint32_t bank;
+    size_t   len;
+    size_t   i;
+
+    memset(samples, 0, sizeof(*samples));
+    f = fopen(path, "rb");
+    if (f == NULL) {
+        toolFail("cannot read %s", path);
+    }
+    while (fgets(line, sizeof(line), f) != NULL) {
+        if (line[0] == 'S' && line[1] == ' ') {
+            address = (uint32_t)strtoul(line + 2, &stop, HEX);
+            n       = (uint32_t)strtoul(stop, NULL, 10);
+            for (i = 0; i < samples->count && samples->addresses[i] != address; i++) {
+            }
+            if (i == samples->count) {
+                if (samples->count == samples->cap) {
+                    samples->cap       = samples->cap == 0 ? START_COUNT : samples->cap * 2u;
+                    samples->addresses = (uint32_t *)xrealloc(samples->addresses, samples->cap * sizeof(uint32_t));
+                    samples->counts    = (uint32_t *)xrealloc(samples->counts, samples->cap * sizeof(uint32_t));
+                }
+                samples->addresses[samples->count++] = address;
+            }
+            samples->counts[i] = n;
+        } else if (line[0] == 'F' && line[1] == ' ') {
+            bank = (uint32_t)strtoul(line + 2, &stop, HEX) & (BANKS - 1u);
+            for (at = stop; *at == ' '; at++) {
+            }
+            len = strspn(at, "0123456789abcdefABCDEF") / 2u;
+            free(samples->prints[bank]);
+            samples->prints[bank]   = (uint8_t *)xrealloc(NULL, len + 1u);
+            samples->printLen[bank] = len;
+            for (i = 0; i < len; i++) {
+                samples->prints[bank][i] = (uint8_t)strtoul((char[]){ at[2u * i], at[2u * i + 1u], 0 }, NULL, HEX);
+            }
+        } else if (line[0] == 'T' && line[1] == ' ') {
+            samples->total = (uint32_t)strtoul(line + 2, NULL, 10);
+        }
+    }
+    fclose(f);
+}
+
+
+// The segments the manifest names (base.json), with their images
+// (base.segN.bin); without a manifest, one segment at bank 0.
+static void segmentsRead(const char *base, SegmentT **segments, size_t *count) {
+    char             path[PATH_CHARS];
+    char             err[TOOL_JSON_ERROR_BYTES];
+    uint8_t         *text;
+    size_t           len;
+    size_t           i;
+    ToolJsonT       *doc;
+    const ToolJsonT *list;
+    const ToolJsonT *num;
+    const ToolJsonT *at;
+
+    *segments = NULL;
+    *count    = 0;
+    snprintf(path, sizeof(path), "%s.json", base);
+    doc = NULL;
+    if (toolReadFile(path, &text, &len)) {
+        doc = toolJsonParse((const char *)text, len, err);
+        if (doc == NULL) {
+            toolFail("%s: %s", path, err);
+        }
+        free(text);
+    }
+    list = doc == NULL ? NULL : toolJsonGet(doc, "segments");
+    if (doc == NULL) {
+        *segments                = (SegmentT *)xrealloc(NULL, sizeof(SegmentT));
+        (*segments)[0].number    = 1;
+        (*segments)[0].linkBank  = 0;
+        *count                   = 1;
+    } else {
+        *segments = (SegmentT *)xrealloc(NULL, (list == NULL ? 0u : list->count) * sizeof(SegmentT) + 1u);
+        for (i = 0; list != NULL && i < list->count; i++) {
+            num = toolJsonGet(list->items[i], "num");
+            at  = toolJsonGet(list->items[i], "base");
+            if (num == NULL || at == NULL || at->type != TOOL_JSON_STRING) {
+                toolFail("%s: a segment without num and base", path);
+            }
+            (*segments)[*count].number   = (uint32_t)num->number;
+            (*segments)[*count].linkBank = (uint32_t)(strtoul(at->string, NULL, HEX) >> BANK_SHIFT);
+            (*count)++;
+        }
+        toolJsonFree(doc);
+    }
+    for (i = 0; i < *count; i++) {
+        snprintf(path, sizeof(path), "%s.seg%u.bin", base, (*segments)[i].number);
+        if (!toolReadFile(path, &(*segments)[i].image, &(*segments)[i].len)) {
+            toolFail("cannot read %s", path);
+        }
+    }
+}
+
+
+// The map's symbols, sorted by address: the globals, and the locals where
+// no global names the address.
+static void symbolsRead(const char *path, SymbolT **symbols, size_t *count) {
+    FILE    *f;
+    char     line[LINE_MAX_CHARS];
+    char     name[NAME_MAX_CHARS];
+    char    *at;
+    char    *end;
+    size_t   cap;
+    size_t   i;
+    size_t   n;
+    uint32_t address;
+    int      section;
+
+    *symbols = NULL;
+    *count   = 0;
+    cap      = 0;
+    section  = 0;
+    f        = fopen(path, "rb");
+    if (f == NULL) {
+        toolFail("cannot read %s", path);
+    }
+    while (fgets(line, sizeof(line), f) != NULL) {
+        if (line[0] == '#') {
+            section = strncmp(line, "# global symbols", strlen("# global symbols")) == 0 ? 1 : (strncmp(line, "# local symbols", strlen("# local symbols")) == 0 ? 2 : 0);
+            continue;
+        }
+        for (at = line; *at == ' ' || *at == '\t'; at++) {
+        }
+        for (end = at + strlen(at); end > at && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n'); end--) {
+        }
+        *end = 0;
+        if (section == 0 || at[0] != '0' || at[1] != 'x' || strspn(at + 2, "0123456789abcdefABCDEF") != ADDRESS_DIGITS || (at[2 + ADDRESS_DIGITS] != ' ' && at[2 + ADDRESS_DIGITS] != '\t')) {
+            continue;
+        }
+        address = (uint32_t)strtoul(at + 2, NULL, HEX);
+        for (at += 2 + ADDRESS_DIGITS; *at == ' ' || *at == '\t'; at++) {
+        }
+        n = strcspn(at, " \t");
+        if (n == 0 || at[n] != 0 || n >= NAME_MAX_CHARS) {
+            continue;
+        }
+        memcpy(name, at, n + 1u);
+        for (i = 0; i < *count && (*symbols)[i].address != address; i++) {
+        }
+        if (i == *count) {
+            if (*count == cap) {
+                cap      = cap == 0 ? START_COUNT : cap * 2u;
+                *symbols = (SymbolT *)xrealloc(*symbols, cap * sizeof(SymbolT));
+            }
+            (*count)++;
+        } else if (section != 1) {
+            continue;
+        }
+        (*symbols)[i].address = address;
+        memcpy((*symbols)[i].name, name, n + 1u);
+    }
+    fclose(f);
+    qsort(*symbols, *count, sizeof(SymbolT), bySymbol);
+}
+
+
+// Where an address is: a matched bank's symbol (the one at or below it in
+// the same link bank) or segment offset, else the kind of bank.
+static void whereOf(uint32_t address, const BankT *banks, const SymbolT *symbols, size_t symbolCount, const SegmentT *segments, char *where) {
+    const BankT *bank;
+    uint32_t     link;
+    size_t       lo;
+    size_t       hi;
+    size_t       mid;
+
+    bank = &banks[address >> BANK_SHIFT];
+    if (bank->matched) {
+        link = (bank->linkBank << BANK_SHIFT) | (address & OFFSET_MASK);
+        lo   = 0;
+        hi   = symbolCount;
+        while (lo < hi) {
+            mid = (lo + hi) / 2u;
+            if (symbols[mid].address <= link) {
+                lo = mid + 1u;
+            } else {
+                hi = mid;
+            }
+        }
+        if (lo > 0 && (symbols[lo - 1u].address >> BANK_SHIFT) == (link >> BANK_SHIFT)) {
+            snprintf(where, WHERE_CHARS, "%s", symbols[lo - 1u].name);
+            return;
+        }
+        snprintf(where, WHERE_CHARS, "seg%u+%04X", segments[bank->segment].number, address & OFFSET_MASK);
+        return;
+    }
+    if ((address >> BANK_SHIFT) >= ROM_FIRST) {
+        snprintf(where, WHERE_CHARS, "ROM $%02X", address >> BANK_SHIFT);
+    } else if ((address >> BANK_SHIFT) >= FIRMWARE_FIRST && (address >> BANK_SHIFT) <= FIRMWARE_LAST) {
+        snprintf(where, WHERE_CHARS, "bank $%02X (firmware/vectors)", address >> BANK_SHIFT);
+    } else {
+        snprintf(where, WHERE_CHARS, "bank $%02X (GS/OS, tools, NTP)", address >> BANK_SHIFT);
+    }
+}
+
+
+static void *xrealloc(void *p, size_t bytes) {
+    p = realloc(p, bytes == 0 ? 1u : bytes);
+    if (p == NULL) {
+        toolFail("out of memory");
+    }
+    return p;
+}
+
+
+int main(int argc, char **argv) {
+    SamplesT  samples;
+    SegmentT *segments;
+    SymbolT  *symbols;
+    PlaceT   *places;
+    PlaceT    held;
+    BankT     banks[BANKS];
+    char      base[BASE_CHARS];
+    char      where[WHERE_CHARS];
+    size_t    segmentCount;
+    size_t    symbolCount;
+    size_t    placeCount;
+    size_t    len;
+    size_t    same;
+    size_t    i;
+    size_t    j;
+    uint32_t  bank;
+    long      top;
+    double    score;
+
+    toolFileName(TOOL_NAME);
+    if (argc != 4) {
+        fprintf(stderr, "usage: %s SAMPLES MAP TOP\n", TOOL_NAME);
+        return GATE_USAGE_EXIT;
+    }
+    top = gateArgLong("top", argv[3]);
+    len = strlen(argv[2]);
+    snprintf(base, sizeof(base), "%.*s", (int)(len >= strlen(MAP_SUFFIX) && strcmp(argv[2] + len - strlen(MAP_SUFFIX), MAP_SUFFIX) == 0 ? len - strlen(MAP_SUFFIX) : len), argv[2]);
+    segmentsRead(base, &segments, &segmentCount);
+    symbolsRead(argv[2], &symbols, &symbolCount);
+    samplesRead(argv[1], &samples);
+
+    // Each running bank to the segment its first bytes best match.
+    memset(banks, 0, sizeof(banks));
+    for (bank = 0; bank < BANKS; bank++) {
+        if (samples.prints[bank] == NULL) {
+            continue;
+        }
+        for (i = 0; i < segmentCount; i++) {
+            len = samples.printLen[bank] < segments[i].len ? samples.printLen[bank] : segments[i].len;
+            if (len < MATCH_BYTES) {
+                continue;
+            }
+            same = 0;
+            for (j = 0; j < len; j++) {
+                same += samples.prints[bank][j] == segments[i].image[j] ? 1u : 0u;
+            }
+            score = (double)same / (double)len;
+            if (!banks[bank].matched || score > banks[bank].score) {
+                banks[bank].matched  = true;
+                banks[bank].segment  = (uint32_t)i;
+                banks[bank].linkBank = segments[i].linkBank;
+                banks[bank].score    = score;
+            }
+        }
+        banks[bank].matched = banks[bank].matched && banks[bank].score >= MATCH_SCORE;
+    }
+
+    places     = NULL;
+    placeCount = 0;
+    for (i = 0; i < samples.count; i++) {
+        whereOf(samples.addresses[i], banks, symbols, symbolCount, segments, where);
+        placeAdd(&places, &placeCount, where, samples.counts[i]);
+    }
+    // Most samples first; equal ones in the order first met.
+    for (i = 1; i < placeCount; i++) {
+        held = places[i];
+        for (j = i; j > 0 && places[j - 1u].count < held.count; j--) {
+            places[j] = places[j - 1u];
+        }
+        places[j] = held;
+    }
+    printf("profile-iigs: %u samples\n", samples.total);
+    for (bank = 0; bank < BANKS; bank++) {
+        if (banks[bank].matched) {
+            printf("  bank $%02X = segment %u (link bank $%02X, %.0f%% of %zu bytes match)\n", bank, segments[banks[bank].segment].number, banks[bank].linkBank, banks[bank].score * PERCENT, samples.printLen[bank]);
+        }
+    }
+    printf("%7s %6s  %s\n", "samples", "pct", "where");
+    for (i = 0; i < placeCount && (long)i < top; i++) {
+        printf("%7u %5.1f%%  %s\n", places[i].count, PERCENT * places[i].count / (samples.total > 0 ? samples.total : 1u), places[i].where);
+    }
+    return 0;
+}
diff --git a/tests/gate/iigsSerialPeer.c b/tests/gate/iigsSerialPeer.c
new file mode 100644
index 0000000..16c85b5
--- /dev/null
+++ b/tests/gate/iigsSerialPeer.c
@@ -0,0 +1,154 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// iigsSerialPeer.c - the host end of the Apple IIgs serial gates. MAME's
+// bitbanger socket connects out, so this listens on 127.0.0.1:PORT.
+//
+//     iigsSerialPeer capture PORT FILE
+//     iigsSerialPeer loss PORT TOTAL LOG
+//
+// capture (verify-iigs-serial.sh): every byte the IIgs sends, added to FILE
+// as it arrives (the gate kills this peer once MAME is done, and reads
+// FILE). Waits CAPTURE_ACCEPT_SECONDS for MAME (FILE left empty when it
+// never comes), then collects for CAPTURE_SECONDS or until MAME goes away.
+// Exit 0.
+//
+// loss (verify-iigs-serial-loss.sh): once the probe has sent READY, TOTAL
+// bytes (byte k = k & 0xFF) in one go; then drains whatever the probe
+// sends until MAME goes away. LOG gets a line when the bytes went and one
+// with the start of what came back. Exit 0; 1 when MAME never connects
+// (within LOSS_ACCEPT_SECONDS).
+//
+// Bad arguments exit 2.
+
+#define _DEFAULT_SOURCE
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateNet.h"
+#include "gateText.h"
+#include "toolBuf.h"
+#include "toolFile.h"
+
+#define TOOL_NAME              "iigsSerialPeer"
+#define ARG_MODE               1
+#define ARG_PORT               2
+#define ARG_FILE               3
+#define ARG_TOTAL              3
+#define ARG_LOG                4
+#define CAPTURE_ARGS           4
+#define LOSS_ARGS              5
+
+#define READ_SECONDS           1.0
+#define READ_SIZE              256u
+#define CAPTURE_ACCEPT_SECONDS 300.0
+#define CAPTURE_SECONDS        280.0
+#define LOSS_ACCEPT_SECONDS    600.0
+#define READY                  "READY"
+#define BYTE_MASK              0xFFu
+#define SHOWN_BYTES            64u
+
+static int  capture(uint16_t port, const char *path);
+static int  loss(uint16_t port, long total, const char *logPath);
+static int  usage(void);
+
+
+// Every byte MAME's connection carries, kept in path.
+static int capture(uint16_t port, const char *path) {
+    FILE    *out;
+    uint8_t  chunk[READ_SIZE];
+    double   deadline;
+    int32_t  got;
+    int      conn;
+
+    conn = gateNetAcceptOne(port, CAPTURE_ACCEPT_SECONDS);
+    out  = fopen(path, "wb");
+    if (out == NULL) {
+        toolFail("cannot write %s: %s", path, strerror(errno));
+    }
+    deadline = gateNetNow() + CAPTURE_SECONDS;
+    while (conn >= 0 && gateNetNow() < deadline) {
+        got = gateNetRecv(conn, chunk, sizeof(chunk), READ_SECONDS);
+        if (got == GATE_NET_CLOSED) {
+            break;
+        }
+        if (got > 0 && (fwrite(chunk, 1u, (size_t)got, out) != (size_t)got || fflush(out) != 0)) {
+            toolFail("cannot write %s: %s", path, strerror(errno));
+        }
+    }
+    fclose(out);
+    return 0;
+}
+
+
+// On READY, the stream; then drain until MAME closes the link.
+static int loss(uint16_t port, long total, const char *logPath) {
+    ToolBufT seen;
+    ToolBufT pattern;
+    FILE    *log;
+    uint8_t  chunk[READ_SIZE];
+    bool     sent;
+    int32_t  got;
+    long     k;
+    int      conn;
+
+    log = fopen(logPath, "w");
+    if (log == NULL) {
+        toolFail("cannot write %s: %s", logPath, strerror(errno));
+    }
+    conn = gateNetAcceptOne(port, LOSS_ACCEPT_SECONDS);
+    if (conn < 0) {
+        toolFail("MAME never connected to port %u", (unsigned)port);
+    }
+    memset(&seen, 0, sizeof(seen));
+    memset(&pattern, 0, sizeof(pattern));
+    sent = false;
+    for (;;) {
+        got = gateNetRecv(conn, chunk, sizeof(chunk), READ_SECONDS);
+        if (got == GATE_NET_CLOSED) {
+            break;
+        }
+        if (got == GATE_NET_IDLE) {
+            continue;
+        }
+        toolBufAdd(&seen, chunk, (size_t)got);
+        if (!sent && gateTextFind(seen.data, seen.len, 0u, READY, strlen(READY)) != GATE_TEXT_NOT_FOUND) {
+            for (k = 0; k < total; k++) {
+                toolBufByte(&pattern, (uint32_t)k & BYTE_MASK);
+            }
+            if (!gateNetSendAll(conn, pattern.data, pattern.len)) {
+                toolFail("send: %s", strerror(errno));
+            }
+            sent = true;
+            fprintf(log, "peer: READY seen, sent %ld bytes\n", total);
+            fflush(log);
+        }
+    }
+    fprintf(log, "peer: received ");
+    gateNetShowBytes(log, seen.data, seen.len < SHOWN_BYTES ? seen.len : SHOWN_BYTES);
+    fprintf(log, "\n");
+    fclose(log);
+    return 0;
+}
+
+
+static int usage(void) {
+    fprintf(stderr, "usage: %s capture PORT FILE\n       %s loss PORT TOTAL LOG\n", TOOL_NAME, TOOL_NAME);
+    return GATE_USAGE_EXIT;
+}
+
+
+int main(int argc, char **argv) {
+    gateNetStart(TOOL_NAME);
+    if (argc == CAPTURE_ARGS && strcmp(argv[ARG_MODE], "capture") == 0) {
+        return capture(gateArgPort(argv[ARG_PORT]), argv[ARG_FILE]);
+    }
+    if (argc == LOSS_ARGS && strcmp(argv[ARG_MODE], "loss") == 0) {
+        return loss(gateArgPort(argv[ARG_PORT]), gateArgLong("total", argv[ARG_TOTAL]), argv[ARG_LOG]);
+    }
+    return usage();
+}
diff --git a/tests/gate/joyScreen.c b/tests/gate/joyScreen.c
new file mode 100644
index 0000000..960e504
--- /dev/null
+++ b/tests/gate/joyScreen.c
@@ -0,0 +1,374 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// joyScreen.c - read both joysticks' state off a screenshot of JOY.
+//
+//     joyScreen PNG [--scale S] [--st] [--at X,Y]
+//
+// JOY (examples/joy) shows each stick in a frame: a dot that sits in the
+// middle at rest and moves DOT_TRAVEL stage pixels per full axis
+// (jlJoystickX/Y * DOT_TRAVEL / 127, grey at rest, green away from it), two
+// button squares (yellow while down) and a patch under them, green when the
+// port reports a joystick and red when it does not. This finds the stage in
+// a screenshot (on a desktop the window is wherever DVX put it, at the
+// window's scale) by the two patches, and prints each stick's state as five
+// characters: connected (1/0), X and Y (- 0 +: the dot at least half its
+// travel left or up, in the middle, at least half its travel right or
+// down), buttons 0 and 1 (1/0); '?' for anything else (a dot part way from
+// 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.
+//
+// --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).
+// --at: the stage starts there on the screen (at the scale --scale gives,
+// else 1), so the patches need not be seen: where something covers one (the
+// PSP's keyboard), its stick's connected character is '?'.
+//
+// Prints "A= B= scale= dots= "; exit
+// 1 when the stage is not found or a stick cannot be read, 2 on a bad
+// command line.
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+
+#define TOOL_NAME        "joyScreen"
+#define FAIL_EXIT        1
+
+// examples/joy/joy.c: its layout (stage pixels).
+#define STICK_COUNT      2u
+#define STICK_TOP        16
+#define FRAME_SIZE       80
+#define FRAME_INSET      2
+#define DOT_SIZE         8
+#define DOT_TRAVEL       ((FRAME_SIZE - DOT_SIZE) / 2 - FRAME_INSET)
+#define BUTTON_W         24
+#define BUTTON_H         16
+#define BUTTON_GAP       4
+#define BUTTON_COUNT     2u
+#define ROW_GAP          6
+#define BUTTON_TOP       (STICK_TOP + FRAME_SIZE + ROW_GAP)
+#define STATUS_W         80
+#define STATUS_H         4
+#define STATUS_TOP       (BUTTON_TOP + BUTTON_H + ROW_GAP)
+
+// A 15-bit desktop is off by at most 8 a channel, the 6-bit VGA DAC by at
+// most 3; JOY's colours sit much further apart. (All are exact in DVX's
+// 256-colour desktop palette.)
+#define MAX_DIST         (3u * 9u * 9u)
+#define SCALE_COUNT      3u
+#define UNKNOWN          '?'
+#define FULL_MASK        0xFFFu
+// A plain ST's 3-bit palette keeps a channel's top three bits.
+#define ST_CHANNEL_MASK  0xEEEu
+// A state: connected, X, Y, and the two buttons; an offset "+dd,+dd".
+#define STATE_CONNECTED  0u
+#define STATE_X          1u
+#define STATE_Y          2u
+#define STATE_BUTTONS    3u
+#define STATE_CHARS      5u
+#define OFFSET_CHARS     24u
+
+// JOY's colours, in the order the nearest is chosen among equals.
+typedef enum JoyColourE {
+    COLOUR_BACKGROUND,
+    COLOUR_FRAME,
+    COLOUR_DOT_OFF,
+    COLOUR_DOT_ON,
+    COLOUR_BUTTON_OFF,
+    COLOUR_BUTTON_ON,
+    COLOUR_DISCONNECTED,
+    COLOUR_COUNT,
+    COLOUR_NONE = -1
+} JoyColourE;
+
+// The patch's green is the dot's (COLOR_CONNECTED = COLOR_DOT_ON).
+#define COLOUR_CONNECTED COLOUR_DOT_ON
+
+// The screenshot as JOY's colour names, and the stage's place and scale.
+typedef struct ScreenT {
+    uint32_t w;
+    uint32_t h;
+    int8_t  *names;
+    long     originX;
+    long     originY;
+    long     scale;
+} ScreenT;
+
+// One stick read: its five characters and its dot's offset.
+typedef struct StickT {
+    char state[STATE_CHARS + 1u];
+    char offset[OFFSET_CHARS];
+} StickT;
+
+static char       axisChar(long offset);
+static bool       findStage(ScreenT *scr);
+static JoyColourE name(const ScreenT *scr, long x, long y);
+static bool       patch(JoyColourE colour);
+static JoyColourE stagePixel(const ScreenT *scr, long x, long y);
+static void       stick(const ScreenT *scr, long left, StickT *out);
+
+static const uint16_t kColours[COLOUR_COUNT]  = { 0x000, 0x444, 0x666, 0x0F0, 0x333, 0xFF0, 0xF00 };
+static const long     kStickLeft[STICK_COUNT] = { 24, 216 };
+static const long     kScales[SCALE_COUNT]    = { 1, 2, 3 };
+
+static const char *const kUsage[] = {
+    "joyScreen - read both joysticks' state off a screenshot of JOY.",
+    "",
+    "    joyScreen PNG [--scale S] [--st] [--at X,Y]",
+    "",
+    "--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). --at: the",
+    "stage starts there on the screen (at the scale --scale gives, else 1), so",
+    "the patches need not be seen: where something covers one (the PSP's",
+    "keyboard), its stick's connected character is '?'.",
+    "",
+    "Prints \"A= B= scale= dots= \"; exit 1",
+    "when the stage is not found or a stick cannot be read."
+};
+
+
+static char axisChar(long offset) {
+    if (offset == 0) {
+        return '0';
+    }
+    if (offset <= -(DOT_TRAVEL / 2)) {
+        return '-';
+    }
+    if (offset >= DOT_TRAVEL / 2) {
+        return '+';
+    }
+    return UNKNOWN;
+}
+
+
+// The screen position of stage (0, 0) at the screen's scale, found by the
+// two status patches: runs of STATUS_W stage pixels in either patch colour
+// on one row, as far apart as the two frames.
+static bool findStage(ScreenT *scr) {
+    long run;
+    long gap;
+    long x;
+    long y;
+    long i;
+    long start;
+    long other;
+    bool same;
+
+    run = STATUS_W * scr->scale;
+    gap = (kStickLeft[1] - kStickLeft[0]) * scr->scale;
+    for (y = 0; y < (long)scr->h - STATUS_H * scr->scale + 1; y++) {
+        x = 0;
+        while (x < (long)scr->w) {
+            if (!patch(name(scr, x, y))) {
+                x++;
+                continue;
+            }
+            start = x;
+            while (x < (long)scr->w && patch(name(scr, x, y))) {
+                x++;
+            }
+            if (x - start != run) {
+                continue;
+            }
+            other = start + gap;
+            same  = true;
+            for (i = 0; i < run && same; i++) {
+                same = patch(name(scr, other + i, y));
+            }
+            if (same && !patch(name(scr, other + run, y))) {
+                scr->originX = start - kStickLeft[0] * scr->scale;
+                scr->originY = y - STATUS_TOP * scr->scale;
+                if (scr->originX >= 0 && scr->originY >= 0) {
+                    return true;
+                }
+            }
+        }
+    }
+    return false;
+}
+
+
+// JOY's colour name at screen (x, y), or none.
+static JoyColourE name(const ScreenT *scr, long x, long y) {
+    if (x < 0 || y < 0 || x >= (long)scr->w || y >= (long)scr->h) {
+        return COLOUR_NONE;
+    }
+    return (JoyColourE)scr->names[(size_t)y * scr->w + (size_t)x];
+}
+
+
+static bool patch(JoyColourE colour) {
+    return colour == COLOUR_CONNECTED || colour == COLOUR_DISCONNECTED;
+}
+
+
+// JOY's colour name of stage pixel (x, y): its block's middle.
+static JoyColourE stagePixel(const ScreenT *scr, long x, long y) {
+    return name(scr, scr->originX + x * scr->scale + scr->scale / 2, scr->originY + y * scr->scale + scr->scale / 2);
+}
+
+
+// The five-character state of the stick whose frame starts at left, and
+// its dot's offset from the middle ("x,y", "?" when there is no dot).
+static void stick(const ScreenT *scr, long left, StickT *out) {
+    JoyColourE colour;
+    uint32_t   dots;
+    uint32_t   colours;
+    uint32_t   b;
+    long       x;
+    long       y;
+    long       x0;
+    long       x1;
+    long       y0;
+    long       y1;
+    long       dx;
+    long       dy;
+    long       centre;
+    long       bx;
+    bool       atRest;
+
+    colour                      = stagePixel(scr, left + STATUS_W / 2, STATUS_TOP + STATUS_H / 2);
+    out->state[STATE_CONNECTED] = (colour == COLOUR_CONNECTED) ? '1' : ((colour == COLOUR_DISCONNECTED) ? '0' : UNKNOWN);
+    out->state[STATE_X]         = UNKNOWN;
+    out->state[STATE_Y]         = UNKNOWN;
+    out->state[STATE_CHARS]     = '\0';
+    snprintf(out->offset, sizeof(out->offset), "%c", UNKNOWN);
+
+    dots    = 0u;
+    colours = 0u;
+    x0      = 0;
+    x1      = 0;
+    y0      = 0;
+    y1      = 0;
+    for (y = STICK_TOP; y < STICK_TOP + FRAME_SIZE; y++) {
+        for (x = left; x < left + FRAME_SIZE; x++) {
+            colour = stagePixel(scr, x, y);
+            if (colour != COLOUR_DOT_ON && colour != COLOUR_DOT_OFF) {
+                continue;
+            }
+            x0       = (dots == 0u || x < x0) ? x : x0;
+            x1       = (dots == 0u || x > x1) ? x : x1;
+            y0       = (dots == 0u || y < y0) ? y : y0;
+            y1       = (dots == 0u || y > y1) ? y : y1;
+            colours |= 1u << colour;
+            dots++;
+        }
+    }
+    if (dots > 0u) {
+        dx = x0 - (left + FRAME_SIZE / 2 - DOT_SIZE / 2);
+        dy = y0 - (STICK_TOP + FRAME_SIZE / 2 - DOT_SIZE / 2);
+        snprintf(out->offset, sizeof(out->offset), "%+ld,%+ld", dx, dy);
+        if (dots == DOT_SIZE * DOT_SIZE && x1 == x0 + DOT_SIZE - 1 && y1 == y0 + DOT_SIZE - 1 && (colours & (colours - 1u)) == 0u) {
+            out->state[STATE_X] = axisChar(dx);
+            out->state[STATE_Y] = axisChar(dy);
+            // Green away from the middle, grey in it.
+            atRest = out->state[STATE_X] == '0' && out->state[STATE_Y] == '0';
+            if (colours != (1u << (atRest ? COLOUR_DOT_OFF : COLOUR_DOT_ON))) {
+                out->state[STATE_X] = UNKNOWN;
+                out->state[STATE_Y] = UNKNOWN;
+            }
+        }
+    }
+
+    centre = left + FRAME_SIZE / 2;
+    for (b = 0; b < BUTTON_COUNT; b++) {
+        bx                            = (b == 0u) ? centre - BUTTON_W - BUTTON_GAP / 2 : centre + BUTTON_GAP / 2;
+        colour                        = stagePixel(scr, bx + BUTTON_W / 2, BUTTON_TOP + BUTTON_H / 2);
+        out->state[STATE_BUTTONS + b] = (colour == COLOUR_BUTTON_ON) ? '1' : ((colour == COLOUR_BUTTON_OFF) ? '0' : UNKNOWN);
+    }
+}
+
+
+int main(int argc, char **argv) {
+    GateImageT  img;
+    ScreenT     scr;
+    StickT      read[STICK_COUNT];
+    uint8_t     palette[COLOUR_COUNT][GATE_RGB_BYTES];
+    const long *scales;
+    uint32_t    scaleCount;
+    uint32_t    mask;
+    uint32_t    distance;
+    uint32_t    best;
+    uint32_t    i;
+    size_t      p;
+    long        oneScale;
+    long        atX;
+    long        atY;
+    bool        haveAt;
+    bool        found;
+    int         arg;
+
+    toolFileName(TOOL_NAME);
+    if (argc < 2) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    scales     = kScales;
+    scaleCount = SCALE_COUNT;
+    mask       = FULL_MASK;
+    oneScale   = 0;
+    atX        = 0;
+    atY        = 0;
+    haveAt     = false;
+    for (arg = 2; arg < argc; arg++) {
+        if (strcmp(argv[arg], "--scale") == 0 && arg + 1 < argc) {
+            oneScale = gateArgLong("scale", argv[++arg]);
+            if (oneScale < 1) {
+                gateArgBad("scale", argv[arg]);
+            }
+            scales     = &oneScale;
+            scaleCount = 1u;
+        } else if (strcmp(argv[arg], "--st") == 0) {
+            mask = ST_CHANNEL_MASK;
+        } else if (strcmp(argv[arg], "--at") == 0 && arg + 1 < argc) {
+            gateArgPair("at", argv[++arg], ',', &atX, &atY);
+            haveAt = true;
+        } else {
+            return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+        }
+    }
+
+    gateImageLoad(argv[1], &img);
+    for (i = 0; i < COLOUR_COUNT; i++) {
+        gateRgb12(kColours[i] & mask, palette[i]);
+    }
+    scr.w     = img.width;
+    scr.h     = img.height;
+    scr.names = (int8_t *)malloc((size_t)img.width * img.height + 1u);
+    if (scr.names == NULL) {
+        toolFail("out of memory");
+    }
+    for (p = 0; p < (size_t)img.width * img.height; p++) {
+        best         = gateNearest(img.rgba + p * GATE_RGBA_BYTES, (const uint8_t (*)[GATE_RGB_BYTES])palette, COLOUR_COUNT, &distance);
+        scr.names[p] = (distance <= MAX_DIST) ? (int8_t)best : (int8_t)COLOUR_NONE;
+    }
+    found = false;
+    for (i = 0; i < scaleCount && !found; i++) {
+        scr.scale   = scales[i];
+        scr.originX = atX;
+        scr.originY = atY;
+        found       = haveAt || findStage(&scr);
+    }
+    if (!found) {
+        printf("joyScreen: FAIL - JOY's two status patches are not on the screen\n");
+        return FAIL_EXIT;
+    }
+    for (i = 0; i < STICK_COUNT; i++) {
+        stick(&scr, kStickLeft[i], &read[i]);
+    }
+    printf("A=%s B=%s scale=%ld dots=%s %s\n", read[0].state, read[1].state, scr.scale, read[0].offset, read[1].offset);
+    free(scr.names);
+    gateImageFree(&img);
+    for (i = 0; i < STICK_COUNT; i++) {
+        if (strchr(read[i].state, UNKNOWN) != NULL) {
+            return FAIL_EXIT;
+        }
+    }
+    return 0;
+}
diff --git a/tests/gate/keysCells.c b/tests/gate/keysCells.c
new file mode 100644
index 0000000..2aa442f
--- /dev/null
+++ b/tests/gate/keysCells.c
@@ -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
+//
+// keysCells.c - read which of KEYS's named-key cells are lit off a
+// screenshot.
+//
+//     keysCells PNG [--at X,Y] [--scale S]
+//
+// KEYS (examples/keys) draws a cell for each named key, lit (colour 2,
+// green) while jlKeyDown says it is down and dark grey (colour 1)
+// otherwise. This reads the cells of its fourth row that a port gives a
+// second physical key for -- Return, Ctrl, Alt, Up, Down, Left, and Right
+// -- at the middle of each, and prints one digit a cell, in that order: 1
+// lit, 0 not, ? neither colour.
+//
+// --at: where the stage's top left is in the screenshot (default 0,0).
+// --scale: screen pixels a stage pixel (default 1). A point off the
+// screenshot is fatal (exit 1); a bad command line exits 2.
+
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+
+#define TOOL_NAME    "keysCells"
+// examples/keys: CELL_W x CELL_H cells GAP apart from (MARGIN_X,
+// MARGIN_Y), the named keys in row 4 at these columns.
+#define CELL_W       28
+#define CELL_H       26
+#define GAP          4
+#define MARGIN_X     2
+#define MARGIN_Y     2
+#define ROW          4
+#define COLUMN_COUNT 7u
+// KEYS's colours 1 and 2, and how near a pixel must be: a 15- or 16-bit
+// screen is off by at most 8 a gun.
+#define UNLIT_INDEX  1u
+#define LIT_INDEX    2u
+#define MAX_DIST     (3u * 9u * 9u)
+
+
+// Return, Ctrl, Alt, Up, Down, Left, Right.
+static const long kColumns[COLUMN_COUNT] = { 2, 3, 4, 5, 6, 7, 8 };
+
+static const char *const kUsage[] = {
+    "keysCells - read which of KEYS's named-key cells are lit off a screenshot.",
+    "",
+    "KEYS (examples/keys) draws a cell for each named key, lit (colour 2, green)",
+    "while jlKeyDown says it is down and dark grey (colour 1) otherwise. This",
+    "reads the cells of its fourth row that a port gives a second physical key",
+    "for -- Return, Ctrl, Alt, Up, Down, Left, and Right -- at the middle of",
+    "each, and prints one digit a cell, in that order: 1 lit, 0 not, ? neither",
+    "colour.",
+    "",
+    "    keysCells PNG [--at X,Y] [--scale S]",
+    "",
+    "--at: where the stage's top left is in the screenshot (default 0,0).",
+    "--scale: screen pixels a stage pixel (default 1)."
+};
+
+
+int main(int argc, char **argv) {
+    GateImageT     img;
+    const char    *png;
+    const uint8_t *pixel;
+    uint16_t       words[GATE_KEYS_COLOURS];
+    uint8_t        lit[GATE_RGB_BYTES];
+    uint8_t        unlit[GATE_RGB_BYTES];
+    char           digits[COLUMN_COUNT + 1u];
+    long           atX;
+    long           atY;
+    long           scale;
+    long           x;
+    long           y;
+    uint32_t       i;
+    int            arg;
+
+    toolFileName(TOOL_NAME);
+    png   = NULL;
+    atX   = 0;
+    atY   = 0;
+    scale = 1;
+    for (arg = 1; arg < argc; arg++) {
+        if (strcmp(argv[arg], "--at") == 0 && arg + 1 < argc) {
+            gateArgPair("at", argv[++arg], ',', &atX, &atY);
+        } else if (strcmp(argv[arg], "--scale") == 0 && arg + 1 < argc) {
+            scale = gateArgLong("scale", argv[++arg]);
+            if (scale < 1) {
+                gateArgBad("scale", argv[arg]);
+            }
+        } else if (argv[arg][0] == '-' || png != NULL) {
+            return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+        } else {
+            png = argv[arg];
+        }
+    }
+    if (png == NULL) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    gateImageLoad(png, &img);
+    gateKeysPalette(words);
+    gateRgb12(words[LIT_INDEX], lit);
+    gateRgb12(words[UNLIT_INDEX], unlit);
+    // Each cell's middle, on the screenshot.
+    y = atY + (MARGIN_Y + ROW * (CELL_H + GAP) + CELL_H / 2) * scale;
+    for (i = 0; i < COLUMN_COUNT; i++) {
+        x = atX + (MARGIN_X + kColumns[i] * (CELL_W + GAP) + CELL_W / 2) * scale;
+        if (!gateImageHas(&img, x, y)) {
+            toolFail("cell %u's middle, %ld,%ld, is off the %ux%u screenshot", (unsigned)i, x, y, (unsigned)img.width, (unsigned)img.height);
+        }
+        pixel = gateImagePixel(&img, (uint32_t)x, (uint32_t)y);
+        if (gateDistance(pixel, lit) <= MAX_DIST) {
+            digits[i] = '1';
+        } else if (gateDistance(pixel, unlit) <= MAX_DIST) {
+            digits[i] = '0';
+        } else {
+            digits[i] = '?';
+        }
+    }
+    digits[COLUMN_COUNT] = '\0';
+    printf("%s\n", digits);
+    gateImageFree(&img);
+    return 0;
+}
diff --git a/tests/gate/keysRow.c b/tests/gate/keysRow.c
new file mode 100644
index 0000000..61a322b
--- /dev/null
+++ b/tests/gate/keysRow.c
@@ -0,0 +1,266 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// keysRow.c - read KEYS's verification row off a screenshot.
+//
+//     keysRow PNG TARGET [--size WxH] [--at X,Y] [--scale S]
+//
+// KEYS (examples/keys) draws every character it received on its row 198 as
+// pixel nibbles: the sentinel A 5 A 5, the count byte, a zero byte, then
+// each character, two pixels a byte, high nibble first. This finds that row
+// in a screenshot (on a desktop the window is wherever DVX put it, at the
+// window's scale: every stage pixel a SCALE x SCALE block) and checks it
+// holds exactly TARGET, and that every block of it is whole: each of its
+// screen pixels the same colour.
+//
+// --size: the screenshot must be that size (the full-screen mode, not the
+// desktop). --at: the row must start exactly there (the stage's own row
+// 198 at 1x on a full screen). --scale: the row must be at that scale (else
+// the first of 1, 2, 3 it is found at). Prints the row it found; exits 0
+// when it is right, 1 when not, 2 on a bad command line.
+//
+// A pixel is the KEYS palette entry nearest it (each 12-bit entry's guns
+// widened x17, as the DVX port's tables do), or no entry when none is
+// within MAX_DIST: a 15- or 16-bit desktop is off by at most 8 a gun after
+// the round trip, the 6-bit VGA DAC by at most 3, while the entries sit 17
+// apart, so nearest is exact. (Every KEYS colour is also one of DVX's
+// 256-colour desktop palette, so 8 bits is exact too.)
+
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolBuf.h"
+#include "toolFile.h"
+
+#define TOOL_NAME      "keysRow"
+#define FAIL_EXIT      1
+#define MAX_DIST       (3u * 9u * 9u)
+#define NO_NIBBLE      (-1)
+#define NIBBLE_BITS    4
+#define NIBBLE_MASK    0xF
+#define SENTINEL_COUNT 4u
+#define HEADER_BYTES   4u
+#define SCALE_COUNT    3u
+#define NIBBLES_A_BYTE 2
+
+static bool     findRow(const int8_t *nibs, uint32_t w, uint32_t h, long scale, const ToolBufT *expect, long *foundX, long *foundY, uint8_t *row);
+static void     nibbleMap(const GateImageT *img, int8_t *nibs);
+static uint32_t splitBlocks(const GateImageT *img, long x, long y, long scale, uint32_t count);
+
+static const int8_t kSentinel[SENTINEL_COUNT] = { 0xA, 0x5, 0xA, 0x5 };
+static const long   kScales[SCALE_COUNT]      = { 1, 2, 3 };
+
+static const char *const kUsage[] = {
+    "keysRow - read KEYS's verification row off a screenshot.",
+    "",
+    "    keysRow PNG TARGET [--size WxH] [--at X,Y] [--scale S]",
+    "",
+    "--size: the screenshot must be that size (the full-screen mode, not the",
+    "desktop). --at: the row must start exactly there (the stage's own row 198",
+    "at 1x on a full screen). --scale: the row must be at that scale (else the",
+    "first of 1, 2, 3 it is found at). Prints the row it found; exit 0 when it",
+    "is right."
+};
+
+
+// The (x, y, bytes) of the first row at scale whose header matches.
+static bool findRow(const int8_t *nibs, uint32_t w, uint32_t h, long scale, const ToolBufT *expect, long *foundX, long *foundY, uint8_t *row) {
+    long          span;
+    long          x;
+    uint32_t      y;
+    uint32_t      i;
+    const int8_t *line;
+    bool          sentinel;
+
+    span = NIBBLES_A_BYTE * (long)expect->len * scale;
+    for (y = 0; y < h; y++) {
+        line = nibs + (size_t)y * w;
+        for (x = 0; x < (long)w - span + 1; x++) {
+            sentinel = true;
+            for (i = 0; i < SENTINEL_COUNT && sentinel; i++) {
+                sentinel = line[x + (long)i * scale] == kSentinel[i];
+            }
+            if (!sentinel) {
+                continue;
+            }
+            for (i = 0; i < expect->len; i++) {
+                row[i] = (uint8_t)(((line[x + NIBBLES_A_BYTE * (long)i * scale] & NIBBLE_MASK) << NIBBLE_BITS) | (line[x + (NIBBLES_A_BYTE * (long)i + 1) * scale] & NIBBLE_MASK));
+            }
+            if (memcmp(row, expect->data, HEADER_BYTES) == 0) {
+                *foundX = x;
+                *foundY = (long)y;
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+
+// Every pixel's KEYS palette index, NO_NIBBLE where no entry is near.
+static void nibbleMap(const GateImageT *img, int8_t *nibs) {
+    uint16_t words[GATE_KEYS_COLOURS];
+    uint8_t  palette[GATE_KEYS_COLOURS][GATE_RGB_BYTES];
+    uint32_t i;
+    uint32_t best;
+    uint32_t distance;
+    size_t   p;
+
+    gateKeysPalette(words);
+    for (i = 0; i < GATE_KEYS_COLOURS; i++) {
+        gateRgb12(words[i], palette[i]);
+    }
+    for (p = 0; p < (size_t)img->width * img->height; p++) {
+        best    = gateNearest(img->rgba + p * GATE_RGBA_BYTES, (const uint8_t (*)[GATE_RGB_BYTES])palette, GATE_KEYS_COLOURS, &distance);
+        nibs[p] = (distance <= MAX_DIST) ? (int8_t)best : (int8_t)NO_NIBBLE;
+    }
+}
+
+
+// How many of the count stage pixels from (x, y) are not whole blocks.
+static uint32_t splitBlocks(const GateImageT *img, long x, long y, long scale, uint32_t count) {
+    uint32_t       split;
+    uint32_t       i;
+    long           left;
+    long           dx;
+    long           dy;
+    const uint8_t *want;
+    bool           whole;
+
+    split = 0u;
+    for (i = 0; i < count; i++) {
+        left  = x + (long)i * scale;
+        want  = gateImagePixel(img, (uint32_t)left, (uint32_t)y);
+        whole = y + scale <= (long)img->height;
+        for (dy = 0; dy < scale && whole; dy++) {
+            for (dx = 0; dx < scale && whole; dx++) {
+                whole = memcmp(gateImagePixel(img, (uint32_t)(left + dx), (uint32_t)(y + dy)), want, GATE_RGB_BYTES) == 0;
+            }
+        }
+        if (!whole) {
+            split++;
+        }
+    }
+    return split;
+}
+
+
+int main(int argc, char **argv) {
+    GateImageT  img;
+    ToolBufT    expect;
+    int8_t     *nibs;
+    uint8_t    *row;
+    const long *scales;
+    uint32_t    scaleCount;
+    long        oneScale;
+    long        sizeW;
+    long        sizeH;
+    long        atX;
+    long        atY;
+    long        x;
+    long        y;
+    long        scale;
+    bool        haveSize;
+    bool        haveAt;
+    bool        found;
+    uint32_t    split;
+    uint32_t    i;
+    int         arg;
+
+    toolFileName(TOOL_NAME);
+    if (argc < 3) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    haveSize   = false;
+    haveAt     = false;
+    scales     = kScales;
+    scaleCount = SCALE_COUNT;
+    sizeW      = 0;
+    sizeH      = 0;
+    atX        = 0;
+    atY        = 0;
+    oneScale   = 0;
+    for (arg = 3; arg < argc; arg += 2) {
+        if (arg + 1 >= argc) {
+            return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+        }
+        if (strcmp(argv[arg], "--size") == 0) {
+            gateArgPair("size", argv[arg + 1], 'x', &sizeW, &sizeH);
+            haveSize = true;
+        } else if (strcmp(argv[arg], "--at") == 0) {
+            gateArgPair("at", argv[arg + 1], ',', &atX, &atY);
+            haveAt = true;
+        } else if (strcmp(argv[arg], "--scale") == 0) {
+            oneScale = gateArgLong("scale", argv[arg + 1]);
+            if (oneScale < 1) {
+                gateArgBad("scale", argv[arg + 1]);
+            }
+            scales     = &oneScale;
+            scaleCount = 1u;
+        } else {
+            return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+        }
+    }
+
+    gateImageLoad(argv[1], &img);
+    if (haveSize && ((long)img.width != sizeW || (long)img.height != sizeH)) {
+        printf("keysRow: FAIL - %s is %ux%u, want %ldx%ld\n", argv[1], (unsigned)img.width, (unsigned)img.height, sizeW, sizeH);
+        return FAIL_EXIT;
+    }
+    nibs = (int8_t *)malloc((size_t)img.width * img.height + 1u);
+    if (nibs == NULL) {
+        toolFail("out of memory");
+    }
+    nibbleMap(&img, nibs);
+    memset(&expect, 0, sizeof(expect));
+    gateKeysExpect(argv[2], &expect);
+    row = (uint8_t *)malloc(expect.len);
+    if (row == NULL) {
+        toolFail("out of memory");
+    }
+    found = false;
+    scale = scales[0];
+    x     = 0;
+    y     = 0;
+    for (i = 0; i < scaleCount && !found; i++) {
+        scale = scales[i];
+        found = findRow(nibs, img.width, img.height, scale, &expect, &x, &y, row);
+    }
+    if (!found) {
+        printf("keysRow: FAIL - no verification row (A5A5 %02X00) on the screen at ", (unsigned)strlen(argv[2]));
+        for (i = 0; i < scaleCount; i++) {
+            printf("%s%ldx", (i == 0u) ? "" : " or ", scales[i]);
+        }
+        printf("\n");
+        return FAIL_EXIT;
+    }
+    printf("keysRow: row at (%ld,%ld) at %ldx=", x, y, scale);
+    gateHexPrint(row, expect.len);
+    printf("\n");
+    if (memcmp(row, expect.data, expect.len) != 0) {
+        printf("keysRow: FAIL - screen row ");
+        gateHexPrint(row, expect.len);
+        printf(", want ");
+        gateHexPrint(expect.data, expect.len);
+        printf("\n");
+        return FAIL_EXIT;
+    }
+    split = splitBlocks(&img, x, y, scale, NIBBLES_A_BYTE * (uint32_t)expect.len);
+    if (split != 0u) {
+        printf("keysRow: FAIL - %u of the row's %u stage pixels are not whole %ldx%ld blocks\n", (unsigned)split, (unsigned)(NIBBLES_A_BYTE * expect.len), scale, scale);
+        return FAIL_EXIT;
+    }
+    if (haveAt && (x != atX || y != atY)) {
+        printf("keysRow: FAIL - the row is at (%ld,%ld), want (%ld,%ld)\n", x, y, atX, atY);
+        return FAIL_EXIT;
+    }
+    free(row);
+    free(nibs);
+    toolBufFree(&expect);
+    gateImageFree(&img);
+    return 0;
+}
diff --git a/tests/gate/netWmIcon.c b/tests/gate/netWmIcon.c
new file mode 100644
index 0000000..4a2029d
--- /dev/null
+++ b/tests/gate/netWmIcon.c
@@ -0,0 +1,257 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// netWmIcon.c - an X window's icon against the picture it should be.
+//
+//     netWmIcon XPROP WANT SIZE LABEL
+//
+// A window's icon on X is its _NET_WM_ICON property: one or more pictures,
+// each its width, its height and then its pixels as 32-bit ARGB, row after
+// row. SDL3 sets it from SDL_SetWindowIcon; Wine from the window class's
+// icon. xprop prints it as numbers with
+//
+//     xprop -id WINDOW -f _NET_WM_ICON 32c ' = $0+' _NET_WM_ICON > XPROP
+//
+// and this compares the picture of the wanted size in that against WANT (a
+// PNG; an .ico's 32-bit picture of that size). A pixel at least half
+// opaque must be so in both, and then of the same colour, give or take
+// TOLERANCE a gun (a system may round its own way).
+//
+// The numbers are those after the first '=', separated by commas and white
+// space, up to anything else. Prints LABEL: PASS or LABEL: FAIL, and exits
+// 0 or 1 (2 on a bad command line).
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+
+#define TOOL_NAME        "netWmIcon"
+#define FAIL_EXIT        1
+#define TOLERANCE        2u
+#define ALPHA_OPAQUE_MIN 128u
+#define ICO_BPP          32u
+#define ALPHA_SHIFT      24u
+#define RED_SHIFT        16u
+#define GREEN_SHIFT      8u
+#define BYTE_MASK        0xFFu
+#define DECIMAL          10
+#define VALUES_INITIAL   1024u
+
+// One picture in xprop's numbers: its size and where its pixels are among
+// the numbers (fewer than width x height when the numbers run out).
+typedef struct PictureT {
+    uint64_t width;
+    uint64_t height;
+    size_t   first;
+    size_t   count;
+} PictureT;
+
+static size_t   parseValues(const char *text, uint64_t **values);
+static uint32_t pictures(const uint64_t *values, size_t count, PictureT **out);
+static void     printSizes(const PictureT *found, uint32_t count);
+
+static const char *const kUsage[] = {
+    "netWmIcon - an X window's icon against the picture it should be.",
+    "",
+    "    xprop -id WINDOW -f _NET_WM_ICON 32c ' = $0+' _NET_WM_ICON > XPROP",
+    "    netWmIcon XPROP WANT SIZE LABEL",
+    "",
+    "compares the window's SIZE x SIZE icon against WANT (a PNG; an .ico's",
+    "32-bit picture of that size). Prints LABEL: PASS or LABEL: FAIL, and exits",
+    "0 or 1."
+};
+
+
+// The numbers after the first '=' in xprop's text, separated by commas and
+// white space, up to anything else.
+static size_t parseValues(const char *text, uint64_t **values) {
+    const char *at;
+    char       *end;
+    size_t      count;
+    size_t      cap;
+    uint64_t   *grown;
+
+    *values = NULL;
+    count   = 0u;
+    cap     = 0u;
+    at      = strchr(text, '=');
+    if (at == NULL) {
+        return 0u;
+    }
+    at++;
+    for (;;) {
+        while (*at == ',' || isspace((unsigned char)*at)) {
+            at++;
+        }
+        if (!isdigit((unsigned char)*at)) {
+            return count;
+        }
+        if (count == cap) {
+            cap   = (cap == 0u) ? VALUES_INITIAL : cap * 2u;
+            grown = (uint64_t *)realloc(*values, cap * sizeof(uint64_t));
+            if (grown == NULL) {
+                toolFail("out of memory");
+            }
+            *values = grown;
+        }
+        (*values)[count++] = (uint64_t)strtoull(at, &end, DECIMAL);
+        at                 = end;
+    }
+}
+
+
+// Every picture in the numbers: width, height, then up to width x height
+// pixels (all that are left when there are fewer).
+static uint32_t pictures(const uint64_t *values, size_t count, PictureT **out) {
+    PictureT *list;
+    PictureT *grown;
+    uint32_t  n;
+    uint32_t  cap;
+    size_t    at;
+    size_t    avail;
+
+    list = NULL;
+    n    = 0u;
+    cap  = 0u;
+    at   = 0u;
+    while (at + 2u <= count) {
+        if (n == cap) {
+            cap   = (cap == 0u) ? VALUES_INITIAL : cap * 2u;
+            grown = (PictureT *)realloc(list, (size_t)cap * sizeof(PictureT));
+            if (grown == NULL) {
+                toolFail("out of memory");
+            }
+            list = grown;
+        }
+        list[n].width  = values[at];
+        list[n].height = values[at + 1u];
+        at            += 2u;
+        avail          = count - at;
+        list[n].first  = at;
+        if (list[n].width != 0u && list[n].height > avail / list[n].width) {
+            list[n].count = avail;
+            at            = count;
+        } else {
+            list[n].count = (size_t)(list[n].width * list[n].height);
+            at           += list[n].count;
+        }
+        n++;
+    }
+    *out = list;
+    return n;
+}
+
+
+// The pictures' sizes, "WxH, WxH", or "none".
+static void printSizes(const PictureT *found, uint32_t count) {
+    uint32_t f;
+
+    for (f = 0; f < count; f++) {
+        printf("%s%" PRIu64 "x%" PRIu64, (f == 0u) ? "" : ", ", found[f].width, found[f].height);
+    }
+    if (count == 0u) {
+        printf("none");
+    }
+}
+
+
+int main(int argc, char **argv) {
+    GateImageT      want;
+    PictureT       *found;
+    const PictureT *shown;
+    uint64_t       *values;
+    uint8_t        *text;
+    const uint8_t  *w;
+    const char     *label;
+    size_t          textLen;
+    size_t          valueCount;
+    size_t          compared;
+    size_t          i;
+    uint32_t        foundCount;
+    uint32_t        f;
+    uint32_t        g;
+    uint32_t        wrong;
+    uint32_t        opaqueCount;
+    uint64_t        argb;
+    uint8_t         rgb[GATE_RGB_BYTES];
+    long            size;
+    bool            shownOpaque;
+    bool            close;
+
+    toolFileName(TOOL_NAME);
+    if (argc != 5) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    size  = gateArgLong("size", argv[3]);
+    if (size < 1) {
+        gateArgBad("size", argv[3]);
+    }
+    label = argv[4];
+    if (!toolReadFile(argv[1], &text, &textLen)) {
+        toolFail("cannot read %s", argv[1]);
+    }
+    valueCount = parseValues((const char *)text, &values);
+    foundCount = pictures(values, valueCount, &found);
+    shown      = NULL;
+    for (f = 0; f < foundCount && shown == NULL; f++) {
+        if (found[f].width == (uint64_t)size && found[f].height == (uint64_t)size) {
+            shown = &found[f];
+        }
+    }
+    if (shown == NULL) {
+        printf("%s: FAIL - the window has no %ldx%ld icon (it has: ", label, size, size);
+        printSizes(found, foundCount);
+        printf(")\n");
+        return FAIL_EXIT;
+    }
+    gateImageLoadIcon(argv[2], (uint32_t)size, ICO_BPP, &want);
+    if ((long)want.width != size || (long)want.height != size) {
+        printf("%s: FAIL - %s is %ux%u, not %ldx%ld\n", label, argv[2], (unsigned)want.width, (unsigned)want.height, size, size);
+        return FAIL_EXIT;
+    }
+    wrong       = 0u;
+    opaqueCount = 0u;
+    compared    = (shown->count < (size_t)size * (size_t)size) ? shown->count : (size_t)size * (size_t)size;
+    for (i = 0; i < compared; i++) {
+        argb        = values[shown->first + i];
+        w           = want.rgba + i * GATE_RGBA_BYTES;
+        shownOpaque = (argb >> ALPHA_SHIFT) >= ALPHA_OPAQUE_MIN;
+        if (shownOpaque != (w[GATE_ALPHA] >= ALPHA_OPAQUE_MIN)) {
+            wrong++;
+            continue;
+        }
+        if (!shownOpaque) {
+            continue;
+        }
+        opaqueCount++;
+        rgb[0] = (uint8_t)((argb >> RED_SHIFT) & BYTE_MASK);
+        rgb[1] = (uint8_t)((argb >> GREEN_SHIFT) & BYTE_MASK);
+        rgb[2] = (uint8_t)(argb & BYTE_MASK);
+        close  = true;
+        for (g = 0; g < GATE_RGB_BYTES; g++) {
+            close = close && (uint32_t)abs((int32_t)rgb[g] - (int32_t)w[g]) <= TOLERANCE;
+        }
+        if (!close) {
+            wrong++;
+        }
+    }
+    if (wrong != 0u || opaqueCount == 0u) {
+        printf("%s: FAIL - %u of the %ldx%ld icon's pixels differ from %s (%u opaque)\n", label, (unsigned)wrong, size, size, argv[2], (unsigned)opaqueCount);
+        return FAIL_EXIT;
+    }
+    printf("%s: PASS (the window's %ldx%ld icon is the baked one: %u opaque pixels and the transparency round them; icons on the window: ", label, size, size, (unsigned)opaqueCount);
+    printSizes(found, foundCount);
+    printf(")\n");
+    gateImageFree(&want);
+    free(found);
+    free(values);
+    free(text);
+    return 0;
+}
diff --git a/tests/gate/pngProbe.c b/tests/gate/pngProbe.c
new file mode 100644
index 0000000..6a594a0
--- /dev/null
+++ b/tests/gate/pngProbe.c
@@ -0,0 +1,130 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// pngProbe.c - small questions the gates ask of a screenshot.
+//
+//     pngProbe size  PNG                  prints WxH
+//     pngProbe pixel PNG X Y              prints R G B
+//     pngProbe count PNG X Y W H R G B    prints how many of the W x H
+//                                         pixels from (X, Y) are R G B
+//
+// pixel fails (exit 1) when the point is off the screenshot; count counts
+// only the part of its box on it. A bad command line exits 2.
+
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateImage.h"
+#include "toolFile.h"
+
+#define TOOL_NAME  "pngProbe"
+#define SAMPLE_MAX 255
+// The arguments after a mode's name: the screenshot, a point, a box's
+// size, and a colour.
+#define ARG_PNG    0u
+#define ARG_X      1u
+#define ARG_Y      2u
+#define ARG_W      3u
+#define ARG_H      4u
+#define ARG_RGB    5u
+
+static int probeCount(char **args);
+static int probePixel(char **args);
+static int probeSize(char **args);
+
+static const GateModeT kModes[] = {
+    { "size",  1, probeSize  },
+    { "pixel", 3, probePixel },
+    { "count", 8, probeCount }
+};
+
+static const char *const kGunNames[GATE_RGB_BYTES] = { "red", "green", "blue" };
+
+static const char *const kUsage[] = {
+    "usage:",
+    "    pngProbe size  PNG                  prints WxH",
+    "    pngProbe pixel PNG X Y              prints R G B",
+    "    pngProbe count PNG X Y W H R G B    prints how many of the W x H pixels",
+    "                                        from (X, Y) are R G B"
+};
+
+
+static int probeCount(char **args) {
+    GateImageT img;
+    uint8_t    want[GATE_RGB_BYTES];
+    long       x0;
+    long       y0;
+    long       w;
+    long       h;
+    long       x;
+    long       y;
+    long       count;
+    long       gun;
+    uint32_t   i;
+
+    x0 = gateArgLong("x", args[ARG_X]);
+    y0 = gateArgLong("y", args[ARG_Y]);
+    w  = gateArgLong("width", args[ARG_W]);
+    h  = gateArgLong("height", args[ARG_H]);
+    for (i = 0; i < GATE_RGB_BYTES; i++) {
+        gun = gateArgLong(kGunNames[i], args[ARG_RGB + i]);
+        if (gun < 0 || gun > SAMPLE_MAX) {
+            gateArgBad(kGunNames[i], args[ARG_RGB + i]);
+        }
+        want[i] = (uint8_t)gun;
+    }
+    gateImageLoad(args[ARG_PNG], &img);
+    count = 0;
+    for (y = (y0 < 0) ? 0 : y0; y < y0 + h && y < (long)img.height; y++) {
+        for (x = (x0 < 0) ? 0 : x0; x < x0 + w && x < (long)img.width; x++) {
+            if (memcmp(gateImagePixel(&img, (uint32_t)x, (uint32_t)y), want, GATE_RGB_BYTES) == 0) {
+                count++;
+            }
+        }
+    }
+    printf("%ld\n", count);
+    gateImageFree(&img);
+    return 0;
+}
+
+
+static int probePixel(char **args) {
+    GateImageT     img;
+    const uint8_t *rgb;
+    long           x;
+    long           y;
+
+    x = gateArgLong("x", args[ARG_X]);
+    y = gateArgLong("y", args[ARG_Y]);
+    gateImageLoad(args[ARG_PNG], &img);
+    if (!gateImageHas(&img, x, y)) {
+        toolFail("%ld,%ld is off the %ux%u screenshot", x, y, (unsigned)img.width, (unsigned)img.height);
+    }
+    rgb = gateImagePixel(&img, (uint32_t)x, (uint32_t)y);
+    printf("%u %u %u\n", (unsigned)rgb[0], (unsigned)rgb[1], (unsigned)rgb[2]);
+    gateImageFree(&img);
+    return 0;
+}
+
+
+static int probeSize(char **args) {
+    GateImageT img;
+
+    gateImageLoad(args[ARG_PNG], &img);
+    printf("%ux%u\n", (unsigned)img.width, (unsigned)img.height);
+    gateImageFree(&img);
+    return 0;
+}
+
+
+int main(int argc, char **argv) {
+    int exitCode;
+
+    toolFileName(TOOL_NAME);
+    if (!gateModeRun(kModes, GATE_COUNT_OF(kModes), argc, argv, &exitCode)) {
+        return gateUsage(kUsage, GATE_COUNT_OF(kUsage));
+    }
+    return exitCode;
+}
diff --git a/tests/gate/pspKeyboardPath.c b/tests/gate/pspKeyboardPath.c
new file mode 100644
index 0000000..64694e3
--- /dev/null
+++ b/tests/gate/pspKeyboardPath.c
@@ -0,0 +1,425 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// pspKeyboardPath.c - the PSP keyboard's d-pad moves and presses for some
+// typing, for a gate to send the PSP.
+//
+//     pspKeyboardPath SOURCE TOKEN...
+//
+// The keyboard (src/hosted/psp/pspKeyboard.c) is read from its own source:
+// its rows of KEY_* entries, each key's width in half keys, what it types,
+// and its legend, and where the cursor starts. Its cursor is followed as
+// the keyboard moves it -- along a row wrapping round, and up or down a row
+// onto the key nearest the middle of the one it was on (ties: down-right
+// going down, up-left going up). Each step is printed on a line of its own:
+//
+//     move left|right|up|down   one d-pad press
+//     press LEGEND              cross on the key with that legend
+//
+// A TOKEN is "key:LEGEND" (a key by its legend: ESC, F5, SHIFT, ENTER...)
+// or "type:TEXT" (each character of TEXT, Shift pressed first for a
+// shifted one, which the keyboard lets go after the next key).
+
+#include 
+#include 
+#include 
+
+#include "toolFile.h"
+
+#define TOOL_NAME     "pspKeyboardPath"
+#define ROWS_MAX      8u
+#define KEYS_MAX      24u
+#define LEGEND_MAX    32u
+#define ARGS_MAX      6u
+#define ARG_MAX       64u
+#define NO_CHAR       (-1)
+#define LETTER_HALVES 2u
+#define DECIMAL       10
+#define SHIFT_LEGEND  "SHIFT"
+#define ROW_START     "#define CURSOR_ROW_START"
+#define COL_START     "#define CURSOR_COL_START"
+#define ROW_OPEN      "static const BoardKeyT gRow"
+#define ROW_OPEN_TAIL "[] = {"
+#define ROW_CLOSE     "\n};"
+
+// A key: its width in half keys, its legend, and what it types plain and
+// shifted (NO_CHAR for nothing).
+typedef struct KeyT {
+    uint32_t halves;
+    char     legend[LEGEND_MAX];
+    int      plain;
+    int      shifted;
+} KeyT;
+
+typedef struct RowT {
+    KeyT     keys[KEYS_MAX];
+    uint32_t count;
+} RowT;
+
+typedef struct BoardT {
+    RowT     rows[ROWS_MAX];
+    uint32_t count;
+    uint32_t row;
+    uint32_t col;
+} BoardT;
+
+// The cast charOf looks through.
+static const char kCast[] = "(uint8_t)(";
+
+static uint32_t argsSplit(const char *call, size_t len, char out[ARGS_MAX][ARG_MAX]);
+static uint32_t centre(const RowT *row, uint32_t col);
+static int      charOf(const char *text);
+static uint32_t defineOf(const char *text, const char *name);
+static void     find(const BoardT *board, const char *want, bool byLegend, uint32_t *row, uint32_t *col, bool *shifted);
+static void     keyAdd(RowT *row, const char *kind, size_t kindLen, char a[ARGS_MAX][ARG_MAX]);
+static void     legendCopy(char *legend, const char *text);
+static int      mod(int a, int n);
+static void     parse(BoardT *board, const char *text);
+static void     path(BoardT *board, uint32_t toRow, uint32_t toCol);
+static void     press(BoardT *board, const char *want, bool byLegend);
+
+
+// A KEY_*(...) entry's arguments, split at its top-level commas and
+// trimmed.
+static uint32_t argsSplit(const char *call, size_t len, char out[ARGS_MAX][ARG_MAX]) {
+    uint32_t count;
+    size_t   n;
+    size_t   i;
+    int      depth;
+    char     quote;
+    char     c;
+
+    count = 0;
+    n     = 0;
+    depth = 0;
+    quote = 0;
+    for (i = 0; i <= len && count < ARGS_MAX; i++) {
+        c = i < len ? call[i] : ',';
+        if (quote != 0) {
+            if (c == '\\' && i + 1 < len && n + 2 < ARG_MAX) {
+                out[count][n++] = c;
+                c               = call[++i];
+            } else if (c == quote) {
+                quote = 0;
+            }
+        } else if (c == '\'' || c == '"') {
+            quote = c;
+        } else if (c == '(') {
+            depth++;
+        } else if (c == ')') {
+            depth--;
+        } else if (c == ',' && depth == 0) {
+            while (n > 0 && (out[count][n - 1u] == ' ' || out[count][n - 1u] == '\t')) {
+                n--;
+            }
+            out[count][n] = 0;
+            count++;
+            n = 0;
+            continue;
+        }
+        if ((c == ' ' || c == '\t') && n == 0) {
+            continue;
+        }
+        if (n + 1 < ARG_MAX) {
+            out[count][n++] = c;
+        }
+    }
+    return count;
+}
+
+
+// Twice the middle of a key, in half keys.
+static uint32_t centre(const RowT *row, uint32_t col) {
+    uint32_t start;
+    uint32_t i;
+
+    start = 0;
+    for (i = 0; i < col; i++) {
+        start += row->keys[i].halves;
+    }
+    return 2u * start + row->keys[col].halves;
+}
+
+
+// A C character literal's character ('\'' and '\\' unescaped), or NO_CHAR
+// when the text is not one; a (uint8_t)(...) cast round it is looked
+// through.
+static int charOf(const char *text) {
+    char   inner[ARG_MAX];
+    size_t len;
+
+    len = strlen(text);
+    if (strncmp(text, kCast, sizeof(kCast) - 1u) == 0 && len > sizeof(kCast) && text[len - 1u] == ')') {
+        len -= sizeof(kCast);
+        memcpy(inner, text + sizeof(kCast) - 1u, len);
+        inner[len] = 0;
+        return charOf(inner);
+    }
+    if (len < 3u || text[0] != '\'' || text[len - 1u] != '\'') {
+        return NO_CHAR;
+    }
+    return (uint8_t)(text[1] == '\\' ? text[2] : text[1]);
+}
+
+
+// The number a "#define NAME 12u" gives.
+static uint32_t defineOf(const char *text, const char *name) {
+    const char *at;
+
+    at = strstr(text, name);
+    if (at == NULL) {
+        toolFail("no %s in the keyboard's source", name);
+    }
+    return (uint32_t)strtoul(at + strlen(name), NULL, DECIMAL);
+}
+
+
+// The key with the legend want, or else the one that types want (shifted
+// when only its shifted character is it).
+static void find(const BoardT *board, const char *want, bool byLegend, uint32_t *row, uint32_t *col, bool *shifted) {
+    uint32_t r;
+    uint32_t c;
+    int      pass;
+    int      got;
+
+    for (pass = 0; pass < 2; pass++) {
+        for (r = 0; r < board->count; r++) {
+            for (c = 0; c < board->rows[r].count; c++) {
+                got = pass == 0 ? board->rows[r].keys[c].plain : board->rows[r].keys[c].shifted;
+                if ((byLegend && pass == 0 && strcmp(board->rows[r].keys[c].legend, want) == 0) || (!byLegend && got != NO_CHAR && got == (uint8_t)want[0])) {
+                    *row     = r;
+                    *col     = c;
+                    *shifted = pass == 1;
+                    return;
+                }
+            }
+        }
+    }
+    if (byLegend) {
+        toolFail("no key shows or types '%s'", want);
+    }
+    toolFail("no key shows or types '%c'", want[0]);
+}
+
+
+// One KEY_* entry as a key.
+static void keyAdd(RowT *row, const char *kind, size_t kindLen, char a[ARGS_MAX][ARG_MAX]) {
+    KeyT *key;
+
+    if (row->count == KEYS_MAX) {
+        toolFail("a keyboard row has more than %u keys", KEYS_MAX);
+    }
+    key = &row->keys[row->count];
+    if (strncmp(kind, "KEY_LETTER", kindLen) == 0) {
+        key->halves  = LETTER_HALVES;
+        legendCopy(key->legend, a[0]);
+        key->plain   = charOf(a[1]);
+        key->shifted = (uint8_t)a[0][0];
+    } else if (strncmp(kind, "KEY_DIGIT", kindLen) == 0) {
+        key->halves  = LETTER_HALVES;
+        legendCopy(key->legend, a[0]);
+        key->plain   = charOf(a[1]);
+        key->shifted = charOf(a[2]);
+    } else if (strncmp(kind, "KEY_PUNCT", kindLen) == 0) {
+        key->halves    = (uint32_t)strtoul(a[0], NULL, DECIMAL);
+        key->legend[0] = (char)charOf(a[1]);
+        key->legend[1] = 0;
+        key->plain     = charOf(a[1]);
+        key->shifted   = charOf(a[2]);
+    } else if (strncmp(kind, "KEY_NAMED", kindLen) == 0) {
+        key->halves  = (uint32_t)strtoul(a[0], NULL, DECIMAL);
+        legendCopy(key->legend, a[3]);
+        key->plain   = charOf(a[2]);
+        key->shifted = key->plain;
+    } else if (strncmp(kind, "KEY_LATCH", kindLen) == 0) {
+        key->halves  = (uint32_t)strtoul(a[0], NULL, DECIMAL);
+        legendCopy(key->legend, a[3]);
+        key->plain   = NO_CHAR;
+        key->shifted = NO_CHAR;
+    } else {
+        return;
+    }
+    row->count++;
+}
+
+
+// A legend from its argument, its quotes taken off.
+static void legendCopy(char *legend, const char *text) {
+    size_t len;
+
+    len = strlen(text);
+    if (len >= 2u && text[0] == '"' && text[len - 1u] == '"') {
+        text++;
+        len -= 2u;
+    }
+    if (len >= LEGEND_MAX) {
+        len = LEGEND_MAX - 1u;
+    }
+    memcpy(legend, text, len);
+    legend[len] = 0;
+}
+
+
+// a modulo n, never negative.
+static int mod(int a, int n) {
+    return ((a % n) + n) % n;
+}
+
+
+// The keyboard's rows, and the cursor where it starts, from its source.
+static void parse(BoardT *board, const char *text) {
+    char        a[ARGS_MAX][ARG_MAX];
+    const char *at;
+    const char *body;
+    const char *bodyEnd;
+    const char *line;
+    const char *lineEnd;
+    const char *kind;
+    const char *open;
+    const char *close;
+    size_t      kindLen;
+
+    memset(board, 0, sizeof(*board));
+    board->row = defineOf(text, ROW_START);
+    board->col = defineOf(text, COL_START);
+    for (at = strstr(text, ROW_OPEN); at != NULL; at = strstr(bodyEnd, ROW_OPEN)) {
+        body    = strstr(at, ROW_OPEN_TAIL);
+        bodyEnd = body == NULL ? NULL : strstr(body, ROW_CLOSE);
+        if (bodyEnd == NULL || board->count == ROWS_MAX) {
+            toolFail("cannot read the keyboard's rows");
+        }
+        for (line = body + strlen(ROW_OPEN_TAIL); line < bodyEnd; line = lineEnd + 1) {
+            lineEnd = memchr(line, '\n', (size_t)(bodyEnd - line));
+            lineEnd = lineEnd == NULL ? bodyEnd : lineEnd;
+            kind    = strstr(line, "KEY_");
+            if (kind == NULL || kind >= lineEnd) {
+                continue;
+            }
+            for (kindLen = 4; kind[kindLen] >= 'A' && kind[kindLen] <= 'Z'; kindLen++) {
+            }
+            open  = kind + kindLen;
+            close = lineEnd;
+            while (close > open && (close[-1] == ' ' || close[-1] == '\t' || close[-1] == '\r')) {
+                close--;
+            }
+            close -= close > open && close[-1] == ',' ? 1 : 0;
+            if (*open != '(' || close <= open + 1 || close[-1] != ')') {
+                continue;
+            }
+            memset(a, 0, sizeof(a));
+            argsSplit(open + 1, (size_t)(close - 1 - (open + 1)), a);
+            keyAdd(&board->rows[board->count], kind, kindLen, a);
+        }
+        board->count++;
+    }
+}
+
+
+// The moves from the cursor to a key, as the keyboard makes them: rows
+// first, then along the row; the cursor (board->row, board->col) is left
+// on it.
+static void path(BoardT *board, uint32_t toRow, uint32_t toCol) {
+    uint32_t row;
+    uint32_t col;
+    uint32_t middle;
+    uint32_t best;
+    uint32_t distance;
+    uint32_t c;
+    int      n;
+    int      right;
+    int      left;
+    bool     down;
+
+    row = board->row;
+    col = board->col;
+    n   = (int)board->count;
+    while (row != toRow) {
+        middle = centre(&board->rows[row], col);
+        down   = mod((int)toRow - (int)row, n) <= mod((int)row - (int)toRow, n);
+        row    = (uint32_t)mod((int)row + (down ? 1 : -1), n);
+        best   = UINT32_MAX;
+        for (c = 0; c < board->rows[row].count; c++) {
+            distance = centre(&board->rows[row], c);
+            distance = distance > middle ? distance - middle : middle - distance;
+            if (distance < best || (down && distance == best)) {
+                best = distance;
+                col  = c;
+            }
+        }
+        puts(down ? "move down" : "move up");
+    }
+    n     = (int)board->rows[row].count;
+    right = mod((int)toCol - (int)col, n);
+    left  = mod((int)col - (int)toCol, n);
+    for (; right <= left && right > 0; right--) {
+        puts("move right");
+    }
+    for (; right > left && left > 0; left--) {
+        puts("move left");
+    }
+    board->row = row;
+    board->col = toCol;
+}
+
+
+// The moves to the key with legend want (or that types it, Shift first for
+// a shifted character) and its press.
+static void press(BoardT *board, const char *want, bool byLegend) {
+    uint32_t row;
+    uint32_t col;
+    uint32_t shiftRow;
+    uint32_t shiftCol;
+    bool     shifted;
+    bool     unused;
+
+    find(board, want, byLegend, &row, &col, &shifted);
+    if (shifted) {
+        find(board, SHIFT_LEGEND, true, &shiftRow, &shiftCol, &unused);
+        path(board, shiftRow, shiftCol);
+        printf("press %s\n", SHIFT_LEGEND);
+    }
+    path(board, row, col);
+    printf("press %s\n", board->rows[row].keys[col].legend);
+}
+
+
+int main(int argc, char **argv) {
+    BoardT      board;
+    uint8_t    *file;
+    size_t      len;
+    char       *text;
+    const char *value;
+    char        one[2];
+    int         i;
+    bool        byLegend;
+
+    toolFileName(TOOL_NAME);
+    if (argc < 3) {
+        fprintf(stderr, "usage: %s SOURCE TOKEN... (TOKEN: key:LEGEND or type:TEXT)\n", TOOL_NAME);
+        return 1;
+    }
+    if (!toolReadFile(argv[1], &file, &len)) {
+        toolFail("cannot read %s", argv[1]);
+    }
+    text = (char *)realloc(file, len + 1u);
+    if (text == NULL) {
+        toolFail("out of memory");
+    }
+    text[len] = 0;
+    parse(&board, text);
+    for (i = 2; i < argc; i++) {
+        value    = strchr(argv[i], ':');
+        value    = value == NULL ? argv[i] + strlen(argv[i]) : value + 1;
+        byLegend = strncmp(argv[i], "key:", strlen("key:")) == 0;
+        if (byLegend) {
+            press(&board, value, true);
+            continue;
+        }
+        for (one[1] = 0; *value != 0; value++) {
+            one[0] = *value;
+            press(&board, one, false);
+        }
+    }
+    return 0;
+}
diff --git a/tests/gate/qmp.c b/tests/gate/qmp.c
new file mode 100644
index 0000000..e938975
--- /dev/null
+++ b/tests/gate/qmp.c
@@ -0,0 +1,478 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// qmp.c - drive a QEMU machine through its QMP socket.
+//
+//     qmp SOCKET screendump FILE.png   the screen, as QEMU's display has it
+//     qmp SOCKET type TEXT             type TEXT (US layout, shift as needed)
+//     qmp SOCKET keys KEY...           press the KEYs, one after another
+//     qmp SOCKET hold KEY MS           hold one key down for MS milliseconds
+//     qmp SOCKET down KEY              press one key, and leave it down
+//     qmp SOCKET up KEY                let go of it
+//     qmp 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 SOCKET quit                  end QEMU
+//
+// KEY is a QEMU key name (a qcode: esc, ret, spc, a, 1, ...).
+//
+// 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. Each command is one QMP request (a typed key,
+// a mouse step, one each) after the greeting and the capabilities
+// handshake; QMP's events in between are skipped. Each request is one line
+// of JSON with a space after every colon and comma. An error reply, or a
+// connection that fails, ends the tool with exit 1; bad arguments (a
+// character in TEXT no key types among them) exit 2 before anything is
+// sent.
+
+#define _DEFAULT_SOURCE
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateNet.h"
+#include "gateText.h"
+#include "toolBuf.h"
+#include "toolFile.h"
+#include "toolJson.h"
+
+#define TOOL_NAME          "qmp"
+#define ARG_SOCKET         1
+#define ARG_COMMAND        2
+#define ARG_FIRST          3
+#define ARG_SECOND         4
+#define ARGS_BARE          3
+#define ARGS_ONE           4
+#define ARGS_TWO           5
+#define READ_SIZE          4096u
+#define NUMBER_CHARS       32u
+
+// 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.
+#define TYPE_HOLD_MS       100L
+#define TYPE_GAP_SECONDS   0.15
+#define 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.
+#define MOVE_STEP_MAX      100L
+#define MOVE_GAP_SECONDS   0.02
+
+// A shifted key is pressed with this one.
+#define SHIFT_QCODE        "shift"
+#define KEYS_MAX           2u
+// A one-character qcode (a letter or digit) and its end.
+#define LETTER_CHARS       2u
+
+// The shifted digits, in the order of their keys' digits.
+#define SHIFTED_DIGITS     ")!@#$%^&*("
+#define JSON_CONTROL_END   0x20u
+
+// One QMP connection: its socket and what it has sent past the last line.
+typedef struct QmpT {
+    int      fd;
+    ToolBufT in;
+} QmpT;
+
+// A character a US keyboard types with a key other than its own.
+typedef struct PunctT {
+    char        ch;
+    const char *qcode;
+    bool        shifted;
+} PunctT;
+
+static void bufNumber(ToolBufT *buf, long value);
+static void bufText(ToolBufT *buf, const char *text);
+static bool charKey(char ch, char *letter, const char **qcode, bool *shifted);
+static long floorDiv(long a, long b);
+static void jsonString(ToolBufT *buf, const char *text);
+static void key(QmpT *q, const char *qcode, bool down);
+static void keys(QmpT *q, const char *const *qcodes, uint32_t count, long holdMs);
+static void move(QmpT *q, long dx, long dy);
+static void qmpExecute(QmpT *q, const char *command, const ToolBufT *arguments);
+static void qmpLine(QmpT *q, ToolBufT *line);
+static void qmpOpen(QmpT *q, const char *path);
+static void tap(QmpT *q, const char *const *qcodes, uint32_t count);
+static int  usage(void);
+
+static const PunctT kPunct[] = {
+    {' ', "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}
+};
+
+
+// A whole number, in decimal.
+static void bufNumber(ToolBufT *buf, long value) {
+    char text[NUMBER_CHARS];
+
+    snprintf(text, sizeof(text), "%ld", value);
+    bufText(buf, text);
+}
+
+
+static void bufText(ToolBufT *buf, const char *text) {
+    toolBufAdd(buf, text, strlen(text));
+}
+
+
+// The key (qcode) that types ch, and whether shifted: a letter or digit is
+// its own key, its one-character name written to letter.
+static bool charKey(char ch, char *letter, const char **qcode, bool *shifted) {
+    const char *digit;
+    size_t      i;
+
+    letter[1] = '\0';
+    *qcode    = letter;
+    *shifted  = false;
+    if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
+        letter[0] = ch;
+        return true;
+    }
+    if (ch >= 'A' && ch <= 'Z') {
+        letter[0] = (char)(ch - 'A' + 'a');
+        *shifted  = true;
+        return true;
+    }
+    digit = strchr(SHIFTED_DIGITS, ch);
+    if (digit != NULL) {
+        letter[0] = (char)('0' + (digit - SHIFTED_DIGITS));
+        *shifted  = true;
+        return true;
+    }
+    for (i = 0; i < sizeof(kPunct) / sizeof(kPunct[0]); i++) {
+        if (kPunct[i].ch == ch) {
+            *qcode   = kPunct[i].qcode;
+            *shifted = kPunct[i].shifted;
+            return true;
+        }
+    }
+    return false;
+}
+
+
+// a / b rounded toward minus infinity, so a move's steps add up to the
+// whole move whatever its sign.
+static long floorDiv(long a, long b) {
+    long q;
+
+    q = a / b;
+    if ((a % b != 0) && ((a < 0) != (b < 0))) {
+        q--;
+    }
+    return q;
+}
+
+
+// text as a JSON string: a quote, a backslash, and the control characters
+// escaped.
+static void jsonString(ToolBufT *buf, const char *text) {
+    char           escape[NUMBER_CHARS];
+    const uint8_t *c;
+
+    toolBufByte(buf, '"');
+    for (c = (const uint8_t *)text; *c != '\0'; c++) {
+        switch (*c) {
+            case '"':
+                bufText(buf, "\\\"");
+                break;
+            case '\\':
+                bufText(buf, "\\\\");
+                break;
+            case '\n':
+                bufText(buf, "\\n");
+                break;
+            case '\r':
+                bufText(buf, "\\r");
+                break;
+            case '\t':
+                bufText(buf, "\\t");
+                break;
+            case '\b':
+                bufText(buf, "\\b");
+                break;
+            case '\f':
+                bufText(buf, "\\f");
+                break;
+            default:
+                if (*c < JSON_CONTROL_END) {
+                    snprintf(escape, sizeof(escape), "\\u%04x", *c);
+                    bufText(buf, escape);
+                } else {
+                    toolBufByte(buf, *c);
+                }
+                break;
+        }
+    }
+    toolBufByte(buf, '"');
+}
+
+
+// Press or let go of one key.
+static void key(QmpT *q, const char *qcode, bool down) {
+    ToolBufT args;
+
+    memset(&args, 0, sizeof(args));
+    bufText(&args, "{\"events\": [{\"type\": \"key\", \"data\": {\"down\": ");
+    bufText(&args, down ? "true" : "false");
+    bufText(&args, ", \"key\": {\"type\": \"qcode\", \"data\": ");
+    jsonString(&args, qcode);
+    bufText(&args, "}}}]}");
+    qmpExecute(q, "input-send-event", &args);
+    toolBufFree(&args);
+}
+
+
+// Press qcodes together, hold them holdMs, let them go.
+static void keys(QmpT *q, const char *const *qcodes, uint32_t count, long holdMs) {
+    ToolBufT args;
+    uint32_t i;
+
+    memset(&args, 0, sizeof(args));
+    bufText(&args, "{\"keys\": [");
+    for (i = 0; i < count; i++) {
+        bufText(&args, i == 0u ? "{\"type\": \"qcode\", \"data\": " : ", {\"type\": \"qcode\", \"data\": ");
+        jsonString(&args, qcodes[i]);
+        bufText(&args, "}");
+    }
+    bufText(&args, "], \"hold-time\": ");
+    bufNumber(&args, holdMs);
+    bufText(&args, "}");
+    qmpExecute(q, "send-key", &args);
+    toolBufFree(&args);
+}
+
+
+// Move the mouse dx, dy in steps of at most MOVE_STEP_MAX.
+static void move(QmpT *q, long dx, long dy) {
+    ToolBufT args;
+    long     most;
+    long     steps;
+    long     step;
+    long     stepX;
+    long     stepY;
+
+    most  = labs(dx) > labs(dy) ? labs(dx) : labs(dy);
+    steps = (most + MOVE_STEP_MAX - 1) / MOVE_STEP_MAX;
+    if (steps < 1) {
+        steps = 1;
+    }
+    memset(&args, 0, sizeof(args));
+    for (step = 0; step < steps; step++) {
+        stepX    = floorDiv(dx * (step + 1), steps) - floorDiv(dx * step, steps);
+        stepY    = floorDiv(dy * (step + 1), steps) - floorDiv(dy * step, steps);
+        args.len = 0u;
+        bufText(&args, "{\"events\": [{\"type\": \"rel\", \"data\": {\"axis\": \"x\", \"value\": ");
+        bufNumber(&args, stepX);
+        bufText(&args, "}}, {\"type\": \"rel\", \"data\": {\"axis\": \"y\", \"value\": ");
+        bufNumber(&args, stepY);
+        bufText(&args, "}}]}");
+        qmpExecute(q, "input-send-event", &args);
+        gateNetSleep(MOVE_GAP_SECONDS);
+    }
+    toolBufFree(&args);
+}
+
+
+// Run one command (arguments a JSON object, or NULL for none) and wait for
+// its reply; an error reply ends the tool.
+static void qmpExecute(QmpT *q, const char *command, const ToolBufT *arguments) {
+    const ToolJsonT *error;
+    const ToolJsonT *desc;
+    ToolJsonT       *reply;
+    ToolBufT         request;
+    ToolBufT         line;
+    char             err[TOOL_JSON_ERROR_BYTES];
+    bool             done;
+
+    memset(&request, 0, sizeof(request));
+    bufText(&request, "{\"execute\": ");
+    jsonString(&request, command);
+    if (arguments != NULL) {
+        bufText(&request, ", \"arguments\": ");
+        toolBufAdd(&request, arguments->data, arguments->len);
+    }
+    bufText(&request, "}\n");
+    if (!gateNetSendAll(q->fd, request.data, request.len)) {
+        toolFail("%s: %s", command, strerror(errno));
+    }
+    toolBufFree(&request);
+    memset(&line, 0, sizeof(line));
+    done = false;
+    while (!done) {
+        qmpLine(q, &line);
+        reply = toolJsonParse((const char *)line.data, line.len, err);
+        if (reply == NULL) {
+            toolFail("%s: QMP's reply is not JSON (%s)", command, err);
+        }
+        error = toolJsonGet(reply, "error");
+        if (error != NULL) {
+            desc = toolJsonGet(error, "desc");
+            toolFail("%s: %s", command, desc != NULL && desc->type == TOOL_JSON_STRING ? desc->string : "error");
+        }
+        done = toolJsonGet(reply, "return") != NULL;
+        toolJsonFree(reply);
+    }
+    toolBufFree(&line);
+}
+
+
+// The next line QMP sent (without its newline) in line; the connection
+// closing first ends the tool.
+static void qmpLine(QmpT *q, ToolBufT *line) {
+    uint8_t chunk[READ_SIZE];
+    size_t  end;
+    ssize_t got;
+
+    for (;;) {
+        end = gateTextFind(q->in.data, q->in.len, 0u, "\n", 1u);
+        if (end != GATE_TEXT_NOT_FOUND) {
+            break;
+        }
+        got = read(q->fd, chunk, sizeof(chunk));
+        if (got <= 0) {
+            toolFail("QMP closed the connection");
+        }
+        toolBufAdd(&q->in, chunk, (size_t)got);
+    }
+    line->len = 0u;
+    toolBufAdd(line, q->in.data, end);
+    q->in.len -= end + 1u;
+    memmove(q->in.data, q->in.data + end + 1u, q->in.len);
+}
+
+
+// Connect, read the greeting, and negotiate the capabilities.
+static void qmpOpen(QmpT *q, const char *path) {
+    struct sockaddr_un addr;
+    ToolBufT           greeting;
+
+    memset(q, 0, sizeof(*q));
+    memset(&addr, 0, sizeof(addr));
+    addr.sun_family = AF_UNIX;
+    if (strlen(path) >= sizeof(addr.sun_path)) {
+        toolFail("%s: the socket's path is too long", path);
+    }
+    strcpy(addr.sun_path, path);
+    q->fd = socket(AF_UNIX, SOCK_STREAM, 0);
+    if (q->fd < 0 || connect(q->fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+        toolFail("%s: %s", path, strerror(errno));
+    }
+    memset(&greeting, 0, sizeof(greeting));
+    qmpLine(q, &greeting);
+    toolBufFree(&greeting);
+    qmpExecute(q, "qmp_capabilities", NULL);
+}
+
+
+// Press qcodes together for a typed key's time, then pause after them.
+static void tap(QmpT *q, const char *const *qcodes, uint32_t count) {
+    keys(q, qcodes, count, TYPE_HOLD_MS);
+    gateNetSleep(TYPE_HOLD_MS / MS_PER_SECOND + TYPE_GAP_SECONDS);
+}
+
+
+static int usage(void) {
+    fprintf(stderr, "usage: %s SOCKET screendump FILE.png   the screen, as QEMU's display has it\n       %s SOCKET type TEXT             type TEXT (US layout, shift as needed)\n       %s SOCKET keys KEY...           press the KEYs, one after another\n       %s SOCKET hold KEY MS           hold one key down for MS milliseconds\n       %s SOCKET down KEY              press one key, and leave it down\n       %s SOCKET up KEY                let go of it\n       %s SOCKET move DX DY            move the mouse DX, DY\n       %s SOCKET quit                  end QEMU\nKEY is a QEMU key name (a qcode: esc, ret, spc, a, 1, ...).\n", TOOL_NAME, TOOL_NAME, TOOL_NAME, TOOL_NAME, TOOL_NAME, TOOL_NAME, TOOL_NAME, TOOL_NAME);
+    return GATE_USAGE_EXIT;
+}
+
+
+int main(int argc, char **argv) {
+    QmpT        q;
+    ToolBufT    args;
+    const char *codes[KEYS_MAX];
+    const char *command;
+    const char *text;
+    char        letter[LETTER_CHARS];
+    long        holdMs;
+    long        dx;
+    long        dy;
+    bool        shifted;
+    int         i;
+
+    gateNetStart(TOOL_NAME);
+    if (argc < ARGS_BARE) {
+        return usage();
+    }
+    command = argv[ARG_COMMAND];
+    if (strcmp(command, "screendump") == 0 && argc == ARGS_ONE) {
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        memset(&args, 0, sizeof(args));
+        bufText(&args, "{\"filename\": ");
+        jsonString(&args, argv[ARG_FIRST]);
+        bufText(&args, ", \"format\": \"png\"}");
+        qmpExecute(&q, "screendump", &args);
+        toolBufFree(&args);
+    } else if (strcmp(command, "type") == 0 && argc == ARGS_ONE) {
+        for (text = argv[ARG_FIRST]; *text != '\0'; text++) {
+            if (!charKey(*text, letter, &codes[1], &shifted)) {
+                fprintf(stderr, "%s: no key types ", TOOL_NAME);
+                gateNetShowBytes(stderr, text, 1u);
+                fputc('\n', stderr);
+                return GATE_USAGE_EXIT;
+            }
+        }
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        codes[0] = SHIFT_QCODE;
+        for (text = argv[ARG_FIRST]; *text != '\0'; text++) {
+            charKey(*text, letter, &codes[1], &shifted);
+            tap(&q, shifted ? codes : &codes[1], shifted ? KEYS_MAX : 1u);
+        }
+    } else if (strcmp(command, "keys") == 0 && argc >= ARGS_ONE) {
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        for (i = ARG_FIRST; i < argc; i++) {
+            tap(&q, (const char *const *)&argv[i], 1u);
+        }
+    } else if (strcmp(command, "hold") == 0 && argc == ARGS_TWO) {
+        holdMs = gateArgLong("hold time", argv[ARG_SECOND]);
+        if (holdMs < 0) {
+            return usage();
+        }
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        keys(&q, (const char *const *)&argv[ARG_FIRST], 1u, holdMs);
+        gateNetSleep(holdMs / MS_PER_SECOND);
+    } else if ((strcmp(command, "down") == 0 || strcmp(command, "up") == 0) && argc == ARGS_ONE) {
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        key(&q, argv[ARG_FIRST], strcmp(command, "down") == 0);
+    } else if (strcmp(command, "move") == 0 && argc == ARGS_TWO) {
+        dx = gateArgLong("dx", argv[ARG_FIRST]);
+        dy = gateArgLong("dy", argv[ARG_SECOND]);
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        move(&q, dx, dy);
+    } else if (strcmp(command, "quit") == 0 && argc == ARGS_BARE) {
+        qmpOpen(&q, argv[ARG_SOCKET]);
+        qmpExecute(&q, "quit", NULL);
+    } else {
+        return usage();
+    }
+    return 0;
+}
diff --git a/tests/gate/serialLoopPeer.c b/tests/gate/serialLoopPeer.c
new file mode 100644
index 0000000..5819742
--- /dev/null
+++ b/tests/gate/serialLoopPeer.c
@@ -0,0 +1,735 @@
+// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
+// See LICENSE.md for license information.
+// SPDX-License-Identifier: MIT
+//
+// serialLoopPeer.c - the host end of the serial loopback gates.
+//
+//     serialLoopPeer tcp-server PORT WALL [BANNER]   listen; the emulator connects
+//     serialLoopPeer tcp-client PORT WALL [BANNER]   connect to the emulator
+//     serialLoopPeer pty PATHFILE WALL [BANNER]      make a raw pty pair, write the
+//                                                    guest end's path to PATHFILE
+//
+// The guest runs SERTEST (examples/sertest): it sends a banner, then echoes
+// every byte it receives. This peer waits for the banner (the guest's
+// transmit works), then sends a probe line and expects it back (its receive
+// works too). WALL is the wall-clock budget in seconds. Prints one LOOP
+// PASS / LOOP FAIL line; exit 0 on pass, 1 on fail, 2 on bad arguments.
+//
+// --close-hold FILE (pty) then has SERTEST close the port: its DTR check,
+// run for the closes alone (a pty has no modem lines), every marker line
+// acknowledged, after which the port stays closed. Prints one CLOSE PASS /
+// CLOSE FAIL line more, and keeps the pty pair open until FILE exists or
+// WALL runs out, so the gate can read the guest end's settings after the
+// close (verify-sdl3-serial.sh).
+//
+// --stream N --rate RATE (TCP, nullmodem transparent:1) then streams N
+// bytes of printable ASCII through SERTEST's echo, at most STREAM_WINDOW
+// bytes ahead of what has come back, and checks every byte returns once, in
+// order. Prints one STREAM PASS / STREAM FAIL line more, with the round
+// trip's bytes a second; PASS needs that to be at least STREAM_MIN_PERCENT
+// of what the line rate RATE (bits a second) carries.
+//
+// --dtr-check (TCP, with DOSBox's nullmodem in transparent:0 mode, which
+// puts the guest's modem lines in the byte stream as they change: 0xFF 0xFF
+// is a data 0xFF, 0xFF  the lines, bit 0 RTS, bit 1 DTR) then runs
+// SERTEST's DTR check: it sends DTR_CHECK, acknowledges each of SERTEST's
+// marker lines (so SERTEST changes a line only once the marker before has
+// gone out whole), and reads the line states against the markers.
+// jlSerialSetDtr(false) and (true) must reach the line, closing and opening
+// the port again must keep DTR up, and after a hang-up (DTR dropped, then
+// the port closed) DTR must stay down for HANGUP_WATCH_SECONDS. Prints one
+// DTR PASS / DTR FAIL line more.
+//
+// The options may come anywhere among the arguments. The verdict lines
+// begin as shown above (the gates read them); what the guest sent is shown
+// in double quotes, escaped where it is not printable.
+
+#define _DEFAULT_SOURCE
+#define _XOPEN_SOURCE 600
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "gateArg.h"
+#include "gateNet.h"
+#include "gateText.h"
+#include "toolBuf.h"
+#include "toolFile.h"
+
+#define TOOL_NAME              "serialLoopPeer"
+// The arguments that are not options: mode, target, wall, and a banner.
+#define POS_MODE               0
+#define POS_TARGET             1
+#define POS_WALL               2
+#define POS_BANNER             3
+#define POS_MIN                3
+#define POS_MAX                4
+
+#define DEFAULT_BANNER         "JLSERTEST-READY"
+#define PROBE                  "PING12345"
+#define PROBE_LINE             PROBE "\r\n"
+#define RESEND_SECONDS         3.0
+#define POLL_SECONDS           0.5
+#define CONNECT_SECONDS        2.0
+#define CONNECT_RETRY_SECONDS  0.5
+#define READ_SIZE              256u
+// How much of the stream a line shows (LOOP FAIL a little more).
+#define SHOWN_BYTES            80u
+#define FAIL_EXTRA_BYTES       40u
+#define FAIL_SHOWN_BYTES       (SHOWN_BYTES + FAIL_EXTRA_BYTES)
+#define TMP_SUFFIX             ".tmp"
+#define OPTION_DASHES          "--"
+
+// --stream: the window, the chunks, the pass mark, and the bytes it sends
+// (printable ASCII, so never SERTEST's DTR_CHECK).
+#define STREAM_WINDOW          1024u
+#define STREAM_CHUNK           64u
+#define STREAM_POLL_SECONDS    0.01
+#define STREAM_SETTLE_SECONDS  1.0
+#define STREAM_MIN_PERCENT     75
+#define STREAM_FIRST_BYTE      0x20u
+#define STREAM_BYTE_VALUES     95u
+#define STREAM_SHOWN_BYTES     16u
+#define PERCENT                100.0
+// 8N1: a start bit, 8 data bits, a stop bit.
+#define LINE_BITS_PER_BYTE     10
+
+// SERTEST's DTR check (examples/sertest/sertest.c).
+#define DTR_CHECK              "\x04"
+#define DTR_ACK                "\x06"
+#define HANGUP_WATCH_SECONDS   3.0
+#define FAILS_MAX              5u
+
+// DOSBox nullmodem transparent:0 framing.
+#define LINE_ESCAPE            0xFFu
+#define LINE_DTR               0x02u
+
+// SERTEST's DTR check markers, in the order it sends them.
+typedef enum MarkE {
+    MARK_OFF = 0,
+    MARK_ON,
+    MARK_REOPENED,
+    MARK_HANGUP,
+    MARK_COUNT
+} MarkE;
+
+// DTR as it was at some point: never reported, down, or up.
+typedef enum DtrStateE {
+    DTR_UNKNOWN = 0,
+    DTR_DOWN,
+    DTR_UP
+} DtrStateE;
+
+// One DTR state the guest reported, with the count of data bytes before it.
+typedef struct DtrT {
+    size_t at;
+    bool   up;
+} DtrT;
+
+// A transparent:0 stream split into data and the guest's DTR states (DtrT
+// records, in the order they came).
+typedef struct LinesT {
+    bool     escaped;
+    size_t   dataCount;
+    ToolBufT states;
+} LinesT;
+
+// The link to the guest: a pty's host end or a TCP connection, how long a
+// read waits, and whether the stream is transparent:0 framed (--dtr-check).
+typedef struct LinkT {
+    int    fd;
+    bool   isPty;
+    bool   framed;
+    double pollSeconds;
+    LinesT lines;
+} LinkT;
+
+// Where in the stream each of SERTEST's markers was, the first count of
+// them found.
+typedef struct MarksT {
+    size_t   at[MARK_COUNT];
+    uint32_t count;
+} MarksT;
+
+static void        ackMarks(LinkT *link, const ToolBufT *got, size_t base, MarksT *marks);
+static int         closeHold(LinkT *link, double wall, double start, ToolBufT *got, const char *holdFile);
+static int         connectOne(uint16_t port, double wall, double start);
+static int         connectWithin(uint16_t port, double seconds);
+static DtrStateE   dtrBefore(const LinesT *lines, size_t position);
+static bool        dtrChanges(const LinesT *lines, size_t first, size_t last, bool up);
+static int         dtrCheck(LinkT *link, double wall, double start, ToolBufT *got);
+static const DtrT *dtrStates(const LinesT *lines, size_t *count);
+static size_t      linesFeed(LinesT *lines, uint8_t *data, size_t len);
+static void        linkSend(LinkT *link, const void *data, size_t len);
+static int         loop(LinkT *link, double wall, double start, const char *banner, ToolBufT *got);
+static void        markFail(const char *label, const MarksT *marks, const ToolBufT *got, size_t base);
+static int         openPty(const char *pathFile);
+static void        rawMode(int fd);
+static bool        receive(LinkT *link, ToolBufT *into);
+static int         stream(LinkT *link, double wall, double start, size_t total, long rate);
+static uint8_t     streamByte(size_t index);
+static int         usage(void);
+
+static const char *const kMarks[MARK_COUNT] = {
+    "JLDTR-OFF\r\n",
+    "JLDTR-ON\r\n",
+    "JLDTR-REOPENED\r\n",
+    "JLDTR-HANGUP\r\n"
+};
+
+
+// Acknowledge each of SERTEST's marker lines (after got[base]) as it
+// completes, noting where it is.
+static void ackMarks(LinkT *link, const ToolBufT *got, size_t base, MarksT *marks) {
+    const char *mark;
+    size_t      at;
+
+    while (marks->count < MARK_COUNT) {
+        mark = kMarks[marks->count];
+        at   = gateTextFind(got->data, got->len, base, mark, strlen(mark));
+        if (at == GATE_TEXT_NOT_FOUND) {
+            return;
+        }
+        marks->at[marks->count] = at;
+        marks->count++;
+        linkSend(link, DTR_ACK, strlen(DTR_ACK));
+    }
+}
+
+
+// After the loopback: SERTEST's DTR check for its closes alone, then the
+// pty held open until holdFile exists.
+static int closeHold(LinkT *link, double wall, double start, ToolBufT *got, const char *holdFile) {
+    MarksT marks;
+    size_t base;
+    int    status;
+
+    base        = got->len;
+    marks.count = 0u;
+    linkSend(link, DTR_CHECK, strlen(DTR_CHECK));
+    while (marks.count < MARK_COUNT && gateNetNow() - start < wall) {
+        if (!receive(link, got)) {
+            break;
+        }
+        ackMarks(link, got, base, &marks);
+    }
+    if (marks.count == MARK_COUNT) {
+        printf("CLOSE PASS: every marker acknowledged; SERTEST has closed the port\n");
+        status = 0;
+    } else {
+        markFail("CLOSE", &marks, got, base);
+        status = 1;
+    }
+    while (access(holdFile, F_OK) != 0 && gateNetNow() - start < wall) {
+        receive(link, NULL);
+    }
+    return status;
+}
+
+
+// A connection to the emulator, tried every CONNECT_RETRY_SECONDS until
+// wall seconds from start have gone; -1 when none was made.
+static int connectOne(uint16_t port, double wall, double start) {
+    int fd;
+
+    while (gateNetNow() - start < wall) {
+        fd = connectWithin(port, CONNECT_SECONDS);
+        if (fd >= 0) {
+            return fd;
+        }
+        gateNetSleep(CONNECT_RETRY_SECONDS);
+    }
+    return -1;
+}
+
+
+// One connection attempt, given seconds to complete; -1 when it fails.
+static int connectWithin(uint16_t port, double seconds) {
+    struct sockaddr_in addr;
+    socklen_t          errLen;
+    int                fd;
+    int                flags;
+    int                err;
+
+    memset(&addr, 0, sizeof(addr));
+    addr.sin_family      = AF_INET;
+    addr.sin_port        = htons(port);
+    addr.sin_addr.s_addr = inet_addr(GATE_NET_HOST);
+    fd                   = socket(AF_INET, SOCK_STREAM, 0);
+    if (fd < 0) {
+        return -1;
+    }
+    flags = fcntl(fd, F_GETFL);
+    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+    err = 0;
+    if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+        err    = errno;
+        errLen = sizeof(err);
+        if (err == EINPROGRESS && gateNetWait(fd, POLLOUT, seconds)) {
+            getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errLen);
+        }
+    }
+    if (err != 0) {
+        close(fd);
+        return -1;
+    }
+    fcntl(fd, F_SETFL, flags);
+    return fd;
+}
+
+
+// DTR as it was when the data byte at position arrived.
+static DtrStateE dtrBefore(const LinesT *lines, size_t position) {
+    const DtrT *states;
+    size_t      count;
+    size_t      i;
+    DtrStateE   state;
+
+    states = dtrStates(lines, &count);
+    state  = DTR_UNKNOWN;
+    for (i = 0; i < count && states[i].at <= position; i++) {
+        state = states[i].up ? DTR_UP : DTR_DOWN;
+    }
+    return state;
+}
+
+
+// Whether DTR was reported as up (or down) after data byte first, up to
+// last.
+static bool dtrChanges(const LinesT *lines, size_t first, size_t last, bool up) {
+    const DtrT *states;
+    size_t      count;
+    size_t      i;
+
+    states = dtrStates(lines, &count);
+    for (i = 0; i < count; i++) {
+        if (first < states[i].at && states[i].at <= last && states[i].up == up) {
+            return true;
+        }
+    }
+    return false;
+}
+
+
+// After the loopback (the stream so far is got, its line states decoded):
+// SERTEST's DTR check, then the watch after its hang-up.
+static int dtrCheck(LinkT *link, double wall, double start, ToolBufT *got) {
+    const LinesT *lines;
+    const DtrT   *states;
+    const char   *fails[FAILS_MAX];
+    MarksT        marks;
+    size_t        base;
+    size_t        count;
+    size_t        i;
+    uint32_t      failCount;
+    uint32_t      f;
+    double        hangupAt;
+    bool          watching;
+    bool          shown;
+
+    lines       = &link->lines;
+    base        = got->len;
+    marks.count = 0u;
+    hangupAt    = 0.0;
+    watching    = false;
+    linkSend(link, DTR_CHECK, strlen(DTR_CHECK));
+    while (gateNetNow() - start < wall) {
+        if (watching && gateNetNow() - hangupAt >= HANGUP_WATCH_SECONDS) {
+            break;
+        }
+        if (!receive(link, got)) {
+            break;
+        }
+        // The last marker starts the watch.
+        ackMarks(link, got, base, &marks);
+        if (!watching && marks.count == MARK_COUNT) {
+            watching = true;
+            hangupAt = gateNetNow();
+        }
+    }
+    if (marks.count < MARK_COUNT) {
+        markFail("DTR", &marks, got, base);
+        return 1;
+    }
+    if (gateNetNow() - hangupAt < HANGUP_WATCH_SECONDS) {
+        printf("DTR FAIL: out of time before the watch after the hang-up ended\n");
+        return 1;
+    }
+    failCount = 0u;
+    if (dtrBefore(lines, marks.at[MARK_OFF]) != DTR_DOWN) {
+        fails[failCount++] = "jlSerialSetDtr(false) did not drop DTR";
+    }
+    if (dtrBefore(lines, marks.at[MARK_ON]) != DTR_UP) {
+        fails[failCount++] = "jlSerialSetDtr(true) did not raise DTR";
+    }
+    if (dtrChanges(lines, marks.at[MARK_ON], marks.at[MARK_REOPENED], false)) {
+        fails[failCount++] = "closing and opening the port dropped DTR";
+    }
+    if (dtrBefore(lines, marks.at[MARK_HANGUP]) != DTR_DOWN) {
+        fails[failCount++] = "jlSerialSetDtr(false) after the reopen did not drop DTR";
+    }
+    if (dtrChanges(lines, marks.at[MARK_HANGUP], lines->dataCount, true)) {
+        fails[failCount++] = "closing the port after the hang-up raised DTR";
+    }
+    if (failCount == 0u) {
+        printf("DTR PASS: SetDtr(false/true) reached the line, close+open kept DTR up, a hang-up stayed down %.0f s after the close\n", HANGUP_WATCH_SECONDS);
+        return 0;
+    }
+    printf("DTR FAIL: ");
+    for (f = 0; f < failCount; f++) {
+        printf("%s%s", f == 0u ? "" : "; ", fails[f]);
+    }
+    // The states reported since the check, each after its count of data
+    // bytes.
+    printf("; DTR since the check (data byte, state):");
+    states = dtrStates(lines, &count);
+    shown  = false;
+    for (i = 0; i < count; i++) {
+        if (states[i].at >= base) {
+            printf("%s%zu %s", shown ? ", " : " ", states[i].at, states[i].up ? "up" : "down");
+            shown = true;
+        }
+    }
+    printf("%s\n", shown ? "" : " none");
+    return 1;
+}
+
+
+// The DTR states the guest has reported, and (in count) how many.
+static const DtrT *dtrStates(const LinesT *lines, size_t *count) {
+    *count = lines->states.len / sizeof(DtrT);
+    return (const DtrT *)lines->states.data;
+}
+
+
+// The data bytes of len raw ones (in place, at the start of data), the DTR
+// states among them recorded with the count of data bytes before each. An
+// escape at the end waits for the byte after it.
+static size_t linesFeed(LinesT *lines, uint8_t *data, size_t len) {
+    DtrT   state;
+    size_t in;
+    size_t out;
+
+    out = 0u;
+    for (in = 0; in < len; in++) {
+        if (!lines->escaped && data[in] == LINE_ESCAPE) {
+            lines->escaped = true;
+        } else if (lines->escaped && data[in] != LINE_ESCAPE) {
+            lines->escaped = false;
+            state.at       = lines->dataCount;
+            state.up       = (data[in] & LINE_DTR) != 0u;
+            toolBufAdd(&lines->states, &state, sizeof(state));
+        } else {
+            lines->escaped = false;
+            data[out++]    = data[in];
+            lines->dataCount++;
+        }
+    }
+    return out;
+}
+
+
+// Every byte to the guest; a link that fails ends the peer (exit 1).
+static void linkSend(LinkT *link, const void *data, size_t len) {
+    if (!gateNetSendAll(link->fd, data, len)) {
+        toolFail("send: %s", strerror(errno));
+    }
+}
+
+
+// Wait for the banner, then send the probe (again every RESEND_SECONDS)
+// until it comes back. got collects the stream.
+static int loop(LinkT *link, double wall, double start, const char *banner, ToolBufT *got) {
+    size_t sentAt;
+    double lastSend;
+
+    sentAt   = GATE_TEXT_NOT_FOUND;
+    lastSend = 0.0;
+    while (gateNetNow() - start < wall) {
+        if (gateTextFind(got->data, got->len, 0u, banner, strlen(banner)) != GATE_TEXT_NOT_FOUND && (sentAt == GATE_TEXT_NOT_FOUND || gateNetNow() - lastSend > RESEND_SECONDS)) {
+            linkSend(link, PROBE_LINE, strlen(PROBE_LINE));
+            lastSend = gateNetNow();
+            if (sentAt == GATE_TEXT_NOT_FOUND) {
+                sentAt = got->len;
+            }
+        }
+        if (!receive(link, got)) {
+            break;
+        }
+        if (sentAt != GATE_TEXT_NOT_FOUND && gateTextFind(got->data, got->len, sentAt, PROBE, strlen(PROBE)) != GATE_TEXT_NOT_FOUND) {
+            printf("LOOP PASS: banner ");
+            gateNetShowBytes(stdout, banner, strlen(banner));
+            printf(" seen, probe echoed; stream=");
+            gateNetShowBytes(stdout, got->data, got->len < SHOWN_BYTES ? got->len : SHOWN_BYTES);
+            printf("\n");
+            return 0;
+        }
+    }
+    printf("LOOP FAIL: stream=");
+    gateNetShowBytes(stdout, got->data, got->len < FAIL_SHOWN_BYTES ? got->len : FAIL_SHOWN_BYTES);
+    printf("\n");
+    return 1;
+}
+
+
+// "