joeylib2/README.md
2026-10-13 14:01:46 -05:00

35 KiB

JoeyLib

A unified C game-development library targeting five early 16-bit platforms, one DOS desktop, today's desktops and browsers, Haiku and BeOS, and the PSP, from a single codebase:

  • Apple IIgs (reference platform)
  • Commodore Amiga (A500 / 68000 baseline)
  • Atari ST (STF / 68000 baseline)
  • MS-DOS (386 / VGA, DJGPP)
  • Sharp X68000 (68000 @ 10 MHz, Human68k, elf2x68k)
  • DVX (the DOS desktop GUI in ../dvxgui: each program a windowed .app that can also take the whole screen, built with the DOS compiler against DVX's SDK)
  • SDL3 (a native windowed program for the machine you work on, Linux today: the hosted core in src/hosted with an SDL3 backend, SDL 3 linked in statically; sprites are interpreted)
  • Web (the SDL3 port compiled to WebAssembly with Emscripten: each program a page, its DATA packed in, saves kept in the browser)
  • Win32 (Windows 95 through today: the hosted core with a backend written to the Win32 API, built with llvm-mingw against MSVCRT.DLL, tested under Wine)
  • Win16 (Windows 3.1 on a 386: the hosted core with a backend written to the Windows 3.1 API, built with Open Watcom's Win386 target, tested on Windows for Workgroups 3.11 in DOSBox Staging)
  • Haiku and BeOS (the hosted core with a backend written to the Be API, which Haiku reimplements: Haiku x86_64 and x86, and BeOS R5 x86, built with gcc 2.95 against R5's own headers and libraries; each tested in a VM under QEMU)
  • PSP (homebrew for custom firmware: the SDL3 port built with pspdev, each program an EBOOT.PBP in a folder for PSP/GAME/, the stage at 1:1 in the 480x272 screen; tested under PPSSPP with every memory access checked for alignment as a PSP checks it)

The Apple IIgs defines the capability ceiling. Stronger platforms coast. Hot paths are hand-written assembly per port; the public API is C.

See claudeDocs/DESIGN.md for the full 1.0 design.

Quick start

JoeyLib builds on Linux x86-64, and on Windows inside WSL2. One script installs every cross toolchain, pinned and checksum-verified, into toolchains/; outside that directory it only installs ordinary packages from your distribution (apt or dnf), and only when asked.

git clone https://forge.duensing.digital/AI_Slop/joeylib2.git joeylib
cd joeylib
./install.sh deps toolchains --install   # distribution packages (sudo)
./install.sh install toolchains          # every cross toolchain
make

The first install takes about an hour, almost all of it compiling the IIgs and Amiga compilers. ./install.sh install toolchain:dos (or any other port) installs just one. ./install.sh list shows every component and whether it is current, and ./install.sh doctor smoke-tests what is installed. The manual's Getting Started chapter (docs/GettingStarted.adoc) has the full installer reference.

No environment setup is needed: make reads the tool paths the installer records in toolchains/config.mk. For running a cross compiler by hand, source toolchains/env.sh sets the same variables.

make builds libjoey.a for every port whose toolchain is installed, plus the example programs (hello, pattern, draw, keys, serial, sertest, save, tilebank, joy, sprite, audio, uber, adventure, adventure2) for each. The IIgs is the exception: make iigs builds the library, and make iigs-disk builds its examples and packages them onto a disk image. DVX needs the dvx installer component, which unpacks DVX and its SDK from a DVX tree's distribution, dist/dvx.zip and dist/sdk.zip (./install.sh --dvx-dir PATH install dvx; the default is ../dvxgui); its examples land in build/dvx/bin/apps/joeylib/<name>/, laid out as they install in DVX. SDL3 needs the sdl3 component (./install.sh install toolchain:sdl3), which builds SDL 3 from its release source; its examples are native programs in build/sdl3/bin/, and running one takes seconds rather than an emulator's minutes. The web port needs the web components (./install.sh install toolchain:web: Emscripten through emsdk, and SDL 3 built with it); its examples are pages in build/web/bin/<NAME>/. The Win32 port needs toolchain:win32 (llvm-mingw) and, to run its programs, emulator:win32 (a portable Wine). The Win16 port needs toolchain:win16 (Open Watcom) and, to run its programs, emulator:win16 (Windows for Workgroups 3.11 in DOSBox Staging, set up from your own install disc and S3 display driver). The PSP port needs toolchain:psp (pspdev's prebuilt release, and SDL 3 built with it) and, to run its programs, emulator:psp (PPSSPP's headless build, patched for the gates); its examples are folders build/psp/bin/<NAME>/ holding EBOOT.PBP and DATA/, ready for a memory stick's PSP/GAME/. The Haiku ports need toolchain:haiku or toolchain:haiku32 (cross compilers built from Haiku R1/beta6) and, to run their programs, emulator:haiku or emulator:haiku32 (a Haiku VM for QEMU); the BeOS port needs toolchain:beos and emulator:beos, both made from your own BeOS 5 Professional CD.

Your own program does not go into this tree: it lives in a directory of its own whose Makefile describes it and includes make/project.mk and make/programs.mk from a JoeyLib checkout (found through JOEYLIB, by default ../joeylib). make there builds it for every installed target with the same rules, flags and packaging as the examples, into the project's own build/; the manual's "Building Your Program" chapter (docs/BuildIntegration.adoc) has the details. Two complete games are built this way as separate projects: Space Taxi, a port of the 1984 Commodore 64 game, and JoeyAGI, an interpreter for Sierra's AGI adventures.

Running programs

joey-run starts a built program in the right emulator; the installer sets up the emulators and their firmware:

./install.sh install emulators       # or emulator:dos, emulator:iigs, ...
./joey-run build/dos/bin/DRAW.EXE
./joey-run --port amiga draw
./joey-run --headless --seconds 30 --snapshot draw.png --port atarist draw
./joey-run --port dvx draw           # DVX in DOSBox, booting into the program
./joey-run --port sdl3 draw          # natively, in a window (no emulator)
./joey-run --port web draw           # served here, opened in your browser
./joey-run --port win32 draw         # under Wine
./joey-run --port win16 draw         # on Windows 3.11 in DOSBox
./joey-run --port psp --headless --snapshot draw.png draw   # under headless PPSSPP
./joey-run --port haiku draw         # in a Haiku VM under QEMU
./joey-run --port beos draw          # in a BeOS R5 VM under QEMU

A few files are never downloaded -- the Amiga Kickstart, the Amiga serial.device and lowlevel.library from Workbench 3.1, the X68000 font ROM, the Windows for Workgroups 3.11 install disc and S3 Trio64V+ driver the Win16 port runs on, and the BeOS 5 Professional CD -- so supply your copies once with ./install.sh import PATH (files, folders, .zip or .7z archives or Amiga .adf disk images; the Windows disc as its .iso, the BeOS CD as its CloneCD .img). The manual's Getting Started chapter covers joey-run's options and the private-file bundle.

Building for a single target

make iigs
make amiga
make atarist
make ste
make dos
make x68000
make dvx

make atarist builds programs for every ST; make ste builds the STe variant, which uses the STe's BLiTTER, DMA sound and 4-bit palette and runs only on an STe or Mega STe.

Every fragment also answers to a bare clean, and every fragment that builds through make/common.mk -- all of them except make/iigs.mk, which assembles its own clang flags -- honors EXTRA_CFLAGS:

make -f make/atarist.mk clean
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 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 (SDL's dummy drivers) and takes about fifteen seconds; the web's runs UBER's page in headless Chromium; the PSP's runs UBER under headless PPSSPP in a few seconds, with every memory access checked as a PSP checks it, so an unaligned 16- or 32-bit access anywhere fails it:

scripts/bench-sdl3.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/sdl3/bin/joeylog.txt
scripts/bench-web.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/web/bin/UBER/joeylog.txt
scripts/bench-psp.sh && tools/diff-uber-hashes tests/goldens/uber/dos.txt build/psp/bin/UBER/joeylog.txt

The X68000 has gates of its own:

make -f make/x68000.mk x68000-verify-golden   # ~1 min under the patched MAME
make -f make/x68000.mk x68000-verify-serial   # both-directions RS-232C, ~1 min
make -f make/x68000.mk x68000-verify-joy      # both joysticks, every line, ~10 s
make -f make/x68000.mk x68000-verify-serial-scb  # RS-232C under per-line palettes, ~3 min

These X68000 gates run under the patched MAME and the X68000 firmware that ./install.sh install emulator:x68000 sets up.

DVX's display is gated as well: scripts/verify-dvx-display.sh runs UBER's probe build (make dvx puts it in build/dvx/probe) on nine desktop depths and scales and on the full screen, and checks every displayed pixel against the stage.

Repository layout

docs/                 design and reference documentation
include/joey/         public headers
src/core/             portable library code
src/generic/          portable-C default for every overridable op
src/codegen/          shared sprite-codegen staging / compile machinery
src/<machine>/        per-machine HAL + per-CPU sprite emitter
                      (iigs, amiga, atarist, dos, x68000, dvx)
src/hosted/           the hosted core (desktop OSes and browsers: RGB
                      present, virtual frame clock, input events, software
                      mixer) and its backends (src/hosted/sdl3, which the
                      sdl3, web and psp ports all build; src/hosted/win32;
                      src/hosted/win16)
src/m68k/             68k code shared across machines (planar and word-
                      interleaved sprite emitters; surface68k.s, which is
                      linked by Amiga and Atari ST only)
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/xdftool.py      Human68k FAT12 .XDF disk image reader / writer
tools/diff-uber-hashes  UBER golden-hash comparator
examples/             example programs
joey-run              run a built program in its emulator
scripts/              bench / verification scripts (scripts/lib: shared helpers)
tests/goldens/uber/   frozen UBER conformance hashes
install.sh            installs toolchains, emulators and firmware
installer/            the installer's component recipes, patches, lists
toolchains/           everything install.sh downloads and builds (not in
                      git; delete it any time and re-run install.sh)
make/                 per-target Makefile fragments: each port's library and
                      program rules (make/<port>.mk), the shared program
                      rules (programs.mk), JoeyLib's examples (examples.mk),
                      the entry point for outside projects (project.mk);
                      make/support: build helpers
build/<plat>/         per-target build outputs

Public API

Game code includes a single umbrella header:

#include <joey/joey.h>

That pulls in every public surface listed below. Full documentation lives in the per-feature headers under include/joey/; what follows is a quick reference. Every entry point is plain C, no C++ extensions.

Lifecycle (joey/core.h)

typedef struct {
    uint32_t  codegenBytes;   // runtime compiled-sprite cache size
} jlConfigT;

bool        jlInit         (const jlConfigT *config);
void        jlShutdown     (void);
const char *jlLastError    (void);
const char *jlPlatformName (void);
const char *jlVersionString(void);

void       *jlAlloc        (uint32_t bytes);   // native allocator, not malloc
void        jlFree         (void *p);

void        jlWaitVBL      (void);     // block until next VBL
uint16_t    jlFrameCount   (void);     // monotonic 16-bit frame counter
uint16_t    jlFrameHz      (void);     // 50 / 55 / 60 / 70 depending on port
uint32_t    jlMillisElapsed(void);     // monotonic ms since jlInit

uint32_t    jlRandom       (void);     // portable, bit-identical per seed
uint16_t    jlRandomRange  (uint16_t bound);
void        jlRandomSeed   (uint32_t seed);

Surfaces (joey/surface.h)

All surfaces are 320x200 16-color images with a 200-entry SCB table and 16 palettes of 16 $0RGB colors. In-memory storage is target-native: chunky 4bpp packed on IIgs, DOS, DVX, SDL3, the web and the PSP, native planar (separate bitplanes on Amiga and Sharp X68000, word-interleaved planes on Atari ST) on the 68k ports. The public API speaks in color indices (0..15) and hides the storage format.

#define SURFACE_WIDTH               320
#define SURFACE_HEIGHT              200
#define SURFACE_BYTES_PER_ROW       160
#define SURFACE_PIXELS_SIZE         (SURFACE_BYTES_PER_ROW * SURFACE_HEIGHT)
#define SURFACE_PALETTE_COUNT       16
#define SURFACE_COLORS_PER_PALETTE  16

typedef struct jlSurfaceT jlSurfaceT;     // opaque

jlSurfaceT *jlSurfaceCreate (void);
void      jlSurfaceDestroy(jlSurfaceT *s);
jlSurfaceT *jlStageGet      (void);                              // library back-buffer
void      jlSurfaceCopy   (jlSurfaceT *dst, const jlSurfaceT *src);

bool      jlSurfaceSaveFile(const jlSurfaceT *src, const char *path);
bool      jlSurfaceLoadFile(jlSurfaceT       *dst, const char *path);
uint32_t  jlSurfaceHash    (const jlSurfaceT *s);                // FNV-1a of logical pixels

jlSurfaceSaveFile writes the surface in target-native form. Files are NOT cross-port portable; the asset pipeline handles conversion.

Drawing (joey/draw.h)

All primitives clip to the surface; off-surface coords are silent no-ops. Color 0 is plotted normally (use the masked variants if you need transparency).

void jlSurfaceClear      (jlSurfaceT *s, uint8_t color);
void jlDrawPixel         (jlSurfaceT *s, int16_t x, int16_t y, uint8_t color);
uint8_t jlSamplePixel    (const jlSurfaceT *s, int16_t x, int16_t y);

void jlDrawLine          (jlSurfaceT *s, int16_t x0, int16_t y0,
                        int16_t x1, int16_t y1, uint8_t color);
void jlDrawRect          (jlSurfaceT *s, int16_t x, int16_t y,
                        uint16_t w, uint16_t h, uint8_t color);
void jlFillRect          (jlSurfaceT *s, int16_t x, int16_t y,
                        uint16_t w, uint16_t h, uint8_t color);
void jlDrawCircle        (jlSurfaceT *s, int16_t cx, int16_t cy,
                        uint16_t r, uint8_t color);
void jlFillCircle        (jlSurfaceT *s, int16_t cx, int16_t cy,
                        uint16_t r, uint8_t color);

void jlFloodFill         (jlSurfaceT *s, int16_t x, int16_t y, uint8_t newColor);
void jlFloodFillBounded  (jlSurfaceT *s, int16_t x, int16_t y,
                        uint8_t newColor, uint8_t boundaryColor);

Palette and SCB (joey/palette.h)

Colors are 12-bit $0RGB. Color 0 of every palette is forced to black on jlPaletteSet. Each scanline picks one of the 16 palettes via the SCB. Every port honours it. The X68000 presents to its text plane, which is planar like the stage (so a present is a copy) but has one 16-colour palette: a raster interrupt reloads that palette wherever the SCB changes, which for a single-palette screen is never. A build switch (X68K_PRESENT_TEXT=0) uses the 256-colour graphics plane the way the DOS port uses its DAC instead -- each pixel byte is (scb[y] << 4) | nibble, exact with no interrupt, but slower.

void    jlPaletteSet  (jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16);
void    jlPaletteGet  (const jlSurfaceT *s, uint8_t paletteIndex, uint16_t *out16);
void    jlScbSet      (jlSurfaceT *s, uint16_t line, uint8_t paletteIndex);
void    jlScbSetRange (jlSurfaceT *s, uint16_t firstLine, uint16_t lastLine,
                     uint8_t paletteIndex);
uint8_t jlScbGet      (const jlSurfaceT *s, uint16_t line);

Tiles (joey/tile.h)

A "tile" is just an 8x8-aligned region of any surface. The API moves 32-byte chunks between surfaces and provides a small jlTileT value type so callers can stash a copy without allocating a scratch surface.

#define TILE_PIXELS_PER_SIDE  8
#define TILE_BYTES_PER_ROW    4
#define TILE_BYTES            (TILE_BYTES_PER_ROW * TILE_PIXELS_PER_SIDE)
#define TILE_BLOCKS_PER_ROW   (SURFACE_WIDTH  / TILE_PIXELS_PER_SIDE)  // 40
#define TILE_BLOCKS_PER_COL   (SURFACE_HEIGHT / TILE_PIXELS_PER_SIDE)  // 25
#define TILE_NO_GLYPH         ((uint16_t)0xFFFFu)

typedef struct jlTileT { uint8_t pixels[TILE_BYTES]; } jlTileT;

void jlTileCopy       (jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy,
                     const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy);
void jlTileCopyMasked (jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy,
                     const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy,
                     uint8_t transparentIndex);
void jlTileFill       (jlSurfaceT *s,   uint8_t bx,    uint8_t by,    uint8_t color);
void jlTileSnap       (const jlSurfaceT *src, uint8_t bx, uint8_t by, jlTileT *out);
void jlTilePaste      (jlSurfaceT *dst, uint8_t bx, uint8_t by,       const jlTileT *in);
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 ...).
// Refuses files baked for the wrong target. outValid (optional) marks
// each loaded slot; outPalette (optional) receives the embedded
// 16-entry $0RGB palette if present.
uint16_t jlTileBankLoad(const char *path, jlTileT *outTiles, uint16_t maxTiles,
                       bool *outValid, uint16_t *outPalette);

void jlDrawText       (jlSurfaceT *dst, uint8_t bx, uint8_t by,
                     const jlSurfaceT *fontSurface, const uint16_t *asciiMap,
                     const char *str);

Sprites (joey/sprite.h)

Rectangles of 8x8 tiles drawn at arbitrary pixel positions with color-0 transparency. Tile data is widthTiles * heightTiles * 32 bytes, tile-major 4bpp packed. Sprites can be runtime-compiled into per-shift code variants for fast draws.

typedef struct jlSpriteT jlSpriteT;            // opaque

typedef struct {
    jlSpriteT  *sprite;
    int16_t   x, y;
    uint16_t  width, height;               // pixels
    uint8_t  *bytes;                       // caller-owned save-under buffer
    uint16_t  sizeBytes;
} jlSpriteBackupT;

jlSpriteT *jlSpriteCreate            (const uint8_t *tileData,
                                  uint8_t widthTiles, uint8_t heightTiles);
jlSpriteT *jlSpriteCreateFromSurface (const jlSurfaceT *src, int16_t x, int16_t y,
                                  uint8_t widthTiles, uint8_t heightTiles);
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
// cel becomes a freshly-allocated jlSpriteT (release with
// jlSpriteDestroy). outPalette (optional) receives the embedded
// 16-entry $0RGB palette if present.
uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels,
                         uint16_t maxCels, uint16_t *outPalette);

// Same contract, but reading a `.spc` bank pre-compiled for THIS target by
// the offline baker `tools/spritebake`: the native blit routines are copied
// straight into the codegen arena, so there is no startup JIT. The runtime
// twin writes one back out after a jlSpriteCompile pass.
uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels,
                         uint16_t maxCels, uint16_t *outPalette);
bool     jlSpriteBankSavePrecompiled(const char *path, jlSpriteT **cels,
                         uint16_t count, const uint16_t *palette);

bool     jlSpriteCompile           (jlSpriteT *sp);   // build per-shift fast path
void     jlSpritePrewarm           (jlSpriteT *sp);   // hint: compile if not already
uint32_t jlSpriteCompiledSize      (const jlSpriteT *sp);

void     jlSpriteDraw              (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y);
void     jlSpriteSaveUnder         (const jlSurfaceT *s, jlSpriteT *sp,
                                  int16_t x, int16_t y, jlSpriteBackupT *backup);
void     jlSpriteRestoreUnder      (jlSurfaceT *s, const jlSpriteBackupT *backup);
void     jlSpriteSaveAndDraw       (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
                                  jlSpriteBackupT *backup);

// TRUSTED variants: skip the per-call geometry validation for a caller that
// pre-compiles its sprites and guarantees the sprite is FULLY on-surface.
// UB if that is violated -- no clipping happens. They fall back to the
// validated entry for uncompiled sprites and on ports with no compiled path.
void     jlSpriteDrawTrusted       (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y);
void     jlSpriteSaveUnderTrusted  (const jlSurfaceT *s, jlSpriteT *sp,
                                  int16_t x, int16_t y, jlSpriteBackupT *backup);
void     jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup);
void     jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
                                  jlSpriteBackupT *backup);

void     jlSpriteCompact           (void);          // defrag the codegen arena
uint32_t jlSpriteCodegenBytesUsed  (void);
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 conversion -- there is no in-memory chunky-to-planar or palette re-indexing on the device.

Two blob formats:

  • .tbk (tile bank) -- one or more 8x8 tiles in per-target planar layout (Amiga and X68000 plane-major; Atari ST row-major-with- planes-per-row; DOS / IIgs chunky 4bpp). The loader jlTileBankLoad rejects files baked for the wrong target.
  • .spr (sprite cel set) -- one or more uniform-sized sprite cels in cross-target chunky 4bpp. The Phase 11 walker reads chunky and converts to planar at draw time, so the same blob serves every platform.

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

A program's description wires its bake rules once for every target (make/examples.mk, or a project's own Makefile), so make <target> produces the baked blobs and stages them into the runtime tree at build/<target>/.../DATA/ -- on the IIgs and DVX into build/<target>/data/<name>/DATA/, which the disk packer and the DVX SDK package with the program.

For runtime-extracted content (sprites peeled out of a procedural or captured surface), use jlSpriteCreateFromSurface -- the on-the-fly path stays alongside the baked load API.

Present (joey/present.h)

void jlStagePresent(void);

Flips the dirty rows of the stage to the display, then clears dirty state. Drawing primitives mark dirty as a side effect, so calling jlStagePresent once at end-of-frame is enough.

Input (joey/input.h)

Call jlInputPoll once per frame, then query the state predicates. Edge predicates (*Pressed, *Released) fire only in the frame the transition happened.

Text entry uses the separate typed-character queue: jlInputGetChar pops the next typed character (printable ASCII 0x20..0x7E plus JL_CHAR_BACKSPACE/TAB/RETURN/ESCAPE) or -1 when empty, with shift, caps lock, and the machine's keyboard layout already applied by the backend -- so punctuation like . and : arrives correctly on every port. Refilled by the same jlInputPoll; see claudeDocs/input.md for per-port sources and limitations.

typedef enum { /* KEY_NONE, KEY_A..KEY_Z, KEY_0..KEY_9, KEY_SPACE,
                  KEY_ESCAPE, KEY_RETURN, KEY_TAB, KEY_BACKSPACE,
                  KEY_UP/DOWN/LEFT/RIGHT, KEY_LSHIFT/RSHIFT/LCTRL/LALT,
                  KEY_F1..KEY_F10, KEY_COUNT */ } jlKeyE;
typedef enum { MOUSE_BUTTON_NONE, MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT,
               MOUSE_BUTTON_MIDDLE, MOUSE_BUTTON_COUNT } jlMouseButtonE;
typedef enum { JOYSTICK_0, JOYSTICK_1, JOYSTICK_COUNT } jlJoystickE;
typedef enum { JOY_BUTTON_0, JOY_BUTTON_1, JOY_BUTTON_COUNT } jlJoyButtonE;

#define JOYSTICK_AXIS_MAX  127
#define JOYSTICK_AXIS_MIN  (-127)

#define JL_CHAR_BACKSPACE  0x08
#define JL_CHAR_TAB        0x09
#define JL_CHAR_RETURN     0x0D
#define JL_CHAR_ESCAPE     0x1B

void    jlInputPoll        (void);
void    jlWaitForAnyKey    (void);
int     jlInputGetChar     (void);

bool    jlKeyDown          (jlKeyE key);
bool    jlKeyPressed       (jlKeyE key);
bool    jlKeyReleased      (jlKeyE key);

int16_t jlMouseX           (void);
int16_t jlMouseY           (void);
bool    jlMouseDown        (jlMouseButtonE b);
bool    jlMousePressed     (jlMouseButtonE b);
bool    jlMouseReleased    (jlMouseButtonE b);

bool    jlJoystickConnected(jlJoystickE js);
int8_t  jlJoystickX        (jlJoystickE js);
int8_t  jlJoystickY        (jlJoystickE js);
bool    jlJoyDown          (jlJoystickE js, jlJoyButtonE b);
bool    jlJoyPressed       (jlJoystickE js, jlJoyButtonE b);
bool    jlJoyReleased      (jlJoystickE js, jlJoyButtonE b);
void    jlJoystickReset    (jlJoystickE js, uint8_t deadZone);

Audio (joey/audio.h)

4-channel Protracker-style music plus five one-shot SFX slots, three PSG-style tone voices and a noise channel. Module data must be the platform-native form produced by tools/joeymod (.mod for Amiga/DOS/ST/X68000; .ntp for IIgs; .amod if you want loop=false on Amiga). Amiga plays .mod through PTPlayer and the IIgs through the NinjaTrackerPlus replayer; DOS, Atari ST and X68000 decode with libxmp-lite. A failed jlAudioInit is non-fatal; the rest of the API stays callable as no-ops.

#define JOEY_AUDIO_SFX_SLOTS  5
#define JOEY_AUDIO_VOICES     3

bool jlAudioInit          (void);
void jlAudioShutdown      (void);

void jlAudioPlayMod       (const uint8_t *data, uint32_t length, bool loop);
void jlAudioStopMod       (void);
bool jlAudioIsPlayingMod  (void);

void jlAudioPlaySfx       (uint8_t slot, const uint8_t *sample,
                             uint32_t length, uint16_t rateHz);
void jlAudioPlaySfxStream (uint8_t slot, jlAudioStreamFillT fill, void *ctx,
                             uint16_t rateHz);
void jlAudioStopSfx       (uint8_t slot);

// Portable chip-tracker music: a JYM1 event stream baked by tools/songbake,
// 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);
void jlMusicStop          (void);
bool jlMusicIsPlaying     (void);
void jlMusicPause         (void);
void jlMusicResume        (void);
void jlMusicSetAtten      (uint8_t atten);

void jlAudioTone          (uint16_t freqHz);
void jlAudioVoice         (uint8_t voice, uint16_t freqHz, uint8_t atten);
void jlAudioNoise         (uint8_t pitch, uint8_t atten);

void jlAudioFrameTick     (void);

Serial (joey/serial.h) -- opt-in add-on

RS-232 serial communications. This is an opt-in add-on: it is the one public header the umbrella <joey/joey.h> does not include, so pull in <joey/serial.h> yourself. Ports that have no serial hardware, and the BLANK template, link a no-op stub, so the API stays callable everywhere -- a failed jlSerialOpen leaves every other call an inert no-op.

The API is non-blocking and buffered: received bytes accumulate in a background ring that you drain once per frame with jlSerialPoll, and writes never stall the frame (they report a short count under flow control -- resend the remainder next frame). One port is open at a time.

typedef enum jlSerialDeviceE {
    JL_SERIAL_DEFAULT = 0,  // the platform's natural port
    JL_SERIAL_MODEM,        // IIgs modem port (SCC channel A)
    JL_SERIAL_PRINTER,      // IIgs printer port (SCC channel B)
    JL_SERIAL_SLOT          // a card in slot/unit `config.unit`
} jlSerialDeviceE;

typedef struct jlSerialConfigT {
    uint32_t         baud;      // 0 -> 9600
    uint8_t          dataBits;  // 5..8, 0 -> 8
    uint8_t          stopBits;  // 1..2, 0 -> 1
    jlSerialParityE  parity;    // NONE / ODD / EVEN
    jlSerialFlowE    flow;      // NONE / RTSCTS / XONXOFF
    uint8_t          unit;      // slot (IIgs SSC) or COM/device index
} jlSerialConfigT;

bool     jlSerialOpen     (jlSerialDeviceE device, const jlSerialConfigT *config);
void     jlSerialClose    (void);
void     jlSerialPoll     (void);   // call once per frame to drain RX
uint16_t jlSerialAvailable(void);
uint16_t jlSerialRead     (uint8_t *buf, uint16_t max);
int16_t  jlSerialReadByte (void);   // -1 if none
uint16_t jlSerialWrite    (const uint8_t *buf, uint16_t len);
bool     jlSerialWriteByte(uint8_t b);
void     jlSerialFlush    (void);   // discard buffered input

Per-port device mapping:

Device IIgs DOS Amiga Atari ST X68000
JL_SERIAL_DEFAULT modem port (SCC A) COM1 serial.device 0 Modem/RS-232 RS-232C (SCC A)
JL_SERIAL_MODEM modem port (SCC A) COM1 serial.device 0 Modem/RS-232 RS-232C (SCC A)
JL_SERIAL_PRINTER printer port (SCC B) COM1 serial.device 0 Modem/RS-232 RS-232C (SCC A)
JL_SERIAL_SLOT 6551 SSC in slot unit COMunit serial.device unit Modem/RS-232 RS-232C (SCC A)

Only the IIgs has multiple built-in ports; elsewhere MODEM/PRINTER fall back to the natural port. The serial example is an echo terminal and loopback self-test. scripts/check-serial.sh unit-tests the portable gate layer against a loopback mock with no hardware. Full reference, per-port behavior, and porting notes: claudeDocs/serial.md.

Files, saves & disk space (joey/file.h) -- opt-in

File access, not pulled in by <joey/joey.h> -- include <joey/file.h>. Two directories sit beside the executable: read-only DATA/ (game content) and writable SAVES/ (save / preference files, created on demand). Names are relative to their directory ("hero.sav", not "SAVES/hero.sav"). Keep save names to 8 characters plus a short extension, letters/digits only, so they fit ProDOS and 8.3. Every call is non-fatal: a failed save reports false/0.

FILE *   jlDataOpen  (const char *name, const char *mode);   // DATA/ (read-only)

FILE *   jlSaveOpen  (const char *name, const char *mode);   // SAVES/ stream
bool     jlSaveWrite (const char *name, const void *buf, uint32_t len);
uint32_t jlSaveRead  (const char *name, void *buf, uint32_t max);
bool     jlSaveExists(const char *name);
bool     jlSaveDelete(const char *name);

uint32_t jlDiskFree  (void);   // free bytes on the save volume (0 if unknown)

Directory create / delete / free-space use a per-port HAL (POSIX mkdir + remove + INT 21h on DOS; mkdir + GEMDOS Dfree on ST; dos.library CreateDir/DeleteFile/Info on Amiga; GS/OS Create + a GetDevNumber/DInfo/Volume chain on the IIgs; Human68k _dos_mkdir + remove + _dos_dskfre on the X68000). scripts/check-save.sh round-trips the core against real files; make iigs-verify-save boots the IIgs save path under MAME. Full reference, per-port behavior, and porting notes: claudeDocs/save.md.

Debug logging (joey/debug.h)

Crash-tracing logger. Writes are buffered and durable across normal exit; call jlLogFlush ahead of suspected hang points if you want a guaranteed last-line-on-disk.

void jlLog     (const char *msg);
void jlLogF    (const char *fmt, ...);
void jlLogFlush(void);
void jlLogReset(void);

Output goes to joeylog.txt in the program's working directory.

Building with EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE additionally mirrors every jlLog / jlLogF line out the port's serial line as it is written -- 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.

Platform macros (joey/platform.h)

The build system normally sets the platform via -D; auto-detection from compiler-predefined macros is a fallback. Game code can conditionally compile on these:

JOEYLIB_PLATFORM_IIGS / _AMIGA / _ATARIST / _DOS / _X68000 / _DVX / _HOSTED   // exactly one defined
JOEYLIB_HOSTED_SDL3 / _WIN32 / _WIN16              // the hosted backend
JOEYLIB_ATARI_STE                                  // with _ATARIST: the STe build (make ste)
JOEYLIB_SPRITE_INTERPRETED                         // no sprite code emitter (hosted, blank)
JOEYLIB_CPU_65816 / _68000 / _X86
JOEYLIB_ENDIAN_LITTLE / _BIG
JOEYLIB_NATIVE_CHUNKY / _NATIVE_PLANAR
JOEYLIB_HAS_BLITTER / _HAS_COPPER                  // Amiga only
JOEYLIB_PLATFORM_NAME                              // human-readable string
JOEYLIB_VERSION_MAJOR / _MINOR / _PATCH / _STRING

License

JoeyLib is distributed under the MIT License (LICENSE.md). A program built with it has JoeyLib compiled in, so it carries JoeyLib's copyright and permission notice, in its documentation for instance. Each of JoeyLib's source files opens with a short header that says so (SPDX-License-Identifier: MIT); a new file gets the same.

The components JoeyLib depends on, their licenses and what a shipped program owes them are in LICENSES.md. The patches in installer/patches/ are under the licenses of the projects they patch.