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

View file

@ -22,6 +22,7 @@
# make haiku builds Haiku x86_64 (the Be API backend, gcc 13)
# make haiku32 builds Haiku 32-bit x86 (the Be API backend, gcc 2.95, BeOS ABI)
# make beos builds BeOS R5 x86 (the same backend and compiler, R5's own headers and libraries)
# make tools builds the host tools (the asset, icon, sprite, and music converters) into build/tools
# make docs renders the manual to build/docs (HTML + PDF)
# make verify builds every port, then runs every gate (needs emulators)
# make verify-audio runs every port's audio gate (needs emulators)
@ -133,7 +134,7 @@ verify-audio:
@$(REPO_DIR)/scripts/verify-all.sh audio $(PORTS)
# Every recording port against the one audio contract (AUDPAR, graded by
# tools/gradeAudioConformance.py), sequentially, with the same exit codes.
# tests/gate/audioGate.c), sequentially, with the same exit codes.
# Takes a subset: make verify-conform PORTS="sdl3 dos"
verify-conform:
@$(REPO_DIR)/scripts/verify-all.sh conform $(PORTS)

View file

@ -173,24 +173,25 @@ make -f make/x68000.mk EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE
## Conformance gate
`examples/uber` is the conformance vehicle: it exercises every public op and
logs a surface hash per op. `tools/diff-uber-hashes <reference-log> <test-log>`
compares two `joeylog.txt` captures and exits non-zero on any mismatch. The
logs a surface hash per op.
`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
on all 51 hashes; the X68000 has no golden of its own and is diffed against
`tests/goldens/uber/iigs.txt`, which it matches exactly, and DVX, SDL3, the
web, and the PSP, which share the DOS stage, are diffed against
`tests/goldens/uber/dos.txt` (`scripts/bench-dvx.sh`, `scripts/bench-sdl3.sh`,
`scripts/bench-web.sh`, or `scripts/bench-psp.sh`, then
`tools/diff-uber-hashes`). SDL3's run needs no emulator and no X server
`build/tools/diffUberHashes`). SDL3's run needs no emulator and no X server
(SDL's dummy drivers) and takes about fifteen seconds; the web's runs UBER's
page in headless Chromium; the PSP's runs UBER under headless PPSSPP in a few
seconds, with every memory access checked as a PSP checks it, so an unaligned
16- or 32-bit access anywhere fails it:
```
scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
scripts/bench-sdl3.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
scripts/bench-web.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
scripts/bench-psp.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
```
The X68000 has gates of its own:
@ -232,17 +233,32 @@ src/m68k/ 68k code shared across machines (planar and word-
src/x86/ x86 code DOS and DVX share (the sprite emitter, the
whole-map tile walker, the set-1 scan code table)
src/blank/ copy-to-start template for a new port
tools/assetbake/ PNG -> native .tbk / .spr baker (Python)
tools/joeymod/ Protracker .MOD converter (passthrough or .NTP)
tools/spritebake/ offline .spr -> .spc pre-compiled sprite baker
tools/songbake/ chip-tracker text notation -> JYM1 stream
tools/vgm2song.py a tracker's VGM export (Furnace) -> chip-tracker .song
tools/iconBake.py one picture -> every system's program icon
tools/xdftool.py Human68k FAT12 .XDF disk image reader / writer
tools/diff-uber-hashes UBER golden-hash comparator
tools/ the host tools, all C (make tools builds them into build/tools/):
adfTool/ read files out of an Amiga disk image (.adf)
assetbake/ PNG -> native .tbk / .spr baker
common/ the C tools' shared code (PNG, DEFLATE, XML, JSON, base64)
diffUberHashes/ UBER golden-hash comparator
diffUberPerf/ UBER ops/sec comparator
iconBake/ one picture -> every system's program icon
iigsStart/ GS/OS boot launcher for the headless IIgs harnesses
joeymod/ Protracker .MOD converter (passthrough, .amod, or .NTP)
modloopend/ an E8F stop marker added to a .MOD (the Amiga's .amod)
modToNtp/ Protracker .MOD -> NinjaTrackerPlus .NTP (the IIgs)
rawCd/ a raw CD image's track or El Torito boot floppy
scanPtrBug/ scan IIgs assembly for a far-pointer miscompile pattern
songbake/ chip-tracker text notation -> JYM1 stream
spritebake/ offline .spr -> .spc pre-compiled sprite baker
szddExpand/ expand a COMPRESS.EXE (SZDD) file
tiled2joey/ a Tiled map (TMX or TMJ) -> tile sheet + tile maps
traceLogo/ the logo PNG -> its traced SVG (runs potrace)
uberPerfTable/ the UBER ops/sec table across ports
vgm2song/ a tracker's VGM export (Furnace) -> chip-tracker .song
winIni/ edit a Windows 3.x .INI file
xdftool/ Human68k FAT12 .XDF disk image reader / writer
examples/ example programs
joey-run run a built program in its emulator
scripts/ bench / verification scripts (scripts/lib: shared helpers)
tests/gate/ the gates' C helpers (screenshot, audio, and serial checks)
tests/goldens/uber/ frozen UBER conformance hashes
install.sh installs toolchains, emulators and firmware
installer/ the installer's component recipes, patches, lists
@ -410,7 +426,7 @@ void jlTilePasteMono (jlSurfaceT *dst, uint8_t bx, uint8_t by,
const jlTileT *in, uint8_t fgColor, uint8_t bgColor);
// Load up to maxTiles tiles from a baked .tbk file (per-target planar
// bytes from tools/assetbake/assetbake.py --type tile --target ...).
// bytes from tools/assetbake --type tile --target ...).
// Refuses files baked for the wrong target. outValid (optional) marks
// each loaded slot; outPalette (optional) receives the embedded
// 16-entry $0RGB palette if present.
@ -448,7 +464,7 @@ jlSpriteT *jlSpriteCreateFromSurface (const jlSurfaceT *src, int16_t x, int16_t
void jlSpriteDestroy (jlSpriteT *sp);
// Load up to maxCels cels from a baked .spr file (cross-target chunky
// 4bpp blob from tools/assetbake/assetbake.py --type sprite). Each
// 4bpp blob from tools/assetbake --type sprite). Each
// cel becomes a freshly-allocated jlSpriteT (release with
// jlSpriteDestroy). outPalette (optional) receives the embedded
// 16-entry $0RGB palette if present.
@ -494,8 +510,8 @@ uint32_t jlSpriteCodegenBytesTotal (void);
### Assets (baked at build time)
Asset PNGs live in each example's `assets/` directory and are baked
to native binary blobs at build time by `tools/assetbake/assetbake.py`
(Python+PIL). The runtime loads those blobs directly with no
to native binary blobs at build time by `tools/assetbake` (C, built
into `build/tools/`). The runtime loads those blobs directly with no
conversion -- there is no in-memory chunky-to-planar or palette
re-indexing on the device.
@ -513,8 +529,8 @@ Two blob formats:
Both formats embed an optional 16-entry `$0RGB` palette.
```
tools/assetbake/assetbake.py --type tile --target {amiga|atarist|dos|iigs|x68000} in.png out.tbk
tools/assetbake/assetbake.py --type sprite --cell WxH in.png out.spr
build/tools/assetbake --type tile --target {amiga|atarist|dos|iigs|x68000} in.png out.tbk
build/tools/assetbake --type sprite --cell WxH in.png out.spr
```
A program's description wires its bake rules once for every target
@ -533,7 +549,7 @@ on-the-fly path stays alongside the baked load API.
Every program gets an icon on each system that shows programs by one --
the IIgs Finder, Workbench, the Program Managers of DVX and Windows 3.1,
Explorer, Tracker, the PSP's menu, the SDL3 window, and the web page --
baked by `tools/iconBake.py` from one picture: the program's `ID_ICON` (a
baked by `tools/iconBake` from one picture: the program's `ID_ICON` (a
PNG), or JoeyLib's kangaroo (`assets/joeyIcon.png`). `docs/BuildIntegration.adoc`
(Icons) says what each system gets, and `scripts/verify-all.sh icon` checks
that each one shows it.
@ -543,7 +559,7 @@ that each one shows it.
`docs/Assets.adoc` (Content Creation Tools) recommends a tool for each kind
of asset: GrafX2 or LibreSprite for tile and sprite sheets, Furnace for
chip-tracker music (`./install.sh install furnace`; its VGM exports become
`.song` files through `tools/vgm2song.py`), MilkyTracker or OpenMPT for
`.song` files through `tools/vgm2song`), MilkyTracker or OpenMPT for
`.MOD` music, and sfxr, jfxr, Audacity, or SoX for sound effects.
@ -643,7 +659,7 @@ void jlAudioStopSfx (uint8_t slot);
// Portable chip-tracker music: a JYM1 event stream baked by tools/songbake
// (from text notation, or from a tracker: write it in Furnace for the
// SN76489 or AY-3-8910, export VGM, convert with tools/vgm2song.py),
// SN76489 or AY-3-8910, export VGM, convert with tools/vgm2song),
// played through the tone/noise layer at register-write cost on every port.
// No CPU mixing, so it is viable where jlAudioPlayMod is not.
bool jlMusicPlay (const uint8_t *data, uint32_t length, bool loop);
@ -769,9 +785,13 @@ Building with `EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE` additionally mirrors every
unbounded, needs no disk, and arrives in real time, which is what makes a
wedged run diagnosable. It is OFF by default and is currently implemented
only for the X68000 (`jlpLogTee` in `src/x68000/hal.c`); the host end is
`scripts/x68kSerialPeer.py`, which must be run under `python3 -u` -- it never
passes `flush=True`, so a killed peer loses its whole capture to stdio
buffering.
the `x68kSerialPeer` gate tool (`tests/gate/x68kSerialPeer.c`, built by
`make -f make/tools.mk x68kSerialPeer` into `build/tools/`), whose output is
line-buffered, so a killed peer keeps everything it captured:
```
build/tools/x68kSerialPeer --listen-only --until '----- end -----' --timeout 3600 | tee x68.log
```
### Platform macros (`joey/platform.h`)

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.
*Returns:* the nominal display frame rate in Hz -- 60 on the IIgs, or 50 on a
PAL machine; 50 on the Amiga, whose screen is PAL, or 60 on an NTSC Amiga that
cannot show PAL; 50 or 60 on the ST, as the machine is set (71 with a
PAL machine; 50 on a PAL Amiga and 60 on an NTSC one, whose screen is in the
machine's own standard; 50 or 60 on the ST, as the machine is set (71 with a
monochrome monitor); 55 on the X68000; 70 on DOS's VGA and on DVX, whose
window has a virtual 70 Hz (and whose full screen is mode 13h's, or a VBE
mode's, with the same frame count); and 60 on every hosted port, whose window

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
|Indexed PNG tile sheet
|`assetbake.py --type tile`
|`assetbake --type tile`
|`.tbk`
|`jlTileBankLoad`, `jlTileBankLoadToSurface`
|A Tiled map (TMX or TMJ) with indexed tilesets
|`tiled2joey.py`, then `assetbake.py --type tile` for its sheet
|`tiled2joey`, then `assetbake --type tile` for its sheet
|a `.map` per layer, and a `.tbk`
|`jlTileMapPaste` (the map read with `jlDataOpen`)
|Indexed PNG sprite sheet
|`assetbake.py --type sprite`
|`assetbake --type sprite`
|`.spr`
|`jlSpriteBankLoad`
@ -32,12 +32,12 @@ type: what you author, which tool bakes it, and which API call loads it.
|`jlSpriteBankLoadPrecompiled`
|Text note notation
|`songbake.py`
|`songbake`
|`.jym` or a C header
|`jlMusicPlay`
|A tracker song (Furnace) for the SN76489 or AY-3-8910, exported as VGM
|`vgm2song.py`, then `songbake.py`
|`vgm2song`, then `songbake`
|`.jym` or a C header
|`jlMusicPlay`
@ -55,6 +55,9 @@ type: what you author, which tool bakes it, and which API call loads it.
Baked assets are staged into `DATA/` beside the executable, and the program
opens them with bare names.
The bakers are C programs built for the computer you build on: `make tools`
puts them in `build/tools/`, and a port's build makes the ones its rules run.
=== Content Creation Tools
These are the tools we recommend for making each kind of asset. All of them
@ -77,7 +80,7 @@ run on Linux, and all but OpenMPT are native there.
|Tile maps
|*Tiled* (GPLv2; what you make with it is yours), a map editor that places
tiles from tilesets in layers.
|Orthogonal maps of 8x8 tiles from indexed tilesets, then `tiled2joey.py`
|Orthogonal maps of 8x8 tiles from indexed tilesets, then `tiled2joey`
(<<_maps_from_tiled>>).
|Program icons
@ -131,7 +134,7 @@ row by row.
[source,console]
----
$ python3 tools/assetbake/assetbake.py --type tile --target atarist \
$ build/tools/assetbake --type tile --target atarist \
font.png build/atarist/bin/DATA/font.tbk
----
@ -145,16 +148,16 @@ loader refuses a file baked for a different target rather than drawing garbage.
==== Maps from Tiled
`tools/tiled2joey.py` turns a map made in Tiled into what a JoeyLib program
pastes: a tile sheet, which `assetbake.py` bakes into a tile bank for each
`tools/tiled2joey` turns a map made in Tiled into what a JoeyLib program
pastes: a tile sheet, which `assetbake` bakes into a tile bank for each
port, and a map for each tile layer, which `jlTileMapPaste` takes as it is:
[source,console]
----
$ python3 tools/tiled2joey.py --header build/level1.h level1.tmx \
$ build/tools/tiled2joey --header build/level1.h level1.tmx \
build/level1.png build/level1
tiled2joey: 60x25 map, 87 tiles, 14 colours, layers: ground.map, detail.map
$ python3 tools/assetbake/assetbake.py --type tile --target iigs \
$ build/tools/assetbake --type tile --target iigs \
build/level1.png build/iigs/bin/DATA/level1.tbk
----
@ -202,7 +205,7 @@ layer = jlTileLayerCreate(tiles, level, LEVEL1_WIDTH, LEVEL1_HEIGHT, 0, 25);
[source,console]
----
$ python3 tools/assetbake/assetbake.py --type sprite --cell 2x3 \
$ build/tools/assetbake --type sprite --cell 2x3 \
player.png build/atarist/bin/DATA/player.spr
----
@ -295,8 +298,8 @@ Bake it either as a binary blob or directly as a C array:
[source,console]
----
$ python3 tools/songbake/songbake.py song.song song.jym
$ python3 tools/songbake/songbake.py -c gDemoSong song.song song.h
$ build/tools/songbake song.song song.jym
$ build/tools/songbake -c gDemoSong song.song song.h
----
The C header form is usually what you want for a small song: it compiles into
@ -313,7 +316,7 @@ same time; `songbake` warns when they overlap.
A tracker is easier than a text file for anything longer than a jingle. Write
the song in Furnace, export it as VGM (a log of the chip's register writes as
the song plays), and `tools/vgm2song.py` turns that into a `.song` file, which
the song plays), and `tools/vgm2song` turns that into a `.song` file, which
is still plain text you can read and touch up:
. Start the song on a system with a *TI SN76489* (the Sega Master System, BBC
@ -328,8 +331,8 @@ is still plain text you can read and touch up:
+
[source,console]
----
$ python3 tools/vgm2song.py song.vgm song.song
$ python3 tools/songbake/songbake.py -c gSong song.song song.h
$ build/tools/vgm2song song.vgm song.song
$ build/tools/songbake -c gSong song.song song.h
----
What comes across:
@ -384,10 +387,9 @@ The output extension selects the conversion:
does not loop at the marker too, and the IIgs's replayer ignores `E8F`, so
an `.amod` plays the same anywhere (looping, with the marker's pattern of
silence at its end).
* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, produced by the
NinjaTrackerPlus converter the installer's `ntp` component provides (part of
`toolchain:iigs`; it needs the PHP command-line interpreter, which the
installer's host packages include).
* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, made by JoeyLib's
`modToNtp` (`build/tools/modToNtp IN.MOD OUT.NTP`, which `joeymod` builds
and runs). Its files are byte for byte those of Ninjaforce's own converter.
Keep modules modest. A four-channel mix is real CPU work on DOS, the ST, and
the X68000, and the sample data has to fit in memory alongside your game.
@ -459,18 +461,19 @@ And a bake rule looks like:
[source,makefile]
----
$(call programDataDir,MYGAME)/font.tbk: assets/font.png
$(call programDataDir,MYGAME)/font.tbk: assets/font.png $(ASSET_BAKE)
@mkdir -p $(dir $@)
python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type tile --target $(ASSET_PLATFORM) $< $@
$(ASSET_BAKE) --type tile --target $(ASSET_PLATFORM) $< $@
----
Tile banks and `.spc` files differ per platform, while `.spr`, `.jym`, and
`.sfx` files are shared, as are the `.map` files `tiled2joey.py` writes:
`.sfx` files are shared, as are the `.map` files `tiled2joey` writes:
`ASSET_PLATFORM` names the target a bake is for (DVX and every hosted port --
SDL3, the web, macOS, Win32, Win16, the PSP, Haiku, and BeOS -- take the DOS
bakes; DVX and Win16 run the x86 code of a `.spc`, the others use only its
cels; the STe build takes the ST's) and `SPRITEBAKE` the matching host
`spritebake`.
cels; the STe build takes the ST's), `SPRITEBAKE` the matching host
`spritebake`, and `ASSET_BAKE` the host `assetbake`; a rule that runs a baker
lists it as a prerequisite, so make builds it first.
`examples/tilebank` is a working reference: one rule in `make/examples.mk`
bakes the same PNG for each target into its `DATA/tilebank` (`TILEBANK_DATA`),
@ -509,16 +512,21 @@ and the streaming loader needs only a small internal scratch buffer.
=== The Logo
The JoeyLib logo lives in `assets/` as both the original 320x200 PNG and a
traced SVG. If the source art ever changes, regenerate the vector with:
traced SVG. If the source art ever changes, regenerate the vector with
`tools/traceLogo`, which needs the potrace program (the distribution's
`potrace` package, or set `POTRACE` to one installed elsewhere):
[source,console]
----
$ python3 -m venv /tmp/tracevenv
$ /tmp/tracevenv/bin/pip install potracer pillow numpy
$ /tmp/tracevenv/bin/python tools/traceLogo.py \
assets/joeylib_logo_-_320x200.png assets/joeylibLogo.svg --colors 5
$ make -f make/tools.mk traceLogo
$ build/tools/traceLogo assets/joeylib_logo_-_320x200.png \
assets/joeylibLogo.svg
----
Five colour clusters measured as the closest match to the original artwork;
more clusters start carving the antialiased edges into separate layers, which
renders as noise rather than detail.
The art is four flat colours (ink, skin, the green shorts, and the beige
monitor), so the default of four clusters is the trace that looks best: each
layer is filled with the exact colour most of its pixels have, and the
clustering is done in CIELAB, where an antialiased grey edge joins the ink or
the beige it lies between rather than the skin colour nearest it in RGB. A
fifth cluster and beyond only carve those grey edges into layers of their
own, which render as speckled outlines rather than detail.

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
JoeyLib's).
|`PROJECT_DIR`, `PROJECT_BUILD` |The project and its `build/`.
|`REPO_DIR` |The JoeyLib checkout, for its tools: `$(REPO_DIR)/tools/assetbake/assetbake.py`.
|`ASSET_BAKE` |The host `assetbake` (its rule is JoeyLib's).
|`REPO_DIR` |The JoeyLib checkout.
|`HOST_CC`, `HOST_CFLAGS` |For a host tool of the project's own.
|===
@ -220,13 +221,13 @@ $(MYGAME_RUN)/theme.jym: assets/theme.jym
@mkdir -p $(dir $@)
cp $< $@
$(MYGAME_RUN)/tiles.tbk: assets/tiles.png
$(MYGAME_RUN)/tiles.tbk: assets/tiles.png $(ASSET_BAKE)
@mkdir -p $(dir $@)
python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type tile --target $(ASSET_PLATFORM) $< $@
$(ASSET_BAKE) --type tile --target $(ASSET_PLATFORM) $< $@
$(MYGAME_GEN)/player.spr: assets/player.png
$(MYGAME_GEN)/player.spr: assets/player.png $(ASSET_BAKE)
@mkdir -p $(dir $@)
python3 $(REPO_DIR)/tools/assetbake/assetbake.py --type sprite --cell 16x16 $< $@
$(ASSET_BAKE) --type sprite --cell 16x16 $< $@
$(MYGAME_RUN)/player.spc: $(MYGAME_GEN)/player.spr $(SPRITEBAKE)
@mkdir -p $(dir $@)
@ -333,7 +334,7 @@ stick; the PSP shows it by its icon and its `ID_TITLE`. The unstripped ELF stays
A program has an icon on every system that shows programs by one. `ID_ICON`
names a picture, a PNG, best square and transparent around the figure, and
`make` bakes it for each target with `tools/iconBake.py`; a program without
`make` bakes it for each target with `tools/iconBake`; a program without
one gets JoeyLib's kangaroo, `assets/joeyIcon.png`. A picture of 64 pixels or
less is taken for pixel art and enlarged by whole pixels; a bigger one is
resampled smoothly. Where an icon has few colours, each pixel takes the
@ -469,7 +470,7 @@ if a gate had to be skipped.
|`scripts/bench-<port>.sh`
|Per-operation throughput for that port.
|`tools/diff-uber-hashes`
|`build/tools/diffUberHashes`
|Compares a port's rendered output against the IIgs golden reference,
operation by operation, by surface hash.
|===

View file

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

View file

@ -140,8 +140,8 @@ too.
==== Frame-Rate Independence
The targets do not share a refresh rate: 50 Hz (the Amiga, and a PAL IIgs or
ST), 55 Hz (X68000), 60 Hz (the IIgs, an NTSC ST, and every hosted port's
The targets do not share a refresh rate: 50 Hz (a PAL Amiga, IIgs, or ST),
55 Hz (X68000), 60 Hz (an NTSC Amiga, IIgs, or ST, and every hosted port's
virtual rate), and 70 Hz (VGA mode 13h, and DVX's virtual rate). A loop
that moves a sprite "two pixels per frame" runs 40 percent faster on DOS than
on a PAL Amiga.

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:
// 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
#define JOEYLIB_BAKED_GDEMOSONG_H

View file

@ -5,7 +5,7 @@
; around the drum rows because the ST shares channel C's volume
; between tone voice 2 and noise.
;
; Bake: python3 tools/songbake/songbake.py -c gDemoSong \
; Bake: build/tools/songbake -c gDemoSong \
; examples/audio/demoSong.song examples/audio/demoSong.h
tickms 110

View file

@ -22,7 +22,7 @@
// select-then-write register sequence in the path of both channels'
// interrupts for the whole run.
// SPACE pauses and resumes transmitting. The peer answers byte for byte
// (scripts/lib/serialStreamPeer.py), so a pause quiets the line both ways.
// (tests/gate/serialStreamPeer.c), so a pause quiets the line both ways.
// ESC quits.
//
// Mouse: a small pointer follows it.
@ -290,7 +290,7 @@ static void stampSerialRow(jlSurfaceT *screen, const StreamStatsT *stats, uint16
// Byte `index` of a stream: the index's low byte XOR its next byte, XOR the
// stream's mask. A lost or repeated byte throws every byte after it off its
// expected value, where a plain counter would miss any gap that is a multiple
// of 256. scripts/lib/serialStreamPeer.py uses the same formula and masks.
// of 256. tests/gate/serialStreamPeer.c uses the same formula and masks.
static uint8_t streamByte(uint32_t index, uint8_t mask) {
return (uint8_t)(index ^ (index >> BYTE_BITS) ^ mask);
}

View file

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

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
// FNV-1a hash of the surface state after timing -- this is the
// pixel-perfect comparison input for the cross-port validation
// harness (tools/diff-uber-hashes.py). Captured against IIgs as the
// harness (build/tools/diffUberHashes). Captured against IIgs as the
// golden reference; planar 68k rewrites validate by matching it.
// The window is `frames` long. settle (NULL for none) runs between the
// timing and the hash, for an op whose last call leaves a picture that
@ -1704,7 +1704,7 @@ static void DRAWSHOWCASE_ATTR drawShowcase(void) {
// ----- Non-timed correctness checks (Phase 0 verification harness) -----
//
// Each check draws a deterministic scene and logs the surface hash so
// tools/diff-uber-hashes can compare ports against each other and against
// build/tools/diffUberHashes can compare ports against each other and against
// the frozen golden logs. Several checks intentionally capture the CURRENT
// behavior of known bugs (claudeDocs/PERF-AUDIT.md #1, #11, #12, #72): their hash
// lines are EXPECTED to change when the Phase 1 fixes land -- re-golden

View file

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

View file

@ -61,7 +61,7 @@ typedef struct {
} jlSpriteBackupT;
// Load up to maxCels cels from a baked .spr file produced by
// tools/assetbake/assetbake.py. Each cel becomes a freshly-allocated
// tools/assetbake/assetbake.c. Each cel becomes a freshly-allocated
// jlSpriteT (release with jlSpriteDestroy); the file's cellCount is
// capped at maxCels.
//

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);
// Load up to maxTiles tiles from a baked .tbk file produced by
// tools/assetbake/assetbake.py. The file's per-target byte is checked
// tools/assetbake/assetbake.c. The file's per-target byte is checked
// against the build platform and the loader refuses mismatches.
//
// outValid (optional, NULL OK) gets one flag per slot in [0, maxTiles):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,14 +5,17 @@
#
# Host tools every JoeyLib setup needs: a native compiler for the in-repo
# tools, GNU make, git with LFS (the sample assets live in LFS), the
# archive tools the other components unpack with, Python 3 with Pillow
# for the asset converters, and socat for joey-run's serial bridging.
# archive tools the other components unpack with, socat for joey-run's
# serial bridging, gawk and patch for the scripts and the IIgs build, and
# potrace for tools/traceLogo. JoeyLib's own tools and gates are C and
# bash; the components that build third-party code with Python list python3
# themselves.
compDescription="host tools: make, host compiler, git-lfs, python3 + Pillow, archivers, socat"
compDescription="host tools: make, host compiler, git-lfs, archivers, socat, potrace"
# Not in any port group: every other component requires it.
compGroups="host"
compRequires=""
compHostDeps="build-tools make git git-lfs curl tar xz bzip2 unzip lhasa python3 python3-pil procps diffutils socat"
compHostDeps="build-tools make gawk patch git git-lfs curl tar xz bzip2 unzip lhasa procps diffutils socat potrace"
compPin="1"
@ -48,7 +51,7 @@ compInstall() {
compInstalled() {
command -v make >/dev/null && command -v cc >/dev/null && command -v python3 >/dev/null && command -v git-lfs >/dev/null
command -v make >/dev/null && command -v cc >/dev/null && command -v git-lfs >/dev/null && command -v gawk >/dev/null && command -v potrace >/dev/null
}
@ -61,7 +64,6 @@ compVerify() {
[[ "$makeVersion" =~ ([0-9]+\.[0-9]+) ]] && makeVersion="${BASH_REMATCH[1]}"
# Grouped targets (&:) in the port makefiles need GNU make 4.3.
printf '%s\n4.3\n' "$makeVersion" | sort -V -C -r || { warn "GNU make $makeVersion is older than 4.3"; return 1; }
python3 -c 'import PIL' || { warn "python3 cannot import PIL (Pillow)"; return 1; }
stubs="$(lfsPointerFiles | wc -l)"
[ "$stubs" = "0" ] || { warn "$stubs Git LFS files are still pointer stubs"; return 1; }
}

View file

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

View file

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

View file

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

View file

@ -19,9 +19,10 @@ unzip unzip unzip
wget wget wget
lhasa lhasa lhasa
python3 python3 python3
python3-pil python3-pil python3-pillow
php-cli php-cli php-cli
patch patch patch
gawk gawk gawk
# tools/traceLogo traces the logo art with the potrace program.
potrace potrace potrace
# joey-run --serial bridges some emulators' serial ports to TCP with socat.
socat socat socat

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).
BEOS_SYSTEM_TRACK_FIRST=35109
BEOS_SYSTEM_TRACK_SECTORS=148780
BEOS_RAW_CD_TOOL="tools/rawCd.py"
# The VM's disks, in the component's folder.
BEOS_VM_SYSTEM_DISK="system.img"
BEOS_VM_FLOPPY="floppy.img"
@ -92,7 +91,7 @@ EOF
beosSystemVolume() {
privateEnsure "$BEOS_CD_FILE"
python3 "$JOEY_REPO/$BEOS_RAW_CD_TOOL" track "$JOEY_REPO/$BEOS_CD_FILE" "$BEOS_SYSTEM_TRACK_FIRST" "$BEOS_SYSTEM_TRACK_SECTORS" "$1" ||
"$(hostTool rawCd)" track "$JOEY_REPO/$BEOS_CD_FILE" "$BEOS_SYSTEM_TRACK_FIRST" "$BEOS_SYSTEM_TRACK_SECTORS" "$1" ||
die "could not read the x86 track of $BEOS_CD_FILE"
}
@ -111,7 +110,7 @@ beosVmPrepare() {
rm -rf "$part"
mkdir -p "$part"
beosSystemVolume "$part/$BEOS_VM_SYSTEM_DISK"
python3 "$JOEY_REPO/$BEOS_RAW_CD_TOOL" eltorito "$JOEY_REPO/$BEOS_CD_FILE" "$part/$BEOS_VM_FLOPPY" ||
"$(hostTool rawCd)" eltorito "$JOEY_REPO/$BEOS_CD_FILE" "$part/$BEOS_VM_FLOPPY" ||
die "could not read the boot floppy image of $BEOS_CD_FILE"
beosJobRunner > "$part/UserBootscript"
printf '%s\n' "$BEOS_KERNEL_SETTINGS" > "$part/kernel"

View file

@ -87,7 +87,7 @@ importPaths() {
*.adf|*.ADF)
rm -rf "$tmp/adf"
mkdir -p "$tmp/adf"
if python3 "$JOEY_REPO/tools/adfTool.py" extractall "$file" "$tmp/adf" 2>/dev/null; then
if "$(hostTool adfTool)" extractall "$file" "$tmp/adf" 2>/dev/null; then
find "$tmp/adf" -type f -print0 | while IFS= read -r -d '' inner; do
importOneFile "$inner"
done

View file

@ -136,8 +136,8 @@ The upstream file changed. Check it, then update the pin in the component file."
fetchOciBlob() {
local token=""
if [ "$(sha256Of "$JOEY_CACHE/$3" 2>/dev/null || true)" != "$2" ]; then
token="$(curl -fsSL "https://ghcr.io/token?scope=repository:$1:pull" | python3 -c 'import json, sys; print(json.load(sys.stdin)["token"])')" \
|| die "cannot get a registry token for $1"
token="$(curl -fsSL "https://ghcr.io/token?scope=repository:$1:pull" | sed -n 's/.*"token" *: *"\([^"]*\)".*/\1/p')" \
&& [ -n "$token" ] || die "cannot get a registry token for $1"
fi
fetchFile "https://ghcr.io/v2/$1/blobs/sha256:$2" "$2" "$3" "Authorization: Bearer $token"
}
@ -199,6 +199,15 @@ freshBuildDir() {
}
# hostTool NAME -- print the path of one of JoeyLib's host tools
# (make/tools.mk's, in build/tools/), built first if it is not current; a
# component that runs one lists "build-tools make" among its compHostDeps.
hostTool() {
make -s -f "$JOEY_REPO/make/tools.mk" "$1" > /dev/null || die "cannot build tools/$1 (the host component installs make and a C compiler)"
printf '%s\n' "${JOEY_BUILD:-$JOEY_REPO/build}/tools/$1"
}
# dropBuildDir ID -- delete ID's scratch tree unless --keep-build.
dropBuildDir() {
if [ "$JOEY_KEEP_BUILD" = "1" ]; then

View file

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

View file

@ -30,7 +30,8 @@ WIN16_FIRMWARE="toolchains/firmware/win16"
WIN16_ISO="$WIN16_FIRMWARE/wfw311.iso"
WIN16_S3_FILES="S3TRIO.DRV VDDS3764.386 VGA_ENG.3GR"
WIN16_DOSBOX_CONF="scripts/dosbox-win16.conf"
WIN16_INI_TOOL="tools/winIni.py"
# 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.
WIN16_SETUP_SECONDS=300
@ -43,7 +44,7 @@ win16Configure() {
for name in $WIN16_S3_FILES; do
cp "$JOEY_REPO/$WIN16_FIRMWARE/s3/$name" "$windows/SYSTEM/$name"
done
python3 "$JOEY_REPO/$WIN16_INI_TOOL" "$windows/SYSTEM.INI" \
"$(hostTool winIni)" "$windows/SYSTEM.INI" \
set boot display.drv s3trio.drv \
set boot 386grabber vga_eng.3gr \
set boot.description display.drv "S3 Trio64V 1.70.04 800x600 256 C SF" \
@ -84,7 +85,7 @@ win16Pin() {
privateSha1For "$file"
done
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
}

View file

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

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)
-include $(call programBin,$(1)).d
$(call programBin,$(1)).info: $(call programIcon,$(1)) $(ICON_BAKE_DEPS)
$(call programBin,$(1)).info: $(call programIcon,$(1)) $(ICON_BAKE)
@mkdir -p $$(dir $$@)
$(ICON_BAKE) info $$< $$@ $(AMIGA_ICON_STACK)
endef

View file

@ -26,7 +26,7 @@ HOSTED_SDK_CFLAGS :=
HOSTED_LINK := -lbe -lmedia -ldevice $(BE_CXX_RUNTIME)
PROGRAM_EXT :=
# Its icons, the BeOS kind Haiku shows too: 32x32 and 16x16 in the BeOS
# palette, raw (tools/iconBake.py be), put into the linked program's
# palette, raw (tools/iconBake be), put into the linked program's
# resources by xres (component haikuTools) as BEOS:L:STD_ICON and
# BEOS:M:STD_ICON, BE_ICON_ID each; and the 32x32 one as Haiku's vector
# icon, BEOS:ICON, which Haiku draws at any size (BeOS R5 ignores it). An

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
# IIgs disk scripts look icons up by it), or by its id for a program built
# apart (ID_BINDIR: a probe build may share a shipped program's name).
# ICON_BAKE is the baker (tools/iconBake.py says what each port gets); a rule
# that runs it depends on ICON_BAKE_DEPS too, so a change to the baker
# rebakes.
programIcon = $(or $(call programVar,$(1),ICON),$(REPO_DIR)/assets/joeyIcon.png)
programIconDir = $(PROGRAM_BUILD)/icon/$(if $(call programVar,$(1),BINDIR),$(1),$(call programName,$(1)))
ICON_BAKE := python3 $(REPO_DIR)/tools/iconBake.py
ICON_BAKE_DEPS := $(REPO_DIR)/tools/iconBake.py
# The host bakers a port's rules run, built by make/tools.mk (make/programs.mk
# hands their builds to it): ASSET_BAKE (tools/assetbake, indexed PNGs into
# tile banks and sprite cels), ICON_BAKE (tools/iconBake, a program's icon
# for each system; its usage says what each port gets), and MOD_TO_NTP
# (tools/modToNtp, a .MOD as the IIgs's .NTP, which tools/joeymod runs). A
# rule that runs one depends on it too, so a change to the baker rebakes.
ASSET_BAKE := $(JOEY_BUILD)/tools/assetbake
ICON_BAKE := $(JOEY_BUILD)/tools/iconBake
MOD_TO_NTP := $(JOEY_BUILD)/tools/modToNtp
# Rules that hand a host tool's build to make/tools.mk depend on FORCE, so

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
# 32x32 BMP on DVX's grey, which sdk.mk takes from the project's folder.
$(APPSDIR)/$(2)/icon32.bmp: $(call programIcon,$(1)) $(ICON_BAKE_DEPS)
$(APPSDIR)/$(2)/icon32.bmp: $(call programIcon,$(1)) $(ICON_BAKE)
@mkdir -p $$(dir $$@)
$(ICON_BAKE) bmp $$< $$@
endef

View file

@ -8,7 +8,7 @@
#
# Worth knowing which ones the gates use:
# UBER the golden-hash vehicle: every public op, a hash per op, which
# tools/diff-uber-hashes compares against tests/goldens/uber (the
# build/tools/diffUberHashes compares against tests/goldens/uber (the
# X68000 against the IIgs's, DVX against the DOS port's: the same
# stage). UBERPROB is UBER with its UBER_PROBE rows.
# PATTERN the SCB acceptance vehicle: 8 bands of 25 lines with 8 gradient
@ -208,7 +208,7 @@ AUDPAR_IIGS_BINDIR := $(PROGRAM_BUILD)/probe/bin
AUDPAR_DATA := $(call programDataDir,AUDPAR)/test.mod
AUDPAR_IIGS_DATA := $(call programDataDir,AUDPAR)/TEST.NTP
$(call programDataDir,AUDPAR)/TEST.NTP: $(REPO_DIR)/assets/test.mod $(REPO_DIR)/tools/joeymod/joeymod
$(call programDataDir,AUDPAR)/TEST.NTP: $(REPO_DIR)/assets/test.mod $(REPO_DIR)/tools/joeymod/joeymod $(MOD_TO_NTP)
@mkdir -p $(dir $@)
$(REPO_DIR)/tools/joeymod/joeymod $< $@
@ -230,9 +230,13 @@ $(call programDataDir,AUDIO)/%: $(REPO_DIR)/assets/%
# DATA/tilebank holds this port's bake of it, every other target's bake (each
# must be refused) and the expected pixels.
TILEBANK_PNG := $(REPO_DIR)/tests/host/tileBank.png
TILEBANK_STAGE := $(REPO_DIR)/tests/host/tileBankStage.py
TILEBANK_BAKE_DEPS := $(TILEBANK_PNG) $(TILEBANK_STAGE) $(REPO_DIR)/tools/assetbake/assetbake.py
TILEBANK_STAGE := $(REPO_DIR)/tests/host/tileBankStage.sh
TILEBANK_EXPECT := $(JOEY_BUILD)/tools/tileBankExpect
TILEBANK_BAKE_DEPS := $(TILEBANK_PNG) $(TILEBANK_STAGE) $(ASSET_BAKE) $(TILEBANK_EXPECT)
TILEBANK_DATA := $(call programDataDir,TILEBANK)/tilebank/own.tbk
$(TILEBANK_EXPECT): FORCE
$(MAKE) -f $(REPO_DIR)/make/tools.mk $(notdir $@)
$(TILEBANK_DATA): $(TILEBANK_BAKE_DEPS)
python3 $(TILEBANK_STAGE) $(ASSET_PLATFORM) $(TILEBANK_PNG) $(dir $@)
$(TILEBANK_STAGE) $(ASSET_PLATFORM) $(TILEBANK_PNG) $(dir $@)

View file

@ -30,7 +30,7 @@
# HOSTED_LINK_OUT, the file one program is linked to, and HOSTED_PACK, the
# command that packs it into the program file ($(1) the id in both).
# A port whose programs show an icon (make/common.mk's programIcon, baked by
# tools/iconBake.py) sets HOSTED_ICON, the file one program's icon is baked
# tools/iconBake) sets HOSTED_ICON, the file one program's icon is baked
# to, and HOSTED_ICON_BAKE, the command that bakes it there from the
# program's picture; the program is rebuilt after it, and HOSTED_ICON_LINK
# is what of it the link takes (a resource object, a resource script), or
@ -160,14 +160,14 @@ $(if $(call HOSTED_ICON,$(1)),$(call hostedIconRules,$(1)))
endef
# $(call hostedIconScript,ID,FORMAT): a Windows port's bake: the program's
# icon in iconBake.py's FORMAT (ico, ico16) as icon.ico in its icon folder,
# icon in iconBake's FORMAT (ico, ico16) as icon.ico in its icon folder,
# and icon.rc, the resource script that gives it the number HOSTED_ICON_ID
# (by its whole path, so the resource compiler may run anywhere).
hostedIconScript = $(ICON_BAKE) $(2) $(call programIcon,$(1)) $(call programIconDir,$(1))/icon.ico && printf '%s ICON "%s"\n' $(HOSTED_ICON_ID) $(call programIconDir,$(1))/icon.ico > $(call programIconDir,$(1))/icon.rc
# One program's icon, $(1) its id, baked by the port's command.
define hostedIconRules
$(call HOSTED_ICON,$(1)): $(call programIcon,$(1)) $(ICON_BAKE_DEPS)
$(call HOSTED_ICON,$(1)): $(call programIcon,$(1)) $(ICON_BAKE)
@mkdir -p $$(dir $$@)
$(call HOSTED_ICON_BAKE,$(1))
endef

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
# disk at run time (src/iigs/audio_full.c NTP_PATH), so every disk a program
# goes on carries it (make-iigs-disk.sh). The source is assembled
# with JoeyLib's interrupt window added to NTPstreamsound (NTP_PATCH).
# with JoeyLib's interrupt window added to NTPstreamsound (NTP_PATCH, applied
# exactly: a hunk that does not apply fails the build).
NTP_SRC := $(IIGS_NTP_DIR)/ninjatrackerplus.s
NTP_PATCH := $(REPO_DIR)/make/support/iigs/ntpIrqWindow.py
NTP_PATCH := $(REPO_DIR)/make/support/iigs/ntpIrqWindow.patch
NTP_BIN := $(BUILD)/audio/ntpplayer.bin
IIGS_MERLIN := $(IIGS_AS)
$(NTP_BIN): $(NTP_SRC) $(NTP_PATCH) $(IIGS_MERLIN)
@mkdir -p $(BUILD)/audio
@python3 $(NTP_PATCH) $(NTP_SRC) $(BUILD)/audio/ninjatrackerplus.s
patch --quiet --fuzz=0 --no-backup-if-mismatch -r - -o $(BUILD)/audio/ninjatrackerplus.s $(NTP_SRC) $(NTP_PATCH)
cd $(BUILD)/audio && $(IIGS_MERLIN) . ninjatrackerplus.s
mv $(BUILD)/audio/ntpplayer $@
@ -117,7 +118,7 @@ programIconFile = $(call programIconDir,$(1))/finder.icons
programTarget = $(call programBin,$(1)) $(call programIconFile,$(1))
# One program, $(1) its id: clang-build.sh compiles, links and wraps it, and
# writes one header-dependency file for all its sources; iconBake.py bakes
# writes one header-dependency file for all its sources; iconBake bakes
# its Finder icon. ID_IIGS_OVERFLOW_CAP
# opts into large overflow segments (see clang-build.sh). NTP rides on every
# disk a program goes on, so it is built first.
@ -127,7 +128,7 @@ $(call programBin,$(1)): $(call programVar,$(1),SRCS) $(LIB) $(IIGS_CLANG_BUILD)
@mkdir -p $$(dir $$@) $(DEP_DIR)
$(IIGS_CLANG_BUILD) -M $(DEP_DIR)/$(1).d $(call programVar,$(1),CFLAGS) $(INCLUDES) -o $$@ $(call programVar,$(1),SRCS) $(LIB)
$(call programIconFile,$(1)): $(call programIcon,$(1)) $(ICON_BAKE_DEPS)
$(call programIconFile,$(1)): $(call programIcon,$(1)) $(ICON_BAKE)
@mkdir -p $$(dir $$@)
$(ICON_BAKE) iigs $$< $$@ $(call programName,$(1))
endef

View file

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

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
# build/tools/ (JOEY_BUILD's, make/common.mk). Currently:
# adfTool -- read files out of an Amiga disk image (.adf): the
# installer's import.
# assetbake -- indexed PNG -> a target's tile bank (.tbk) or sprite
# cels (.spr).
# diffUberHashes -- two UBER logs compared op by op by surface hash: the
# golden gates' check against tests/goldens/uber.
# diffUberPerf -- two UBER logs compared op by op by ops/sec, the worst
# gaps against a threshold first.
# iconBake -- one picture -> each system's program icon.
# modloopend -- inject an E8F stop-marker pattern into a .MOD ahead
# of time (the Amiga audio HAL marks a song played with
# loop false itself). joeymod invokes it for .amod output.
# modToNtp -- a .MOD -> a NinjaTrackerPlus song (.NTP), the IIgs
# replayer's: joeymod invokes it for .ntp output.
# rawCd -- a raw CD image's track, or its El Torito boot floppy:
# the installer's BeOS VM.
# scanPtrBug -- find the w65816 compiler's far-pointer bug (a pointer's
# bank in its offset half) in its assembly output.
# songbake -- chip-tracker text notation (.song) -> a JYM1 stream.
# spritebake -- the sprite pre-compiler, one per target (below).
#
# Asset baking (PNG -> .tbk / .spr) is handled by tools/assetbake/
# assetbake.py (Python). It is invoked directly from each platform's
# Makefile and does not need a C build step.
# szddExpand -- expand a COMPRESS.EXE (SZDD) file: the installer's WinG.
# tiled2joey -- a Tiled map (TMX or TMJ) -> a tile sheet and maps.
# tileBankExpect -- the tile-bank gates' expected pixels
# (tests/host/tileBankStage.sh).
# traceLogo -- a flat-colour PNG logo -> a layered SVG, through the
# potrace program: remakes assets/joeylibLogo.svg.
# uberPerfTable -- every port's UBER log -> one Markdown table of ops/sec
# against a reference port.
# vgm2song -- a tracker's VGM export (Furnace) -> a .song.
# winIni -- edit a Windows 3.x .INI file: the installer's Windows 3.11
# and the Win16 gates.
# xdftool -- read and write Human68k floppy images (.XDF): joey-run
# and the X68000 gates stage programs with it.
include $(dir $(lastword $(MAKEFILE_LIST)))/common.mk
BUILD_DIR := $(JOEY_BUILD)/tools
TOOLS_DIR := $(REPO_DIR)/tools
# The tools of one C file (and any headers beside it) over tools/common (PNG,
# DEFLATE, whole files, byte buffers): tools/<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_BIN := $(BUILD_DIR)/modloopend
@ -35,16 +75,37 @@ SPRITEBAKE_INC := -I$(INCLUDE_DIR) -I$(SRC_CORE) -I$(SRC_CG)
# SPC_MAGIC must rebuild every baker, or its .spc files would be refused).
SPRITEBAKE_HDRS := $(wildcard $(INCLUDE_DIR)/joey/*.h $(SRC_CORE)/*.h $(SRC_CG)/*.h)
.PHONY: all tools clean-tools modloopend \
.PHONY: all tools clean-tools modloopend tileBankExpect $(COMMON_TOOLS) $(GATE_TOOLS) \
spritebake-dos spritebake-amiga spritebake-atarist spritebake-iigs spritebake-x68000
all tools: $(MODLOOPEND_BIN)
all tools: $(MODLOOPEND_BIN) $(addprefix $(BUILD_DIR)/,$(COMMON_TOOLS))
modloopend: $(MODLOOPEND_BIN)
tileBankExpect: $(BUILD_DIR)/tileBankExpect
$(foreach t,$(COMMON_TOOLS),$(eval $(t): $(BUILD_DIR)/$(t)))
$(foreach t,$(GATE_TOOLS),$(eval $(t): $(BUILD_DIR)/$(t)))
spritebake-dos: $(BUILD_DIR)/spritebake-dos
spritebake-amiga: $(BUILD_DIR)/spritebake-amiga
spritebake-atarist: $(BUILD_DIR)/spritebake-atarist
spritebake-iigs: $(BUILD_DIR)/spritebake-iigs
spritebake-x68000: $(BUILD_DIR)/spritebake-x68000
# $(call commonToolRule,NAME,SOURCE): NAME from SOURCE and tools/common.
define commonToolRule
$(BUILD_DIR)/$(1): $(2) $(wildcard $(dir $(2))*.h) $(TOOL_COMMON_SRC) $(TOOL_COMMON_HDRS)
@mkdir -p $$(dir $$@)
$(HOST_CC) $(HOST_CFLAGS) -I$(TOOLS_DIR)/common $$(filter %.c,$$^) -o $$@ -lm
endef
$(foreach t,$(COMMON_TOOLS),$(eval $(call commonToolRule,$(t),$(TOOLS_DIR)/$(t)/$(t).c)))
$(eval $(call commonToolRule,tileBankExpect,$(REPO_DIR)/tests/host/tileBankExpect.c))
# $(call gateToolRule,NAME): tests/gate/NAME.c over tools/common and
# tests/gate/common.
define gateToolRule
$(BUILD_DIR)/$(1): $(GATE_DIR)/$(1).c $(GATE_COMMON_SRC) $(GATE_COMMON_HDRS) $(TOOL_COMMON_SRC) $(TOOL_COMMON_HDRS)
@mkdir -p $$(dir $$@)
$(HOST_CC) $(HOST_CFLAGS) -I$(TOOLS_DIR)/common -I$(GATE_DIR)/common $$(filter %.c,$$^) -o $$@ -lm
endef
$(foreach t,$(GATE_TOOLS),$(eval $(call gateToolRule,$(t))))
$(MODLOOPEND_BIN): $(MODLOOPEND_SRC) $(SRC_CORE)/modStopMarker.h
@mkdir -p $(dir $@)
$(HOST_CC) $(HOST_CFLAGS) -I$(SRC_CORE) $(MODLOOPEND_SRC) -o $@

View file

@ -10,7 +10,7 @@
# the real Kickstart on a private Xvfb display, polls the staged
# joeylog.txt until UBER prints its "press any key to exit" completion
# marker (or a wall-clock timeout), then kills the emulator and copies
# the log to build/amiga/bin/joeylog.txt for tools/uber-perf-table.
# the log to build/amiga/bin/joeylog.txt for build/tools/uberPerfTable.
#
# scripts/bench-amiga.sh # default 150s timeout
# TIMEOUT=240 scripts/bench-amiga.sh # override

View file

@ -19,7 +19,7 @@
# 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
# 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
# compared afterwards.
#

View file

@ -10,9 +10,9 @@
#
# The stage is the generic chunky one, so the hashes must equal the DOS
# port's:
# scripts/bench-be.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt
# scripts/bench-be.sh haiku32 && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt
# scripts/bench-be.sh beos && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/beos/bin/joeylog.txt
# scripts/bench-be.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/haiku/bin/joeylog.txt
# scripts/bench-be.sh haiku32 && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/haiku32/bin/joeylog.txt
# scripts/bench-be.sh beos && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/beos/bin/joeylog.txt
# The ops/sec are this machine's in a VM: compare them only run against run.
#
# scripts/bench-be.sh [haiku|haiku32|beos] # default haiku (x86_64)

View file

@ -8,7 +8,7 @@
# Mounts build/dos/bin as C: in a headless DOSBox (Xvfb), runs UBER.EXE
# (which writes JOEYLOG.TXT to its CWD == C: == the host bin dir), polls
# that log for UBER's "press any key to exit" completion marker, then
# kills DOSBox. tools/uber-perf-table reads build/dos/bin/JOEYLOG.TXT.
# kills DOSBox. build/tools/uberPerfTable reads build/dos/bin/JOEYLOG.TXT.
#
# Uses the same dosbox-386sx16.conf as joey-run so the cycle budget
# matches the claudeDocs/PERF.md reference machine.
@ -74,7 +74,7 @@ if [[ ! -f $log ]]; then
exit 1
fi
# uber-perf-table reads build/dos/bin/JOEYLOG.TXT directly (uppercase).
# uberPerfTable reads build/dos/bin/JOEYLOG.TXT directly (uppercase).
ops=$(grep -c "ops/sec" "$log" || true)
done_marker=$(grep -c "press any key to exit" "$log" || true)
if (( done_marker == 0 )); then

View file

@ -9,11 +9,11 @@
# it in DOSBox Staging under Xvfb, and waits for UBER's "press any key to
# exit" line in its joeylog.txt -- which a DVX program writes to its settings
# folder, CONFIG\APPS\<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
# read it from there) and DOSBox is stopped.
# build/dvx/bin/joeylog.txt (build/tools/diffUberHashes and
# build/tools/uberPerfTable read it from there) and DOSBox is stopped.
#
# The stage is the DOS stage, so the hashes must equal the DOS port's:
# scripts/bench-dvx.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt
# scripts/bench-dvx.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/dvx/bin/joeylog.txt
# The ops/sec are the emulated machine's at DOSBox's full host speed, not a
# reference machine's: compare them only run against run.
#

View file

@ -13,7 +13,7 @@
# polls the Super Hi-Res framebuffer for UBER's solid-green "done" screen
# (jlSurfaceClear color 2 -> SHR bytes 0x22). When seen, MAME exits and
# cadius extracts JOEYLOG.TXT from the work image into build/iigs/bin/
# joeylog.txt, where tools/uber-perf-table and diff-uber-hashes expect it.
# joeylog.txt, where build/tools/uberPerfTable and diffUberHashes expect it.
#
# Usage: scripts/bench-iigs.sh
# MAME_ROMPATH override ROM dir (default ~/.mame/roms)

View file

@ -11,7 +11,7 @@
#
# The stage is the generic chunky one, so the hashes must equal the DOS
# port's:
# scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
# scripts/bench-psp.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt
# The ops/sec are PPSSPP's emulated clock, which runs unthrottled: compare
# them only run against run.
#

View file

@ -11,7 +11,7 @@
#
# The stage is the generic chunky one, so the hashes must equal the DOS
# port's:
# scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
# scripts/bench-sdl3.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
# The ops/sec are this machine's, not a reference machine's: compare them
# only run against run.
#

View file

@ -11,7 +11,7 @@
#
# The stage is the generic chunky one, so the hashes must equal the DOS
# port's:
# scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
# scripts/bench-web.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
# The ops/sec are this machine's browser's: compare them only run against run.
#
# scripts/bench-web.sh # default 300 s timeout

View file

@ -9,7 +9,7 @@
#
# The stage is the generic chunky one, so the hashes must equal the DOS
# port's:
# scripts/bench-win16.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win16/bin/JOEYLOG.TXT
# scripts/bench-win16.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/win16/bin/JOEYLOG.TXT
# The ops/sec are this machine's with DOSBox at full speed: compare them
# only run against run.
#

View file

@ -10,7 +10,7 @@
#
# The stage is the generic chunky one, so the hashes must equal the DOS
# port's:
# scripts/bench-win32.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/win32/bin/joeylog.txt
# scripts/bench-win32.sh && build/tools/diffUberHashes tests/goldens/uber/dos.txt build/win32/bin/joeylog.txt
# The ops/sec are this machine's under Wine: compare them only run against run.
#
# scripts/bench-win32.sh # default 300 s timeout

View file

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

View file

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

View file

@ -5,8 +5,8 @@
#
# check-icons.sh - Host gate for programs' icons: on every port built here,
# DRAW (examples/draw) carries the icon make baked it (make/common.mk's
# programIcon, tools/iconBake.py), where its system looks for it. Read with
# the systems' own tools where they are installed, otherwise by the format:
# programIcon, tools/iconBake), where its system looks for it. Read with the
# systems' own tools where they are installed, otherwise by the format:
#
# amiga Draw.info beside it: a DiskObject (magic $E310) for a tool,
# AMIGA_ICON_STACK bytes of stack, the image iconBake makes
@ -39,6 +39,44 @@ label=check-icons
fails=""
checked=""
# The Amiga's Workbench icon (workbench.h): a DiskObject's magic and a tool's
# do_Type, at their offsets with do_StackSize's and its Image's Width,
# Height, and Depth; iconBake's tool icons are 48x24 in 2 planes.
WB_DISKMAGIC=$((0xE310))
WBTOOL=3
DO_TYPE_AT=48
DO_STACK_AT=74
IMAGE_SIZE_AT=82
AMIGA_ICON="48 24 2"
# Win16's NE header (its offset at 0x3C): the resource and resident-name
# tables' offsets, equal when there are no resources; a type's entry is 8
# bytes, a resource's 12 with its ID 6 bytes in; an integer ID has bit 15.
NE_HEADER_AT=$((0x3C))
NE_RESOURCES_AT=$((0x24))
NE_NAMES_AT=$((0x26))
NE_TYPE_BYTES=8
NE_RESOURCE_BYTES=12
NE_RESOURCE_ID_AT=6
RT_GROUP_ICON=$((0x800E))
INTEGER_ID=$((0x8000))
# A PNG's signature, and the width and height in its IHDR.
PNG_SIGNATURE=89504e470d0a1a0a
PNG_WIDTH_AT=16
PNG_HEIGHT_AT=20
# The PSP's EBOOT: "\0PBP", then the parts' offsets from byte 8, ICON0 the
# second (and the third's start its end).
PBP_MAGIC=00504250
PBP_ICON0_AT=12
PBP_ICON1_AT=16
# The IIgs icon file: its file ID, and in its first record (from byte 26)
# the application's name (a Pascal string at 66) and file type (at 82).
IIGS_FILE_ID_AT=4
IIGS_RECORD_AT=26
IIGS_NAME_AT=66
IIGS_TYPE_AT=82
IIGS_ICON_FILE_ID=1
IIGS_APPLICATION=$((0xB3))
fail() {
fails="${fails:+$fails; }$1: $2"
}
@ -48,31 +86,60 @@ makeValue() {
make -s -f "$repo/make/$1.mk" --eval "valueOf: ; @echo \$($2)" valueOf 2> /dev/null
}
# fileNumber FILE OFFSET BYTES be|le: an unsigned number in FILE; fails past
# its end.
fileNumber() {
od -An -tu1 -v -j "$2" -N "$3" "$1" 2> /dev/null | awk -v n="$3" -v endian="$4" '
{ for (i = 1; i <= NF; i++) { b[k++] = $i } }
END { if (k < n) { exit 1 } v = 0; for (i = 0; i < n; i++) { v = v * 256 + b[endian == "be" ? i : n - 1 - i] } print v }'
}
# fileHex FILE OFFSET BYTES: those bytes as lower-case hex.
fileHex() {
od -An -tx1 -v -j "$2" -N "$3" "$1" 2> /dev/null | tr -d ' \n'
}
# neGroupIcon EXE ID: true when the Win16 EXE's NE resource table holds a
# group icon numbered ID.
neGroupIcon() {
local exe=$1
local want=$((INTEGER_ID | $2))
local ne
local table
local at
local typeId
local count
local i
ne=$(fileNumber "$exe" "$NE_HEADER_AT" 4 le) || return 1
table=$((ne + $(fileNumber "$exe" $((ne + NE_RESOURCES_AT)) 2 le)))
[ "$table" -ne $((ne + $(fileNumber "$exe" $((ne + NE_NAMES_AT)) 2 le))) ] || return 1
at=$((table + 2))
while true; do
typeId=$(fileNumber "$exe" "$at" 2 le) || return 1
count=$(fileNumber "$exe" $((at + 2)) 2 le) || return 1
[ "$typeId" -ne 0 ] || return 1
at=$((at + NE_TYPE_BYTES))
for ((i = 0; i < count; i++)); do
if [ "$typeId" -eq "$RT_GROUP_ICON" ] && [ "$(fileNumber "$exe" $((at + NE_RESOURCE_ID_AT)) 2 le)" = "$want" ]; then
return 0
fi
at=$((at + NE_RESOURCE_BYTES))
done
done
}
for port in amiga win32 win16 sdl3 macos web psp haiku haiku32 beos dvx iigs; do
program=$(joeyExampleProgram draw "$port" 2> /dev/null) || continue
[ -f "$program" ] || continue
checked="${checked:+$checked }$port"
case "$port" in
amiga)
python3 - "$program.info" "$(makeValue amiga AMIGA_ICON_STACK)" "$repo/tools" <<'PY' || fail amiga "Draw.info"
import struct
import sys
info, stack, tools = sys.argv[1], int(sys.argv[2]), sys.argv[3]
sys.path.insert(0, tools)
import iconBake # noqa: E402
data = open(info, "rb").read()
magic, version = struct.unpack_from(">HH", data, 0)
doType = data[48]
doStack = struct.unpack_from(">I", data, 74)[0]
w, h, depth = struct.unpack_from(">hhh", data, 82)
want = (iconBake.WB_DISKMAGIC, iconBake.WBTOOL, stack, iconBake.AMIGA_W, iconBake.AMIGA_H, iconBake.AMIGA_DEPTH)
got = (magic, doType, doStack, w, h, depth)
if got != want:
print(" amiga: magic, type, stack, image %s, want %s" % (got, want))
sys.exit(1)
PY
info=$program.info
want="$WB_DISKMAGIC $WBTOOL $(makeValue amiga AMIGA_ICON_STACK) $AMIGA_ICON"
got="$(fileNumber "$info" 0 2 be) $(fileNumber "$info" "$DO_TYPE_AT" 1 be) $(fileNumber "$info" "$DO_STACK_AT" 4 be)"
got="$got $(fileNumber "$info" "$IMAGE_SIZE_AT" 2 be) $(fileNumber "$info" $((IMAGE_SIZE_AT + 2)) 2 be) $(fileNumber "$info" $((IMAGE_SIZE_AT + 4)) 2 be)"
[ "$got" = "$want" ] || fail amiga "Draw.info: magic, type, stack, image $got, want $want"
;;
win32)
readobj=$(dirname "$WIN32_CC")/llvm-readobj
@ -81,72 +148,35 @@ PY
awk -v want="Name: (ID $id)" '/Type: GROUP_ICON/ { group = 1; next } group && /Name:/ { found = index($0, want) > 0; exit } END { exit !found }' <<< "$listing" || fail win32 "no group icon $id among DRAW.EXE's resources"
;;
win16)
python3 - "$program" "$(makeValue win16 HOSTED_ICON_ID)" <<'PY' || fail win16 "no group icon in DRAW.EXE's NE resources"
import struct
import sys
RT_GROUP_ICON = 0x800E
INTEGER_ID = 0x8000
data = open(sys.argv[1], "rb").read()
wantId = INTEGER_ID | int(sys.argv[2])
ne = struct.unpack_from("<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
neGroupIcon "$program" "$(makeValue win16 HOSTED_ICON_ID)" || fail win16 "no group icon in DRAW.EXE's NE resources"
;;
sdl3)
symbols=$(nm "$program" 2> /dev/null)
grep -q ' jlgHostedIconRgba$' <<< "$symbols" || fail sdl3 "DRAW links no window icon"
;;
macos)
python3 - "$(dirname "$(dirname "$(dirname "$program")")")" <<'PY' || fail macos "DRAW.app's icon"
import plistlib
import sys
from PIL import Image
app = sys.argv[1]
with open(app + "/Contents/Info.plist", "rb") as f:
info = plistlib.load(f)
icon = Image.open("%s/Contents/Resources/%s.icns" % (app, info["CFBundleIconFile"]))
sys.exit(icon.format != "ICNS")
PY
app=$(dirname "$(dirname "$(dirname "$program")")")
iconName=$(awk '/<key>CFBundleIconFile<\/key>/ { getline; gsub(/.*<string>|<\/string>.*/, ""); print; exit }' "$app/Contents/Info.plist" 2> /dev/null)
icns=$app/Contents/Resources/$iconName.icns
if [ -z "$iconName" ] || [ "$(head -c 4 "$icns" 2> /dev/null)" != icns ] || [ "$(fileNumber "$icns" 4 4 be)" != "$(wc -c < "$icns" | tr -d " ")" ]; then
fail macos "DRAW.app's icon"
fi
;;
web)
folder=$(dirname "$program")
grep -qE '<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)
python3 - "$program" "$repo/build/psp/icon/DRAW/icon0.png" "$(makeValue psp PSP_ICON0_SIZE)" <<'PY' || fail psp "the EBOOT's ICON0"
import io
import struct
import sys
from PIL import Image
eboot, baked, size = sys.argv[1], sys.argv[2], sys.argv[3]
data = open(eboot, "rb").read()
if data[:4] != b"\0PBP":
sys.exit(1)
offsets = struct.unpack_from("<8I", data, 8)
icon = data[offsets[1]:offsets[2]]
picture = Image.open(io.BytesIO(icon))
if icon != open(baked, "rb").read() or "%dx%d" % picture.size != size:
print(" psp: ICON0 is %d bytes, %dx%d" % (len(icon), picture.size[0], picture.size[1]))
sys.exit(1)
PY
baked=$repo/build/psp/icon/DRAW/icon0.png
size=$(makeValue psp PSP_ICON0_SIZE)
start=$(fileNumber "$program" "$PBP_ICON0_AT" 4 le)
end=$(fileNumber "$program" "$PBP_ICON1_AT" 4 le)
if [ "$(fileHex "$program" 0 4)" != "$PBP_MAGIC" ] || ! cmp -s <(tail -c +$((start + 1)) "$program" | head -c $((end - start))) "$baked"; then
fail psp "the EBOOT's ICON0 is not the baked icon"
elif [ "$(fileNumber "$baked" "$PNG_WIDTH_AT" 4 be)x$(fileNumber "$baked" "$PNG_HEIGHT_AT" 4 be)" != "$size" ]; then
fail psp "the EBOOT's ICON0 is not $size"
fi
;;
haiku|haiku32|beos)
listing=$("$HAIKU_TOOLS/xres" -l "$program" 2> /dev/null)
@ -169,21 +199,9 @@ PY
iconFile=$(find "$out" -iname 'DRAW.ICONS*' -not -name '_FileInformation.txt' -print -quit)
listing=$("$CADIUS" CATALOG "$disk" 2> /dev/null)
grep -qE '^ +DRAW\.ICONS +ICN ' <<< "$listing" || fail iigs "no DRAW.ICONS of type \$CA in joey.2mg's Icons folder"
python3 - "$iconFile" <<'PY' || fail iigs "DRAW.ICONS is no icon file for DRAW"
import struct
import sys
ICON_FILE_ID = 0x0001
APPLICATION = 0x00B3
data = open(sys.argv[1], "rb").read()
fileId = struct.unpack_from("<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
nameLength=$(fileNumber "$iconFile" $((IIGS_RECORD_AT + IIGS_NAME_AT)) 1 le)
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)"
[ "$got" = "$IIGS_ICON_FILE_ID DRAW $IIGS_APPLICATION" ] || fail iigs "DRAW.ICONS is no icon file for DRAW (id, name, type $got)"
rm -rf "$out"
;;
esac

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
#
# check-tilebank.sh - Host-cc gate for the baked tile-bank loaders
# (jlTileBankLoad / jlTileBankLoadToSurface in src/core/assetLoad.c), end to
# end: assetbake.py bakes tests/host/tileBank.png, the real loader reads it,
# end: assetbake bakes tests/host/tileBank.png, the real loader reads it,
# the real generic tile paste draws it, and tests/host/tileBankHost.c checks
# every pixel against the PNG. Also checks the refusals (a bank baked for any
# other target, bad magic, short header, missing file), a truncated bank,
@ -17,12 +17,13 @@
# target byte there, so the DOS bake is the bank that must load. Every other
# target assetbake knows is baked too and must be refused.
#
# tests/host/tileBankStage.py stages the banks and the expected pixels and
# palette (decoded from the PNG with PIL, independent of assetbake's
# encoders), exactly as make/<port>.mk stages them for examples/tilebank on
# the real ports. The harness prints the surface hash of its to-surface load;
# scripts/verify-<port>-tilebank.sh run this script and require each port's
# own bake to hash the same. Exits nonzero on any failed check.
# tests/host/tileBankStage.sh stages the banks and the expected pixels and
# palette (decoded from the PNG by tileBankExpect, independent of assetbake's
# encoders; make/tools.mk builds both), exactly as make/examples.mk stages
# them for examples/tilebank on the real ports. The harness prints the
# surface hash of its to-surface load; scripts/verify-<port>-tilebank.sh run
# this script and require each port's own bake to hash the same. Exits
# nonzero on any failed check.
set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
@ -38,7 +39,8 @@ cd "$repo"
src/core/*.c src/generic/*.c src/blank/blank.c tests/host/tileBankHost.c \
-o "$work/tileBankHost"
python3 "$repo/tests/host/tileBankStage.py" "$hostTarget" "$png" "$work/DATA"
make -s -f "$repo/make/tools.mk" assetbake tileBankExpect > /dev/null
"$repo/tests/host/tileBankStage.sh" "$hostTarget" "$png" "$work/DATA"
refuse=()
for bank in "$work"/DATA/refuse*.tbk; do
refuse+=("$(basename "$bank")")

View file

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

View file

@ -3,18 +3,18 @@
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
#
# check-vgm.sh - Host gate for tools/vgm2song.py, the VGM-to-.song converter
# check-vgm.sh - Host gate for tools/vgm2song, the VGM-to-.song converter
# that brings tracker-made chip music (Furnace's, docs/Assets.adoc) to
# JoeyLib's chip tracker.
#
# 1. tests/host/vgmCheck.py synthetic: VGM files written to known register
# 1. tests/gate/vgmCheck.c synthetic: VGM files written to known register
# writes, converted and baked; every baked row must hold the voices worked
# out by hand.
# 2. With Furnace installed (./install.sh install furnace): every SN76489 and
# AY-3-8910 demo song Furnace comes with that uses that chip alone is
# exported by Furnace to VGM and to audio (WAV), converted and baked; each
# must bake, and Furnace's own audio must sound the pitches the baked song
# plays (tests/host/vgmCheck.py pitch): at least PITCH_MIN_SONG percent of
# plays (tests/gate/vgmCheck.c pitch): at least PITCH_MIN_SONG percent of
# each song's pitched windows, PITCH_MIN_MEAN on average (a wrong clock or
# divider gives under 10). The demos are their authors' and stay where the
# installer put them; nothing made from them is kept.
@ -34,8 +34,14 @@ DEMO_FOLDERS="sn7:sn ay8910:ay"
work=$(mktemp -d -t joeylib-vgm.XXXXXX)
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/synthetic" "$work/demos" "$work/home"
# The converter, the baker, and the checker, make/tools.mk's (vgmCheck finds
# the first two in JOEY_BUILD's tools/ too).
vgm2song=$(joeyTool vgm2song) || exit 1
songbake=$(joeyTool songbake) || exit 1
vgmCheck=$(joeyTool vgmCheck) || exit 1
export JOEY_BUILD=${JOEY_BUILD:-$repo/build}
synthetic=$(python3 "$repo/tests/host/vgmCheck.py" synthetic "$repo" "$work/synthetic")
synthetic=$("$vgmCheck" synthetic "$repo" "$work/synthetic")
status=$?
echo "$synthetic" | grep -v '^SYNTHETIC'
counts=$(echo "$synthetic" | sed -n 's/^SYNTHETIC //p')
@ -65,7 +71,7 @@ for spec in $DEMO_FOLDERS; do
fails="${fails:+$fails; }$folder/$name: Furnace did not export it"
continue
fi
if ! python3 "$repo/tools/vgm2song.py" --chip "$chip" "$base.vgm" "$base.song" 2> "$base.warn"; then
if ! "$vgm2song" --chip "$chip" "$base.vgm" "$base.song" 2> "$base.warn"; then
fails="${fails:+$fails; }$folder/$name: $(tr '\n' ' ' < "$base.warn")"
continue
fi
@ -75,11 +81,11 @@ for spec in $DEMO_FOLDERS; do
echo "$label: $folder/$name: skipped (the song uses another chip too)"
continue
fi
if ! python3 "$repo/tools/songbake/songbake.py" "$base.song" "$base.jym" > /dev/null 2> "$base.bake"; then
if ! "$songbake" "$base.song" "$base.jym" > /dev/null 2> "$base.bake"; then
fails="${fails:+$fails; }$folder/$name: songbake: $(grep -v warning "$base.bake" | tr '\n' ' ')"
continue
fi
result=$(python3 "$repo/tests/host/vgmCheck.py" pitch "$base.wav" "$base.jym" | sed -n 's/^PITCH //p')
result=$("$vgmCheck" pitch "$base.wav" "$base.jym" | sed -n 's/^PITCH //p')
matched=${result%%/*}
tested=${result##*/}
if [ -z "$tested" ] || [ "$tested" -eq 0 ]; then

View file

@ -24,7 +24,7 @@
# the ENV settings (NAME=VALUE) until its
# joeylog.txt has a line starting UNTIL (empty:
# until it exits) or SECONDS pass
# beQmp WORK ARGS... scripts/lib/qmp.py on the VM's QMP socket
# beQmp WORK ARGS... tests/gate/qmp.c on the VM's QMP socket
# beStart WORK DISK [QEMU-ARGS...]
# boot the VM with the job disk, on BE_DISPLAY
# (default none: headless); sets BE_PID. A
@ -92,27 +92,23 @@ beCrashed() {
# beDesktopUp WORK -- BeOS R5's desktop is up in BEOS_VIDEO_MODE: the
# screen is that size and Deskbar, which R5's boot starts last, shows its
# logo in the top right corner (the boot shows a bare desktop for some
# seconds before it).
# seconds before it). The logo's red in the 16-bit mode, the corner looked
# at, and how many of the logo's pixels show there with Deskbar up (about
# 280).
BE_LOGO_RED=(206 0 49)
BE_LOGO_CORNER_W=120
BE_LOGO_CORNER_H=24
BE_LOGO_PIXELS_MIN=100
beDesktopUp() {
local probe
local width
local red
beQmp "$1" screendump "$1/mode.png" 2> /dev/null || return 1
python3 - "$1/mode.png" "$BEOS_VIDEO_MODE" <<'PY'
import sys
from PIL import Image
# The logo's red, in the 16-bit mode, and how many of its pixels show in
# the corner when Deskbar is there (about 280).
LOGO_RED = (206, 0, 49)
CORNER_W = 120
CORNER_H = 24
LOGO_PIXELS_MIN = 100
width, height = (int(n) for n in sys.argv[2].split('x'))
screen = Image.open(sys.argv[1]).convert('RGB')
if screen.size != (width, height):
sys.exit(1)
corner = screen.crop((width - CORNER_W, 0, width, CORNER_H))
sys.exit(0 if list(corner.getdata()).count(LOGO_RED) >= LOGO_PIXELS_MIN else 1)
PY
probe=$(joeyTool pngProbe) || return 1
[ "$("$probe" size "$1/mode.png")" = "$BEOS_VIDEO_MODE" ] || return 1
width=${BEOS_VIDEO_MODE%x*}
red=$("$probe" count "$1/mode.png" $((width - BE_LOGO_CORNER_W)) 0 "$BE_LOGO_CORNER_W" "$BE_LOGO_CORNER_H" "${BE_LOGO_RED[@]}") || return 1
[ "$red" -ge "$BE_LOGO_PIXELS_MIN" ]
}
@ -195,7 +191,7 @@ beNeed() {
beQmp() {
local work="$1"
shift
python3 "$JOEY_REPO/scripts/lib/qmp.py" "$work/qmp.sock" "$@"
"$(joeyTool qmp)" "$work/qmp.sock" "$@"
}

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

View file

@ -186,3 +186,18 @@ joeyStop() {
wait "$pid" 2>/dev/null || true
done
}
# joeyLe16 FILE -- print the little-endian 16-bit number FILE starts with.
joeyLe16() {
od -An -tu2 -N2 --endian=little "$1" | tr -d ' '
}
# joeyTool NAME -- print the path of one of JoeyLib's host tools
# (make/tools.mk's, in JOEY_BUILD's tools/), built first if it is not
# current. Fails, with make's message, when it cannot be built.
joeyTool() {
make -s -f "$JOEY_REPO/make/tools.mk" "$1" > /dev/null || return 1
printf '%s\n' "${JOEY_BUILD:-$JOEY_REPO/build}/tools/$1"
}

View file

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

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,
# INPUT_KEY_SECOND). Each step holds its keys (letting go of every other),
# waits KEYS_SETTLE_SECONDS, and reads KEYS's Return, Ctrl, Alt, Up, Down,
# Left, and Right cells (examples/keys) as seven digits, keysCells.py's.
# Left, and Right cells (examples/keys) as seven digits, keysCells's.
#
# The gate sets KEYS_LABEL (its name, for the lines it prints) and defines,
# by the key names below:

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
# (SDL3, Win32 under Wine): two virtual Xbox 360 pads
# (scripts/lib/virtualPad.py) as sticks A and B, or two generic joysticks
# (tests/gate/virtualPad.c) as sticks A and B, or two generic joysticks
# no gamepad mapping knows. The pads are the whole machine's while they
# exist, so a real pad plugged in here would be seen too: PAD_ONLY_ENV is
# the environment that hides every other pad from SDL, as a gamepad and as
@ -30,7 +30,7 @@ PAD_SENT=0
PAD_SWAP=0
PAD_READY_SECONDS=10
PAD_REPLY_SECONDS=5
# The virtual pads' USB vendor/product (virtualPad.py's), by kind.
# The virtual pads' USB vendor/product (virtualPad's), by kind.
PAD_IDS_XBOX360=0x045e/0x028e
PAD_IDS_GENERIC=0x1209/0x0001
PAD_ONLY_ENV=(SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$PAD_IDS_XBOX360)
@ -58,7 +58,7 @@ padOrder() {
padSend press A-left
sleep "$JOY_PRESS_SECONDS"
joyShot "$JOY_WORK/order.png"
got=$(python3 "$JOEY_REPO/scripts/lib/joyScreen.py" "$JOY_WORK/order.png" "${JOY_SCREEN_ARGS[@]}")
got=$("$(joeyTool joyScreen)" "$JOY_WORK/order.png" "${JOY_SCREEN_ARGS[@]}")
padSend release A-left
sleep "$JOY_PRESS_SECONDS"
case "$got" in
@ -93,6 +93,7 @@ padSend() {
padStart() {
local fifo="$1/pad.in"
local kind=()
local pad
if [ ! -w /dev/uinput ]; then
echo "$JOY_LABEL: cannot write /dev/uinput, which the virtual gamepads need (a desktop session's user usually can)" >&2
exit 2
@ -107,7 +108,8 @@ padStart() {
PAD_SWAP=0
rm -f "$fifo" "$PAD_OUT"
mkfifo "$fifo"
python3 "$JOEY_REPO/scripts/lib/virtualPad.py" "${kind[@]}" "JoeyLib gate" < "$fifo" > "$PAD_OUT" 2>&1 &
pad=$(joeyTool virtualPad) || exit 1
"$pad" "${kind[@]}" "JoeyLib gate" < "$fifo" > "$PAD_OUT" 2>&1 &
PAD_PID=$!
exec {PAD_FD}> "$fifo"
if ! padWait 0 "$PAD_READY_SECONDS"; then

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
# 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),
# gets the program's TILEBANK log line off the machine, and judges it here
# against the host gate: scripts/check-tilebank.sh is run for its surface
@ -102,9 +102,11 @@ tileBankNeedBuild() {
# pass (its pixel checks are what make its hash worth matching), and set
# TILEBANK_HASH the surface hash of its to-surface load of its own bake
# TILEBANK_OTHERS how many targets besides any one port assetbake knows
# (tests/host/tileBankStage.sh --targets)
# Exits 2 without a host C compiler, 1 if the host gate fails.
tileBankReference() {
local out
local targets
if ! command -v "${CC:-cc}" > /dev/null; then
echo "$TILEBANK_GATE: no host C compiler (${CC:-cc}) for the reference run of scripts/check-tilebank.sh" >&2
@ -120,11 +122,7 @@ tileBankReference() {
echo "$TILEBANK_GATE: FAIL - scripts/check-tilebank.sh printed no to-surface hash" >&2
exit 1
fi
TILEBANK_OTHERS="$(python3 - "$JOEY_REPO/tools/assetbake" <<'PY'
import sys
sys.path.insert(0, sys.argv[1])
from assetbake import TARGET_NAMES
print(len(TARGET_NAMES) - 1)
PY
)"
# The stager asks assetbake (check-tilebank.sh has built it).
targets="$("$JOEY_REPO/tests/host/tileBankStage.sh" --targets)" || exit 1
TILEBANK_OTHERS=$(($(wc -w <<< "$targets") - 1))
}

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() {
local tries
local server
server=$(joeyTool httpServe) || exit 1
for ((tries = 0; tries < 10; tries++)); do
WEB_PORT=$((WEB_PORT_MIN + RANDOM % WEB_PORT_SPAN))
python3 -m http.server "$WEB_PORT" --bind 127.0.0.1 --directory "$1" > /dev/null 2>&1 &
"$server" "$WEB_PORT" "$1" > /dev/null 2>&1 &
WEB_SERVER_PID=$!
sleep 0.5
if joeyAlive "$WEB_SERVER_PID"; then

View file

@ -80,7 +80,7 @@ win16Stage() {
cp -r "$WIN16_C" "$work/c"
cp "$KPJOY_DRV" "$work/c/WINDOWS/SYSTEM/KPJOY.DRV"
cp "$KPCOMM_DRV" "$work/c/WINDOWS/SYSTEM/KPCOMM.DRV"
python3 "$JOEY_REPO/tools/winIni.py" "$work/c/WINDOWS/SYSTEM.INI" \
"$(joeyTool winIni)" "$work/c/WINDOWS/SYSTEM.INI" \
set boot shell "D:\\$3" \
set boot comm.drv kpcomm.drv \
set drivers joystick kpjoy.drv
@ -107,13 +107,18 @@ WIN16_WING_PROFILE="ECHGECHGECHGECHGDDFFDDFFDDFFDDFFZZZZZZZZZZZZZZZZDDFGDGFGDDFF
win16StageWinG() {
local file
local dosDate
local year
local month
local day
for file in WING.DLL WINGDE.DLL WINGDIB.DRV WINGPAL.WND DVA.386; do
cp "$WING_DIR/$file" "$1/c/WINDOWS/SYSTEM/$file"
done
dosDate=$(python3 -c 'import os, sys, time; t = time.localtime(os.path.getmtime(sys.argv[1])); print("%04X" % (((t.tm_year - 1980) << 9) | (t.tm_mon << 5) | t.tm_mday))' "$1/c/WINDOWS/SYSTEM/$WIN16_WING_DRIVER")
python3 "$JOEY_REPO/tools/winIni.py" "$1/c/WINDOWS/SYSTEM.INI" \
# The driver's date as a DOS date word: years from 1980, month, day.
read -r year month day <<< "$(date -r "$1/c/WINDOWS/SYSTEM/$WIN16_WING_DRIVER" '+%Y %m %d')"
dosDate=$(printf '%04X' $((((year - 1980) << 9) | (10#$month << 5) | 10#$day)))
"$(joeyTool winIni)" "$1/c/WINDOWS/SYSTEM.INI" \
add 386Enh device "C:\\WINDOWS\\SYSTEM\\DVA.386"
python3 "$JOEY_REPO/tools/winIni.py" "$1/c/WINDOWS/WIN.INI" \
"$(joeyTool winIni)" "$1/c/WINDOWS/WIN.INI" \
set WinG "$WIN16_WING_PROFILE_HEAD-$dosDate-$WIN16_WING_PROFILE_TAIL" "$WIN16_WING_PROFILE"
}

View file

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

View file

@ -142,93 +142,4 @@ if [ -n "${PROFILE_SAMPLES:-}" ]; then
cp "$work/samples.txt" "$PROFILE_SAMPLES"
fi
python3 - "$work/samples.txt" "$map" "$top" <<'PY'
import bisect
import json
import os
import re
import sys
samplesPath, mapPath, top = sys.argv[1], sys.argv[2], int(sys.argv[3])
mapBase = mapPath[:-4] if mapPath.endswith('.map') else mapPath
# The segments: number -> (link bank, image bytes).
segments = {}
manifest = mapBase + '.json'
if os.path.exists(manifest):
for seg in json.load(open(manifest))['segments']:
segments[seg['num']] = int(seg['base'], 16) >> 16
else:
segments[1] = 0
images = {num: open('%s.seg%d.bin' % (mapBase, num), 'rb').read() for num in segments}
# The symbols, by address: the globals, and the locals (static functions)
# when the map has them; a global wins an address both name.
byAddr = {}
section = None
for line in open(mapPath):
if line.startswith('#'):
section = 'global' if line.startswith('# global symbols') else ('local' if line.startswith('# local symbols') else None)
continue
m = re.match(r'^0x([0-9a-fA-F]{6})\s+(\S+)$', line.strip())
if m and section is not None:
addr = int(m.group(1), 16)
if section == 'global' or addr not in byAddr:
byAddr[addr] = m.group(2)
syms = sorted(byAddr.items())
symAddrs = [a for a, _ in syms]
counts = {}
fingerprints = {}
total = 0
for line in open(samplesPath):
parts = line.split()
if parts[0] == 'S':
counts[int(parts[1], 16)] = int(parts[2])
elif parts[0] == 'F':
fingerprints[int(parts[1], 16)] = bytes.fromhex(parts[2])
elif parts[0] == 'T':
total = int(parts[1])
# Runtime bank -> link bank, by the best-scoring segment image.
bankMap = {}
for bank, fp in fingerprints.items():
best = None
for num, image in images.items():
n = min(len(fp), len(image))
if n < 16:
continue
same = sum(1 for i in range(n) if fp[i] == image[i])
score = same / n
if best is None or score > best[0]:
best = (score, num)
if best is not None and best[0] >= 0.85:
bankMap[bank] = (segments[best[1]], best[1], best[0])
def where(addr):
bank = addr >> 16
if bank in bankMap:
link = (bankMap[bank][0] << 16) | (addr & 0xFFFF)
i = bisect.bisect_right(symAddrs, link) - 1
if i >= 0 and (symAddrs[i] >> 16) == (link >> 16):
return syms[i][1]
return 'seg%d+%04X' % (bankMap[bank][1], addr & 0xFFFF)
if bank >= 0xFC:
return 'ROM $%02X' % bank
if bank in (0xE0, 0xE1):
return 'bank $%02X (firmware/vectors)' % bank
return 'bank $%02X (GS/OS, tools, NTP)' % bank
byName = {}
for addr, n in counts.items():
key = where(addr)
byName[key] = byName.get(key, 0) + n
print('profile-iigs: %d samples' % total)
for bank in sorted(bankMap):
link, num, score = bankMap[bank]
print(' bank $%02X = segment %d (link bank $%02X, %.0f%% of %d bytes match)' % (bank, num, link, score * 100, len(fingerprints[bank])))
print('%7s %6s %s' % ('samples', 'pct', 'where'))
for key, n in sorted(byName.items(), key=lambda kv: -kv[1])[:top]:
print('%7d %5.1f%% %s' % (n, 100.0 * n / max(total, 1), key))
PY
"$(joeyTool iigsProfile)" "$work/samples.txt" "$map" "$top"

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
# verify-amiga-icon.sh - a program's Workbench icon: read and drawn by the
# Amiga's own icon.library, and the program started from it.
#
# make bakes Name.info beside every Amiga program (tools/iconBake.py info:
# make bakes Name.info beside every Amiga program (tools/iconBake info:
# a tool's icon in Workbench's four colours, with the stack it starts
# with). WBLAUNCH (examples/wblaunch) runs under FS-UAE on an A500 with
# PALPROBE and its icon in a folder of their own, Prog/. It reads the icon
@ -91,99 +91,4 @@ if ! waitFor "WBLAUNCH: returned"; then
fi
cat "$log"
python3 - "$work" "$want_stack" "$WBTOOL" "$label" <<'PY'
import os
import re
import struct
import sys
from PIL import Image
work, wantStack, wbTool, label = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
fails = []
log = open(os.path.join(work, "wblaunch.txt"), encoding="ascii", errors="replace").read()
for line in log.splitlines():
if "FAIL" in line:
fails.append(line)
# What the file holds: its image's size, depth and planes (after the 78-byte
# DiskObject and the 20-byte struct Image).
info = open(os.path.join(work, "Prog", "Palprobe.info"), "rb").read()
width, height, depth = struct.unpack(">hhh", info[82:88])
rowBytes = (width + 15) // 16 * 2
planeBytes = rowBytes * height
indices = [[0] * width for _ in range(height)]
for plane in range(depth):
for y in range(height):
for x in range(width):
byte = info[98 + plane * planeBytes + y * rowBytes + x // 8]
if byte & (0x80 >> (x % 8)):
indices[y][x] |= 1 << plane
m = re.search(r"icon type=(\d+) stack=(\d+) gadget=(\d+)x(\d+) image=(\d+)x(\d+)x(\d+)", log)
if m is None:
fails.append("icon.library never read the icon")
else:
got = tuple(int(v) for v in m.groups())
if got[0] != wbTool:
fails.append(f"icon type {got[0]}, want WBTOOL ({wbTool})")
if got[1] != wantStack:
fails.append(f"icon stack {got[1]}, want {wantStack}")
if got[4:] != (width, height, depth):
fails.append(f"icon.library's image {got[4]}x{got[5]}x{got[6]}, the file's {width}x{height}x{depth}")
# Where the icon is on the screenshot: the screen is Workbench's grey, and
# the icon's coloured pixels lie inside it. The file's coloured pixels
# (index not 0) and the screenshot's (not grey) must have the same bounds,
# scaled; then each of the file's pixels is looked at in the middle of its
# cell.
colours = [(170, 170, 170), (0, 0, 0), (255, 255, 255), (102, 136, 187)]
shot = Image.open(os.path.join(work, "screen.png")).convert("RGB")
w, h = shot.size
pixels = shot.load()
grey = [(x, y) for y in range(0, h, 2) for x in range(0, w, 2) if pixels[x, y] == colours[0]]
if not grey:
fails.append("no Workbench grey on the screenshot")
else:
gx0 = min(p[0] for p in grey)
gx1 = max(p[0] for p in grey)
gy0 = min(p[1] for p in grey)
# The upper half: FS-UAE prints its notices over the bottom.
gy1 = (gy0 + max(p[1] for p in grey)) // 2
shown = [(x, y) for y in range(gy0, gy1 + 1) for x in range(gx0, gx1 + 1) if pixels[x, y] != colours[0]]
held = [(x, y) for y in range(height) for x in range(width) if indices[y][x] != 0]
if not shown or not held:
fails.append("no icon on the screenshot" if held else "the icon file is empty")
else:
sx0 = min(p[0] for p in shown)
sx1 = max(p[0] for p in shown)
sy0 = min(p[1] for p in shown)
sy1 = max(p[1] for p in shown)
fx0 = min(p[0] for p in held)
fx1 = max(p[0] for p in held)
fy0 = min(p[1] for p in held)
fy1 = max(p[1] for p in held)
scaleX = (sx1 + 1 - sx0) / (fx1 + 1 - fx0)
scaleY = (sy1 + 1 - sy0) / (fy1 + 1 - fy0)
wrong = 0
for y in range(fy0, fy1 + 1):
for x in range(fx0, fx1 + 1):
at = (int(sx0 + (x - fx0 + 0.5) * scaleX), int(sy0 + (y - fy0 + 0.5) * scaleY))
if pixels[at] != colours[indices[y][x]]:
wrong += 1
cells = (fx1 + 1 - fx0) * (fy1 + 1 - fy0)
print(f"{label}: icon shown at {sx0},{sy0} scaled {scaleX:.2f}x{scaleY:.2f}: {cells - wrong}/{cells} pixels right")
if wrong:
fails.append(f"{wrong} of the icon's {cells} pixels wrong on the screen")
palLog = os.path.join(work, "Prog", "joeylog.txt")
if not os.path.exists(palLog) or "PALPROBE: done" not in open(palLog, encoding="ascii", errors="replace").read():
fails.append("Palprobe's log is not in Prog/ with its last line: it did not run in its own folder")
if os.path.exists(os.path.join(work, "joeylog.txt")):
fails.append("a joeylog.txt is in the drive's root: Palprobe ran outside its folder")
if fails:
print(f"{label}: FAIL - " + "; ".join(fails))
sys.exit(1)
print(f"{label}: PASS (icon.library reads a tool's icon, {wantStack} bytes of stack, {width}x{height}x{depth}, and draws every pixel of it; started from it as Workbench does, Palprobe ran in its own folder and its message came back)")
PY
"$(joeyTool amigaCheck)" icon "$work" "$want_stack" "$WBTOOL" "$label"

View file

@ -16,9 +16,9 @@
# keypad's 8, 2, 4, and 6 as the arrows, its Enter as Return, and the right
# Alt as Alt, each held alone, and Up and the keypad's 8 held together with
# either let go first, which must leave Up down (scripts/lib/keysGate.sh) --
# read off KEYS's cells in FS-UAE's screenshots (scripts/lib/keysCells.py),
# read off KEYS's cells in FS-UAE's screenshots (tests/gate/keysCells.c),
# the stage found by its still empty verification row
# (scripts/lib/keysRow.py).
# (tests/gate/keysRow.c).
#
# Usage: scripts/verify-amiga-input.sh
# Requires: fs-uae, Xvfb, xdotool; build/amiga/bin/Keys (make amiga);
@ -100,7 +100,7 @@ if ! amigaShot ":$display_num" "$shotKey" "$work/shots" "$work/stage.png"; then
echo "verify-amiga-input: FAIL - FS-UAE took no screenshot" >&2
exit 1
fi
found=$(python3 "$repo/scripts/lib/keysRow.py" "$work/stage.png" "") || true
found=$("$(joeyTool keysRow)" "$work/stage.png" "") || true
if [[ ! "$found" =~ row\ at\ \(([0-9]+),([0-9]+)\)\ at\ ([0-9]+)x ]]; then
echo "verify-amiga-input: FAIL - KEYS's stage is not on the screen: $found" >&2
exit 1
@ -118,7 +118,7 @@ keysRelease() {
}
keysRead() {
if amigaShot ":$display_num" "$shotKey" "$work/shots" "$work/cells.png"; then
python3 "$repo/scripts/lib/keysCells.py" "$work/cells.png" --at "$stageAt" --scale "$scale"
"$(joeyTool keysCells)" "$work/cells.png" --at "$stageAt" --scale "$scale"
else
echo "no screenshot"
fi

View file

@ -22,13 +22,13 @@
# the boot drive carries Workbench's (component amigaLowlevel). FS-UAE's
# ports are both in joystick mode, every line of each on a host key of its
# own (keyboard_key_*), and xdotool holds the keys; FS-UAE's screenshots
# (its screenshot action, on F11) go through scripts/lib/joyScreen.py
# (its screenshot action, on F11) go through tests/gate/joyScreen.c
# (scripts/lib/joyGate.sh). Escape must then end JOY.
#
# Usage: scripts/verify-amiga-joy.sh
# Env: JOY_SHOTS also keep the screenshots in this folder
# Requires: FS-UAE, the Kickstart, lowlevel.library (install emulator:amiga),
# Xvfb, xdotool, python3 + PIL; build/amiga/bin/Joy (make amiga).
# Xvfb, xdotool; build/amiga/bin/Joy (make amiga).
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
@ -119,7 +119,7 @@ joyShot() {
# joyStage -- whether a screenshot now shows JOY's stage.
joyStage() {
joyShot "$JOY_WORK/probe.png" && python3 "$repo/scripts/lib/joyScreen.py" "$JOY_WORK/probe.png" > /dev/null 2>&1
joyShot "$JOY_WORK/probe.png" && "$(joeyTool joyScreen)" "$JOY_WORK/probe.png" > /dev/null 2>&1
}

View file

@ -85,44 +85,4 @@ sleep 2
DISPLAY=$display import -window root "$work/screen.png"
grep "PALPROBE:" "$log"
python3 - "$work/screen.png" "$log" "$MIN_RATE" "$label" <<'PY'
import re
import sys
from PIL import Image
png, log, minRate, label = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
rates = {}
for line in open(log, encoding="ascii", errors="replace"):
m = re.search(r"PALPROBE: (palette|scb)\+present: \d+ in \d+ ms = (\d+)/s", line)
if m:
rates[m.group(1)] = int(m.group(2))
if sorted(rates) != ["palette", "scb"]:
print(f"{label}: FAIL - the log does not have both rates ({rates})")
sys.exit(1)
# The bands down the middle column, top to bottom, black (the border)
# dropped: each run of one colour is a band.
img = Image.open(png).convert("RGB")
w, h = img.size
named = {(255, 0, 0): "red", (0, 255, 0): "green", (255, 255, 0): "yellow"}
bands = []
for y in range(h):
rgb = img.getpixel((w // 2, y))
if rgb == (0, 0, 0):
continue
name = named.get(rgb, "other " + str(rgb))
if not bands or bands[-1] != name:
bands.append(name)
want = ["red", "green", "yellow", "red"]
fails = []
for kind in ("palette", "scb"):
if rates[kind] < minRate:
fails.append(f"{kind} changes presented {rates[kind]}/s, want at least {minRate}")
if bands != want:
fails.append(f"bands {bands}, want {want}")
if fails:
print(f"{label}: FAIL - " + "; ".join(fails))
sys.exit(1)
print(f"{label}: PASS (palette changes {rates['palette']}/s, SCB changes {rates['scb']}/s; bands {', '.join(want)} after a palette change and a same-layout SCB change)")
PY
"$(joeyTool amigaCheck)" palette "$work/screen.png" "$log" "$MIN_RATE" "$label"

View file

@ -7,7 +7,7 @@
#
# Boots SerTest (examples/sertest: banner, then echo) off a directory hard
# drive in FS-UAE with the serial port on a TCP socket, and has the host
# peer (scripts/lib/serialLoopPeer.py) wait for the banner, send a probe and
# peer (tests/gate/serialLoopPeer.c) wait for the banner, send a probe and
# expect it echoed. That exercises the chain: m68k-amigaos-gcc -> AmigaDOS ->
# serial.device -> JoeyLib serial HAL -> Paula's UART -> host.
#
@ -60,7 +60,7 @@ HOME="$work/home" DISPLAY="$display" SDL_AUDIODRIVER=dummy ALSOFT_DRIVERS=null "
> "$work/fsuae.log" 2>&1 &
fsuaePid=$!
result=$(python3 "$repo/scripts/lib/serialLoopPeer.py" tcp-client "$port" "$wall")
result=$("$(joeyTool serialLoopPeer)" tcp-client "$port" "$wall")
rc=$?
echo "$result"
if [ "$rc" -eq 0 ]; then

View file

@ -81,6 +81,7 @@ repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
source "$repo/scripts/lib/joeyEnv.sh"
joeyNeed HATARI hatari
joeyNeed EMUTOS_IMG emutos
stAudioGrade=$(joeyTool stAudioGrade) || exit 1
machine=st
if [ "${1:-}" = "--machine" ]; then
machine=${2:-}
@ -198,222 +199,4 @@ sleep 1
[ -s "$work/ym.log" ] || { echo "verify-atarist-audio: FAIL - empty PSG trace" >&2; exit 1; }
python3 - "$work/ym.log" "$work/baseline.log" "$prog" "$taps" "$machine" <<'PY'
import re
import sys
# ym write data reg=0x8 val=0xc video_cyc=8696 60@17 pc=e1197e instr_cycle 16
LINE = re.compile(r'^ym write data reg=0x([0-9a-f]+) val=0x([0-9a-f]+) '
r'video_cyc=(\d+).*pc=([0-9a-f]+)')
PUMP_PER_FRAME_MIN = 100 # tracker writes <= 1; the pump writes 205-246
PUMP_RATE_LO = 150 # 12288/60 = 205, 12288/50 = 246. Below this the
PUMP_RATE_HI = 280 # Timer A divider is wrong, not the sound.
STRAY_FRAME_PCT_MAX = 5.0 # start/stop transitions only; a fighting
# tracker touches reg 8 EVERY frame.
REG_VOL_A = 8
REG_MIXER = 7
TONE_B_REGS = (2, 3) # channel B period == the tracker's own voice
VOICE_A_REGS = (0, 1, 8) # channel A period + volume == the tracker's voice 0
# STe DMA sound: "DMA snd mode write: 0x01 mode=stereo freq=12517 ... pc=22eec"
DMA_WRITE = re.compile(r'^DMA snd (?:mode|control) write: 0x([0-9a-f]+) .*pc=([0-9a-f]+)')
DMA_MODE_STEREO_12K = 0x01 # bit 7 clear stereo, rate 1 = 12517 Hz
DMA_CTRL_PLAY_LOOP = 0x03 # play + repeat
DMA_UNCONVERTED_SILENCE = -128 # $80, the mixer's unsigned silence, read as signed
SFX_SAMPLES_MIN = 100 # test.sfx is ~390 non-silent samples at 12517 Hz
SFX_PEAK_MIN = 32 # it peaks near half scale, the mix's unit (61 measured)
UNSIGNED_PCT_MAX = 5.0
def perFrameWrites(path):
"""{frame: {reg: [pc, ...]}} for APPLICATION writes only."""
out = {}
frame = 0
prev = -1
for line in open(path, errors='ignore'):
m = LINE.match(line)
if m is None:
continue
reg = int(m.group(1), 16)
cyc = int(m.group(3))
pc = m.group(4)
if cyc < prev: # video_cyc restarts every frame
frame += 1
prev = cyc
if pc.startswith('e'): # TOS, not us
continue
out.setdefault(frame, {}).setdefault(reg, []).append(pc)
return out
def countReg(frames, table, regs):
return sum(len(table[f].get(r, ())) for f in frames for r in regs)
full = perFrameWrites(sys.argv[1])
base = perFrameWrites(sys.argv[2])
prog = sys.argv[3]
taps = int(sys.argv[4])
machine = sys.argv[5]
if not full:
print("verify-atarist-audio: FAIL - no application PSG writes at all", file=sys.stderr)
sys.exit(1)
frames = sorted(full)
fails = []
# 1. The tracker ran before any SFX overlapped it.
baseFrames = sorted(base)
baseTone = countReg(baseFrames, base, TONE_B_REGS)
baseMixer = countReg(baseFrames, base, (REG_MIXER,))
if baseTone == 0 or baseMixer == 0:
fails.append(f"the tracker never played: {baseTone} channel-B period writes and "
f"{baseMixer} mixer writes before the SFX. jlMusicPlay did not drive the PSG")
def gradePump(summary):
"""Plain ST: the PWM pump through channel A's volume, checks 2-4."""
# 2. The pump ran, at the right rate. Group pump frames into contiguous runs
# so each SFX can be told from the next.
runs = []
current = []
for f in frames:
if len(full[f].get(REG_VOL_A, ())) >= PUMP_PER_FRAME_MIN:
current.append(f)
elif current:
runs.append(current)
current = []
if current:
runs.append(current)
peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
if not runs:
fails.append(f"the PWM pump never ran: peak {peak} reg-8 writes in a frame, want "
f">= {PUMP_PER_FRAME_MIN}. jlAudioPlaySfx did not start Timer A, so "
f"every digital sound on this port is silent")
elif not (PUMP_RATE_LO <= peak <= PUMP_RATE_HI):
fails.append(f"the pump ran at {peak} reg-8 writes/frame, want {PUMP_RATE_LO}-{PUMP_RATE_HI} "
f"(12288 Hz / 50-60 fps). The MFP Timer A divider is wrong, so sampled "
f"sound plays at the wrong pitch")
if runs:
# 3 + 4. Inside each run, minus its first and last frame.
interior = [f for r in runs for f in (r[1:-1] if len(r) > 2 else [])]
writers = {}
for f in interior:
for pc in full[f].get(REG_VOL_A, ()):
writers[pc] = writers.get(pc, 0) + 1
ranked = sorted(writers.items(), key=lambda kv: -kv[1])
isr = ranked[0][0] if ranked else None
strayFrames = sum(1 for f in interior
if any(pc != isr for pc in full[f].get(REG_VOL_A, ())))
strayPct = (100.0 * strayFrames / len(interior)) if interior else 0.0
if strayPct > STRAY_FRAME_PCT_MAX:
others = ", ".join(f"pc={pc} x{n}" for pc, n in ranked[1:4])
fails.append(f"reg 8 (volume A) has {len(ranked)} writers in {strayPct:.1f}% of the "
f"pump's frames: ISR pc={isr} plus {others}. The tracker is overwriting "
f"the PWM sample - digital and chiptune are colliding on channel A")
toneDuring = countReg(interior, full, TONE_B_REGS)
if toneDuring == 0:
fails.append("the tracker stopped writing its own channel B while the pump ran, so "
"the SFX silenced the music instead of mixing with it")
summary += (f"; pump {len(runs)} runs (expected {taps}) peak {peak} reg8/frame, "
f"{len(writers)} reg8 writer(s), stray frames {strayPct:.1f}%, "
f"tracker toneB during pump {toneDuring}")
else:
summary += f"; pump never ran (peak {peak} reg8/frame)"
return summary
def gradeDma(summary):
"""STe: the DMA chip plays the digital sound and the PSG keeps channel A."""
modes = []
controls = []
runs = []
run = None
for line in open(sys.argv[1], errors='ignore'):
if line.startswith('DMA snd mode write:'):
m = DMA_WRITE.match(line)
if m and not m.group(2).startswith('e'):
modes.append(int(m.group(1), 16))
elif line.startswith('DMA snd control write: starting'):
run = {'samples': [], 'ym': {}}
runs.append(run)
elif line.startswith('DMA snd control write: stopping'):
run = None
elif line.startswith('DMA snd control write:'):
m = DMA_WRITE.match(line)
if m and not m.group(2).startswith('e'):
controls.append(int(m.group(1), 16))
elif run is None:
continue
elif line.startswith('DMA snd fifo pull'):
v = int(line.split()[-1], 16)
run['samples'].append(v - 256 if v > 127 else v)
else:
m = LINE.match(line)
if m and not m.group(4).startswith('e'):
reg = int(m.group(1), 16)
run['ym'][reg] = run['ym'].get(reg, 0) + 1
# 2. The chip ran, programmed as the HAL programs it.
if not runs:
fails.append("the DMA sound chip never started: jlAudioPlaySfx did not reach it, so "
"every digital sound on the STe is silent")
if not modes or any(m != DMA_MODE_STEREO_12K for m in modes):
fails.append(f"DMA mode writes {[hex(m) for m in modes]}, want {hex(DMA_MODE_STEREO_12K)} "
f"(stereo, 12517 Hz) -- the mix is made for that rate and layout")
if DMA_CTRL_PLAY_LOOP not in controls:
fails.append(f"DMA control writes {[hex(c) for c in controls]} never set play + repeat "
f"({hex(DMA_CTRL_PLAY_LOOP)}), so the chip cannot loop over the mix buffer")
# 3. Each run played the SFX.
played = [sum(1 for v in r['samples'] if v != 0) for r in runs]
peaks = [max((abs(v) for v in r['samples']), default=0) for r in runs]
pulled = sum(len(r['samples']) for r in runs)
unsignedSilence = sum(1 for r in runs for v in r['samples'] if v == DMA_UNCONVERTED_SILENCE)
if runs and (min(played) < SFX_SAMPLES_MIN or min(peaks) < SFX_PEAK_MIN):
fails.append(f"a DMA run played no SFX: non-silent samples per run {played} (want >= "
f"{SFX_SAMPLES_MIN}), peaks {peaks} (want >= {SFX_PEAK_MIN}). The mix never "
f"reached the buffer the chip plays")
if pulled and 100.0 * unsignedSilence / pulled > UNSIGNED_PCT_MAX:
fails.append(f"{unsignedSilence} of {pulled} samples are -128: unsigned silence the HAL "
f"never converted to the chip's signed format, a DC step rather than a wave")
# 4. No PWM pump on this machine.
peak = max((len(full[f].get(REG_VOL_A, ())) for f in frames), default=0)
if peak >= PUMP_PER_FRAME_MIN:
fails.append(f"reg 8 was written {peak} times in one frame: the PWM pump ran on a "
f"machine with DMA sound, so channel A is still the DAC")
# 5. The tracker keeps channel A (and B) while the chip plays.
voiceA = sum(r['ym'].get(reg, 0) for r in runs for reg in VOICE_A_REGS)
toneB = sum(r['ym'].get(reg, 0) for r in runs for reg in TONE_B_REGS)
if runs and voiceA == 0:
fails.append("the tracker wrote none of voice 0's registers (0, 1, 8) while the DMA chip "
"played: channel A is still being given up to digital sound")
if runs and toneB == 0:
fails.append("the tracker stopped writing its own channel B while the DMA chip played, "
"so the SFX silenced the music instead of mixing with it")
return (summary + f"; DMA {len(runs)} runs (expected {taps}), mode "
f"{','.join(sorted(set(hex(m) for m in modes)))}, non-silent samples/run "
f"{min(played, default=0)}..{max(played, default=0)}, peak "
f"{max(peaks, default=0)}, peak reg8/frame {peak}, tracker voice A {voiceA} / "
f"toneB {toneB} writes during DMA")
summary = f"{prog} tracker baseline {baseTone} toneB/{baseMixer} mixer"
summary = gradeDma(summary) if machine == 'ste' else gradePump(summary)
print(f"verify-atarist-audio: {summary}")
for f in fails:
print(f"verify-atarist-audio: {f}", file=sys.stderr)
if fails:
print("verify-atarist-audio: FAIL", file=sys.stderr)
sys.exit(1)
print("verify-atarist-audio: PASS")
PY
"$stAudioGrade" "$work/ym.log" "$work/baseline.log" "$prog" "$taps" "$machine"

View file

@ -44,6 +44,7 @@ source "$repo/scripts/lib/keysGate.sh"
joeyNeed HATARI hatari
joeyNeed EMUTOS_IMG emutos
joeyNeed EMUTOS_DE_IMG emutos
stScreenDump=$(joeyTool stScreenDump) || exit 1
bin_dir=$repo/build/atarist/bin
label=verify-atarist-input
@ -122,43 +123,7 @@ rowCheck() {
sleep 3
send "hatari-debug m \$$(printf '%x' "$rowAddr") 160"
sleep 2
python3 - "$work/hatari.log" "$rowAddr" "$1" <<'PY'
import re
import sys
log, rowAddr, target = sys.argv[1], int(sys.argv[2]), sys.argv[3]
data = open(log, "rb").read().decode(errors="replace")
lines = {}
# Keep only the LAST dump of each address (earlier sentinel probes also match).
for m in re.finditer(r"^(00[0-9A-F]{6}): ((?:[0-9a-f]{2} ){1,16})", data, re.M):
lines[int(m.group(1), 16)] = m.group(2)
raw = b""
for off in range(0, 160, 16):
if rowAddr + off not in lines:
print(f"verify-atarist-input: FAIL - missing dump line at +{off}")
sys.exit(1)
raw += bytes.fromhex(lines[rowAddr + off].replace(" ", ""))
def group(words):
p = [int.from_bytes(words[i * 2:i * 2 + 2], "big") for i in range(4)]
return [((p[0] >> b) & 1) | (((p[1] >> b) & 1) << 1) |
(((p[2] >> b) & 1) << 2) | (((p[3] >> b) & 1) << 3)
for b in range(15, -1, -1)]
pix = []
for g in range(len(raw) // 8):
pix += group(raw[g * 8:g * 8 + 8])
if pix[0:4] != [0xA, 0x5, 0xA, 0x5]:
print(f"verify-atarist-input: FAIL - sentinel wrong: {pix[0:4]}")
sys.exit(1)
count = (pix[4] << 4) | pix[5]
chars = bytes((pix[8 + 2 * i] << 4) | pix[8 + 2 * i + 1] for i in range(count))
print(f"VERIFY-ST-INPUT count={count} chars={chars!r}")
if chars.decode("ascii", errors="replace") != target:
print(f'verify-atarist-input: FAIL - expected "{target}"')
sys.exit(1)
PY
"$stScreenDump" keys "$work/hatari.log" "$rowAddr" "$1"
}
@ -195,28 +160,7 @@ keysRelease() {
keysRead() {
send "hatari-debug m \$$(printf '%x' "$cellAddr") 160"
sleep 1
python3 - "$work/hatari.log" "$cellAddr" <<'PY'
import re
import sys
log, addr = sys.argv[1], int(sys.argv[2])
GROUPS = (5, 7, 9, 11, 13, 15, 17)
data = open(log, "rb").read().decode(errors="replace")
lines = {}
for m in re.finditer(r"^(00[0-9A-F]{6}): ((?:[0-9a-f]{2} ){1,16})", data, re.M):
lines[int(m.group(1), 16)] = m.group(2)
raw = b""
for off in range(0, 160, 16):
raw += bytes.fromhex(lines.get(addr + off, "00 " * 16).replace(" ", ""))
digits = ""
for g in GROUPS:
colour = 0
for plane in range(4):
word = int.from_bytes(raw[g * 8 + plane * 2:g * 8 + plane * 2 + 2], "big")
colour |= ((word >> 15) & 1) << plane
digits += "1" if colour == 2 else ("0" if colour == 1 else "?")
print(digits)
PY
"$stScreenDump" cells "$work/hatari.log" "$cellAddr"
}
KEYS_SETTLE_SECONDS=1
keysRunSteps kp8 kp2 kp4 kp6 kpenter

View file

@ -28,13 +28,13 @@
# Hatari's [Joystick0] and [Joystick1] (a config file of the gate's own) are
# keyboard joysticks on host keys, which xdotool holds on a private Xvfb
# display; Hatari's own screenshots (its command FIFO's screenshot shortcut)
# go through scripts/lib/joyScreen.py (scripts/lib/joyGate.sh). Escape must
# go through tests/gate/joyScreen.c (scripts/lib/joyGate.sh). Escape must
# then end JOY.
#
# Usage: scripts/verify-atarist-joy.sh [atarist|ste] [MACHINE]
# (default atarist on an st; ste on an ste)
# Env: JOY_SHOTS also keep the screenshots in this folder
# Requires: Hatari, EmuTOS, Xvfb, xdotool, python3 + PIL;
# Requires: Hatari, EmuTOS, Xvfb, xdotool;
# build/<build>/bin/JOY.PRG (make atarist / make ste).
set -uo pipefail
@ -46,7 +46,7 @@ joeyNeed HATARI hatari
joeyNeed EMUTOS_IMG emutos
build="${1:-atarist}"
# The ST build writes 3-bit colours on any machine (joyScreen.py --st).
# The ST build writes 3-bit colours on any machine (joyScreen --st).
case "$build" in
atarist) machine="${2:-st}"; JOY_SCREEN_ARGS=(--st) ;;
ste) machine="${2:-ste}" ;;
@ -184,7 +184,7 @@ joyShot() {
# joyStage -- whether a screenshot now shows JOY's stage.
joyStage() {
joyShot "$JOY_WORK/probe.png" && python3 "$repo/scripts/lib/joyScreen.py" "$JOY_WORK/probe.png" "${JOY_SCREEN_ARGS[@]}" > /dev/null 2>&1
joyShot "$JOY_WORK/probe.png" && "$(joeyTool joyScreen)" "$JOY_WORK/probe.png" "${JOY_SCREEN_ARGS[@]}" > /dev/null 2>&1
}

View file

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

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