Tools ported to C and bash. Some timing issues fixed on targets that support both NTSC and PAL regions.

This commit is contained in:
Scott Duensing 2026-10-18 23:53:06 -05:00
parent 154b116208
commit c2be2e4b07
254 changed files with 29496 additions and 9130 deletions

View file

@ -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 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 assets, apart from the patches in `installer/patches/`, which are under
the licenses of the projects they patch (noted in those entries), and the 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 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. it, where it comes from, its license, and where the installer puts it.
@ -46,8 +46,9 @@ most.
not in the archive. not in the archive.
- **Role:** Apple IIgs Ensoniq DOC `.NTP` module replayer + SFX - **Role:** Apple IIgs Ensoniq DOC `.NTP` module replayer + SFX
streamer. Merlin32-assembled into `ntpplayer`, which ships on the disk streamer. Merlin32-assembled into `ntpplayer`, which ships on the disk
next to each IIgs program and is loaded at run time. Its next to each IIgs program and is loaded at run time. The `.NTP` files it
`ntpconverter.php` makes the `.NTP` files (tools/joeymod). 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`) - **Installed at:** `toolchains/opt/ntp/` (source; installer component `ntp`)
### PTPlayer 6.4 ### PTPlayer 6.4
@ -405,15 +406,27 @@ above.
- **Installed at:** `toolchains/opt/haikuTools/` (installer component - **Installed at:** `toolchains/opt/haikuTools/` (installer component
`haikuTools`) `haikuTools`)
### The BeOS palette (in `tools/iconBake.py`) ### The BeOS palette (in `tools/iconBake/bePalette.h`)
- **Used on:** Haiku, Haiku x86, BeOS - **Used on:** Haiku, Haiku x86, BeOS
- **URL:** <https://github.com/haiku/haiku> (`headers/private/interface/Palette.h`) - **URL:** <https://github.com/haiku/haiku> (`headers/private/interface/Palette.h`)
- **License:** MIT (Haiku). - **License:** MIT (Haiku).
- **Role:** the system's 256 colours, copied into `tools/iconBake.py` as - **Role:** the system's 256 colours, copied into `tools/iconBake/bePalette.h`
`BE_PALETTE`, which the Be API programs' icons are baked to (an icon is 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). 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:** <https://github.com/php/php-src> (`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 ### 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:** <https://github.com/tildearrow/furnace> - **URL:** <https://github.com/tildearrow/furnace>
- **License:** GPLv2 or later. Its demo songs are not under the GPL: they are - **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 their authors', all rights reserved, and nothing made from them is kept or
shipped (`scripts/check-vgm.sh` converts them in a scratch folder). shipped (`scripts/check-vgm.sh` converts them in a scratch folder).
- **Role:** the recommended tracker for writing chip-tracker songs - **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`, - **Installed at:** `toolchains/opt/furnace/` (installer component `furnace`,
optional: `./install.sh install 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) ## Distribution packages (build and test hosts only)
Installed from the Linux distribution by the components named below 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 - **host:** GNU make, the host C compiler (gcc), gawk, patch, tar, and
v3+); git and socat (GNU GPL v2); git-lfs (MIT); curl (the curl diffutils (GNU GPL v3+); git and socat (GNU GPL v2); potrace (GNU GPL
license); xz (0BSD, parts GPL); bzip2 (bzip2's BSD-style license); v2+); git-lfs (MIT); curl (the curl license); xz (0BSD, parts GPL);
unzip (the Info-ZIP license); lhasa (ISC); procps (GNU GPL v2+ and LGPL); bzip2 (bzip2's BSD-style license); unzip (the Info-ZIP license); lhasa
Python 3 (PSF License) with Pillow (MIT-CMU). (ISC); procps (GNU GPL v2+ and LGPL).
- **headless:** Xvfb (X.Org's MIT/X11 license); xdotool (3-clause BSD); - **headless:** Xvfb (X.Org's MIT/X11 license); xdotool (3-clause BSD);
ImageMagick (the ImageMagick License); pactl and parec, from ImageMagick (the ImageMagick License); pactl and parec, from
pulseaudio-utils (GNU LGPL v2.1+). pulseaudio-utils (GNU LGPL v2.1+).
- **ntp:** PHP's command line (the PHP License 3.01) runs NTP's converter. - **tools/traceLogo**, which made `assets/joeylibLogo.svg` and is run
- **tools/traceLogo.py**, which made `assets/joeylibLogo.svg` and is run only if the logo art changes, runs the potrace program (Peter Selinger's,
only if the logo art changes, takes potracer (GNU GPL v2+), Pillow and GNU GPL v2+; the distribution's `potrace` package, which the `host`
numpy (BSD), installed by hand with pip. The script holds none of component installs) as a separate program. The tool holds none of
potracer's code, and the logo it made is not covered by potracer's potrace's code, and the logo it made is not covered by potrace's license.
license.
- Building MAME, QEMU, PPSSPP, and SDL 3 also takes the distribution's - Building MAME, QEMU, PPSSPP, and SDL 3 also takes the distribution's
development packages (SDL2, X11, Wayland, PulseAudio, ALSA, GLib, and the 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.
--- ---

View file

@ -22,6 +22,7 @@
# make haiku builds Haiku x86_64 (the Be API backend, gcc 13) # 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 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 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 docs renders the manual to build/docs (HTML + PDF)
# make verify builds every port, then runs every gate (needs emulators) # make verify builds every port, then runs every gate (needs emulators)
# make verify-audio runs every port's audio 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) @$(REPO_DIR)/scripts/verify-all.sh audio $(PORTS)
# Every recording port against the one audio contract (AUDPAR, graded by # 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" # Takes a subset: make verify-conform PORTS="sdl3 dos"
verify-conform: verify-conform:
@$(REPO_DIR)/scripts/verify-all.sh conform $(PORTS) @$(REPO_DIR)/scripts/verify-all.sh conform $(PORTS)

View file

@ -173,24 +173,25 @@ make -f make/x68000.mk EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE
## Conformance gate ## Conformance gate
`examples/uber` is the conformance vehicle: it exercises every public op and `examples/uber` is the conformance vehicle: it exercises every public op and
logs a surface hash per op. `tools/diff-uber-hashes <reference-log> <test-log>` logs a surface hash per op.
compares two `joeylog.txt` captures and exits non-zero on any mismatch. The `build/tools/diffUberHashes <reference-log> <test-log>` (`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 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 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 `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 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`, `tests/goldens/uber/dos.txt` (`scripts/bench-dvx.sh`, `scripts/bench-sdl3.sh`,
`scripts/bench-web.sh`, or `scripts/bench-psp.sh`, then `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 (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 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 seconds, with every memory access checked as a PSP checks it, so an unaligned
16- or 32-bit access anywhere fails it: 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-sdl3.sh && build/tools/diffUberHashes 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-web.sh && build/tools/diffUberHashes 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-psp.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
``` ```
The X68000 has gates of its own: 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 src/x86/ x86 code DOS and DVX share (the sprite emitter, the
whole-map tile walker, the set-1 scan code table) whole-map tile walker, the set-1 scan code table)
src/blank/ copy-to-start template for a new port src/blank/ copy-to-start template for a new port
tools/assetbake/ PNG -> native .tbk / .spr baker (Python) tools/ the host tools, all C (make tools builds them into build/tools/):
tools/joeymod/ Protracker .MOD converter (passthrough or .NTP) adfTool/ read files out of an Amiga disk image (.adf)
tools/spritebake/ offline .spr -> .spc pre-compiled sprite baker assetbake/ PNG -> native .tbk / .spr baker
tools/songbake/ chip-tracker text notation -> JYM1 stream common/ the C tools' shared code (PNG, DEFLATE, XML, JSON, base64)
tools/vgm2song.py a tracker's VGM export (Furnace) -> chip-tracker .song diffUberHashes/ UBER golden-hash comparator
tools/iconBake.py one picture -> every system's program icon diffUberPerf/ UBER ops/sec comparator
tools/xdftool.py Human68k FAT12 .XDF disk image reader / writer iconBake/ one picture -> every system's program icon
tools/diff-uber-hashes UBER golden-hash comparator 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 examples/ example programs
joey-run run a built program in its emulator joey-run run a built program in its emulator
scripts/ bench / verification scripts (scripts/lib: shared helpers) 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 tests/goldens/uber/ frozen UBER conformance hashes
install.sh installs toolchains, emulators and firmware install.sh installs toolchains, emulators and firmware
installer/ the installer's component recipes, patches, lists 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); const jlTileT *in, uint8_t fgColor, uint8_t bgColor);
// Load up to maxTiles tiles from a baked .tbk file (per-target planar // 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 // Refuses files baked for the wrong target. outValid (optional) marks
// each loaded slot; outPalette (optional) receives the embedded // each loaded slot; outPalette (optional) receives the embedded
// 16-entry $0RGB palette if present. // 16-entry $0RGB palette if present.
@ -448,7 +464,7 @@ jlSpriteT *jlSpriteCreateFromSurface (const jlSurfaceT *src, int16_t x, int16_t
void jlSpriteDestroy (jlSpriteT *sp); void jlSpriteDestroy (jlSpriteT *sp);
// Load up to maxCels cels from a baked .spr file (cross-target chunky // 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 // cel becomes a freshly-allocated jlSpriteT (release with
// jlSpriteDestroy). outPalette (optional) receives the embedded // jlSpriteDestroy). outPalette (optional) receives the embedded
// 16-entry $0RGB palette if present. // 16-entry $0RGB palette if present.
@ -494,8 +510,8 @@ uint32_t jlSpriteCodegenBytesTotal (void);
### Assets (baked at build time) ### Assets (baked at build time)
Asset PNGs live in each example's `assets/` directory and are baked Asset PNGs live in each example's `assets/` directory and are baked
to native binary blobs at build time by `tools/assetbake/assetbake.py` to native binary blobs at build time by `tools/assetbake` (C, built
(Python+PIL). The runtime loads those blobs directly with no into `build/tools/`). The runtime loads those blobs directly with no
conversion -- there is no in-memory chunky-to-planar or palette conversion -- there is no in-memory chunky-to-planar or palette
re-indexing on the device. re-indexing on the device.
@ -513,8 +529,8 @@ Two blob formats:
Both formats embed an optional 16-entry `$0RGB` palette. 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 build/tools/assetbake --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 sprite --cell WxH in.png out.spr
``` ```
A program's description wires its bake rules once for every target 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 -- 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, 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 -- 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` PNG), or JoeyLib's kangaroo (`assets/joeyIcon.png`). `docs/BuildIntegration.adoc`
(Icons) says what each system gets, and `scripts/verify-all.sh icon` checks (Icons) says what each system gets, and `scripts/verify-all.sh icon` checks
that each one shows it. 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 `docs/Assets.adoc` (Content Creation Tools) recommends a tool for each kind
of asset: GrafX2 or LibreSprite for tile and sprite sheets, Furnace for of asset: GrafX2 or LibreSprite for tile and sprite sheets, Furnace for
chip-tracker music (`./install.sh install furnace`; its VGM exports become 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. `.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 // 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 // (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. // played through the tone/noise layer at register-write cost on every port.
// No CPU mixing, so it is viable where jlAudioPlayMod is not. // No CPU mixing, so it is viable where jlAudioPlayMod is not.
bool jlMusicPlay (const uint8_t *data, uint32_t length, bool loop); 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 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 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 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 the `x68kSerialPeer` gate tool (`tests/gate/x68kSerialPeer.c`, built by
passes `flush=True`, so a killed peer loses its whole capture to stdio `make -f make/tools.mk x68kSerialPeer` into `build/tools/`), whose output is
buffering. 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`) ### Platform macros (`joey/platform.h`)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -233,8 +233,8 @@ uint16_t jlFrameHz(void);
*Parameters:* none. *Parameters:* none.
*Returns:* the nominal display frame rate in Hz -- 60 on the IIgs, or 50 on a *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 PAL machine; 50 on a PAL Amiga and 60 on an NTSC one, whose screen is in the
cannot show PAL; 50 or 60 on the ST, as the machine is set (71 with a 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 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 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 mode's, with the same frame count); and 60 on every hosted port, whose window

View file

@ -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 |You author |Tool |Baked file |Loaded by
|Indexed PNG tile sheet |Indexed PNG tile sheet
|`assetbake.py --type tile` |`assetbake --type tile`
|`.tbk` |`.tbk`
|`jlTileBankLoad`, `jlTileBankLoadToSurface` |`jlTileBankLoad`, `jlTileBankLoadToSurface`
|A Tiled map (TMX or TMJ) with indexed tilesets |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` |a `.map` per layer, and a `.tbk`
|`jlTileMapPaste` (the map read with `jlDataOpen`) |`jlTileMapPaste` (the map read with `jlDataOpen`)
|Indexed PNG sprite sheet |Indexed PNG sprite sheet
|`assetbake.py --type sprite` |`assetbake --type sprite`
|`.spr` |`.spr`
|`jlSpriteBankLoad` |`jlSpriteBankLoad`
@ -32,12 +32,12 @@ type: what you author, which tool bakes it, and which API call loads it.
|`jlSpriteBankLoadPrecompiled` |`jlSpriteBankLoadPrecompiled`
|Text note notation |Text note notation
|`songbake.py` |`songbake`
|`.jym` or a C header |`.jym` or a C header
|`jlMusicPlay` |`jlMusicPlay`
|A tracker song (Furnace) for the SN76489 or AY-3-8910, exported as VGM |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 |`.jym` or a C header
|`jlMusicPlay` |`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 Baked assets are staged into `DATA/` beside the executable, and the program
opens them with bare names. 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 === Content Creation Tools
These are the tools we recommend for making each kind of asset. All of them 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 |Tile maps
|*Tiled* (GPLv2; what you make with it is yours), a map editor that places |*Tiled* (GPLv2; what you make with it is yours), a map editor that places
tiles from tilesets in layers. 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>>). (<<_maps_from_tiled>>).
|Program icons |Program icons
@ -131,7 +134,7 @@ row by row.
[source,console] [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 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 ==== Maps from Tiled
`tools/tiled2joey.py` turns a map made in Tiled into what a JoeyLib program `tools/tiled2joey` 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 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: port, and a map for each tile layer, which `jlTileMapPaste` takes as it is:
[source,console] [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 build/level1.png build/level1
tiled2joey: 60x25 map, 87 tiles, 14 colours, layers: ground.map, detail.map 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 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] [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 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] [source,console]
---- ----
$ python3 tools/songbake/songbake.py song.song song.jym $ build/tools/songbake song.song song.jym
$ python3 tools/songbake/songbake.py -c gDemoSong song.song song.h $ 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 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 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 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: 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 . 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] [source,console]
---- ----
$ python3 tools/vgm2song.py song.vgm song.song $ build/tools/vgm2song song.vgm song.song
$ python3 tools/songbake/songbake.py -c gSong song.song song.h $ build/tools/songbake -c gSong song.song song.h
---- ----
What comes across: 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 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 an `.amod` plays the same anywhere (looping, with the marker's pattern of
silence at its end). silence at its end).
* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, produced by the * *`.ntp`* -- NinjaTrackerPlus format for the IIgs, made by JoeyLib's
NinjaTrackerPlus converter the installer's `ntp` component provides (part of `modToNtp` (`build/tools/modToNtp IN.MOD OUT.NTP`, which `joeymod` builds
`toolchain:iigs`; it needs the PHP command-line interpreter, which the and runs). Its files are byte for byte those of Ninjaforce's own converter.
installer's host packages include).
Keep modules modest. A four-channel mix is real CPU work on DOS, the ST, and 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. 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] [source,makefile]
---- ----
$(call programDataDir,MYGAME)/font.tbk: assets/font.png $(call programDataDir,MYGAME)/font.tbk: assets/font.png $(ASSET_BAKE)
@mkdir -p $(dir $@) @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 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 -- `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 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 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 cels; the STe build takes the ST's), `SPRITEBAKE` the matching host
`spritebake`. `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` `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`), 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 Logo
The JoeyLib logo lives in `assets/` as both the original 320x200 PNG and a 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] [source,console]
---- ----
$ python3 -m venv /tmp/tracevenv $ make -f make/tools.mk traceLogo
$ /tmp/tracevenv/bin/pip install potracer pillow numpy $ build/tools/traceLogo assets/joeylib_logo_-_320x200.png \
$ /tmp/tracevenv/bin/python tools/traceLogo.py \ assets/joeylibLogo.svg
assets/joeylib_logo_-_320x200.png assets/joeylibLogo.svg --colors 5
---- ----
Five colour clusters measured as the closest match to the original artwork; The art is four flat colours (ink, skin, the green shorts, and the beige
more clusters start carving the antialiased edges into separate layers, which monitor), so the default of four clusters is the trace that looks best: each
renders as noise rather than detail. 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.

View file

@ -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 |`SPRITEBAKE` |The host `spritebake` for this target (its rule is
JoeyLib's). JoeyLib's).
|`PROJECT_DIR`, `PROJECT_BUILD` |The project and its `build/`. |`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. |`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 $@) @mkdir -p $(dir $@)
cp $< $@ cp $< $@
$(MYGAME_RUN)/tiles.tbk: assets/tiles.png $(MYGAME_RUN)/tiles.tbk: assets/tiles.png $(ASSET_BAKE)
@mkdir -p $(dir $@) @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 $@) @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) $(MYGAME_RUN)/player.spc: $(MYGAME_GEN)/player.spr $(SPRITEBAKE)
@mkdir -p $(dir $@) @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` 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 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 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 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 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-<port>.sh` |`scripts/bench-<port>.sh`
|Per-operation throughput for that port. |Per-operation throughput for that port.
|`tools/diff-uber-hashes` |`build/tools/diffUberHashes`
|Compares a port's rendered output against the IIgs golden reference, |Compares a port's rendered output against the IIgs golden reference,
operation by operation, by surface hash. operation by operation, by surface hash.
|=== |===

View file

@ -227,7 +227,7 @@ The API is uniform; these are the places where what you get differs.
|Frame rate |Frame rate
|60 Hz (50 on a PAL machine) |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 |50 or 60 Hz, as the machine is set
|70 Hz |70 Hz
|55 Hz (56 under MAME) |55 Hz (56 under MAME)

View file

@ -140,8 +140,8 @@ too.
==== Frame-Rate Independence ==== Frame-Rate Independence
The targets do not share a refresh rate: 50 Hz (the Amiga, and a PAL IIgs or The targets do not share a refresh rate: 50 Hz (a PAL Amiga, IIgs, or ST),
ST), 55 Hz (X68000), 60 Hz (the IIgs, an NTSC ST, and every hosted port's 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 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 that moves a sprite "two pixels per frame" runs 40 percent faster on DOS than
on a PAL Amiga. on a PAL Amiga.

View file

@ -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: // Do not hand-edit; edit the .song source and rebake:
// python3 tools/songbake/songbake.py -c gDemoSong examples/audio/demoSong.song <this file> // build/tools/songbake -c gDemoSong examples/audio/demoSong.song <this file>
#ifndef JOEYLIB_BAKED_GDEMOSONG_H #ifndef JOEYLIB_BAKED_GDEMOSONG_H
#define JOEYLIB_BAKED_GDEMOSONG_H #define JOEYLIB_BAKED_GDEMOSONG_H

View file

@ -5,7 +5,7 @@
; around the drum rows because the ST shares channel C's volume ; around the drum rows because the ST shares channel C's volume
; between tone voice 2 and noise. ; 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 ; examples/audio/demoSong.song examples/audio/demoSong.h
tickms 110 tickms 110

View file

@ -22,7 +22,7 @@
// select-then-write register sequence in the path of both channels' // select-then-write register sequence in the path of both channels'
// interrupts for the whole run. // interrupts for the whole run.
// SPACE pauses and resumes transmitting. The peer answers byte for byte // 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. // ESC quits.
// //
// Mouse: a small pointer follows it. // 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 // 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 // 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 // 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) { static uint8_t streamByte(uint32_t index, uint8_t mask) {
return (uint8_t)(index ^ (index >> BYTE_BITS) ^ mask); return (uint8_t)(index ^ (index >> BYTE_BITS) ^ mask);
} }

View file

@ -9,8 +9,8 @@
// that assetbake's encoder for THIS target writes exactly the layout this // 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. // 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 // DATA/tilebank/ is staged by tests/host/tileBankStage.sh (the make rule in
// make/<port>.mk), from tests/host/tileBank.png: // make/examples.mk), from tests/host/tileBank.png:
// own.tbk this port's bake: the bank that must load // own.tbk this port's bake: the bank that must load
// refuse<n>.tbk n = 1, 2, ...: every other target's bake // refuse<n>.tbk n = 1, 2, ...: every other target's bake
// expect.bin the PNG decoded by PIL, independent of assetbake's // expect.bin the PNG decoded by PIL, independent of assetbake's
@ -49,7 +49,7 @@
// ----- Constants ----- // ----- 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 OWN_BANK "tilebank/own.tbk"
#define EXPECT_NAME "tilebank/expect.bin" #define EXPECT_NAME "tilebank/expect.bin"
#define REFUSE_BANK_FORMAT "tilebank/refuse%u.tbk" #define REFUSE_BANK_FORMAT "tilebank/refuse%u.tbk"

View file

@ -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 // regardless of CPU speed or display refresh rate. Also logs an
// FNV-1a hash of the surface state after timing -- this is the // FNV-1a hash of the surface state after timing -- this is the
// pixel-perfect comparison input for the cross-port validation // 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. // golden reference; planar 68k rewrites validate by matching it.
// The window is `frames` long. settle (NULL for none) runs between the // 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 // 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) ----- // ----- Non-timed correctness checks (Phase 0 verification harness) -----
// //
// Each check draws a deterministic scene and logs the surface hash so // 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 // the frozen golden logs. Several checks intentionally capture the CURRENT
// behavior of known bugs (claudeDocs/PERF-AUDIT.md #1, #11, #12, #72): their hash // 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 // lines are EXPECTED to change when the Phase 1 fixes land -- re-golden

View file

@ -64,7 +64,7 @@ static bool launch(const char *folder, const char *name, ULONG stack);
static void logLine(const char *format, ...); static void logLine(const char *format, ...);
// Workbench's four colours (2.0 on), 4 bits a gun: grey, black, white and // 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 const UWORD gWorkbenchColours[] = { 0xAAA, 0x000, 0xFFF, 0x68B };
static FILE *gLog = NULL; static FILE *gLog = NULL;

View file

@ -61,7 +61,7 @@ typedef struct {
} jlSpriteBackupT; } jlSpriteBackupT;
// Load up to maxCels cels from a baked .spr file produced by // 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 // jlSpriteT (release with jlSpriteDestroy); the file's cellCount is
// capped at maxCels. // capped at maxCels.
// //

View file

@ -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); 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 // 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. // against the build platform and the loader refuses mismatches.
// //
// outValid (optional, NULL OK) gets one flag per slot in [0, maxTiles): // outValid (optional, NULL OK) gets one flag per slot in [0, maxTiles):

View file

@ -13,7 +13,7 @@
compDescription="toolchain: BeOS R5 headers + libraries from your BeOS 5 Pro CD (beos; private media: import)" compDescription="toolchain: BeOS R5 headers + libraries from your BeOS 5 Pro CD (beos; private media: import)"
compGroups="toolchain:beos" compGroups="toolchain:beos"
compRequires="haikuGcc2 haikuTools" compRequires="haikuGcc2 haikuTools"
compHostDeps="python3" compHostDeps="build-tools make"
# shellcheck source=installer/lib/beos.sh # shellcheck source=installer/lib/beos.sh
source "$JOEY_INSTALLER/lib/beos.sh" source "$JOEY_INSTALLER/lib/beos.sh"
# What the sysroot is made of, as "path on the CD -> folder in sysroot". # What the sysroot is made of, as "path on the CD -> folder in sysroot".

View file

@ -12,7 +12,7 @@
compDescription="emulator: BeOS R5 VM from your BeOS 5 Pro CD (beos; private media: import)" compDescription="emulator: BeOS R5 VM from your BeOS 5 Pro CD (beos; private media: import)"
compGroups="emulator:beos" compGroups="emulator:beos"
compRequires="qemu haikuTools" compRequires="qemu haikuTools"
compHostDeps="python3" compHostDeps="build-tools make"
# shellcheck source=installer/lib/beos.sh # shellcheck source=installer/lib/beos.sh
source "$JOEY_INSTALLER/lib/beos.sh" source "$JOEY_INSTALLER/lib/beos.sh"

View file

@ -7,7 +7,7 @@
# later), the recommended tool for JoeyLib's chip-tracker music # later), the recommended tool for JoeyLib's chip-tracker music
# (docs/Assets.adoc, "Content Creation Tools"): write the song for the TI # (docs/Assets.adoc, "Content Creation Tools"): write the song for the TI
# SN76489 (or the AY-3-8910 / YM2149), export it as VGM, and # 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 # x86_64 release, unpacked into toolchains/opt/furnace. Nothing builds with
# it, so no group installs it: ./install.sh install furnace. # it, so no group installs it: ./install.sh install furnace.
# scripts/check-vgm.sh exports the SN76489 and AY demo songs it comes with # scripts/check-vgm.sh exports the SN76489 and AY demo songs it comes with

View file

@ -13,7 +13,7 @@
compDescription="emulator: Haiku R1/beta6 x86_64 VM image (haiku)" compDescription="emulator: Haiku R1/beta6 x86_64 VM image (haiku)"
compGroups="emulator:haiku" compGroups="emulator:haiku"
compRequires="qemu haikuTools" compRequires="qemu haikuTools"
compHostDeps="python3" compHostDeps=""
haikuArch="x86_64" haikuArch="x86_64"
# shellcheck source=installer/lib/haiku.sh # shellcheck source=installer/lib/haiku.sh

View file

@ -13,7 +13,7 @@
compDescription="emulator: Haiku R1/beta6 x86 gcc2h VM image (haiku32)" compDescription="emulator: Haiku R1/beta6 x86 gcc2h VM image (haiku32)"
compGroups="emulator:haiku32" compGroups="emulator:haiku32"
compRequires="qemu haikuTools" compRequires="qemu haikuTools"
compHostDeps="python3" compHostDeps=""
haikuArch="x86_gcc2" haikuArch="x86_gcc2"
# shellcheck source=installer/lib/haiku.sh # shellcheck source=installer/lib/haiku.sh

View file

@ -5,14 +5,17 @@
# #
# Host tools every JoeyLib setup needs: a native compiler for the in-repo # 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 # tools, GNU make, git with LFS (the sample assets live in LFS), the
# archive tools the other components unpack with, Python 3 with Pillow # archive tools the other components unpack with, socat for joey-run's
# for the asset converters, and socat for joey-run's serial bridging. # 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. # Not in any port group: every other component requires it.
compGroups="host" compGroups="host"
compRequires="" 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" compPin="1"
@ -48,7 +51,7 @@ compInstall() {
compInstalled() { 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]}" [[ "$makeVersion" =~ ([0-9]+\.[0-9]+) ]] && makeVersion="${BASH_REMATCH[1]}"
# Grouped targets (&:) in the port makefiles need GNU make 4.3. # 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; } 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="$(lfsPointerFiles | wc -l)"
[ "$stubs" = "0" ] || { warn "$stubs Git LFS files are still pointer stubs"; return 1; } [ "$stubs" = "0" ] || { warn "$stubs Git LFS files are still pointer stubs"; return 1; }
} }

View file

@ -4,14 +4,15 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# #
# NinjaTrackerPlus: Ninjaforce's IIgs MOD replayer, shipped as Merlin # 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 # Redistribution is granted by the copyright holder. The URL is not
# versioned, so the pin is the archive's checksum. # 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" compGroups="toolchain:iigs"
compRequires="host" compRequires="host"
compHostDeps="unzip php-cli" compHostDeps="unzip"
compUrl="https://www.ninjaforce.com/downloads/ntpsources.zip" compUrl="https://www.ninjaforce.com/downloads/ntpsources.zip"
compSha256="461260e35dc0b10176c05865d01c4e31694f704c0c3a9e5760fc093623dc52fb" compSha256="461260e35dc0b10176c05865d01c4e31694f704c0c3a9e5760fc093623dc52fb"
compPin="$compSha256" compPin="$compSha256"
@ -32,7 +33,7 @@ compInstall() {
compInstalled() { compInstalled() {
[ -f "$compDir/ninjatrackerplus.s" ] && [ -f "$compDir/ntpconverter.php" ] [ -f "$compDir/ninjatrackerplus.s" ]
} }

View file

@ -14,7 +14,7 @@
compDescription="emulator: Windows for Workgroups 3.11 on DOSBox (win16; private media: import)" compDescription="emulator: Windows for Workgroups 3.11 on DOSBox (win16; private media: import)"
compGroups="emulator:win16" compGroups="emulator:win16"
compRequires="dosbox" compRequires="dosbox"
compHostDeps="python3 xvfb" compHostDeps="build-tools make xvfb"
# shellcheck source=installer/lib/win16.sh # shellcheck source=installer/lib/win16.sh
source "$JOEY_INSTALLER/lib/win16.sh" source "$JOEY_INSTALLER/lib/win16.sh"

View file

@ -19,7 +19,7 @@
compDescription="win16: Microsoft WinG 1.0 runtime for its Windows (optional; private: import WING10.EXE)" compDescription="win16: Microsoft WinG 1.0 runtime for its Windows (optional; private: import WING10.EXE)"
compGroups="optional" compGroups="optional"
compRequires="win311" compRequires="win311"
compHostDeps="python3 unzip" compHostDeps="build-tools make unzip"
compPrivateFile="toolchains/firmware/win16/WING10.EXE" compPrivateFile="toolchains/firmware/win16/WING10.EXE"
compPin="$(privateSha1For "$compPrivateFile")" compPin="$(privateSha1For "$compPrivateFile")"
@ -36,7 +36,7 @@ compInstall() {
mkdir -p "$compDir" mkdir -p "$compDir"
# The SDK's compressed names end in _ where the last letter was. # The SDK's compressed names end in _ where the last letter was.
for file in $wingRuntime; do 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 done
cp "$tmp/LICENSE.TXT" "$compDir/LICENSE.TXT" cp "$tmp/LICENSE.TXT" "$compDir/LICENSE.TXT"
rm -rf "$tmp" rm -rf "$tmp"

View file

@ -19,9 +19,10 @@ unzip unzip unzip
wget wget wget wget wget wget
lhasa lhasa lhasa lhasa lhasa lhasa
python3 python3 python3 python3 python3 python3
python3-pil python3-pil python3-pillow
php-cli php-cli php-cli
patch patch patch 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. # joey-run --serial bridges some emulators' serial ports to TCP with socat.
socat socat socat socat socat socat

View file

@ -46,7 +46,6 @@ BEOS_CD_FILE="toolchains/firmware/beos/beos5pro.img"
# (the image is pinned by its checksum, so they cannot move). # (the image is pinned by its checksum, so they cannot move).
BEOS_SYSTEM_TRACK_FIRST=35109 BEOS_SYSTEM_TRACK_FIRST=35109
BEOS_SYSTEM_TRACK_SECTORS=148780 BEOS_SYSTEM_TRACK_SECTORS=148780
BEOS_RAW_CD_TOOL="tools/rawCd.py"
# The VM's disks, in the component's folder. # The VM's disks, in the component's folder.
BEOS_VM_SYSTEM_DISK="system.img" BEOS_VM_SYSTEM_DISK="system.img"
BEOS_VM_FLOPPY="floppy.img" BEOS_VM_FLOPPY="floppy.img"
@ -92,7 +91,7 @@ EOF
beosSystemVolume() { beosSystemVolume() {
privateEnsure "$BEOS_CD_FILE" 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" die "could not read the x86 track of $BEOS_CD_FILE"
} }
@ -111,7 +110,7 @@ beosVmPrepare() {
rm -rf "$part" rm -rf "$part"
mkdir -p "$part" mkdir -p "$part"
beosSystemVolume "$part/$BEOS_VM_SYSTEM_DISK" 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" die "could not read the boot floppy image of $BEOS_CD_FILE"
beosJobRunner > "$part/UserBootscript" beosJobRunner > "$part/UserBootscript"
printf '%s\n' "$BEOS_KERNEL_SETTINGS" > "$part/kernel" printf '%s\n' "$BEOS_KERNEL_SETTINGS" > "$part/kernel"

View file

@ -87,7 +87,7 @@ importPaths() {
*.adf|*.ADF) *.adf|*.ADF)
rm -rf "$tmp/adf" rm -rf "$tmp/adf"
mkdir -p "$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 find "$tmp/adf" -type f -print0 | while IFS= read -r -d '' inner; do
importOneFile "$inner" importOneFile "$inner"
done done

View file

@ -136,8 +136,8 @@ The upstream file changed. Check it, then update the pin in the component file."
fetchOciBlob() { fetchOciBlob() {
local token="" local token=""
if [ "$(sha256Of "$JOEY_CACHE/$3" 2>/dev/null || true)" != "$2" ]; then 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"])')" \ token="$(curl -fsSL "https://ghcr.io/token?scope=repository:$1:pull" | sed -n 's/.*"token" *: *"\([^"]*\)".*/\1/p')" \
|| die "cannot get a registry token for $1" && [ -n "$token" ] || die "cannot get a registry token for $1"
fi fi
fetchFile "https://ghcr.io/v2/$1/blobs/sha256:$2" "$2" "$3" "Authorization: Bearer $token" 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 ID -- delete ID's scratch tree unless --keep-build.
dropBuildDir() { dropBuildDir() {
if [ "$JOEY_KEEP_BUILD" = "1" ]; then if [ "$JOEY_KEEP_BUILD" = "1" ]; then

View file

@ -75,20 +75,17 @@ haikuBfsNew() {
# haikuBootOffset IMAGE -- the byte offset of an anyboot image's first # 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() { haikuBootOffset() {
python3 - "$1" <<'PY' local mbrEntry1=446
import struct local entryStartLba=8
import sys local sectorBytes=512
local b
MBR_ENTRY_1 = 446 read -ra b <<< "$(od -An -tu1 -v -j $((mbrEntry1 + entryStartLba)) -N 4 "$1")"
ENTRY_START_LBA = 8 [ "${#b[@]}" -eq 4 ] || return 1
SECTOR_BYTES = 512 echo $(((b[0] | b[1] << 8 | b[2] << 16 | b[3] << 24) * sectorBytes))
with open(sys.argv[1], 'rb') as image:
mbr = image.read(SECTOR_BYTES)
print(struct.unpack_from('<I', mbr, MBR_ENTRY_1 + ENTRY_START_LBA)[0] * SECTOR_BYTES)
PY
} }

View file

@ -30,7 +30,8 @@ WIN16_FIRMWARE="toolchains/firmware/win16"
WIN16_ISO="$WIN16_FIRMWARE/wfw311.iso" WIN16_ISO="$WIN16_FIRMWARE/wfw311.iso"
WIN16_S3_FILES="S3TRIO.DRV VDDS3764.386 VGA_ENG.3GR" WIN16_S3_FILES="S3TRIO.DRV VDDS3764.386 VGA_ENG.3GR"
WIN16_DOSBOX_CONF="scripts/dosbox-win16.conf" WIN16_DOSBOX_CONF="scripts/dosbox-win16.conf"
WIN16_INI_TOOL="tools/winIni.py" # The INI editor (tools/winIni): the image is remade when its source changes.
WIN16_INI_SOURCE="tools/winIni/winIni.c"
# Setup takes about 20 s at full speed. # Setup takes about 20 s at full speed.
WIN16_SETUP_SECONDS=300 WIN16_SETUP_SECONDS=300
@ -43,7 +44,7 @@ win16Configure() {
for name in $WIN16_S3_FILES; do for name in $WIN16_S3_FILES; do
cp "$JOEY_REPO/$WIN16_FIRMWARE/s3/$name" "$windows/SYSTEM/$name" cp "$JOEY_REPO/$WIN16_FIRMWARE/s3/$name" "$windows/SYSTEM/$name"
done done
python3 "$JOEY_REPO/$WIN16_INI_TOOL" "$windows/SYSTEM.INI" \ "$(hostTool winIni)" "$windows/SYSTEM.INI" \
set boot display.drv s3trio.drv \ set boot display.drv s3trio.drv \
set boot 386grabber vga_eng.3gr \ set boot 386grabber vga_eng.3gr \
set boot.description display.drv "S3 Trio64V 1.70.04 800x600 256 C SF" \ set boot.description display.drv "S3 Trio64V 1.70.04 800x600 256 C SF" \
@ -84,7 +85,7 @@ win16Pin() {
privateSha1For "$file" privateSha1For "$file"
done done
declare -f win16Configure win16Prepare win16Setup win16SetupAnswers declare -f win16Configure win16Prepare win16Setup win16SetupAnswers
cat "$JOEY_REPO/$WIN16_DOSBOX_CONF" "$JOEY_REPO/$WIN16_INI_TOOL" cat "$JOEY_REPO/$WIN16_DOSBOX_CONF" "$JOEY_REPO/$WIN16_INI_SOURCE"
} | sha256sum | cut -d' ' -f1 } | sha256sum | cut -d' ' -f1
} }

View file

@ -415,21 +415,12 @@ runMame() {
# ---- the serial link ---------------------------------------------------------- # ---- the serial link ----------------------------------------------------------
# portFree PORT -- nothing on this machine listens on TCP PORT yet. Checked # portFree PORT -- nothing on this machine listens on TCP PORT yet: a
# up front because an emulator that cannot take the port only says so in a # connection to it here is refused. Checked up front because an emulator
# log nobody reads, and runs with the serial port unplugged. # that cannot take the port only says so in a log nobody reads, and runs
# with the serial port unplugged.
portFree() { portFree() {
python3 - "$1" <<'PY' ! (exec 3<> "/dev/tcp/127.0.0.1/$1") 2> /dev/null
import socket
import sys
probe = socket.socket()
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("", int(sys.argv[1])))
except OSError:
sys.exit(1)
PY
} }
@ -486,7 +477,7 @@ mameSerial() {
local bitb="socket.$serialHost:$serialPort" local bitb="socket.$serialHost:$serialPort"
[ -n "$serialMode" ] || return 0 [ -n "$serialMode" ] || return 0
mkdir -p "$work/cfg" 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" > "$work/cfg/$driver.cfg" || fail "--serial-baud $serialBaud is not a rate MAME's null modem has"
if [ "$serialMode" = "listen" ]; then if [ "$serialMode" = "listen" ]; then
relaySerial "unix-listen:serial.sock" "$work/serial.sock" relaySerial "unix-listen:serial.sock" "$work/serial.sock"

View file

@ -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) $(AMIGA_CC) $(CFLAGS) $(call programVar,$(1),CFLAGS) $(call programVar,$(1),SRCS) $(LIB) -o $$@ $(LDFLAGS) $(call programVar,$(1),LDFLAGS)
-include $(call programBin,$(1)).d -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 $$@) @mkdir -p $$(dir $$@)
$(ICON_BAKE) info $$< $$@ $(AMIGA_ICON_STACK) $(ICON_BAKE) info $$< $$@ $(AMIGA_ICON_STACK)
endef endef

View file

@ -26,7 +26,7 @@ HOSTED_SDK_CFLAGS :=
HOSTED_LINK := -lbe -lmedia -ldevice $(BE_CXX_RUNTIME) HOSTED_LINK := -lbe -lmedia -ldevice $(BE_CXX_RUNTIME)
PROGRAM_EXT := PROGRAM_EXT :=
# Its icons, the BeOS kind Haiku shows too: 32x32 and 16x16 in the BeOS # 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 # 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 # 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 # icon, BEOS:ICON, which Haiku draws at any size (BeOS R5 ignores it). An

View file

@ -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 # $(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 # 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). # 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) programIcon = $(or $(call programVar,$(1),ICON),$(REPO_DIR)/assets/joeyIcon.png)
programIconDir = $(PROGRAM_BUILD)/icon/$(if $(call programVar,$(1),BINDIR),$(1),$(call programName,$(1))) 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 # Rules that hand a host tool's build to make/tools.mk depend on FORCE, so

View file

@ -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 # 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. # 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 $$@) @mkdir -p $$(dir $$@)
$(ICON_BAKE) bmp $$< $$@ $(ICON_BAKE) bmp $$< $$@
endef endef

View file

@ -8,7 +8,7 @@
# #
# Worth knowing which ones the gates use: # Worth knowing which ones the gates use:
# UBER the golden-hash vehicle: every public op, a hash per op, which # 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 # X68000 against the IIgs's, DVX against the DOS port's: the same
# stage). UBERPROB is UBER with its UBER_PROBE rows. # stage). UBERPROB is UBER with its UBER_PROBE rows.
# PATTERN the SCB acceptance vehicle: 8 bands of 25 lines with 8 gradient # 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_DATA := $(call programDataDir,AUDPAR)/test.mod
AUDPAR_IIGS_DATA := $(call programDataDir,AUDPAR)/TEST.NTP 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 $@) @mkdir -p $(dir $@)
$(REPO_DIR)/tools/joeymod/joeymod $< $@ $(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 # DATA/tilebank holds this port's bake of it, every other target's bake (each
# must be refused) and the expected pixels. # must be refused) and the expected pixels.
TILEBANK_PNG := $(REPO_DIR)/tests/host/tileBank.png TILEBANK_PNG := $(REPO_DIR)/tests/host/tileBank.png
TILEBANK_STAGE := $(REPO_DIR)/tests/host/tileBankStage.py TILEBANK_STAGE := $(REPO_DIR)/tests/host/tileBankStage.sh
TILEBANK_BAKE_DEPS := $(TILEBANK_PNG) $(TILEBANK_STAGE) $(REPO_DIR)/tools/assetbake/assetbake.py 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_DATA := $(call programDataDir,TILEBANK)/tilebank/own.tbk
$(TILEBANK_EXPECT): FORCE
$(MAKE) -f $(REPO_DIR)/make/tools.mk $(notdir $@)
$(TILEBANK_DATA): $(TILEBANK_BAKE_DEPS) $(TILEBANK_DATA): $(TILEBANK_BAKE_DEPS)
python3 $(TILEBANK_STAGE) $(ASSET_PLATFORM) $(TILEBANK_PNG) $(dir $@) $(TILEBANK_STAGE) $(ASSET_PLATFORM) $(TILEBANK_PNG) $(dir $@)

View file

@ -30,7 +30,7 @@
# HOSTED_LINK_OUT, the file one program is linked to, and HOSTED_PACK, the # 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). # 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 # 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 # 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 # 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 # 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 endef
# $(call hostedIconScript,ID,FORMAT): a Windows port's bake: the program's # $(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 # 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). # (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 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. # One program's icon, $(1) its id, baked by the port's command.
define hostedIconRules define hostedIconRules
$(call HOSTED_ICON,$(1)): $(call programIcon,$(1)) $(ICON_BAKE_DEPS) $(call HOSTED_ICON,$(1)): $(call programIcon,$(1)) $(ICON_BAKE)
@mkdir -p $$(dir $$@) @mkdir -p $$(dir $$@)
$(call HOSTED_ICON_BAKE,$(1)) $(call HOSTED_ICON_BAKE,$(1))
endef endef

View file

@ -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 # 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 # 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 # 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_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 NTP_BIN := $(BUILD)/audio/ntpplayer.bin
IIGS_MERLIN := $(IIGS_AS) IIGS_MERLIN := $(IIGS_AS)
$(NTP_BIN): $(NTP_SRC) $(NTP_PATCH) $(IIGS_MERLIN) $(NTP_BIN): $(NTP_SRC) $(NTP_PATCH) $(IIGS_MERLIN)
@mkdir -p $(BUILD)/audio @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 cd $(BUILD)/audio && $(IIGS_MERLIN) . ninjatrackerplus.s
mv $(BUILD)/audio/ntpplayer $@ mv $(BUILD)/audio/ntpplayer $@
@ -117,7 +118,7 @@ programIconFile = $(call programIconDir,$(1))/finder.icons
programTarget = $(call programBin,$(1)) $(call programIconFile,$(1)) programTarget = $(call programBin,$(1)) $(call programIconFile,$(1))
# One program, $(1) its id: clang-build.sh compiles, links and wraps it, and # 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 # its Finder icon. ID_IIGS_OVERFLOW_CAP
# opts into large overflow segments (see clang-build.sh). NTP rides on every # opts into large overflow segments (see clang-build.sh). NTP rides on every
# disk a program goes on, so it is built first. # 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) @mkdir -p $$(dir $$@) $(DEP_DIR)
$(IIGS_CLANG_BUILD) -M $(DEP_DIR)/$(1).d $(call programVar,$(1),CFLAGS) $(INCLUDES) -o $$@ $(call programVar,$(1),SRCS) $(LIB) $(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 $$@) @mkdir -p $$(dir $$@)
$(ICON_BAKE) iigs $$< $$@ $(call programName,$(1)) $(ICON_BAKE) iigs $$< $$@ $(call programName,$(1))
endef endef

View file

@ -70,9 +70,10 @@ programs: $(foreach p,$(PORT_PROGRAMS),$(call programTarget,$(p)) $(call program
.PHONY: $(addprefix program-,$(PORT_PROGRAMS) $(PORT_TEST_PROGRAMS)) .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))) $(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 # The host bakers a description's bakes use (the sprite pre-compiler, the
# its sources. # asset and icon bakers, the .NTP converter); make/tools.mk owns their
$(SPRITEBAKE): FORCE # sources.
$(SPRITEBAKE) $(ASSET_BAKE) $(ICON_BAKE) $(MOD_TO_NTP): FORCE
$(MAKE) -f $(REPO_DIR)/make/tools.mk $(notdir $@) $(MAKE) -f $(REPO_DIR)/make/tools.mk $(notdir $@)
# A project's goal for one port: its programs, their DATA and the packages # A project's goal for one port: its programs, their DATA and the packages

View file

@ -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

View file

@ -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()

View file

@ -6,20 +6,60 @@
# #
# These build with the host's default `cc` (no cross-toolchain) into # These build with the host's default `cc` (no cross-toolchain) into
# build/tools/ (JOEY_BUILD's, make/common.mk). Currently: # 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 # modloopend -- inject an E8F stop-marker pattern into a .MOD ahead
# of time (the Amiga audio HAL marks a song played with # of time (the Amiga audio HAL marks a song played with
# loop false itself). joeymod invokes it for .amod output. # 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). # spritebake -- the sprite pre-compiler, one per target (below).
# # szddExpand -- expand a COMPRESS.EXE (SZDD) file: the installer's WinG.
# Asset baking (PNG -> .tbk / .spr) is handled by tools/assetbake/ # tiled2joey -- a Tiled map (TMX or TMJ) -> a tile sheet and maps.
# assetbake.py (Python). It is invoked directly from each platform's # tileBankExpect -- the tile-bank gates' expected pixels
# Makefile and does not need a C build step. # (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 include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk
BUILD_DIR := $(JOEY_BUILD)/tools BUILD_DIR := $(JOEY_BUILD)/tools
TOOLS_DIR := $(REPO_DIR)/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/<name>/<name>.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/<name>.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_SRC := $(TOOLS_DIR)/modloopend/modloopend.c $(SRC_CORE)/modStopMarker.c
MODLOOPEND_BIN := $(BUILD_DIR)/modloopend 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). # 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) 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 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) 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-dos: $(BUILD_DIR)/spritebake-dos
spritebake-amiga: $(BUILD_DIR)/spritebake-amiga spritebake-amiga: $(BUILD_DIR)/spritebake-amiga
spritebake-atarist: $(BUILD_DIR)/spritebake-atarist spritebake-atarist: $(BUILD_DIR)/spritebake-atarist
spritebake-iigs: $(BUILD_DIR)/spritebake-iigs spritebake-iigs: $(BUILD_DIR)/spritebake-iigs
spritebake-x68000: $(BUILD_DIR)/spritebake-x68000 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 $(MODLOOPEND_BIN): $(MODLOOPEND_SRC) $(SRC_CORE)/modStopMarker.h
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(HOST_CC) $(HOST_CFLAGS) -I$(SRC_CORE) $(MODLOOPEND_SRC) -o $@ $(HOST_CC) $(HOST_CFLAGS) -I$(SRC_CORE) $(MODLOOPEND_SRC) -o $@

View file

@ -10,7 +10,7 @@
# the real Kickstart on a private Xvfb display, polls the staged # the real Kickstart on a private Xvfb display, polls the staged
# joeylog.txt until UBER prints its "press any key to exit" completion # joeylog.txt until UBER prints its "press any key to exit" completion
# marker (or a wall-clock timeout), then kills the emulator and copies # 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 # scripts/bench-amiga.sh # default 150s timeout
# TIMEOUT=240 scripts/bench-amiga.sh # override # TIMEOUT=240 scripts/bench-amiga.sh # override

View file

@ -19,7 +19,7 @@
# UBER writes "UBER: <op>: N iters / M frames = K ops/sec" lines to # UBER writes "UBER: <op>: N iters / M frames = K ops/sec" lines to
# joeylog.txt in its CWD == the GEMDOS drive C: == the bin dir, so the # joeylog.txt in its CWD == the GEMDOS drive C: == the bin dir, so the
# log lands directly in build/<build>/bin/joeylog.txt (for the ST build # log lands directly in build/<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 # joeylog-MACHINE.txt beside it, so runs on several machines can be
# compared afterwards. # compared afterwards.
# #

View file

@ -10,9 +10,9 @@
# #
# The stage is the generic chunky one, so the hashes must equal the DOS # The stage is the generic chunky one, so the hashes must equal the DOS
# port's: # port's:
# scripts/bench-be.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku/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 && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku32/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 && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/beos/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. # 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) # scripts/bench-be.sh [haiku|haiku32|beos] # default haiku (x86_64)

View file

@ -8,7 +8,7 @@
# Mounts build/dos/bin as C: in a headless DOSBox (Xvfb), runs UBER.EXE # 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 # (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 # 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 # Uses the same dosbox-386sx16.conf as joey-run so the cycle budget
# matches the claudeDocs/PERF.md reference machine. # matches the claudeDocs/PERF.md reference machine.
@ -74,7 +74,7 @@ if [[ ! -f $log ]]; then
exit 1 exit 1
fi 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) ops=$(grep -c "ops/sec" "$log" || true)
done_marker=$(grep -c "press any key to exit" "$log" || true) done_marker=$(grep -c "press any key to exit" "$log" || true)
if (( done_marker == 0 )); then if (( done_marker == 0 )); then

View file

@ -9,11 +9,11 @@
# it in DOSBox Staging under Xvfb, and waits for UBER's "press any key to # 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 # exit" line in its joeylog.txt -- which a DVX program writes to its settings
# folder, CONFIG\APPS\<VENDOR>\UBER on the staged drive. The log is copied to # folder, CONFIG\APPS\<VENDOR>\UBER on the staged drive. The log is copied to
# build/dvx/bin/joeylog.txt (tools/diff-uber-hashes and tools/uber-perf-table # build/dvx/bin/joeylog.txt (build/tools/diffUberHashes and
# read it from there) and DOSBox is stopped. # 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: # 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 # 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. # reference machine's: compare them only run against run.
# #

View file

@ -13,7 +13,7 @@
# polls the Super Hi-Res framebuffer for UBER's solid-green "done" screen # polls the Super Hi-Res framebuffer for UBER's solid-green "done" screen
# (jlSurfaceClear color 2 -> SHR bytes 0x22). When seen, MAME exits and # (jlSurfaceClear color 2 -> SHR bytes 0x22). When seen, MAME exits and
# cadius extracts JOEYLOG.TXT from the work image into build/iigs/bin/ # 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 # Usage: scripts/bench-iigs.sh
# MAME_ROMPATH override ROM dir (default ~/.mame/roms) # MAME_ROMPATH override ROM dir (default ~/.mame/roms)

View file

@ -11,7 +11,7 @@
# #
# The stage is the generic chunky one, so the hashes must equal the DOS # The stage is the generic chunky one, so the hashes must equal the DOS
# port's: # 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 # The ops/sec are PPSSPP's emulated clock, which runs unthrottled: compare
# them only run against run. # them only run against run.
# #

View file

@ -11,7 +11,7 @@
# #
# The stage is the generic chunky one, so the hashes must equal the DOS # The stage is the generic chunky one, so the hashes must equal the DOS
# port's: # 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 # The ops/sec are this machine's, not a reference machine's: compare them
# only run against run. # only run against run.
# #

View file

@ -11,7 +11,7 @@
# #
# The stage is the generic chunky one, so the hashes must equal the DOS # The stage is the generic chunky one, so the hashes must equal the DOS
# port's: # 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. # The ops/sec are this machine's browser's: compare them only run against run.
# #
# scripts/bench-web.sh # default 300 s timeout # scripts/bench-web.sh # default 300 s timeout

View file

@ -9,7 +9,7 @@
# #
# The stage is the generic chunky one, so the hashes must equal the DOS # The stage is the generic chunky one, so the hashes must equal the DOS
# port's: # 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 # The ops/sec are this machine's with DOSBox at full speed: compare them
# only run against run. # only run against run.
# #

View file

@ -10,7 +10,7 @@
# #
# The stage is the generic chunky one, so the hashes must equal the DOS # The stage is the generic chunky one, so the hashes must equal the DOS
# port's: # 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. # The ops/sec are this machine's under Wine: compare them only run against run.
# #
# scripts/bench-win32.sh # default 300 s timeout # scripts/bench-win32.sh # default 300 s timeout

View file

@ -15,7 +15,7 @@
# folder beside it. # folder beside it.
# #
# The HTML build fails if any code it shows holds a character that is not # 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+`. # and `...` into an ellipsis even in a code span, so write those as `+text+`.
# #
# Usage: scripts/build-docs.sh [html|pdf] # Usage: scripts/build-docs.sh [html|pdf]
@ -25,6 +25,8 @@
set -euo pipefail set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=scripts/lib/joeyEnv.sh
source "$repo/scripts/lib/joeyEnv.sh"
src=$repo/docs/Manual.adoc src=$repo/docs/Manual.adoc
out=$repo/build/docs out=$repo/build/docs
what=${1:-both} what=${1:-both}
@ -53,7 +55,7 @@ common=(-a "revdate=$(date +%Y-%m-%d)" -a allow-uri-read)
if [ "$what" = "html" ] || [ "$what" = "both" ]; then if [ "$what" = "html" ] || [ "$what" = "both" ]; then
asciidoctor "${common[@]}" -a data-uri \ asciidoctor "${common[@]}" -a data-uri \
"$src" -o "$out/JoeyLib-Manual.html" "$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" echo "build-docs: $out/JoeyLib-Manual.html"
fi fi

View file

@ -45,18 +45,23 @@ done
# Link-completeness: any JoeyLib symbol undefined across the whole object set # 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. # (and not defined by any of them) is a real gap in blank.c or the generics.
missing=$(python3 - "$work" <<'PY' missing=$(nm "$work"/*.o 2> /dev/null | LC_ALL=C awk '
import subprocess, sys, glob NF >= 3 && index("TDBRtdbr", $2) > 0 { defs[$3] = 1; next }
defs=set(); und=set() NF >= 3 && $2 == "U" { undefined[$3] = 1; next }
for o in glob.glob(sys.argv[1]+"/*.o"): NF == 2 && $1 == "U" { undefined[$2] = 1 }
for line in subprocess.run(["nm",o],capture_output=True,text=True).stdout.splitlines(): END {
p=line.split() n = 0
if len(p)>=3 and p[1] in "TDBRtdbr": defs.add(p[2]) for (s in undefined) {
elif len(p)>=3 and p[1]=="U": und.add(p[2]) if (!(s in defs) && (s ~ /^jlp/ || s ~ /^jlSprite/)) {
elif len(p)==2 and p[0]=="U": und.add(p[1]) names[++n] = s
print(" ".join(sorted(s for s in und-defs if s.startswith(("jlp","jlSprite","jlSpriteCompiledDraw"))))) }
PY }
) asort(names)
for (i = 1; i <= n; i++) {
printf "%s%s", (i > 1 ? " " : ""), names[i]
}
print ""
}')
if [ -n "$missing" ]; then if [ -n "$missing" ]; then
echo "check-blank: FAIL - unresolved JoeyLib symbols (blank.c / generics gap):" >&2 echo "check-blank: FAIL - unresolved JoeyLib symbols (blank.c / generics gap):" >&2
echo " $missing" >&2 echo " $missing" >&2

View file

@ -5,8 +5,8 @@
# #
# check-icons.sh - Host gate for programs' icons: on every port built here, # 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 # 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 # programIcon, tools/iconBake), where its system looks for it. Read with the
# the systems' own tools where they are installed, otherwise by the format: # systems' own tools where they are installed, otherwise by the format:
# #
# amiga Draw.info beside it: a DiskObject (magic $E310) for a tool, # amiga Draw.info beside it: a DiskObject (magic $E310) for a tool,
# AMIGA_ICON_STACK bytes of stack, the image iconBake makes # AMIGA_ICON_STACK bytes of stack, the image iconBake makes
@ -39,6 +39,44 @@ label=check-icons
fails="" fails=""
checked="" 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() { fail() {
fails="${fails:+$fails; }$1: $2" 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 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 for port in amiga win32 win16 sdl3 macos web psp haiku haiku32 beos dvx iigs; do
program=$(joeyExampleProgram draw "$port" 2> /dev/null) || continue program=$(joeyExampleProgram draw "$port" 2> /dev/null) || continue
[ -f "$program" ] || continue [ -f "$program" ] || continue
checked="${checked:+$checked }$port" checked="${checked:+$checked }$port"
case "$port" in case "$port" in
amiga) amiga)
python3 - "$program.info" "$(makeValue amiga AMIGA_ICON_STACK)" "$repo/tools" <<'PY' || fail amiga "Draw.info" info=$program.info
import struct want="$WB_DISKMAGIC $WBTOOL $(makeValue amiga AMIGA_ICON_STACK) $AMIGA_ICON"
import sys 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)"
info, stack, tools = sys.argv[1], int(sys.argv[2]), sys.argv[3] [ "$got" = "$want" ] || fail amiga "Draw.info: magic, type, stack, image $got, want $want"
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
;; ;;
win32) win32)
readobj=$(dirname "$WIN32_CC")/llvm-readobj 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" 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) win16)
python3 - "$program" "$(makeValue win16 HOSTED_ICON_ID)" <<'PY' || fail win16 "no group icon in DRAW.EXE's NE resources" neGroupIcon "$program" "$(makeValue win16 HOSTED_ICON_ID)" || 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("<I", data, 0x3C)[0]
table = ne + struct.unpack_from("<H", data, ne + 0x24)[0]
if table == ne + struct.unpack_from("<H", data, ne + 0x26)[0]:
sys.exit(1)
at = table + 2
while True:
typeId, count = struct.unpack_from("<HH", data, at)
if typeId == 0:
sys.exit(1)
at += 8
for i in range(count):
if typeId == RT_GROUP_ICON and struct.unpack_from("<H", data, at + 6)[0] == wantId:
sys.exit(0)
at += 12
PY
;; ;;
sdl3) sdl3)
symbols=$(nm "$program" 2> /dev/null) symbols=$(nm "$program" 2> /dev/null)
grep -q ' jlgHostedIconRgba$' <<< "$symbols" || fail sdl3 "DRAW links no window icon" grep -q ' jlgHostedIconRgba$' <<< "$symbols" || fail sdl3 "DRAW links no window icon"
;; ;;
macos) macos)
python3 - "$(dirname "$(dirname "$(dirname "$program")")")" <<'PY' || fail macos "DRAW.app's icon" app=$(dirname "$(dirname "$(dirname "$program")")")
import plistlib iconName=$(awk '/<key>CFBundleIconFile<\/key>/ { getline; gsub(/.*<string>|<\/string>.*/, ""); print; exit }' "$app/Contents/Info.plist" 2> /dev/null)
import sys 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
from PIL import Image fail macos "DRAW.app's icon"
fi
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
;; ;;
web) web)
folder=$(dirname "$program") folder=$(dirname "$program")
grep -qE '<link[^>]*rel=("?)icon\1[^>]*>' "$program" && grep -qE 'href=("?)icon\.png' "$program" || fail web "DRAW.html names no icon.png" grep -qE '<link[^>]*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) psp)
python3 - "$program" "$repo/build/psp/icon/DRAW/icon0.png" "$(makeValue psp PSP_ICON0_SIZE)" <<'PY' || fail psp "the EBOOT's ICON0" baked=$repo/build/psp/icon/DRAW/icon0.png
import io size=$(makeValue psp PSP_ICON0_SIZE)
import struct start=$(fileNumber "$program" "$PBP_ICON0_AT" 4 le)
import sys 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
from PIL import Image 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
eboot, baked, size = sys.argv[1], sys.argv[2], sys.argv[3] fail psp "the EBOOT's ICON0 is not $size"
data = open(eboot, "rb").read() fi
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
;; ;;
haiku|haiku32|beos) haiku|haiku32|beos)
listing=$("$HAIKU_TOOLS/xres" -l "$program" 2> /dev/null) 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) iconFile=$(find "$out" -iname 'DRAW.ICONS*' -not -name '_FileInformation.txt' -print -quit)
listing=$("$CADIUS" CATALOG "$disk" 2> /dev/null) 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" 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" nameLength=$(fileNumber "$iconFile" $((IIGS_RECORD_AT + IIGS_NAME_AT)) 1 le)
import struct got="$(fileNumber "$iconFile" "$IIGS_FILE_ID_AT" 2 le) $(dd if="$iconFile" bs=1 skip=$((IIGS_RECORD_AT + IIGS_NAME_AT + 1)) count="${nameLength:-0}" status=none 2> /dev/null) $(fileNumber "$iconFile" $((IIGS_RECORD_AT + IIGS_TYPE_AT)) 2 le)"
import sys [ "$got" = "$IIGS_ICON_FILE_ID DRAW $IIGS_APPLICATION" ] || fail iigs "DRAW.ICONS is no icon file for DRAW (id, name, type $got)"
ICON_FILE_ID = 0x0001
APPLICATION = 0x00B3
data = open(sys.argv[1], "rb").read()
fileId = struct.unpack_from("<H", data, 4)[0]
record = 26
name = data[record + 67:record + 67 + data[record + 66]].decode("ascii")
fileType = struct.unpack_from("<H", data, record + 82)[0]
if (fileId, name, fileType) != (ICON_FILE_ID, "DRAW", APPLICATION):
print(" iigs: id, name, type %s" % ((fileId, name, fileType),))
sys.exit(1)
PY
rm -rf "$out" rm -rf "$out"
;; ;;
esac esac

View file

@ -5,7 +5,7 @@
# #
# check-macos-apps.sh - Host gate for the macOS port's apps (make macos), # 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 # 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 # 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 # 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 # 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 exit 2
fi fi
python3 - "$bin" "$MACOS_LIPO" "$objdump" "$minimum" "$label" <<'PY' archs="arm64 x86_64"
import glob signed="arm64"
import os
import plistlib
import subprocess
import sys
bin, lipo, objdump, minimum, label = sys.argv[1:] # joinBy SEPARATOR WORD... -- the words with SEPARATOR between them.
ARCHS = {"arm64", "x86_64"} joinBy() {
SIGNED = {"arm64"} local separator="$1"
SYSTEM = ("/System/Library/", "/usr/lib/") local out="$2"
shift 2
for word in "$@"; do
out+="$separator$word"
done
printf '%s\n' "$out"
}
def run(*args): # plistString PLIST KEY -- the string KEY holds in an Info.plist (the
return subprocess.run(args, capture_output=True, text=True, check=True).stdout # build's own XML, each value on the line after its key).
plistString() {
awk -v key="<key>$2</key>" '
found {
if (match($0, /<string>[^<]*<\/string>/)) {
print substr($0, RSTART + length("<string>"), RLENGTH - length("<string></string>"))
}
exit
}
index($0, key) {
found = 1
}' "$1"
}
def sliceProblems(program, arch): # sliceProblems PROGRAM ARCH -- what is wrong with one arch of PROGRAM.
problems = [] sliceProblems() {
headers = run(objdump, "--macho", "--private-headers", "--arch=" + arch, program).split() local headers
if "LC_BUILD_VERSION" not in headers or headers[headers.index("minos") + 1] != minimum: local library
problems.append("%s not built for macOS %s" % (arch, minimum)) headers=" $("$objdump" --macho --private-headers --arch="$2" "$1" | tr -s ' \t\n' ' ') "
if arch in SIGNED and "LC_CODE_SIGNATURE" not in headers: if [[ $headers != *" LC_BUILD_VERSION "* || $headers != *" minos $minimum "* ]]; then
problems.append("%s unsigned" % arch) problems+=("$2 not built for macOS $minimum")
for line in run(objdump, "--macho", "--dylibs-used", "--arch=" + arch, program).splitlines()[1:]: fi
library = line.split()[0] if [[ " $signed " == *" $2 "* && $headers != *" LC_CODE_SIGNATURE "* ]]; then
if not library.startswith(SYSTEM): problems+=("$2 unsigned")
problems.append("%s links %s" % (arch, library)) fi
return problems 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): # appProblems APP -- what is wrong with APP, in problems.
contents = os.path.join(app, "Contents") appProblems() {
with open(os.path.join(contents, "Info.plist"), "rb") as f: local contents="$1/Contents"
info = plistlib.load(f) local plist="$contents/Info.plist"
problems = [] local program
if info.get("CFBundlePackageType") != "APPL" or info.get("LSMinimumSystemVersion") != minimum: local held
problems.append("Info.plist is not an application for macOS %s" % minimum) local arch
if not os.path.isfile(os.path.join(contents, "Resources", info.get("CFBundleIconFile", "") + ".icns")): problems=()
problems.append("no icon") [ -f "$plist" ] || { problems=("no Info.plist"); return; }
program = os.path.join(contents, "MacOS", info.get("CFBundleExecutable", "")) if [ "$(plistString "$plist" CFBundlePackageType)" != APPL ] || [ "$(plistString "$plist" LSMinimumSystemVersion)" != "$minimum" ]; then
if not (os.path.isfile(program) and os.access(program, os.X_OK)): problems+=("Info.plist is not an application for macOS $minimum")
return problems + ["no program"] fi
archs = set(run(lipo, "-archs", program).split()) [ -f "$contents/Resources/$(plistString "$plist" CFBundleIconFile).icns" ] || problems+=("no icon")
if archs != ARCHS: program="$contents/MacOS/$(plistString "$plist" CFBundleExecutable)"
problems.append("holds %s, not %s" % (" ".join(sorted(archs)), " ".join(sorted(ARCHS)))) if [ ! -f "$program" ] || [ ! -x "$program" ]; then
for arch in sorted(archs & ARCHS): problems+=("no program")
problems += sliceProblems(program, arch) return
return problems 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=()
fails = [] count=0
for app in apps: for app in "$bin"/*.app; do
try: count=$((count + 1))
problems = appProblems(app) appProblems "$app"
except (OSError, ValueError, KeyError, plistlib.InvalidFileException, subprocess.CalledProcessError) as error: if [ ${#problems[@]} -gt 0 ]; then
problems = [str(error)] fails+=("$(basename "$app"): $(joinBy ', ' "${problems[@]}")")
if problems: fi
fails.append("%s: %s" % (os.path.basename(app), ", ".join(problems))) done
if fails: if [ ${#fails[@]} -gt 0 ]; then
print("%s: FAIL - %s" % (label, "; ".join(fails))) echo "$label: FAIL - $(joinBy '; ' "${fails[@]}")"
sys.exit(1) 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)) fi
PY echo "$label: PASS ($count app$([ "$count" = 1 ] || echo s): universal ${archs// /+}, arm64 signed, macOS $minimum on, system libraries only)"

View file

@ -37,22 +37,39 @@ fi
tables=() tables=()
asm="${AMIGA_PTPLAYER:-}/ptplayer.asm" asm="${AMIGA_PTPLAYER:-}/ptplayer.asm"
if [ -f "$asm" ]; then if [ -f "$asm" ]; then
python3 - "$asm" "$work/tables.txt" <<'EOF' awk -v out="$work/tables.txt" '
import re, sys index($0, "mt_PeriodTable:") && !periodsDone {
asm = open(sys.argv[1]).read() inPeriods = 1
def values(start, stop): }
block = asm[asm.index(start):asm.index(stop, asm.index(start))] inPeriods && $0 == "" {
out = [] inPeriods = 0
for line in block.splitlines(): periodsDone = 1
m = re.match(r'\s*dc\.[bw]\s+([^;]*)', line) }
if m: index($0, "mt_VibratoSineTable:") && !wavesDone {
out += [int(v) for v in m.group(1).split(',')] inWaves = 1
return out }
periods = values('mt_PeriodTable:', '\n\n') index($0, "mt_VibratoRectTable:") {
waves = values('mt_VibratoSineTable:', 'mt_VibratoSawTable:') + values('mt_VibratoSawTable:', 'mt_VibratoRectTable:') + values('mt_VibratoRectTable:', 'endc') afterRect = 1
with open(sys.argv[2], 'w') as f: }
f.write('periods ' + ' '.join(map(str, periods)) + '\nvibrato ' + ' '.join(map(str, waves)) + '\n') inWaves && afterRect && index($0, "endc") {
EOF 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") tables=("$work/tables.txt")
fi fi

View file

@ -62,68 +62,87 @@ else
echo "check-sfxmix: comparing against baseline (exact hash mode)" echo "check-sfxmix: comparing against baseline (exact hash mode)"
fi fi
if python3 - "$baseline" "$work/out1.txt" "$tolerance" <<'PY' if awk -v tolerance="$tolerance" '
import sys BEGIN {
for (i = 0; i < 256; i++) {
byteOf[sprintf("%02X", i)] = i
def parse(path): }
order = [] }
scen = {} FNR == 1 {
name = None name = ""
with open(path) as f: }
for line in f: {
line = line.strip() gsub(/^[ \t\r]+|[ \t\r]+$/, "")
if line.startswith("name="): }
fields = dict(p.split("=", 1) for p in line.split()) /^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"] name = fields["name"]
order.append(name) order[ARGIND, ++count[ARGIND]] = name
scen[name] = {"hash": fields["hash"], "slots": int(fields["slots"]), "raw": b""} hash[ARGIND, name] = fields["hash"]
elif line.startswith("raw=") and name is not None: slots[ARGIND, name] = fields["slots"] + 0
scen[name]["raw"] = bytes.fromhex(line[4:]) raw[ARGIND, name] = ""
return order, scen seen[ARGIND, name] = 1
}
/^raw=/ && name != "" {
baseOrder, base = parse(sys.argv[1]) raw[ARGIND, name] = toupper(substr($0, 5))
curOrder, cur = parse(sys.argv[2]) }
tolerance = int(sys.argv[3]) if sys.argv[3] else None END {
same = count[1] == count[2]
fail = False for (i = 1; i <= count[1] && same; i++) {
if curOrder != baseOrder: same = order[1, i] == order[2, i]
print("check-sfxmix: scenario list changed vs baseline") }
print(" baseline: " + " ".join(baseOrder)) if (!same) {
print(" current: " + " ".join(curOrder)) print "check-sfxmix: scenario list changed vs baseline"
fail = True line = " baseline:"
for (i = 1; i <= count[1]; i++) {
for name in baseOrder: line = line " " order[1, i]
if name not in cur: }
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 continue
b = base[name] }
c = cur[name] label = sprintf(" %-24s", name ":")
label = " %-24s" % (name + ":") baseLen = length(raw[1, name]) / 2
if len(b["raw"]) != len(c["raw"]): curLen = length(raw[2, name]) / 2
print("%s FAIL (length %d -> %d)" % (label, len(b["raw"]), len(c["raw"]))) if (baseLen != curLen) {
fail = True printf "%s FAIL (length %d -> %d)\n", label, baseLen, curLen
failed = 1
continue continue
if tolerance is None: }
ok = (b["hash"] == c["hash"]) and (b["raw"] == c["raw"]) if (tolerance == "") {
if ok: if (hash[1, name] == hash[2, name] && raw[1, name] == raw[2, name]) {
print("%s PASS (hash %s)" % (label, c["hash"])) printf "%s PASS (hash %s)\n", label, hash[2, name]
else: } else {
print("%s FAIL (hash %s -> %s)" % (label, b["hash"], c["hash"])) printf "%s FAIL (hash %s -> %s)\n", label, hash[1, name], hash[2, name]
fail = True failed = 1
else: }
continue
}
maxDelta = 0 maxDelta = 0
for x, y in zip(b["raw"], c["raw"]): for (b = 1; b <= baseLen; b++) {
d = abs(x - y) delta = byteOf[substr(raw[1, name], 2 * b - 1, 2)] - byteOf[substr(raw[2, name], 2 * b - 1, 2)]
if d > maxDelta: delta = delta < 0 ? -delta : delta
maxDelta = d maxDelta = delta > maxDelta ? delta : maxDelta
allowed = tolerance * b["slots"] }
ok = maxDelta <= allowed allowed = tolerance * slots[1, name]
print("%s maxAbsDelta=%d (allowed %d) %s" % (label, maxDelta, allowed, "PASS" if ok else "FAIL")) printf "%s maxAbsDelta=%d (allowed %d) %s\n", label, maxDelta, allowed, (maxDelta <= allowed ? "PASS" : "FAIL")
fail = fail or not ok failed = failed || maxDelta > allowed
}
sys.exit(1 if fail else 0) exit failed ? 1 : 0
PY }' "$baseline" "$work/out1.txt"
then then
echo "check-sfxmix: PASS" echo "check-sfxmix: PASS"
else else

View file

@ -5,7 +5,7 @@
# #
# check-tilebank.sh - Host-cc gate for the baked tile-bank loaders # check-tilebank.sh - Host-cc gate for the baked tile-bank loaders
# (jlTileBankLoad / jlTileBankLoadToSurface in src/core/assetLoad.c), end to # (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 # 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 # 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, # 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 byte there, so the DOS bake is the bank that must load. Every other
# target assetbake knows is baked too and must be refused. # target assetbake knows is baked too and must be refused.
# #
# tests/host/tileBankStage.py stages the banks and the expected pixels and # tests/host/tileBankStage.sh stages the banks and the expected pixels and
# palette (decoded from the PNG with PIL, independent of assetbake's # palette (decoded from the PNG by tileBankExpect, independent of assetbake's
# encoders), exactly as make/<port>.mk stages them for examples/tilebank on # encoders; make/tools.mk builds both), exactly as make/examples.mk stages
# the real ports. The harness prints the surface hash of its to-surface load; # them for examples/tilebank on the real ports. The harness prints the
# scripts/verify-<port>-tilebank.sh run this script and require each port's # surface hash of its to-surface load; scripts/verify-<port>-tilebank.sh run
# own bake to hash the same. Exits nonzero on any failed check. # this script and require each port's own bake to hash the same. Exits
# nonzero on any failed check.
set -euo pipefail set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) 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 \ src/core/*.c src/generic/*.c src/blank/blank.c tests/host/tileBankHost.c \
-o "$work/tileBankHost" -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=() refuse=()
for bank in "$work"/DATA/refuse*.tbk; do for bank in "$work"/DATA/refuse*.tbk; do
refuse+=("$(basename "$bank")") refuse+=("$(basename "$bank")")

View file

@ -3,26 +3,31 @@
# See LICENSE.md for license information. # See LICENSE.md for license information.
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# #
# check-tiled.sh - Host gate for tools/tiled2joey.py, which turns a map # 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.py) and # made in Tiled into a tile sheet (baked into tile banks by assetbake) and
# one jlTileMapPaste map per tile layer (docs/Assets.adoc). tests/host/ # one jlTileMapPaste map per tile layer (docs/Assets.adoc). tiledCheck
# tiledCheck.py writes one map as TMX and as TMJ, with every flip Tiled can # (tests/gate/tiledCheck.c) writes one map as TMX and as TMJ, with every flip
# ask for, two tilesets (one external, with a margin and spacing), and every # Tiled can ask for, two tilesets (one external, with a margin and spacing),
# layer encoding the tool reads; both must convert to the sheet and maps # and every layer encoding the tool reads; both must convert to the sheet and
# worked out by hand, the sheet must bake, and maps it cannot take (16x16 # maps worked out by hand, the sheet must bake, and maps it cannot take (16x16
# tiles, an RGB tileset) must be refused. # tiles, a tileset that is not indexed) must be refused.
# #
# Usage: scripts/check-tiled.sh # Usage: scripts/check-tiled.sh
# Exit: 0 PASS, 1 FAIL # Exit: 0 PASS, 1 FAIL
set -uo pipefail set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
label=check-tiled label=check-tiled
work=$(mktemp -d -t joeylib-tiled.XXXXXX) work=$(mktemp -d -t joeylib-tiled.XXXXXX)
trap 'rm -rf "$work"' EXIT 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=$? status=$?
echo "$out" echo "$out"
checks=$(grep -c '^TILED:' <<< "$out") checks=$(grep -c '^TILED:' <<< "$out")

View file

@ -3,18 +3,18 @@
# See LICENSE.md for license information. # See LICENSE.md for license information.
# SPDX-License-Identifier: MIT # 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 # that brings tracker-made chip music (Furnace's, docs/Assets.adoc) to
# JoeyLib's chip tracker. # 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 # writes, converted and baked; every baked row must hold the voices worked
# out by hand. # out by hand.
# 2. With Furnace installed (./install.sh install furnace): every SN76489 and # 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 # 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 # 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 # 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 # 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 # divider gives under 10). The demos are their authors' and stay where the
# installer put them; nothing made from them is kept. # 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) work=$(mktemp -d -t joeylib-vgm.XXXXXX)
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
mkdir -p "$work/synthetic" "$work/demos" "$work/home" 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=$? status=$?
echo "$synthetic" | grep -v '^SYNTHETIC' echo "$synthetic" | grep -v '^SYNTHETIC'
counts=$(echo "$synthetic" | sed -n 's/^SYNTHETIC //p') 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" fails="${fails:+$fails; }$folder/$name: Furnace did not export it"
continue continue
fi 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")" fails="${fails:+$fails; }$folder/$name: $(tr '\n' ' ' < "$base.warn")"
continue continue
fi fi
@ -75,11 +81,11 @@ for spec in $DEMO_FOLDERS; do
echo "$label: $folder/$name: skipped (the song uses another chip too)" echo "$label: $folder/$name: skipped (the song uses another chip too)"
continue continue
fi 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' ' ')" fails="${fails:+$fails; }$folder/$name: songbake: $(grep -v warning "$base.bake" | tr '\n' ' ')"
continue continue
fi 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%%/*} matched=${result%%/*}
tested=${result##*/} tested=${result##*/}
if [ -z "$tested" ] || [ "$tested" -eq 0 ]; then if [ -z "$tested" ] || [ "$tested" -eq 0 ]; then

View file

@ -24,7 +24,7 @@
# the ENV settings (NAME=VALUE) until its # the ENV settings (NAME=VALUE) until its
# joeylog.txt has a line starting UNTIL (empty: # joeylog.txt has a line starting UNTIL (empty:
# until it exits) or SECONDS pass # 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...] # beStart WORK DISK [QEMU-ARGS...]
# boot the VM with the job disk, on BE_DISPLAY # boot the VM with the job disk, on BE_DISPLAY
# (default none: headless); sets BE_PID. A # (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 # 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 # 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 # 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() { beDesktopUp() {
local probe
local width
local red
beQmp "$1" screendump "$1/mode.png" 2> /dev/null || return 1 beQmp "$1" screendump "$1/mode.png" 2> /dev/null || return 1
python3 - "$1/mode.png" "$BEOS_VIDEO_MODE" <<'PY' probe=$(joeyTool pngProbe) || return 1
import sys [ "$("$probe" size "$1/mode.png")" = "$BEOS_VIDEO_MODE" ] || return 1
from PIL import Image 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
# The logo's red, in the 16-bit mode, and how many of its pixels show in [ "$red" -ge "$BE_LOGO_PIXELS_MIN" ]
# 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
} }
@ -195,7 +191,7 @@ beNeed() {
beQmp() { beQmp() {
local work="$1" local work="$1"
shift shift
python3 "$JOEY_REPO/scripts/lib/qmp.py" "$work/qmp.sock" "$@" "$(joeyTool qmp)" "$work/qmp.sock" "$@"
} }

View file

@ -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[^>]*>(.*?)</\1>", 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())

View file

@ -122,28 +122,39 @@ dvxFullScreen() {
# no first-run Welcome window, the gate video mode, and no desktop sounds (a # 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. # recording gate must hear only the program). DOS line ends are kept.
dvxIniSet() { dvxIniSet() {
python3 - "$1" "$2" "$DVX_VIDEO_W" "$DVX_VIDEO_H" "$DVX_VIDEO_BPP" <<'PY' awk -v desktop="$2" -v width="$DVX_VIDEO_W" -v height="$DVX_VIDEO_H" -v bpp="$DVX_VIDEO_BPP" '
import sys BEGIN {
path, desktop, width, height, bpp = sys.argv[1:] RS = "\r\n"
sets = {("shell", "desktop"): desktop, ("video", "width"): width, ("video", "height"): height, ("video", "bpp"): bpp} sets["shell/desktop"] = desktop
lines = open(path, newline="").read().split("\r\n") sets["video/width"] = width
out = [] sets["video/height"] = height
section = "" sets["video/bpp"] = bpp
for line in lines: }
stripped = line.strip() {
if stripped.startswith("["): line = $0
section = stripped.strip("[]").lower() stripped = $0
elif "=" in stripped and not stripped.startswith(";"): gsub(/^[ \t\r\n\v\f]+|[ \t\r\n\v\f]+$/, "", stripped)
key = stripped.split("=")[0].strip() if (substr(stripped, 1, 1) == "[") {
if section == "shell" and key.lower() == "welcome": section = stripped
continue gsub(/^[][]+|[][]+$/, "", section)
if section == "sounds": section = tolower(section)
line = "%s =" % key } else if (index(stripped, "=") > 0 && substr(stripped, 1, 1) != ";") {
elif (section, key.lower()) in sets: key = substr(stripped, 1, index(stripped, "=") - 1)
line = "%s = %s" % (key, sets[(section, key.lower())]) gsub(/^[ \t\r\n\v\f]+|[ \t\r\n\v\f]+$/, "", key)
out.append(line) if (section == "shell" && tolower(key) == "welcome") {
open(path, "w", newline="").write("\r\n".join(out)) next
PY }
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"
} }

View file

@ -186,3 +186,18 @@ joeyStop() {
wait "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true
done 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"
}

View file

@ -5,7 +5,7 @@
# #
# Shared by the joystick gates that read examples/joy's screen # Shared by the joystick gates that read examples/joy's screen
# (scripts/verify-*-joy.sh): the steps, the check of each screenshot # (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: # defines three functions first:
# #
# joyShot PNG save a screenshot of JOY as PNG; non-zero if none # joyShot PNG save a screenshot of JOY as PNG; non-zero if none
@ -32,12 +32,14 @@
# exit 1 on a failure # exit 1 on a failure
# #
# The gate sets JOY_LABEL (its name, for the lines it prints) and JOY_WORK # 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 # (its scratch folder). JOY_SCALE (joyScreen --scale) is the stage's
# scale in the screenshot, empty to let joyScreen.py find it; # scale in the screenshot, empty to let joyScreen find it;
# JOY_SCREEN_ARGS are more joyScreen.py options (--st); JOY_SHOTS, if # JOY_SCREEN_ARGS are more joyScreen options (--st); JOY_SHOTS, if
# set, keeps every screenshot there; JOY_PRESS_SECONDS is how long a change # 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 # takes to show. A read with a "?" in it (a dot caught between places) is
# taken before the checks (they cannot be taken again). # 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_REST="10000" # a stick connected, in the middle, buttons up
JOY_ABSENT="00000" # no stick 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_SCALE="${JOY_SCALE:-}"
JOY_SCREEN_ARGS=() JOY_SCREEN_ARGS=()
JOY_PRESS_SECONDS="${JOY_PRESS_SECONDS:-1}" 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_RETRY_SECONDS=0.5
JOY_FAILS="" JOY_FAILS=""
JOY_CHECKED=0 JOY_CHECKED=0
@ -99,7 +101,7 @@ joyCheck() {
joyFail "$1: no screenshot" joyFail "$1: no screenshot"
return return
fi fi
got=$(python3 "$JOEY_REPO/scripts/lib/joyScreen.py" "$png" "${args[@]}") got=$("$(joeyTool joyScreen)" "$png" "${args[@]}")
[[ "${got% scale=*}" == *"?"* ]] || break [[ "${got% scale=*}" == *"?"* ]] || break
done done
if [ -n "${JOY_SHOTS:-}" ]; then if [ -n "${JOY_SHOTS:-}" ]; then

View file

@ -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=<state> B=<state> scale=<S> dots=<A's x,y offset> <B's>"; 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))

View file

@ -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))

View file

@ -10,7 +10,7 @@
# go first, which must leave Up down (src/core/inputInternal.h, # go first, which must leave Up down (src/core/inputInternal.h,
# INPUT_KEY_SECOND). Each step holds its keys (letting go of every other), # 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, # 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, # The gate sets KEYS_LABEL (its name, for the lines it prints) and defines,
# by the key names below: # by the key names below:

View file

@ -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))

View file

@ -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('<?xml version="1.0"?>\n<mameconfig version="10">\n <system name="%s">\n <input>' % system)
for name, default, value in ports:
print(f' <port tag="{tag}:{name}" type="CONFIG" mask="255" defvalue="{default}" value="{value}" />')
print(' </input>\n </system>\n</mameconfig>')
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

82
scripts/lib/mameNullModemCfg.sh Executable file
View file

@ -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 '<?xml version="1.0"?>\n<mameconfig version="10">\n <system name="%s">\n <input>\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 ' <port tag="%s:%s" type="CONFIG" mask="255" defvalue="%s" value="%s" />\n' "$tag" "$name" "$default" "$value"
done
printf ' </input>\n </system>\n</mameconfig>\n'

View file

@ -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))

View file

@ -5,7 +5,7 @@
# #
# Shared by the joystick gates of the ports that read the host's gamepads # Shared by the joystick gates of the ports that read the host's gamepads
# (SDL3, Win32 under Wine): two virtual Xbox 360 pads # (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 # 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 # 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 # 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_SWAP=0
PAD_READY_SECONDS=10 PAD_READY_SECONDS=10
PAD_REPLY_SECONDS=5 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_XBOX360=0x045e/0x028e
PAD_IDS_GENERIC=0x1209/0x0001 PAD_IDS_GENERIC=0x1209/0x0001
PAD_ONLY_ENV=(SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$PAD_IDS_XBOX360) PAD_ONLY_ENV=(SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$PAD_IDS_XBOX360)
@ -58,7 +58,7 @@ padOrder() {
padSend press A-left padSend press A-left
sleep "$JOY_PRESS_SECONDS" sleep "$JOY_PRESS_SECONDS"
joyShot "$JOY_WORK/order.png" 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 padSend release A-left
sleep "$JOY_PRESS_SECONDS" sleep "$JOY_PRESS_SECONDS"
case "$got" in case "$got" in
@ -93,6 +93,7 @@ padSend() {
padStart() { padStart() {
local fifo="$1/pad.in" local fifo="$1/pad.in"
local kind=() local kind=()
local pad
if [ ! -w /dev/uinput ]; then 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 echo "$JOY_LABEL: cannot write /dev/uinput, which the virtual gamepads need (a desktop session's user usually can)" >&2
exit 2 exit 2
@ -107,7 +108,8 @@ padStart() {
PAD_SWAP=0 PAD_SWAP=0
rm -f "$fifo" "$PAD_OUT" rm -f "$fifo" "$PAD_OUT"
mkfifo "$fifo" 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=$! PAD_PID=$!
exec {PAD_FD}> "$fifo" exec {PAD_FD}> "$fifo"
if ! padWait 0 "$PAD_READY_SECONDS"; then if ! padWait 0 "$PAD_READY_SECONDS"; then

View file

@ -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))

View file

@ -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()

View file

@ -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)

View file

@ -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 <b> 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))

View file

@ -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))

View file

@ -5,7 +5,7 @@
# #
# Shared by the tile-bank gates, scripts/verify-<port>-tilebank.sh. Each runs # Shared by the tile-bank gates, scripts/verify-<port>-tilebank.sh. Each runs
# examples/tilebank on its port with the DATA/tilebank make/<port>.mk staged # examples/tilebank on its port with the DATA/tilebank make/<port>.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), # 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 # 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 # 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 # 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_HASH the surface hash of its to-surface load of its own bake
# TILEBANK_OTHERS how many targets besides any one port assetbake knows # 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. # Exits 2 without a host C compiler, 1 if the host gate fails.
tileBankReference() { tileBankReference() {
local out local out
local targets
if ! command -v "${CC:-cc}" > /dev/null; then 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 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 echo "$TILEBANK_GATE: FAIL - scripts/check-tilebank.sh printed no to-surface hash" >&2
exit 1 exit 1
fi fi
TILEBANK_OTHERS="$(python3 - "$JOEY_REPO/tools/assetbake" <<'PY' # The stager asks assetbake (check-tilebank.sh has built it).
import sys targets="$("$JOEY_REPO/tests/host/tileBankStage.sh" --targets)" || exit 1
sys.path.insert(0, sys.argv[1]) TILEBANK_OTHERS=$(($(wc -w <<< "$targets") - 1))
from assetbake import TARGET_NAMES
print(len(TARGET_NAMES) - 1)
PY
)"
} }

View file

@ -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))

View file

@ -43,9 +43,11 @@ webNeed() {
webServe() { webServe() {
local tries local tries
local server
server=$(joeyTool httpServe) || exit 1
for ((tries = 0; tries < 10; tries++)); do for ((tries = 0; tries < 10; tries++)); do
WEB_PORT=$((WEB_PORT_MIN + RANDOM % WEB_PORT_SPAN)) 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=$! WEB_SERVER_PID=$!
sleep 0.5 sleep 0.5
if joeyAlive "$WEB_SERVER_PID"; then if joeyAlive "$WEB_SERVER_PID"; then

View file

@ -80,7 +80,7 @@ win16Stage() {
cp -r "$WIN16_C" "$work/c" cp -r "$WIN16_C" "$work/c"
cp "$KPJOY_DRV" "$work/c/WINDOWS/SYSTEM/KPJOY.DRV" cp "$KPJOY_DRV" "$work/c/WINDOWS/SYSTEM/KPJOY.DRV"
cp "$KPCOMM_DRV" "$work/c/WINDOWS/SYSTEM/KPCOMM.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 shell "D:\\$3" \
set boot comm.drv kpcomm.drv \ set boot comm.drv kpcomm.drv \
set drivers joystick kpjoy.drv set drivers joystick kpjoy.drv
@ -107,13 +107,18 @@ WIN16_WING_PROFILE="ECHGECHGECHGECHGDDFFDDFFDDFFDDFFZZZZZZZZZZZZZZZZDDFGDGFGDDFF
win16StageWinG() { win16StageWinG() {
local file local file
local dosDate local dosDate
local year
local month
local day
for file in WING.DLL WINGDE.DLL WINGDIB.DRV WINGPAL.WND DVA.386; do for file in WING.DLL WINGDE.DLL WINGDIB.DRV WINGPAL.WND DVA.386; do
cp "$WING_DIR/$file" "$1/c/WINDOWS/SYSTEM/$file" cp "$WING_DIR/$file" "$1/c/WINDOWS/SYSTEM/$file"
done 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") # The driver's date as a DOS date word: years from 1980, month, day.
python3 "$JOEY_REPO/tools/winIni.py" "$1/c/WINDOWS/SYSTEM.INI" \ 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" 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" set WinG "$WIN16_WING_PROFILE_HEAD-$dosDate-$WIN16_WING_PROFILE_TAIL" "$WIN16_WING_PROFILE"
} }

View file

@ -69,8 +69,12 @@ x68kDataDisk() {
} }
# x68kXdf ARGS... -- tools/xdftool.py (list, add, delete, deltree, empty, # x68kXdf ARGS... -- tools/xdftool (list, add, delete, deltree, empty,
# extract, free). # extract, free). make/tools.mk builds it the first time a shell calls this
# (staging a DATA tree calls it once a file).
x68kXdf() { x68kXdf() {
python3 "$JOEY_REPO/tools/xdftool.py" "$@" if [ -z "${X68K_XDFTOOL:-}" ]; then
X68K_XDFTOOL="$(joeyTool xdftool)" || return 1
fi
"$X68K_XDFTOOL" "$@"
} }

View file

@ -142,93 +142,4 @@ if [ -n "${PROFILE_SAMPLES:-}" ]; then
cp "$work/samples.txt" "$PROFILE_SAMPLES" cp "$work/samples.txt" "$PROFILE_SAMPLES"
fi fi
python3 - "$work/samples.txt" "$map" "$top" <<'PY' "$(joeyTool iigsProfile)" "$work/samples.txt" "$map" "$top"
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

View file

@ -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. # 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 }') 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; } [[ -n ${relocSite:-} ]] || { echo "profile-x68000: no R_68K_32 relocation to fingerprint with" >&2; exit 2; }
relocValue=$(python3 -c " relocValue=$(od -An -tu4 -N4 --endian=big -j $((64 + relocSite)) "$bin" | tr -d ' ')
import sys
d = open('$bin','rb').read()
o = 64 + $relocSite
print(int.from_bytes(d[o:o+4],'big'))
")
# jlgMouseX is written once per input poll, i.e. once per game frame. # 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 }') mouseXOff=$(m68k-xelf-nm "$elf" | awk '$3 == "jlgMouseX" { print strtonum("0x" $1); exit }')
@ -255,73 +250,113 @@ if [[ -n ${PROFILE_SAMPLES:-} ]]; then
fi fi
m68k-xelf-nm -n "$elf" > "$work/syms.txt" m68k-xelf-nm -n "$elf" > "$work/syms.txt"
python3 - "$work/prof.txt" "$work/syms.txt" <<'PY' LC_ALL=C awk '
import sys # Most first; equal ones in the order first seen.
prof, symfile = sys.argv[1], sys.argv[2] function byCount(i1, v1, i2, v2) {
syms = [] if (count[v1] != count[v2]) {
for line in open(symfile): return count[v2] - count[v1]
p = line.split() }
if len(p) == 3 and p[1] in ('t', 'T'): return first[v1] - first[v2]
syms.append((int(p[0], 16), p[2])) }
syms.sort() # 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.
def owner(off): function owner(off, lo, hi, mid, best) {
lo, hi = 0, len(syms) - 1 lo = 1
best = None hi = symCount
while lo <= hi: best = 0
mid = (lo + hi) // 2 while (lo <= hi) {
if syms[mid][0] <= off: mid = int((lo + hi) / 2)
best = syms[mid][1] if (symAddr[mid] <= off) {
best = mid
lo = mid + 1 lo = mid + 1
else: } else {
hi = mid - 1 hi = mid - 1
return best or "?" }
}
total = 0 return best
fps = None }
hits = {} function bySymbol(i1, v1, i2, v2) {
buckets = {} if (symAddr[v1] != symAddr[v2]) {
outside = 0 return symAddr[v1] - symAddr[v2]
for line in open(prof): }
p = line.split() return symName[v1] < symName[v2] ? -1 : (symName[v1] > symName[v2])
if p and p[0] == 'total': }
total = int(p[1]) FNR == NR {
elif len(p) == 2 and p[0].lstrip('-').isdigit(): if (NF == 3 && ($2 == "t" || $2 == "T")) {
off, n = int(p[0]), int(p[1]) rawCount++
buckets[off] = buckets.get(off, 0) + n rawAddr[rawCount] = strtonum("0x" $1)
if off < 0: 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 outside += n
else: next
hits[owner(off)] = hits.get(owner(off), 0) + n }
if total == 0: s = owner(off)
sys.exit("profile-x68000: no samples recorded") key = "s" (s > 0 ? symName[s] : "?")
rows = sorted(hits.items(), key=lambda kv: -kv[1]) if (!(key in count)) {
fpsText = f", game running at {fps:.1f} fps" if fps is not None and fps > 0.0 else " (frame count unavailable)" first[key] = ++seen
print(f"profile-x68000: {total} samples, {outside} ({outside*100.0/total:.1f}%) outside the program (ROM/IOCS){fpsText}") hits[++hitCount] = key
shown = 0 }
for name, n in rows[:25]: count[key] += n
print(f" {n*100.0/total:5.1f}% {n:6d} {name}") }
shown += n END {
print(f" {(total-outside-shown)*100.0/total:5.1f}% {total-outside-shown:6d} (everything else)") if (total == 0) {
print "profile-x68000: no samples recorded" > "/dev/stderr"
# The hottest 64-byte buckets, named as symbol+offset. Function totals say exit 1
# which routine to look at; these say which LINE of it, which is the }
# difference between rewriting a loop and rewriting the wrong loop. printf "profile-x68000: %d samples, %d (%.1f%%) outside the program (ROM/IOCS) (frame count unavailable)\n", total, outside, outside * 100.0 / total
print("\nhottest addresses (symbol + offset into it):") asort(hits, ranked, "byCount")
def ownerOff(off): shown = 0
lo, hi = 0, len(syms) - 1 for (i = 1; i <= hitCount && i <= 25; i++) {
best = None printf " %5.1f%% %6d %s\n", count[ranked[i]] * 100.0 / total, count[ranked[i]], substr(ranked[i], 2)
while lo <= hi: shown += count[ranked[i]]
mid = (lo + hi) // 2 }
if syms[mid][0] <= off: printf " %5.1f%% %6d (everything else)\n", (total - outside - shown) * 100.0 / total, total - outside - shown
best = syms[mid] # The hottest 64-byte buckets, named as symbol+offset. Function
lo = mid + 1 # totals say which routine to look at; these say which LINE of it,
else: # which is the difference between rewriting a loop and rewriting
hi = mid - 1 # the wrong loop.
return (best[1], off - best[0]) if best else ("?", 0) print "\nhottest addresses (symbol + offset into it):"
for off, n in sorted(buckets.items(), key=lambda kv: -kv[1])[:20]: asort(buckets, ranked, "byCount")
if off < 0: for (i = 1; i <= bucketCount && i <= 20; i++) {
off = bucketOff[ranked[i]]
if (off < 0) {
continue continue
name, delta = ownerOff(off) }
print(f" {n*100.0/total:5.1f}% {n:6d} {name}+0x{delta:x}") s = owner(off)
PY 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"

View file

@ -97,7 +97,7 @@
# dos, dvx, win16 DOSBox's 4-axis game port from host keys (and no # dos, dvx, win16 DOSBox's 4-axis game port from host keys (and no
# game port: no stick) # game port: no stick)
# sdl3, win32 two virtual uinput gamepads (scripts/lib/ # 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 # sdl3 again with two joysticks SDL cannot map
# web the page's Gamepad API, stood in for by the driver # web the page's Gamepad API, stood in for by the driver
# psp: no keyboard; its d-pad, nub, cross and circle as joystick # 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 # timeline of tones, voice and noise steps, effects, pans, a
# one-shot module and ticks; its sound is recorded and graded # one-shot module and ticks; its sound is recorded and graded
# against what it logged (scripts/verify-audio-conformance.sh, # 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 # step, noise rate, mix balance, centring and pan where the port
# is stereo, the module's end, the tick rate. Every port that # is stereo, the module's end, the tick rate. Every port that
# records sound: all but beos, whose VM has no sound card # 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" 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. # UBER on the Amiga needs longer than bench-amiga.sh's interactive default.
AMIGA_UBER_SECONDS=400 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. # kind port name command...: every gate, in run order within its kind.
GATES=$(cat <<EOF GATES=$(cat <<EOF
@ -244,26 +247,26 @@ host host tilebank scripts/check-tilebank.sh
host host layer scripts/check-tilelayer.sh host host layer scripts/check-tilelayer.sh
host host tiled scripts/check-tiled.sh host host tiled scripts/check-tiled.sh
host host vgm scripts/check-vgm.sh host host vgm scripts/check-vgm.sh
golden iigs uber scripts/bench-iigs.sh && tools/diff-uber-hashes tests/goldens/uber/iigs.txt build/iigs/bin/joeylog.txt golden iigs uber scripts/bench-iigs.sh && $UBER_HASHES tests/goldens/uber/iigs.txt build/iigs/bin/joeylog.txt
golden x68000 uber scripts/verify-x68000-golden.sh golden x68000 uber scripts/verify-x68000-golden.sh
golden atarist uber scripts/bench-atarist.sh && tools/diff-uber-hashes tests/goldens/uber/atarist.txt build/atarist/bin/joeylog.txt golden atarist uber scripts/bench-atarist.sh && $UBER_HASHES tests/goldens/uber/atarist.txt build/atarist/bin/joeylog.txt
golden atarist marks scripts/verify-atarist-dirtymark.sh golden atarist marks scripts/verify-atarist-dirtymark.sh
golden ste uber scripts/bench-atarist.sh ste && tools/diff-uber-hashes tests/goldens/uber/atarist.txt build/ste/bin/joeylog-ste.txt golden ste uber scripts/bench-atarist.sh ste && $UBER_HASHES tests/goldens/uber/atarist.txt build/ste/bin/joeylog-ste.txt
golden ste megaste scripts/bench-atarist.sh megaste && tools/diff-uber-hashes tests/goldens/uber/atarist.txt build/ste/bin/joeylog-megaste.txt golden ste megaste scripts/bench-atarist.sh megaste && $UBER_HASHES tests/goldens/uber/atarist.txt build/ste/bin/joeylog-megaste.txt
golden ste machine scripts/verify-ste-machine.sh golden ste machine scripts/verify-ste-machine.sh
golden amiga uber TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && tools/diff-uber-hashes tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt golden amiga uber TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && $UBER_HASHES tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt
golden amiga a1200 JOEY_AMIGA_MODEL=A1200 TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && tools/diff-uber-hashes tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt golden amiga a1200 JOEY_AMIGA_MODEL=A1200 TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && $UBER_HASHES tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt
golden amiga a4000 JOEY_AMIGA_MODEL=A4000 TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && tools/diff-uber-hashes tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt golden amiga a4000 JOEY_AMIGA_MODEL=A4000 TIMEOUT=$AMIGA_UBER_SECONDS scripts/bench-amiga.sh && $UBER_HASHES tests/goldens/uber/amiga.txt build/amiga/bin/joeylog.txt
golden dos uber scripts/bench-dos.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/dos/bin/JOEYLOG.TXT golden dos uber scripts/bench-dos.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/dos/bin/JOEYLOG.TXT
golden dvx uber scripts/bench-dvx.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt golden dvx uber scripts/bench-dvx.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt
golden sdl3 uber scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt golden sdl3 uber scripts/bench-sdl3.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
golden web uber scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt golden web uber scripts/bench-web.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
golden win32 uber scripts/bench-win32.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win32/bin/joeylog.txt golden win32 uber scripts/bench-win32.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/win32/bin/joeylog.txt
golden win16 uber scripts/bench-win16.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win16/bin/JOEYLOG.TXT golden win16 uber scripts/bench-win16.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/win16/bin/JOEYLOG.TXT
golden psp uber scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt golden psp uber scripts/bench-psp.sh && $UBER_HASHES tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
golden haiku uber scripts/bench-be.sh haiku && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt golden haiku uber scripts/bench-be.sh haiku && $UBER_HASHES tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt
golden haiku32 uber scripts/bench-be.sh haiku32 && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt golden haiku32 uber scripts/bench-be.sh haiku32 && $UBER_HASHES tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt
golden beos uber scripts/bench-be.sh beos && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/beos/bin/joeylog.txt golden beos uber scripts/bench-be.sh beos && $UBER_HASHES tests/goldens/uber/dos.txt build/beos/bin/joeylog.txt
visual iigs examples scripts/verify-iigs-all.sh visual iigs examples scripts/verify-iigs-all.sh
visual iigs display make -s -f make/iigs.mk iigs-verify-shrtail || exit 1 visual iigs display make -s -f make/iigs.mk iigs-verify-shrtail || exit 1
visual dvx full scripts/verify-dvx-fullscreen.sh visual dvx full scripts/verify-dvx-fullscreen.sh

View file

@ -44,7 +44,7 @@
# example binary name in build/amiga/bin, default Audio # example binary name in build/amiga/bin, default Audio
# Env: AMIGA_AUDIO_KEEP set to keep the work dir and the WAV # Env: AMIGA_AUDIO_KEEP set to keep the work dir and the WAV
# Requires: fs-uae, Xvfb, xdotool, parec/pactl (PulseAudio or PipeWire), # Requires: fs-uae, Xvfb, xdotool, parec/pactl (PulseAudio or PipeWire),
# python3, the Kickstart (install.sh install emulator:amiga), # the Kickstart (install.sh install emulator:amiga),
# build/amiga/bin/<example> # build/amiga/bin/<example>
set -uo pipefail set -uo pipefail
@ -55,15 +55,14 @@ source "$repo/scripts/lib/amigaGate.sh"
amigaMachine amigaMachine
prog=${1:-Audio} prog=${1:-Audio}
binDir=$repo/build/amiga/bin binDir=$repo/build/amiga/bin
grader=$repo/tools/gradeAudioPhases.py grader=$(joeyTool audioGate) || exit 1
pulseLib=$repo/scripts/pulseRecordLib.sh pulseLib=$repo/scripts/pulseRecordLib.sh
label=verify-amiga-audio label=verify-amiga-audio
[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
[ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; } [ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; }
# shellcheck source=scripts/pulseRecordLib.sh # shellcheck source=scripts/pulseRecordLib.sh
. "$pulseLib" . "$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; } command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
done 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; } 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, # Thresholds sit far from the measured values (music ~5700, paused ~240,
# sfx ~1170) so ordinary mixing jitter cannot flip the verdict. # 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:" \ "music:1:5:2000:" \
"paused:8:13::800" \ "paused:8:13::800" \
"sfx:16:22:800:" \ "sfx:16:22:800:" \

View file

@ -6,7 +6,7 @@
# verify-amiga-icon.sh - a program's Workbench icon: read and drawn by the # 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. # 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 # 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 # 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 # 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 fi
cat "$log" cat "$log"
python3 - "$work" "$want_stack" "$WBTOOL" "$label" <<'PY' "$(joeyTool amigaCheck)" icon "$work" "$want_stack" "$WBTOOL" "$label"
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

View file

@ -16,9 +16,9 @@
# keypad's 8, 2, 4, and 6 as the arrows, its Enter as Return, and the right # 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 # 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) -- # 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 # the stage found by its still empty verification row
# (scripts/lib/keysRow.py). # (tests/gate/keysRow.c).
# #
# Usage: scripts/verify-amiga-input.sh # Usage: scripts/verify-amiga-input.sh
# Requires: fs-uae, Xvfb, xdotool; build/amiga/bin/Keys (make amiga); # 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 echo "verify-amiga-input: FAIL - FS-UAE took no screenshot" >&2
exit 1 exit 1
fi 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 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 echo "verify-amiga-input: FAIL - KEYS's stage is not on the screen: $found" >&2
exit 1 exit 1
@ -118,7 +118,7 @@ keysRelease() {
} }
keysRead() { keysRead() {
if amigaShot ":$display_num" "$shotKey" "$work/shots" "$work/cells.png"; then 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 else
echo "no screenshot" echo "no screenshot"
fi fi

View file

@ -22,13 +22,13 @@
# the boot drive carries Workbench's (component amigaLowlevel). FS-UAE's # 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 # 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 # 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. # (scripts/lib/joyGate.sh). Escape must then end JOY.
# #
# Usage: scripts/verify-amiga-joy.sh # Usage: scripts/verify-amiga-joy.sh
# Env: JOY_SHOTS also keep the screenshots in this folder # Env: JOY_SHOTS also keep the screenshots in this folder
# Requires: FS-UAE, the Kickstart, lowlevel.library (install emulator:amiga), # 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 set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
@ -119,7 +119,7 @@ joyShot() {
# joyStage -- whether a screenshot now shows JOY's stage. # joyStage -- whether a screenshot now shows JOY's stage.
joyStage() { 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
} }

View file

@ -85,44 +85,4 @@ sleep 2
DISPLAY=$display import -window root "$work/screen.png" DISPLAY=$display import -window root "$work/screen.png"
grep "PALPROBE:" "$log" grep "PALPROBE:" "$log"
python3 - "$work/screen.png" "$log" "$MIN_RATE" "$label" <<'PY' "$(joeyTool amigaCheck)" palette "$work/screen.png" "$log" "$MIN_RATE" "$label"
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

View file

@ -7,7 +7,7 @@
# #
# Boots SerTest (examples/sertest: banner, then echo) off a directory hard # 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 # 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 -> # expect it echoed. That exercises the chain: m68k-amigaos-gcc -> AmigaDOS ->
# serial.device -> JoeyLib serial HAL -> Paula's UART -> host. # 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 & > "$work/fsuae.log" 2>&1 &
fsuaePid=$! fsuaePid=$!
result=$(python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-client "$port" "$wall") result=$("$(joeyTool serialLoopPeer)" tcp-client "$port" "$wall")
rc=$? rc=$?
echo "$result" echo "$result"
if [ "$rc" -eq 0 ]; then if [ "$rc" -eq 0 ]; then

View file

@ -81,6 +81,7 @@ repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh" source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed HATARI hatari joeyNeed HATARI hatari
joeyNeed EMUTOS_IMG emutos joeyNeed EMUTOS_IMG emutos
stAudioGrade=$(joeyTool stAudioGrade) || exit 1
machine=st machine=st
if [ "${1:-}" = "--machine" ]; then if [ "${1:-}" = "--machine" ]; then
machine=${2:-} machine=${2:-}
@ -198,222 +199,4 @@ sleep 1
[ -s "$work/ym.log" ] || { echo "verify-atarist-audio: FAIL - empty PSG trace" >&2; exit 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' "$stAudioGrade" "$work/ym.log" "$work/baseline.log" "$prog" "$taps" "$machine"
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

View file

@ -44,6 +44,7 @@ source "$repo/scripts/lib/keysGate.sh"
joeyNeed HATARI hatari joeyNeed HATARI hatari
joeyNeed EMUTOS_IMG emutos joeyNeed EMUTOS_IMG emutos
joeyNeed EMUTOS_DE_IMG emutos joeyNeed EMUTOS_DE_IMG emutos
stScreenDump=$(joeyTool stScreenDump) || exit 1
bin_dir=$repo/build/atarist/bin bin_dir=$repo/build/atarist/bin
label=verify-atarist-input label=verify-atarist-input
@ -122,43 +123,7 @@ rowCheck() {
sleep 3 sleep 3
send "hatari-debug m \$$(printf '%x' "$rowAddr") 160" send "hatari-debug m \$$(printf '%x' "$rowAddr") 160"
sleep 2 sleep 2
python3 - "$work/hatari.log" "$rowAddr" "$1" <<'PY' "$stScreenDump" keys "$work/hatari.log" "$rowAddr" "$1"
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
} }
@ -195,28 +160,7 @@ keysRelease() {
keysRead() { keysRead() {
send "hatari-debug m \$$(printf '%x' "$cellAddr") 160" send "hatari-debug m \$$(printf '%x' "$cellAddr") 160"
sleep 1 sleep 1
python3 - "$work/hatari.log" "$cellAddr" <<'PY' "$stScreenDump" cells "$work/hatari.log" "$cellAddr"
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
} }
KEYS_SETTLE_SECONDS=1 KEYS_SETTLE_SECONDS=1
keysRunSteps kp8 kp2 kp4 kp6 kpenter keysRunSteps kp8 kp2 kp4 kp6 kpenter

View file

@ -28,13 +28,13 @@
# Hatari's [Joystick0] and [Joystick1] (a config file of the gate's own) are # 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 # keyboard joysticks on host keys, which xdotool holds on a private Xvfb
# display; Hatari's own screenshots (its command FIFO's screenshot shortcut) # 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. # then end JOY.
# #
# Usage: scripts/verify-atarist-joy.sh [atarist|ste] [MACHINE] # Usage: scripts/verify-atarist-joy.sh [atarist|ste] [MACHINE]
# (default atarist on an st; ste on an ste) # (default atarist on an st; ste on an ste)
# Env: JOY_SHOTS also keep the screenshots in this folder # Env: JOY_SHOTS also keep the screenshots in this folder
# Requires: Hatari, EmuTOS, Xvfb, xdotool, python3 + PIL; # Requires: Hatari, EmuTOS, Xvfb, xdotool;
# build/<build>/bin/JOY.PRG (make atarist / make ste). # build/<build>/bin/JOY.PRG (make atarist / make ste).
set -uo pipefail set -uo pipefail
@ -46,7 +46,7 @@ joeyNeed HATARI hatari
joeyNeed EMUTOS_IMG emutos joeyNeed EMUTOS_IMG emutos
build="${1:-atarist}" 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 case "$build" in
atarist) machine="${2:-st}"; JOY_SCREEN_ARGS=(--st) ;; atarist) machine="${2:-st}"; JOY_SCREEN_ARGS=(--st) ;;
ste) machine="${2:-ste}" ;; ste) machine="${2:-ste}" ;;
@ -184,7 +184,7 @@ joyShot() {
# joyStage -- whether a screenshot now shows JOY's stage. # joyStage -- whether a screenshot now shows JOY's stage.
joyStage() { 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
} }

View file

@ -71,58 +71,79 @@ for machine in $machines; do
--auto 'C:\PATTERN.PRG' 2>&1 \ --auto 'C:\PATTERN.PRG' 2>&1 \
| grep -E '^write col addr=ff82[45]' | grep -v ' pc=e' > "$work/$machine.log" | grep -E '^write col addr=ff82[45]' | grep -v ' pc=e' > "$work/$machine.log"
python3 - "$work/$machine.log" "$machine" "$build" <<'PY' || fails=$((fails + 1)) awk -v machine="$machine" -v build="$build" '
import re # One 4-bit channel as that build writes the shifter nibble.
import sys function channel(v) {
return build == "atarist" ? rshift(v, 1) : or(rshift(v, 1), lshift(and(v, 1), 3))
# 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]+) ') # A set of colour words as Python prints a sorted list of their hex.
PALETTE_BASE = 0x40 function hexList(set, count, names, i, n, out) {
COLORS = 16 n = 0
# PATTERN's eight palettes: which of (red, green, blue) carry the gradient. for (i = 0; i < count; i++) {
GRADIENTS = [(1, 1, 1), (1, 0, 0), (1, 1, 0), (0, 1, 0), names[++n] = sprintf("0x%x", set[i])
(0, 1, 1), (0, 0, 1), (1, 0, 1), (1, 1, 1)] }
asort(names)
out = ""
def channel(build, v): for (i = 1; i <= n; i++) {
"""One 4-bit channel as that build writes the shifter nibble.""" out = out (i > 1 ? ", " : "") "\x27" names[i] "\x27"
if build == 'atarist': }
return v >> 1 return "[" out "]"
return (v >> 1) | ((v & 1) << 3) }
BEGIN {
# PATTERN'"'"'s eight palettes: which of red, green, and blue carry
path, machine, build = sys.argv[1], sys.argv[2], sys.argv[3] # the gradient.
seen = [set() for _ in range(COLORS)] split("111 100 110 010 011 001 101 111", gradients, " ")
writes = 0 }
for line in open(path, errors='ignore'): # write col addr=ff825e col=70 video_cyc_w=55892 ... pc=1f00e
m = LINE.match(line) match($0, /^write col addr=ff82([45][0-9a-f]) col=([0-9a-f]+) /, m) {
if m: register = int((strtonum("0x" m[1]) - 64) / 2)
seen[(int(m.group(1), 16) - PALETTE_BASE) // 2].add(int(m.group(2), 16)) value = strtonum("0x" m[2])
writes += 1 if (!((register, value) in seen)) {
seen[register, value] = 1
bad = [] got[register, seenCount[register]++] = value
for i in range(COLORS): }
want = set() writes++
for r, g, b in GRADIENTS: }
v = channel(build, i) if i else 0 END {
want.add(((v if r else 0) << 8) | ((v if g else 0) << 4) | (v if b else 0)) bits = build == "atarist" ? "3-bit" : "4-bit"
if seen[i] != want: if (writes == 0) {
bad.append(f"register {i}: wrote {sorted(hex(v) for v in seen[i])}, " printf "verify-atarist-palette: %s build on %s FAIL - PATTERN never wrote the palette\n", build, machine > "/dev/stderr"
f"want {sorted(hex(v) for v in want)}") exit 1
}
bits = '3-bit' if build == 'atarist' else '4-bit' badCount = 0
if writes == 0: for (i = 0; i < 16; i++) {
print(f"verify-atarist-palette: {build} build on {machine} FAIL - PATTERN never wrote the palette", file=sys.stderr) delete want
sys.exit(1) delete wantList
if bad: wantCount = 0
print(f"verify-atarist-palette: {build} build on {machine} FAIL - not the {bits} encoding of PATTERN's palettes:", v = i ? channel(i) : 0
file=sys.stderr) for (g = 1; g <= 8; g++) {
for b in bad[:4]: 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)
print(f"verify-atarist-palette: {b}", file=sys.stderr) if (!(word in want)) {
sys.exit(1) want[word] = 1
print(f"verify-atarist-palette: {build} build on {machine} OK - {writes} palette writes, all 16 " wantList[wantCount++] = word
f"registers exactly the {bits} encoding of the 8 band palettes") }
PY }
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 done
if [ "$fails" -gt 0 ]; then if [ "$fails" -gt 0 ]; then

Some files were not shown because too many files have changed in this diff Show more