Docs! Getting ready for more platforms.

This commit is contained in:
Scott Duensing 2026-09-26 21:02:41 -05:00
parent b2ba0c33e9
commit 07f18d9ef5
68 changed files with 5526 additions and 5964 deletions

1
.gitattributes vendored
View file

@ -3,3 +3,4 @@
*.spc filter=lfs diff=lfs merge=lfs -text *.spc filter=lfs diff=lfs merge=lfs -text
*.spr filter=lfs diff=lfs merge=lfs -text *.spr filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text

5
.gitignore vendored
View file

@ -59,9 +59,12 @@ nvram/
# Crap I added # Crap I added
stuff/* stuff/*
docs/*
__pycache__/ __pycache__/
# Claude's working notes (design scratch, port write-ups, session state).
# NOT the user manual -- that lives in docs/ and IS tracked.
claudeDocs/*
# AGI game data (Sierra/fan-made AGI games used for testing; never committed) # AGI game data (Sierra/fan-made AGI games used for testing; never committed)
examples/agi/gamedata/ examples/agi/gamedata/

View file

@ -1,217 +0,0 @@
# AGI port — session handoff
This document captures the state of the AGI interpreter port as of
2026-05-15. Hand it to a fresh Claude Code session to pick up where
the previous one left off.
## Project context
JoeyLib is a unified C game-dev library targeting Apple IIgs (perf
floor / reference), Amiga, Atari ST, MS-DOS. The AGI port is a
from-scratch reimplementation of Sierra's Adventure Game Interpreter
v2 on top of JoeyLib's surface / draw / input APIs. Written from
public AGI format specs only -- NOT derived from ScummVM / NAGI /
Sarien (those are GPL'd; JoeyLib is not).
Test data: KQ3 v2, locally installed at
`examples/agi/gamedata/kq3/`. The user owns the game and is not
distributing it; gamedata is gitignored.
## Current phase
**Phase 1 sub-D** (callback-driven rendering): verified working.
Logic.0 + room 45 init paint KQ3's title screen on every supported
port. Ego sprite (VIEW 0, Graham) draws on top with priority masking,
WASD/arrow keys move him, ESC quits.
What's next: **Phase 1 sub-E** (input + animation opcodes). The
title screen does not auto-advance because the VM still stubs ~150
opcodes including `wait`, `set.menu`, `accept.input`, animation
timers, etc. Real KQ3 progression past the title requires implementing
enough of those to let logic.45 complete its intro and call new.room.
Phase 2 sub-A onward: parser, NPC actors, sound (NTP / PSG).
## What works (verified)
- Resource loader (LOGDIR / PICDIR / VIEWDIR / SNDDIR / VOL.x)
- PIC decoder (visual + priority planes, flood fill, vector draw)
- VIEW decoder (cel parse + RLE blit + priority masking + mirror)
- LOGIC VM: 256 vars, 256 flags, 8-deep call stack
- Real handlers: arithmetic (0x00-0x11), OP_IF (0xFF), OP_GOTO
(0xFE), OP_NEW_ROOM (0x12/0x13), OP_LOAD_LOGIC (0x14/0x15),
OP_CALL (0x16/0x17), OP_LOAD_PIC (0x18), OP_DRAW_PIC (0x19),
OP_SHOW_PIC (0x1A), OP_DISCARD_PIC (0x1B), OP_OVERLAY_PIC (0x1C)
- All ~180 other action opcodes are stubs (skip their arg bytes
via `kActionArgBytes[256]` table)
- Host callbacks: fetchLogic / loadPic / drawPic / showPic /
discardPic / overlayPic
- Per-platform save-under for the ego sprite (no full-stage memcpy
per frame; IIgs uses MVN-based codegen)
- Cross-platform binary works on all 4 ports (`AGI.EXE`,
`AGI.PRG`, `Agi`, IIgs `AGI`)
## What does NOT work
- **Title screen never advances.** Stubs the input / timer opcodes
the title uses. Workaround: starting-room override (argv[2]).
- **No NPC actors.** animate.obj / set.cel / set.loop are stubs.
- **No parser.** said() and the word-tokenize opcodes are stubs.
- **No sound.** Phase 3 work.
- **IIgs run path for AGI** is not wired. `scripts/run-agi.sh iigs`
prints an explicit "not yet wired" message describing the missing
disk-packager work (need bigger 2mg volume + case-folded ProDOS
names + game-data argument to `package-disk.sh`).
- **ST / Amiga can't pass argv.** Hatari `--auto` and Amiga
startup-sequence don't forward args, so the starting-room override
only works on DOS. ST / Amiga AGI starts at the title screen.
Fix needs either a config-file stage (`ROOM.TXT` read at startup)
or enough title opcodes for the title to advance naturally.
## How to test
```bash
source toolchains/env.sh
# Host-side parsers + VM (fast iteration, no emulator)
./scripts/test-agi.sh
# Cross-platform binary in DOSBox
./scripts/run-agi.sh dos # title screen
./scripts/run-agi.sh dos kq3 1 # jump to room 1
./scripts/run-agi.sh dos kq3 3 # jump to room 3
# Other platforms (no argv support yet, always title)
./scripts/run-agi.sh atarist
./scripts/run-agi.sh amiga
./scripts/run-agi.sh iigs # prints "not wired" message
```
## Files touched (this session)
New:
- `src/core/random.c` — xorshift32 PRNG (jlRandom / jlRandomRange / jlRandomSeed)
- `examples/agi/*.c` — agi.c, agiRes.c, agiPic.c, agiView.c, agiVm.c
- `examples/agi/agi.h` — all AGI types + public API
- `tests/agi/*.c` — testAgiRes / testAgiPic / testAgiView / testAgiVm / testAgiPipeline
- `scripts/test-agi.sh` — host-build runner for all five tests
- `scripts/run-agi.sh` — cross-platform emulator launcher (dos | amiga | atarist | iigs)
Modified:
- `include/joey/core.h` — random prototypes
- `include/joey/platform.h` — auto-detect from compiler defines
- `.gitignore` — examples/agi/gamedata/
- All four make/*.mk — added agi to EXAMPLES list
## Architectural decisions worth remembering
### Render loop: save-under, not full stage copy
The naive per-frame `jlSurfaceCopy(stage, gBackdrop)` is 32 KB of
memcpy + present overhead -- not acceptable on a 2.8 MHz IIgs. The
current code:
1. `cbShowPic` paints the picture onto the stage ONCE and snapshots
it as `gBackdrop`. Sets `gPicReady = true` and invalidates any
prior ego save-under.
2. Per frame: `jlSpriteRestoreUnder` the last-frame ego rect (puts
clean backdrop bytes back), `jlSpriteSaveUnder` the new ego rect,
then `agiViewDraw` paints the cel with priority masking.
3. Placeholder sprite for save-under is 4x5 tiles = 32x40 stage
pixels, sized to cover any AGI v2 ego cel. Compiled via
`jlSpriteCompile` so save/restore use the platform's compiled
fast path (MVN block-copy on IIgs, etc.) instead of the C
interpreter fallback.
Per-frame memory traffic: ~1.3 KB save+restore vs ~32 KB full copy.
### VM throttling
AGI's native cycle is ~6 Hz. Running logic.0 every frame (60 Hz) is
6800-byte switch dispatcher × 60 = ~400k opcodes/sec for nothing.
`agi.c` now runs runVmCycle once every 10 frames (~6 Hz on 60 Hz
refresh, ~5 Hz on 50 Hz PAL). Input polling, ego movement, render
still run at full refresh -- only the bytecode interp is throttled.
Startup pumps the VM until `gPicReady` (or 32 cycles) so the user
never sees a flash of COLOR_MISSING before the first room paints.
### VIEW mirror handling
Sierra's encoder writes `mirror=1, src=N` on BOTH loops in a mirror
pair, where N is the lower-numbered loop. `agiView.c:resolveMirror`
must only treat a cel as mirrored when `src != current_loop`. KQ3
view 0 hit this directly: both loop 0 (right) and loop 1 (left) had
`mirror=1, src=0`, and our original code flipped both -- so pressing
left and right both faced left. Fixed.
### Starting-room override
`agi.c` argv[2] = starting room number 1..255. When set,
`runVmCycle`'s NEW_ROOM handler substitutes the override for
`vm.newRoomId` on the FIRST NEW_ROOM halt only (one-shot). Lets the
user skip past KQ3's title room (45) into the actual game. Once the
override fires, subsequent transitions go through unchanged.
`scripts/run-agi.sh` accepts `<platform> [game] [room]` and forwards
the room to DOS via dosbox `-c "AGI.EXE . <room>"`. ST and Amiga
can't pass argv via autostart so their AGI starts at the title.
### Default game dir is "." not "agidemo"
`resolveGameDir` defaults to "." (CWD). On platforms whose autostart
can't pass argv (Hatari, AmigaDOS), `run-agi.sh` stages the AGI
binary plus the v2 game data files (LOGDIR / PICDIR / etc.) flat in
a temp directory and points the emulator at it; AGI runs from CWD =
that temp dir and finds the data files relative to "./".
## Likely next-step work
The user's natural next ask is "implement enough title opcodes to
let KQ3's title screen auto-advance." That probably means:
- `wait` (cycle.time gate)
- `input.line` / `accept.input`
- Animation timers: `cycle.time`, `current.loop`, etc.
- `set.menu` / `disable.item` (KQ3 title shows a menu)
Alternative path: skip the title work and implement enough core
opcodes to make a gameplay room functional. animate.obj,
set.view.v, set.cel, set.loop, position, draw, erase. That gets us
walking around KQ3 room 1 (Manannan's house) with Graham instead
of a static title.
The user should pick which to prioritize.
## Memory entries you should know about
The repo's auto-memory at
`~/.claude/projects/-home-scott-claude-joeylib/memory/` includes
JoeyLib-wide context (CMake quirks, IIgs toolchain gotchas, perf
constraints) plus `project_joeylib_agi_plan.md` which describes the
overall AGI port plan. All persist into the next session
automatically; no need to reload.
Specifically relevant to the AGI port:
- `project_joeylib_agi_plan.md` — overall plan, 3-4 month estimate
- AGI code uses a manual `parseArgU8` instead of atoi to keep the
IIgs link budget small.
- `project_perf_directive.md` — IIgs is the perf floor; every
other target must match or beat it
- `feedback_iigs_speed.md` — IIgs must be absolute fastest
technically possible; the save-under decision was made under
this constraint.
## Git / user preferences
- The user manages git themselves -- do NOT commit / push / branch
on JoeyLib without explicit ask (see memory
`feedback_git.md`).
- Always `source toolchains/env.sh` before any `make` -- the user
has added that to .claude/settings.local.json allow list.
- DJGPP / llvm-mos / vasm / m68k-atari-mint paths come from env.sh.
- The user does NOT need hand-holding -- they're an expert C dev.
Skip excessive explanation; describe what was done and move on.

View file

@ -1,294 +0,0 @@
# IIgs Performance Fix Plan
Derived from the 2026-06 IIgs render-path audit (per-op 65816 cycle analysis +
adversarial verification). Numbers below are verifier-confirmed unless marked
"est." Ordered by dependency, then by realistic payoff.
## Implementation status (2026-06-23)
All phases implemented and compile/link clean via the clang/llvm-mos w65816
toolchain. Every change is
correct-by-construction from the verified analysis; the hot-path 65816 asm
(Phases 3, 4, 5.1) still needs **pixel validation on hardware/MAME** -- the
headless emulator here can't load the 1.6 MB disk image, so I could not observe
output. Two micro-opts are left as marked residuals where the phase's value is
already captured.
- **Phase 0 -- DONE.** Removed the vestigial `SEI` + chunk-CLI from
`iigsFillRectInner`; added the ~29 ops/sec ceiling + SEI-inflation note to
`PERF.md` and the `uber-perf-table` generator.
- **Phase 1 -- DONE.** `drawShowcase()` in `examples/uber/uber.c` renders one of
each primitive into a labelled grid; the timed benchmark is unchanged (op
sequence/args identical, so cross-port hashes still compare); ends green. No
live progress bar (it would clobber the deliberately-seeded per-op input
states; frozen showcase + joeylog is the fidelity-preserving choice).
- **Phase 2 -- DONE.** Two parts: (a) `surfaceMarkDirtyRect` widens single-row
(`h==1`) marks inline in C, dropping the cross-segment `iigsMarkDirtyRowsInner`
JSL; (b) **`jlDrawPixel` fully inlined on IIgs** (draw.c) -- no `plotPixelNoMark`
/ `surfaceMarkDirtyRect` call layers around the single asm plot, mark widened
inline via `jlStageGet()` guard. This is the 5-10x path. (The plan's
fold-into-asm form is UNSAFE -- `iigsDrawPixelInner` can't see `s`, would mark
`gStage` for heap-surface draws; the C inline keeps the guard.)
- **Phase 3 -- DONE.** Computed-jump slam for ALL middle widths (`friMidSlamTbl`
via an **RTS computed-goto** + X bias -- ascending table kept, no bank-0 vector
hazard; `nWords==0` lands on the odd-byte writer). Partial fills go 7 -> 3
cyc/byte. Plus the `friMidMvn` X-reuse cleanup (now dead code path removed).
- **Phase 4 -- DONE (4.1 residual).** 4.2: removed the per-plot `dcSavedCol`
round-trip (parity via free X). 4.3: the 4 octant row bases are maintained
incrementally (+/-160 per step) instead of 4 LUT lookups/iteration. 4.1 (move
remaining `dc*` scratch to direct page) is the **residual**: ~4% more after
4.2+4.3, but needs a stack-frame-locals refactor to resolve the `D=SP+8` arg
conflict -- disproportionate blind-refactor risk for the gain.
- **Phase 5 -- DONE.** 5.1: `iigsSurfaceClearFastInner` -- post-init PHA stack-slam
(2 cyc/byte, ~1.5x) with AUXWRITE redirect to bank $01; SHR shadow already
inhibited by halInit; SP restored before CLI; the C macro dispatches here and
keeps the STA `iigsSurfaceClearInner` as the init-safe fallback. (Non-chunked
SEI ~23 ms; chunk like the present if audio glitches.) 5.2: `surfaceMarkDirtyAll`
is now two `memset`s. 5.3 subsumed by 5.1.
- **Phase 6 -- DONE (deep residual).** `jlSpriteSaveAndDraw` already shares
`shift`, dodges the MUL4 2D-index, and marks once (the C-level win). The deeper
dest-address sharing threaded through `spriteCompiledSaveUnder`/`Draw` is
codegen-level surgery for ~1.05x -- left as the **residual**.
- **Phase 7 -- DONE.** `docs/DESIGN.md` documents the deliberate double-buffer
cost and why single-buffering the IIgs was rejected.
### Highest hardware-validation priority (new asm, pixel-validate first)
1. `jlFillRect` at assorted x/w (NOT just 0/320): the computed-goto slam must
fill exactly `[x..x+w)` with no over/underrun. Check odd widths + odd x.
2. `iigsSurfaceClearFastInner`: the screen must clear to the right color with no
stray writes; watch for audio glitches (the ~23 ms SEI window).
3. `jlDrawCircle`: shape unchanged after the incremental-row-base rewrite.
4. `jlDrawPixel`: visually correct + ops/sec jump.
### Hardware-validation checklist (run on your MAME/GSplus + the 4 ports)
1. Build + run `examples/uber` on IIgs; confirm the showcase grid renders all
primitives legibly and the benchmark still completes (green exit).
2. Re-capture `joeylog.txt` on all four ports; regenerate `PERF.md` via
`tools/uber-perf-table`. Expect IIgs `fillRect 320x200` and `stagePresent`
to drop toward ~25-28 (SEI removal de-inflates fillRect).
3. `tools/diff-uber-hashes` IIgs-vs-{amiga,st,dos}: all ops must still match
(the C dirty-mark + memset changes are portable and must not alter pixels).
If any op diverges, the dirty-mark fold changed a present extent -- inspect.
4. Eyeball the circle (`jlDrawCircle`) output unchanged after the dcSavedCol
change; confirm `jlDrawPixel` ops/sec rose.
## Ground truth / framing
- **Hard floor:** any full-screen 32000-byte move = 16000 word-stores x 6 cyc
(`STA long,X $9F` or `PEI`, both 3 cyc/byte) / 2.8 MHz = ~34 ms = **~29 ops/sec
max**. Clear, full-width fillRect, and present are all pinned to this; you
cannot beat it without moving fewer bytes (the dirty-row system already does
that for sparse frames).
- **The benchmark lies for SEI ops.** `PERF.md` `fillRect 320x200`=60 and
`stagePresent`=42 exceed/approach the 29-ops/sec floor and are inflated; clear=28
is honest. Fix measurement FIRST (Phase 0) or you cannot tell wins from clock noise.
- **Two architectural realities that are NOT bugs:** (a) the back-buffer ->
`$E1` design pays the screen twice on full-redraw frames, but the single-buffer
"fix" is refuted (slow `$E1`, overdraw inverts the win, tearing) -- keep it; the
dirty-row system is the right mitigation. (b) The circle OUTLINE trails the ST
due to 65816 register pressure and may never fully win; bank the partial gain.
## Cross-cutting rules (apply to every asm change)
- Keep the assembler's register-width state correct after every `REP`/`SEP` so
immediates are sized correctly.
- Keep each asm routine in the SAME load segment as its C caller (DRAWPRIMS for
the draw primitives); a separate segment silently produces calls-to-nowhere.
- `gStageMinWord` / `gStageMaxWord` must stay `uint8_t[]` so the asm that walks
them per byte stays correctly aligned with the C-side element size.
- PHA/PEI stack-slam paths REQUIRE `SEI` and are unsafe during `joeyInit` -- gate
them post-init (`gModeSet`).
- 4bpp word stores need even byte index (`x/2` even, i.e. `x % 4 == 0`) for the
computed-jump slam entry.
- Build hygiene: `source toolchains/env.sh`; DELETE the example binary before
rebuild (make leaves IIgs binaries stale); pass `spriteEmitIigs.c` first in
LIB_SRCS (linker bank-packing order).
- **Validation after every change:** re-run `examples/uber`, confirm
`jlSurfaceHash` per op is UNCHANGED (pixel-identical to the IIgs golden) and
ops/sec moved the right way. Verify visually in MAME (checkpoint poll or
snapshot) -- joeyLog alone misses wrong-pixel-but-right-control-flow bugs.
---
## Phase 0 -- Make measurement trustworthy (PREREQUISITE)
Everything downstream is judged against the benchmark; fix it first.
- **0.1 Audit & likely remove `iigsFillRectInner`'s `SEI`** (joeyDraw.asm:315,
chunk-CLI machinery :585-614). The body does only long-mode writes to the
bank-$01 back buffer with no SP/shadow hijack (per its own comment :284-292),
so the SEI appears vestigial. Confirm nothing in the fill needs interrupt
atomicity (MVN is interruptible/resumable by design), then remove SEI + the
FRI_CHUNK_ROWS CLI logic. Expected: fillRect-full de-inflates 60 -> ~27
(matching clear), AND the audio-starvation risk goes away. If some atomicity IS
required, document exactly what and keep it.
- **0.2 Add a cycle-ceiling sanity check / annotation.** Document the ~29 ops/sec
full-screen floor in `PERF.md`; flag any full-screen op reading above it as
suspect. Optional: assert in the harness.
- **0.3 Reconcile `present`=42** (its SEI is REAL -- stack/shadow hijack -- so it
can't just be removed). Capture an honest present rate via an SEI-immune time
source or a diagnostic no-SEI measurement build; expect ~25 ops/sec. Update the
table and the cross-port comparison accordingly.
- **Re-baseline `PERF.md`** with honest numbers before Phase 2+.
Exit criterion: clear, fillRect-full, and present all read within ~10% of each
other (~25-28 ops/sec), consistent with the cycle model.
## Phase 1 -- Make the uber demo legible (show, don't smear)
Right now `examples/uber` shows the user a solid colored screen and teaches them
nothing. Cause, concretely:
- `runAllTests()` (uber.c:222-285) runs each op in a 16-frame tight loop and
NEVER calls `jlStagePresent()` during or between the timed ops. The screen is
frozen on the last present before the run (the red-bar-on-black at uber.c:379)
for the whole ~80 s run, then ends on a bare `jlSurfaceClear(gStage, 2)`
(uber.c:390) -> solid green.
- Even if it did present, the timed ops are full-screen or fixed-coordinate
(`fillRect 320x200`, `surfaceClear`, pixel@100,100, line 0,50->319,50, ...), so
they overwrite each other into one solid color. There is no spatial separation.
Goal: the demo should visibly demonstrate what each primitive does AND run the
benchmark without leaving a meaningless screen. Separate "show the user" from
"measure throughput" -- they are different jobs.
- **1.1 Visual showcase pass (new), runs once before the benchmark, UNTIMED.**
Lay out a labeled grid (e.g. 4 cols x 5 rows of ~72x36 cells with a gutter) and
render each primitive ONCE into its own cell so nothing overlaps: pixel, line
H/V/diag, rect outline, circle (small), fillRect (small), fillCircle, a tile,
tileCopyMasked result, the compiled sprite, a 16-entry palette ramp swatch, an
SCB per-line strip, a sample-pixel readout. Full-screen ops (surfaceClear,
fillRect 320x200, present) cannot be shown at native size in a cell -- represent
each with a small labeled swatch and say so. `jlStagePresent()` once, then hold
for a readable beat (frame-counted ~2-3 s, or `jlWaitForAnyKey` -> "press a key
to begin benchmark"). THIS is the part that makes sense to a user.
- **1.2 Benchmark pass: keep timing/hash fidelity, show a sane status screen.**
Keep the `timeOp` loop and every op's args + call order EXACTLY as-is -- the
cross-port `jlSurfaceHash` golden values depend on the precise op sequence, so
changing them invalidates the validation harness. Stop presenting the timing
garbage: draw a static "BENCHMARKING..." screen plus a progress indicator (op
counter / bar, optionally each op's just-measured ops/sec) updated BETWEEN
`timeOp` calls -- never inside the timed loop. End on an explicit "DONE -- press
any key" screen, not a bare solid clear.
- **1.3 Labels.** `jlDrawText` exists (tile.h:119) but needs a font surface +
asciiMap, which uber does not currently load. Either add a small font surface +
asciiMap and label each cell, or (fallback) use fixed spatial layout + a short
on-screen color-key strip and a legend printed via `jlLogF`.
Cautions:
- All showcase drawing AND the progress-UI updates must be OUTSIDE any timed
window -- zero effect on ops/sec.
- Do NOT change the timed ops' arguments or sequence (golden-hash stability).
- Keep it port-agnostic: uber is shared example code that also runs on
Amiga/ST/DOS -- no IIgs-only assumptions in the showcase.
- Do this AFTER Phase 0 so any per-op ops/sec shown on the status screen is the
corrected (honest) number, not the inflated one.
Exit criterion: a first-time user watching the demo can see and identify each
primitive's output, and the benchmark phase shows readable progress + a clear
finish -- never a bare solid color.
## Phase 2 -- Kill the per-primitive C+JSL tax (biggest real-world win)
`jlDrawPixel` costs **~1595 cyc/call** (2.8e6 / 1755), ~94% overhead; the actual
nibble RMW is ~30 cyc. Every marking primitive pays a second cross-segment JSL.
- **2.1 Fold dirty-marking into the asm inner loops.** `jlDrawPixel` (draw.c:95),
`jlFillRect` (draw.c:448-ish via fillRectOnSurface), line, and circle each call
`surfaceMarkDirtyRect` AFTER the plot JSL -- a second JSL into
`iigsMarkDirtyRowsInner` (~49 cyc plumbing x2 + the row loop). The inner loops
already know the row(s) and extent (fillRect has `midStart`/`trailingByte` per
row; pixel has `y` and the byte). Update `gStageMinWord[y]`/`gStageMaxWord[y]`
inline and make `surfaceMarkDirtyRect` a no-op on the IIgs fast paths.
- Confirmed win: removes ~120 cyc from EVERY marking primitive; **5-10x on
`jlDrawPixel`**.
- **2.2 Trim the asm prologue/epilogue for the tiny ops.** `iigsDrawPixelInner`
(joeyDraw.asm:1158-1227) keeps scratch in 6-cyc long-absolute (`dpxlTmp`,
`dpxlNibPart`) and pays full PHP/PHB/PHD ... PLD/PLB/PLP framing around a ~50-cyc
plot. Use registers/DP for the scratch; drop framing not needed when DBR/D are
already correct. (est ~1.2-1.4x on the inner once 2.1 lands.)
- **2.3 Batch/inline entry for pixel-heavy callers (optional).** A span/row-run
API (or inlining the in-bounds nibble RMW into the C via macro, the way fillRect
was de-wrappered) amortizes the JSL across many pixels for plotting-heavy code.
Exit criterion: `jlDrawPixel` >> 1755; line/rect call cost drops by the second-JSL
amount; hashes unchanged.
## Phase 3 -- fillRect partial-width slam gate
The 3-cyc/byte slam fires ONLY for exactly `x==0 && w==320` (`friFullWidth`,
joeyDraw.asm:406-420). Everything else falls to seed+`MVN` at **7 cyc/byte**
(2.33x slower), confirmed.
- **3.1 Computed jump into the unrolled `STA long,X` table.** Set `X =
curRow + midStart`, then `JMP (addr)` into the 80-store run at store
`#(80 - midBytes)`. Gives 3 cyc/byte for ANY width. Keep MVN only for tiny odd
remainders. Drop-in safe (bank-$01 long stores, no SEI/shadow). Respect the
4bpp even-byte-index rule for the entry. **1.8-2.3x on all partial-width fills.**
- **3.2 Cleanups (minor):** cache `curRow+midStart` instead of recomputing it
(joeyDraw.asm:542-565); hoist loop-invariant edge offsets (`leadingByte`/
`trailingByte` relative to curRow, += 160/row); one `sep`/`rep` window per row
for both edge RMWs.
## Phase 4 -- circle / line register pressure (~1.27x, partial)
- **4.1 Move Bresenham scratch to direct page.** `dcX/dcY/dcErr/dcRowYP/dcRowYN/
dcRowXP/dcRowXN` (circle, joeyDraw.asm:1500+) and the line's per-pixel state
(joeyDraw.asm:1253+) are 6-cyc long-absolute; DP is 5 cyc here (DL != 0 because
D=SP+8). Set up a DP scratch page for the hot state.
- **4.2 Remove the per-plot parity round-trip.** `sta dcSavedCol` / reload /
`and #1` (joeyDraw.asm:1603-1610) recovers a bit `lsr a` already left in carry
-- branch on carry instead. ~15 cyc/plot x 8 plots/step.
- **4.3 Maintain the 4 row bases incrementally** (+/-160 per Bresenham step)
instead of 4 LUT lookups/step.
- Net ~1.27x (0.65x -> ~0.83x vs ST). Stop there -- the architecture won't give a
full win on the outline. The fill-circle already beats the ST; leave it.
## Phase 5 -- faster clear (post-Phase-0; lower priority)
- **5.1 Post-init PHA-slam clear path.** `STA long,X` = 3 cyc/byte; SP-into-`$01`
PHA-slam = 2 cyc/byte. ~1.5x (37.6 -> ~24 ms). Gate on `gModeSet` (SEI unsafe at
init); keep the `STA long,X` path as the init-safe fallback. Chunk the SEI like
present to avoid audio starvation. NOTE: only meaningful to measure once Phase 0
fixes the clock.
- **5.2 Route `surfaceMarkDirtyAll` through `iigsMarkDirtyRowsInner`** (it's a
200-iter C loop today; surface.c:233-243) -- one source of truth with the
fillRect marker. ~7600 cyc/clear.
- **5.3 Hold `fillWord` in a register** across the clear loop instead of
`lda 9,s` per iteration (joeyDraw.asm:137). Subsumed if 5.1 lands.
## Phase 6 -- sprite dispatch overhead
Body store mode (`LDA #imm16`/`STA abs,Y`) is already near-optimal -- DO NOT touch
it. ~3000-4000 cyc/call of C dispatch wraps ~1000-1500 cyc of real blit.
- **6.1 Share address/shift/fnAddr across `SaveAndDraw`'s two halves** (sprite.c)
-- it recomputes dest address, shift, fnAddr, and re-patches the self-modifying
stub twice. (est ~1.05x SaveAndDraw.)
- **6.2 Trim per-call C dispatch:** cache fnAddr, dodge the 2D-index MUL4, do
`isFullyOnSurface` in 8-bit. (est ~1.10-1.15x per sprite op.)
## Phase 7 -- architecture (decision, no code)
Keep the back-buffer + dirty-row model. Document in `docs/DESIGN.md` that
full-redraw frames inherently pay the screen cost ~2x and that sparse-update
(sprite/HUD) games should lean on partial updates. Do NOT pursue single-buffer
direct-to-`$E1` (refuted on slow-bank/overdraw/tearing grounds).
---
## Suggested order of execution
1. Phase 0 (0.1 fillRect SEI, 0.2 annotate, 0.3 present) -> re-baseline PERF.md.
2. Phase 1 (make the demo legible) -- so every later change can be eyeballed, not
just hashed; cheap and unblocks visual validation.
3. Phase 2.1 (fold dirty-marking) -- highest real-world payoff, touches all primitives.
4. Phase 3.1 (fillRect computed-jump slam).
5. Phase 2.2, Phase 4 (circle/line DP scratch + parity).
6. Phase 5, Phase 6 (clear PHA-slam, sprite dispatch) as time permits.
7. Phase 7 doc.
Each step: re-run uber, assert hashes identical, record ops/sec delta in PERF.md.

View file

@ -1,447 +0,0 @@
# Asks for the llvm816 toolchain session
JoeyLib-side findings that need toolchain-side fixes. Context:
PERF-AUDIT.md (findings #79, #77) and PERF-AUDIT-PLAN.md Phase 1.
JoeyLib does not modify toolchains/iigs/llvm-mos per the ownership
split -- these are requests, with evidence.
## 1. MVN-based memset/memcpy in runtime/src/libc.c (PERF-AUDIT #79)
`memset`, `memcpy` (and `memmove`'s copy loops) in
`toolchains/iigs/llvm-mos/runtime/src/libc.c` are one-byte-at-a-time
loops through 24-bit pointers:
void *memset(void *dst, int c, size_t n) {
char *d = (char *)dst;
while (n--) *d++ = (char)c;
return dst;
}
That is ~30 cycles/byte on the 65816. The ORCA/C baseline these
replaced lowers both to MVN block moves (~7 cycles/byte after setup).
Measured impact on the reference platform (UBER, honest post-Phase-0
clock): jlScbSetRange -- a 200-byte memset plus a flag write --
measures 163 ops/sec on the clang build vs 1005 ops/sec on the
ORCA-era capture (~4x regression at the whole-op level). Every
memset/memcpy-based JoeyLib path pays it: surface copies, sprite
save/restore fallbacks, SCB range fills, stage-alloc clears, the log
buffer, and all example/game code.
Ask: MVN-based memset/memcpy in libc.c (or intrinsic lowering in the
backend). Requirements from the JoeyLib side:
- Handle bank-crossing source/destination (MVN wraps within a bank;
the routine must split runs at bank boundaries).
- memset needs a 1-byte seed write + overlapping MVN (classic
self-copy fill), or an unrolled STA loop for short fills.
- Keep the size_t=unsigned long (32-bit) signature -- JoeyLib call
sites pass 32-bit counts today and the current behavior is correct,
just slow.
- A short-count fast path (n < ~16) matters too: the sprite
interpreter's per-row copies are 4-17 bytes.
## 2. FYI: sprite-codegen corruption under investigation (PERF-AUDIT #77)
JoeyLib's IIgs runtime sprite code generation corrupts memory when
enabled (bisected; disabled by default in spriteCompile.c until
root-caused). The current suspect list is JoeyLib-side (emit-time
buffer handling), but if the hunt lands on codegen/ABI behavior of
clang-emitted calls into arena-resident routines, it becomes a
toolchain question. Nothing to do yet -- flagged so the context is not
a surprise if it arrives.
## 3. fflush -> FST commit semantics (PERF-AUDIT #80)
JoeyLib's logger (src/core/debug.c) fflush()es after every line so a
crash/hang leaves the log on disk. On the clang runtime the lines do
not reach the disk image until fclose: a run that executed 33 logged
operations left 3 lines on disk after a forced emulator exit. Question:
does the libc fflush propagate to a GS/OS FST flush for the file, or
only drain the userland buffer? If the latter, an FST-flush call in
fflush (or an exposed fsync-like hook) would restore crash-durable
logging. Not urgent -- JoeyLib works around it with a memory mailbox
under emulation -- but real-hardware crash forensics wants it.
## 4. malloc/calloc returns 1 (not NULL) at heap exhaustion (PERF-AUDIT #81) -- CRITICAL
Empirically confirmed under MAME with in-app probes: with the default
link-layout heap (665 bytes at $00:BC67-$00:BF00 in the JoeyLib UBER
build), `calloc(1, ~720)` returns **1**. The bump-path overflow branch
in libc.c malloc -- whose comment already records a "historical
`p + HDR_SZ + n > heapEnd` over-heap miscompile (only manifested for
oversized n)" and assumes the path is unexercised -- is still
miscompiled in exactly that oversized case. Downstream, JoeyLib ran for
weeks on a phantom struct at (void*)1 whose first field happened to
alias the real framebuffer pointer; see PERF-AUDIT.md #81 for the full
forensics. JoeyLib no longer allocates anything large on the C heap,
but ANY program that exhausts this heap gets silent corruption instead
of NULL. Minimal repro: link any GS/OS app with default heap, call
calloc(1, 720), observe the return value.
## 5. Segment BSS/heap spans are not reserved from the Memory Manager (PERF-AUDIT #81/#77/#85) -- CRITICAL, EMPIRICALLY PROVEN
Three independent confirmations, in escalating severity:
1. The bank-0 C heap (unreserved) -- #81's phantom-stage chain.
2. The pinned SHR framebuffer $01:2000-$9CFF (unreserved) -- an MM
handle landed at $01:8B04 inside the display (#85).
3. THE GENERAL CASE: a 32KB NewHandle was placed at $08:4200 -- inside
the app's own ENTRY-SEGMENT BANK, overlapping seg1's BSS span. The
loader only claims each segment's image bytes; everything above
(BSS, heap) is MM-free. ANY GS/OS allocation -- the app's own or
the system's -- can land on live program data.
JoeyLib has stopgap claims for cases 1-2 (jlpInit reserves the
framebuffer and $00:B900-$BEFF). Case 3 was attempted app-side on
2026-07-05 (spacetaxi acceptance test) and the attempt produced two
findings you should have:
(a) The app CAN learn its BSS spans: the linker-emitted
__bss_seg{0..3}_{lo16,bank,size} symbols (the ones crt0.s zeroes
BSS through) are readable from C via a `.long` DATA32 table in an
asm TU -- direct C IMM16 references fail to link ("cross-segment
reloc ... uses 2-byte form; only IMM24 / DATA32 supports cINTERSEG
patching"). The DATA32 entries resolve as RELOCATED FULL ADDRESSES:
the lo16 entry reads back as the 24-bit runtime span base
($08:4F00 observed for the spacetaxi build), the size entry
carries the length in its low 16 bits, empty segments read as
bank-base-only. Recipe verified working end-to-end under MAME.
(b) BUT actually claiming the span kills the app: NewHandle(
attrAddr|attrFixed|attrLocked, base=$08:4F00, size=$6619) from
jlpInit SUCCEEDS -- and the app then dies before reaching main's
first log line, with the PC profile showing GS/OS desktop idle
(silent return-to-Finder). Reproduced twice; reverting only the
claim restores the previous behavior. Mechanism unknown from the
app side -- presumably the Loader/MM bookkeeping objects to a
user handle overlapping a loaded segment's address range, or the
claim starves something during the remaining init. The reverted
implementation was never committed; the full code (asm table +
claim loop) is reproduced verbatim in the copy of this item handed
to the toolchain session at /home/scott/claude/llvm816/JOEYLIB-ASKS.md.
Ask: crt0/omfEmit/loader cooperation so every segment's FULL
in-memory span (image + BSS + heap) is Memory-Manager-reserved at
load time -- with (b) suggesting the reservation must be made BY the
loader glue (or before/while segments are registered), not by app
code after the fact. This single fix unblocks IIgs sprite codegen
(PERF-AUDIT #77), the Space Taxi IIgs port (a 32KB font-surface
jlpBigAlloc lands on live state layout-dependently -- three distinct
failure modes observed as the binary layout shifted), and removes the
whole corruption class.
link816 defines [__heap_start, __heap_end) as the leftover entry-bank
space above BSS, but crt0Gsos never claims that range from the GS/OS
Memory Manager, so NewHandle can legally place blocks on top of live
malloc data (a second, independent corruption channel -- and the
crt0Gsos comments already record a Phase-1.1 fopen-hang caused by this
class). Ask: reserve the heap range at startup (attrAddr|attrBank|
attrFixed|attrLocked), or reimplement malloc on top of an MM handle.
Related hardening: pass attrNoSpec on NewHandle calls in the runtime,
and consider reserving the pinned SHR stage range $01:2000-$9FFF that
JoeyLib's jlpStageAllocPixels hands out as a bare constant.
## 6. Post-main exit path crashes to the monitor (COP $00 at $00:0002) -- EMPIRICALLY PROVEN, app-independent (2026-07-20)
Every JoeyLib IIgs example that RETURNS from main() dies in the
monitor instead of quitting to Finder. Reproduced identically for
KEYS and STAXI under MAME (deterministic, ~3 min): press the app's
exit key, and execution ends at `COP #$00` executing at $00:0002 with
S=$17FD. Nobody saw this before because every harness kills the
emulator after reading logs; an interactive ESC was the first real
exit ever exercised.
MAME debugger execution history at the crash (bp at $000002):
00:9EE0: sta $00c068 ; GS/OS call tail: state reg restore
00:9EE8: xce
00:9EE9: plp
00:9EEA: rtl ; returns to the app-side thunk:
08:0072: php ; <- pushes 1 byte...
08:0073: rtl ; <- ...then pulls 3: garbage return
00:0002: cop #$00 ; monitor
The thunk at segment offset $0072 (bank $08 at run time) executes
`php / rtl` after a GS/OS call returns to it -- the php mis-balances
the stack and the rtl returns to $00:0002. This is in the crt0Gsos /
runtime exit code (the offset is constant across different apps, so
it is runtime code, not app code). Suspects: a Quit stub whose body
was meant to sit between the php and the rtl, or a mis-assembled
epilogue.
Reproducer scripts (MAME, headless, breakpoint + history + RAM dump):
this session's scratchpad escCrash.sh / escCrashKeys.sh -- ask me and
I'll re-stage them anywhere durable.
WORKAROUND LANDED IN JOEYLIB (so this is no longer user-visible):
src/iigs/hal.c jlpShutdown now issues GS/OS QuitGS ($2029, pCount=0)
itself via a runtime-built jsl stub and never returns to the runtime
exit path. jlShutdown therefore does not return on IIgs. The root
cause still deserves a runtime fix: any app that returns from main
WITHOUT calling jlShutdown (or any non-JoeyLib program built on this
runtime) still crashes.
## 7. Volatile uint8_t loads through variable pointers widen to 16-bit bus reads (2026-08-18) -- EMPIRICALLY PROVEN
A `volatile uint8_t` LOAD through a variable (long) pointer is emitted as a
16-bit `lda [dp],y` with m=0 and the value masked afterwards (`and #$ff`) --
so the BUS performs a phantom read of address+1. For memory that is harmless;
for I/O it reads a neighbouring register, and hardware with adjacent registers
has read side effects. Concrete damage on the IIgs SCC (Z8530, four adjacent
registers $C038-$C03B): a data-register read of channel B ($C03A) phantom-pops
channel A's RX FIFO ($C03B -- the AppleTalk port), and in a printer-port
configuration a status poll of $C039 phantom-pops the MODEM port's data at
$C03A. (A per-poll RR1 harvest also correlates with MAME 0.264 SCC RX-deafness, but
that is NOT this bug: it reproduced through a guaranteed 8-bit read too, so
the phantom read is refuted as its mechanism and the harvest ships disabled
under emulation. The width violation itself stands proven by disassembly.)
The width handling is inconsistent, which is what makes it look like a bug
rather than a policy:
- STORES through the same pointers are correctly sep #$20-wrapped 8-bit.
- LOADS from CONSTANT addresses are correctly sep-wrapped 8-bit
(`sep #$20 / lda $c038 / rep #$20` -- verified via a probe TU).
- Only variable-pointer loads take the 16-bit `lda [dp],y` + mask form.
Ask: honour the declared access width for volatile loads in the long-indirect
form -- sep-wrap them exactly like the constant-address and store cases.
WORKAROUND LANDED IN JOEYLIB: src/iigs/serial.c routes every serial register
read through a hand-asm `jlpIoRead8` (global function, so no call site can be
inlined back into the widened form): `sep #$20 / lda [$e0],y / rep #$30 /
and #$ff`. ABI verified from a compiled probe: pointer lo16 in A, bank in X,
return in A, $e0-$e3 imaginary-register scratch. Any OTHER volatile I/O read
through a variable pointer (input/video HALs, future code) is still exposed
until the codegen fix lands.
## 8. Peeled-first-iteration address negation is one's complement, not two's (2026-08-20) -- FIXED IN TOOLCHAIN same day (see end of item)
A loop that indexes a local array at a base computed by SUBTRACTING a
uint8_t parameter (`k = 8u - nb;` then `t[(uint8_t)(k + i)]` inside a
counted loop) is compiled with the FIRST iteration peeled, and that
peeled iteration's address is computed as
base + sign_extend(~nb) ; one byte LOW: ~nb = -nb - 1
where two's complement (`-nb`) was needed. Every later iteration
increments correctly, so exactly the first element is read one byte
low and the remaining seven are right -- a silent wrong-VALUES class
with no crash, in the same derived-induction-variable family as the
modulo-advance item.
The emitted idiom (RetroNet src/crypto/rnSipHash.c sipRot, w65816
-O2 -ffreestanding -ffunction-sections, disassembly of the peeled
first read's address):
lda 0xb6,s ; nb
eor #0x1aff ; low byte: nb ^ 0xff = ~nb (high byte junk)
and #0xff
eor #0x80 ; sign-extend byte
clc
adc #0xff80 ; (x ^ 0x80) - 0x80
...
tsc
clc
adc #0xa3 ; t[] frame base (t + 8 region)
clc
adc 0x1f,s ; += sext(~nb) --> t + 8 - nb - 1 = t[k-1]
Blast radius measured before the workaround: EVERY SipHash-2-4 tag the
IIgs client computed was wrong (the rotate primitive feeds every round),
so every sealed uplink failed server-side authentication. Nothing
client-side ever caught it because the protocol's selective encryption
leaves downlink frames plaintext -- silent until a peer verifies.
Root-caused via on-target known-answer tests + a per-primitive state
probe (SIPBLOCK) diffed against the host build of the same source; the
"first iteration reads index k-1" model reproduces both on-target wrong
tags bit-for-bit end to end.
WORKAROUND LANDED IN RETRONET: sipRot now takes k (the precomputed read
base) instead of nb, removing the 8-nb negation from the function; the
emitted first-iteration address becomes a plain base + k and all KATs
pass on target. Any other loop whose peeled first iteration derives
its address from a subtracted narrow parameter is still exposed.
RESOLUTION (2026-08-20): root cause was NOT the address lowering -- the
IR and ISel were correct (a true NEGA8 pseudo). The AsmPrinter expanded
NEGA8 as an UNWRAPPED `EOR #$FF ; INA` on a false "already 8-bit M"
assumption: in the real 16-bit-M context the CPU decoded the 2-byte
EOR #imm8 as 3-byte EOR #imm16 and swallowed the INA opcode ($1A) as the
immediate's high byte -- the disassembly tell is `eor #$1AFF`. FIXED in
W65816AsmPrinter.cpp: NEGA8 now emits emitSepM/emitRepM around the pair,
exactly like LDA8absX/STA8absX (safe: SepRepCleanup's isMNeutral never
extends an M=8 window across the pseudo). Audited: NEGA8 was the only
unwrapped Imm8 expansion. Blast radius before the fix: 6 sites in the
RetroNet IIgs client -- the SipHash rotate (every sealed uplink failed to
authenticate) and 5 rnWidget functions (index corruption; wild-jump BRK
during the download offer frame; likely the phantom-FORM garbage too).
Original ask (kept for the repro): compile the pre-workaround sipRot
shape below at -O2 for w65816 and inspect the first read's address chain.
(Verbatim pre-workaround function; gSipV is a 40-byte global.)
```c
static void sipRot(uint8_t a, uint8_t nb, uint8_t mode) {
uint8_t t[16];
uint8_t i;
uint8_t k;
uint8_t x;
for (i = 0u; i < 8u; i++) {
x = gSipV[(uint8_t)(a + i)];
t[i] = x;
t[(uint8_t)(i + 8u)] = x;
}
k = (uint8_t)(8u - nb);
for (i = 0u; i < 8u; i++) {
x = t[(uint8_t)(k + i)];
if (mode == 3u) {
x = (uint8_t)((x >> 3) | (uint8_t)(t[(uint8_t)(k + i + 1u)] << 5));
} else if (mode == 1u) {
x = (uint8_t)((x << 1) | (uint8_t)(t[(uint8_t)(k + i - 1u)] >> 7));
}
gSipV[(uint8_t)(a + i)] = x;
}
}
```
## 9. i8 store to a 1-byte stack slot is a 16-bit STA d,s that overflows the return address (2026-08-20) -- FIXED IN TOOLCHAIN same day
M=16 is the default mode, so `sta d,s` writes TWO bytes. When the target
frame object is only 1 byte (an i8 spill / i8 arg-save) at the TOP slot of a
frame, the second byte lands on the caller's saved RETURN ADDRESS low byte and
the callee's RTL jumps mid-instruction into a different function. Live proof:
RetroNet's rnClientOnFileRecord saving its uint8_t `op` clobbered its own
return 0x80->0x01, so its RTL landed inside a different rasterizer switch case,
skipped the caller's arg cleanup, and left the DISPLAY record-walk's stack
pointer 4 low -> $F6 (big-frame pointer) wrong -> the walk byte-crawled forever
on the offer frame (the IIgs download "offer-silence": FILE_OFFER never
processed, client never drained the serial).
Minimal repro: `void fwd(uint8_t op, const uint8_t *r){ sink(op, r); }` at -O2
for w65816 emits `sta 0x2, s` (16-bit) to save op adjacent to the return.
FIX (W65816RegisterInfo.cpp eliminateFrameIndex, STAfi case): when
`MFI.getObjectSize(FI) == 1`, wrap the STA_StackRel in SEP #$20 / REP #$20 so a
1-byte slot gets a genuine 8-bit store. Only the STORE is a hazard; a 16-bit
LOAD of the extra byte is a harmless read (the high byte of an i8 value is
ignored), so LDAfi is left 16-bit. Verified: fwd now emits
`sep #$20 ; sta 0x1,s ; rep #$20`. Same defect family as ASKS #8 (NEGA8) and
the $F8 far-frame bank byte -- "an 8-bit operation emitted without the mode
wrap in default-16-bit-M code."
## 10. Pointer-deref to a GLOBAL hardcodes bank 0 (STZ $E2) under the GS/OS Loader (2026-08-20) -- FIXED IN TOOLCHAIN 2026-08-21 (provenance-based; see end)
The LDAptr / STAptr / STBptr custom inserter (W65816ISelLowering.cpp) lowers a
[dp],Y indirect-long deref with the BANK byte forced to 0 via `STZ $E2` (unless
the LoaderBankDeref path, which uses $BE, is taken). Bank-0 is correct for a
pointer to a STACK local (the 65816 stack is always bank 0) but WRONG for a
pointer to a GLOBAL: under the GS/OS Loader the program's BSS/globals are placed
in the program bank ($BE, e.g. $0A), not bank 0. So `&someGlobal` dereferenced
through this path reads bank-0 garbage, while the SAME global accessed directly
via `sta/lda abs` (DBR-relative, DBR=$BE) is correct -- a silent, layout-
dependent miscompile. The inserter's own comment already flags this tension
(gmtime's __gmtimeBuf hit it).
Live proof: RetroNet's inlined rnXferRequest materialised `&gX.id` (a static
global) and read it through LDAptr; the FILE_REQUEST then carried xferId=0xAD
(bank-0 garbage) instead of 1, and the server rejected the download. 0xAD is
not any valid gX field -- the tell of a wrong-bank read. Disasm:
`lda #<gX.id off> ; sta $e0 ; stz $e2 ; lda [$e0],y`.
WORKAROUND in app: `noinline` on the function so the global is read in the
caller through DBR-relative abs16 and passed by the ABI (same shape as the
existing rnXferState workaround).
Ask: the deref bank cannot be a blanket 0 or blanket $BE when stack pointers
(bank 0) and global pointers ($BE) are both derefed through one pseudo. The
principled fix is a 24-bit pointer representation (carry the bank in the pointer
value) or provenance tracking so a global-derived pointer derefs with $BE and a
stack-derived pointer with 0.
Fixes EVALUATED and rejected from the app side (2026-08-20):
1. -w65816-loader-bank-deref (existing flag): whole-program, flips EVERY
LDAptr to $BE. Fixes global derefs but breaks stack-pointer derefs (the
reason LDAptrBank0 exists for va_arg). Also the runtime *.o are shared
across Loader AND non-Loader (GNO/smoke) builds, so it can't be enabled
for just the S16/Loader target without a per-mode runtime split.
2. Fold (load <materialized global addr>) -> abs16+DBR at selection -- the
clean targeted fix, but NOT minimally reproducible: every small repro
(inlined forwarder, barrier-forced re-read, struct field) correctly
selects abs16. The mis-selection only appears inside a very large inlined
function (RetroNet rnFileRecord, ~0x916 bytes, three transfer routines
inlined), so it is a scheduling/CSE artifact of address materialization
under register pressure, not a stable pattern -- hard to fix safely
without provenance.
RESOLUTION (2026-08-21, W65816ISelLowering.cpp LDAptr/STAptr/STBptr custom
inserter): instead of a blanket bank policy, use PROVENANCE. When the pointer
being derefed traces (through COPYs) to an LDAi16imm of a global / external
symbol, the deref is exactly a DBR-relative absolute access to that global --
the same thing a direct `global` reference compiles to -- so emit LDAabs /
STAabs / STA8abs with the global operand and skip the [dp],Y deref entirely.
That lands in DBR's bank (correct for a global) with no bank guess, and only
GLOBAL-address derefs are touched -- stack pointers (bank 0) and heap/other
pointers keep the existing deref path, so no stack-out-param regression. NB the
bank canNOT be sourced from $BE: $BE is the code segment's PBR and in a
multi-segment program the data (BSS) segment is a different bank; only DBR (via
abs) is right. Single-segment builds are unaffected (DBR==0==old STZ bank).
Verified: RetroNet's rnXferRequest/rnXferState no longer need `noinline` (both
removed) and the inlined rnFileRecord emits zero bank-0 pointer-derefs; the IIgs
download DISK-VERIFIES from the pure-clean build. Also fixes the runtime gmtime
__gmtimeBuf case this item's parent note describes. What is still NOT covered:
a global address that flows through pointer ARITHMETIC the trace can't follow
(e.g. `p = &g; p += i; *p`) -- there the provenance is lost and the deref falls
back to bank 0; those remain candidates for the 24-bit-pointer rework.
## 11. ADDframe (stack-slot LEA) scheduled between a CMP and its branch clobbers the flags (2026-08-24) -- FIXED IN TOOLCHAIN same day
`ADDframe` - the LEA-equivalent that puts the address of a stack slot in A -
expands at PEI (W65816RegisterInfo.cpp eliminateFrameIndex) to `TSC; CLC;
ADC #disp`. That sequence rewrites N/Z (TSC, ADC), C (CLC, ADC) and V (ADC).
The pseudo deliberately carries no `Defs = [P]` (the same reasoning as the
LDA pseudos, see the NOTE above LDAi16imm in W65816InstrInfo.td) and is
`isReMaterializable`, so a post-RA pass may legally place it between a CMP
and the Bxx that consumes the CMP's flags. Machine Copy Propagation did
exactly that in RetroNet's src/platform/rnClient.c (main, the widget action
dispatch):
lda 0xf,s ; act.kind
cmp #0x3 ; == RN_WA_SUBMIT ?
tsc ; \
clc ; > ADDframe: &act, hoisted here for the SUBMIT arm
adc #0xc1 ; /
bne .LBB3_61 ; tests (SP+0xC1) != 0 - ALWAYS taken
so `act.kind == RN_WA_SUBMIT` could never be true: the LOGIN button
activated, rnWidgetActivate wrote kind=3, and rnClientOnSubmit was never
reached. On the IIgs the login form was never submitted; the harness's
Tab/Return retry loop then produced exactly nine no-widget Return uplinks,
which is what pinned it. A scan of every TU in the IIgs client found this
ONE instance (`cmp` immediately followed by `tsc` before the branch), which
is why only the submit was affected.
The existing mitigation - the PHP/PLP wrap in W65816StackSlotCleanup.cpp
Pass -2.5 - did not catch it for two reasons: (1) ADDframe was not in the
pass's corrupting set (isLdaLike), so the backward walk from the branch hit
it, took isFlagDefining's default ("anything else defines flags") and called
it the TEST, then declined to wrap; (2) the pass only collects BEQ/BNE/BMI/
BPL, on the (true) grounds that LDA-like ops leave C alone - but ADDframe
also clobbers C and V, so a `cmp; ADDframe; bcc` would have been miscompiled
with no protection at all.
RESOLUTION (2026-08-24, W65816StackSlotCleanup.cpp Pass -2.5):
* ADDframe is now in isLdaLike (N/Z-corrupting), so the walk continues
past it to the real CMP and wraps it: `cmp #3; php; tsc; clc; adc #disp+1;
plp; bne`. It was already in isStackRel and the bump list, so its
ImmOffset gets the +1 that compensates PHP's S decrement; the FP-relative
exemption in the bump is skipped for ADDframe because it has no FP-
relative expansion (always TSC+ADC, i.e. always SP-relative).
* BCC/BCS/BVC/BVS are collected as well, with a separate
isCarryCorrupting predicate (ADDframe only): under a C/V branch an
N/Z-only corrupter is walked past without a wrap, so the size of every
existing carry-branch sequence is unchanged.
* Pinned by llvm/test/CodeGen/W65816/addframe-flag-wrap.mir (nz, carry,
and the lda-under-carry no-wrap case).
Verified: the rnClient.c TU rescans clean (zero cmp/tsc/branch sequences in
all 20 IIgs client TUs) and the IIgs login submits live - see the RetroNet
e2e matrix (`joey-iigs session`).

View file

@ -7,6 +7,8 @@
# make amiga builds Commodore Amiga only # make amiga builds Commodore Amiga only
# make atarist builds Atari ST only # make atarist builds Atari ST only
# make dos builds MS-DOS only # make dos builds MS-DOS only
# make docs renders the manual to build/docs (HTML + PDF)
# make verify-audio runs every port's audio gate (needs emulators)
# make clean removes all build outputs # make clean removes all build outputs
# #
# The build references cross tools exclusively from toolchains/, set up # The build references cross tools exclusively from toolchains/, set up
@ -35,7 +37,7 @@ ifeq ($(HAVE_DOS),1)
ALL_TARGETS += dos ALL_TARGETS += dos
endif endif
.PHONY: all iigs iigs-disk iigs-verify amiga atarist dos blank-check tools clean help status .PHONY: all iigs iigs-disk iigs-verify amiga atarist dos blank-check tools clean help status verify-audio docs
all: $(ALL_TARGETS) tools all: $(ALL_TARGETS) tools
ifeq ($(ALL_TARGETS),) ifeq ($(ALL_TARGETS),)
@ -69,6 +71,20 @@ dos:
blank-check: blank-check:
@$(REPO_DIR)/scripts/check-blank.sh @$(REPO_DIR)/scripts/check-blank.sh
# The manual: docs/Manual.adoc plus its chapter includes -> build/docs.
# Needs asciidoctor and asciidoctor-pdf, no cross toolchain.
# make docs FORMAT=html renders only the web version (much faster).
docs:
@$(REPO_DIR)/scripts/build-docs.sh $(FORMAT)
# Every port's audio gate, run sequentially (the recording gates grade
# fixed time windows, so a competing emulator can slide a phase boundary).
# Exits 1 on a real failure and 3 if a gate SKIPPED for an unbuilt port --
# non-zero on purpose, so a run that tested nothing cannot look green.
# Takes a subset: make verify-audio PORTS="atarist amiga"
verify-audio:
@$(REPO_DIR)/scripts/verify-audio-all.sh $(PORTS)
clean: clean:
rm -rf $(REPO_DIR)/build rm -rf $(REPO_DIR)/build
@ -83,4 +99,6 @@ help:
@echo "Targets: iigs amiga atarist dos all clean status help" @echo "Targets: iigs amiga atarist dos all clean status help"
@echo " iigs-disk iigs-verify (IIgs MAME visual check)" @echo " iigs-disk iigs-verify (IIgs MAME visual check)"
@echo " blank-check (host-cc smoke test of the new-port template)" @echo " blank-check (host-cc smoke test of the new-port template)"
@echo " verify-audio (all 5 audio gates; PORTS=\"atarist amiga\" for a subset)"
@echo " docs (manual to build/docs; FORMAT=html for HTML only)"
@echo "Source toolchains/env.sh first (blank-check needs no toolchain)." @echo "Source toolchains/env.sh first (blank-check needs no toolchain)."

File diff suppressed because it is too large Load diff

View file

@ -1,750 +0,0 @@
# PERF-AUDIT Fix Plan
Execution plan for all 71 confirmed findings in PERF-AUDIT.md plus the
benchmark-harness fix from its preamble. Finding numbers below (#N) are
PERF-AUDIT.md numbers. Read each finding's "Verifier correction" before
implementing -- several tighten the fix or the claimed impact, and this
plan encodes those corrections as constraints.
This plan was itself adversarially reviewed (completeness, ordering
conflicts, gate adequacy -- 31 issues found and folded in). The most
important consequence: Phase 0 builds the verification harness that
every later gate depends on, because the existing gates cannot see most
of the changes this plan makes.
Ground rules for every phase:
- Source toolchains/env.sh before any make; a missing $AMIGA_CC etc.
silently produces stale binaries.
- After editing any IIgs .s/.asm file, delete the affected example
binaries before rebuilding (stale-binary trap).
- Hash gates compare against the golden hashes captured at the end of
Phase 0 (the harness additions change the old hashes once, then they
are frozen).
- "iigs-verify" in a gate always names its example and compares
before/after checksums: `make iigs-verify VERIFY_EXAMPLE=<name>`
(default is draw; that only covers draw-path changes). It reads SHR
memory -- there is NO MAME headless screenshot; do not plan for one.
- Scott commits; the plan never includes git operations.
- Style rules apply to all touched code (camelCase, // comments, braces
always, prototypes at top and alphabetized, two blank lines between
functions, ASCII only).
Phase order rationale: fix the clock and build the gates first
(otherwise no perf change can be validated and most bug fixes have no
covering check), then correctness bugs, then shared infrastructure,
then per-subsystem work, and the new platform asm last (highest effort
and risk, benefits from everything before it being measurable).
---
## Phase 0 -- Fix the clock and build the verification harness
The audit's headline: every small-op UBER number carries ~600-800
cycles of per-iteration harness tax on IIgs (GetTick toolbox chain).
And the plan review's headline: most fixes below are invisible to the
current gates (UBER only hashes on-surface compiled-path ops; Amiga/ST
have no screen-content check; several ops are not exercised at all).
Phase 0 fixes both.
1. **uber.c batch polling.** In runForFrames, run the op in batches
between jlFrameCount() polls. Batch adaptively: batch=1 for ops
slower than ~1 frame (present, surfaceClear, fillRect-full would
otherwise overrun the window by up to a full batch), 8-16 for fast
ops (pick from the first iteration's duration). Fix the false
"~10-30 cyc" comment. Ops are fixed-coordinate/idempotent (review
verified), so batching does not change what an iteration means.
2. **#15 + #71** -- collapse the IIgs jlFrameCount forwarding: declare
iigsGetTickWord in the IIgs section of port.h and
`#define jlpFrameCount() iigsGetTickWord()`; delete the hal.c
wrapper AND its stale 15-line $C019/gFrameCount comment (#71 -- the
comment sits on the function being deleted, so it lands here, not in
Phase 8); put a one-line GetTick pointer next to the new macro.
Remove the now-stale JL_HAS_FRAME_COUNT entry at platform.h:108.
CAUTION (verifier): do NOT do the same for jlpFrameHz -- gFrameHz is
hal.c-private state; leave it a function.
3. **uber.c non-timed correctness section.** New hashed/logged checks
that run after the timed ops and before the final hash, added NOW so
golden hashes cover every path later phases touch:
- edge-case draws: off-surface pixel spam (both signs, both axes),
jlDrawRect w=65535 and h=2 cases, jlFillCircle huge-r (r=300 with
clipping, r=40000 full-coverage), jlDrawLine far-off-surface
endpoints;
- clipped-sprite draws: all four edges, a corner, fully off-surface
(exercises the interpreted path on every port);
- jlTilePasteMono and jlFloodFill ops drawn into the stage (neither
is currently in the hashed set -- Phase 7 replaces both paths);
- jlDrawText including a string starting out-of-range (Phase 5
changes its validation);
- jlPaletteSet readback assert via jlPaletteGet: one conforming row,
one deliberately non-conforming row, row[0] forced to 0x0000,
logged PASS/FAIL (palette is not in the surface hash);
- jlRandom golden-sequence check (first N values from a known seed);
- allocator torture: sprite create/compile/destroy churn to fragment
then compact the arena (#5/#34 coverage), a sprite-bank
load/destroy pass (#31 coverage -- allocator mismatch is fatal on
IIgs), and a jlShutdown -> jlInit -> destroy-stale-sprite sequence
(#34) IF the harness structure allows re-init; otherwise this one
becomes a dedicated tiny example.
4. **bench-iigs.sh** -- the IIgs capture path, which does not exist
today (PERF.md's IIgs column was carried over, not re-run): boot
UBER off a writable joey.2mg under headless MAME, cadius-extract
joeylog.txt afterward (or extend the verify-iigs.sh Lua to dump the
log lines from emulated memory). Without this, no "hashes identical
x4" gate in this plan is executable on the reference port.
5. **millis host harness** -- audioSfxMix-style host compile of
genericPort millis logic (check-blank.sh pattern) driving simulated
frame-delta sequences including the 16-bit wrap, for Phase 1 #23.
6. **#25 (first half)** -- run the new harness on all four ports;
record the new honest baseline table in PERF.md marked
post-clock-fix. MANDATORY: tag the SEI-holding rows (present,
surfaceClear, fillRect 320x200) as clock-unreliable in the table --
the SEI tick-loss artifact survives the batching fix.
7. **#73 (landed during Phase 0 out of necessity)** -- DOS jlpFrameCount
was an $3DA edge-poll counter that lost every frame whose ~1ms
retrace pulse fell between calls; the batched timing loop stalled on
it, and it had been inflating the old DOS column 5-40x on slow ops.
Now derived from the PIT/uclock millis source (poll-rate-independent);
jlpWaitVBL still syncs to real retrace.
8. **#76 (landed during Phase 0 out of necessity)** -- IIgs jlpWaitVBL
polled $C019's VBL bit, which never asserts under MAME's apple2gs:
every VBL-paced program hung forever in emulation, and UBER (the
only in-tree caller) could never complete headless -- the real
reason PERF.md's IIgs column was "carried over". Now waits on a
GetTick change (VBL-interrupt-driven, same boundary semantics).
9. **Freeze golden hashes** -- DONE 2026-07-04 for DOS/ST/Amiga in
tests/goldens/uber/ (43/43/42 hash lines; the Amiga log lost its
final check line to the bench-script timeout -- re-capture with a
longer TIMEOUT when convenient). The IIgs golden is PARTIAL (3 op
lines, codegen-off): the clang-era IIgs port cannot complete UBER
yet -- sprite codegen corrupts memory (#77), the codegen-off run
stalls in the jlDrawPixel timing window after 3 ops (uninvestigated
-- first Phase 1 stabilization item), and jlAudioInit fails
headless. PERF.md documents the 3-port baseline and the IIgs
status; the old IIgs column was ORCA-era and is retired.
Gate: 4-port build; all new correctness checks PASS; goldens recorded.
Expect small-op numbers to jump 2-10x against the old table.
Effort: ~half a day to a day (the harness additions are the bulk).
---
## Phase 1 -- Correctness bugs + IIgs stabilization
All behavior fixes land before perf work so later phases optimize
correct code. Every item here has a covering check from Phase 0.
THE IIGS STABILIZATION CLUSTER COMPLETED 2026-07-04: UBER runs
end-to-end (green screen, audio live, all checks PASS, 39/43 hashes
matching DOS, honest floor numbers in PERF.md). Fixed along the way:
#81 (phantom stage / tiny-heap malloc-returns-1), #82 (RAM-ring
logger), #83/#86 (line+fillCircle asm DBR-vs-D scratch stash), #85
(framebuffer + bank-0 MM reservations), audio packaging. #77 is
root-caused to the toolchain (loader does not MM-reserve segment
BSS spans; arena landed at $08:4200 inside the app's own bank) --
codegen stays off until LLVM816-ASKS item 5 lands. New/remaining
items for the rest of Phase 1: #87 (circle-outline pixel divergence,
the last 4 hash mismatches). Original cluster notes follow:
(a) #77 sprite-codegen corruption (MAME write-watchpoint hunt);
(b) the codegen-off UBER "stall" -- RESOLVED 2026-07-04 in three
layers: #80 (log-durability illusion misdirected the hunt), #81
(the master bug: 665-byte unprotected heap + malloc-returns-1 ->
phantom stage struct; fixed by moving all core structs to
jlpBigAlloc), and #82 (per-line floppy log writes stalled the app
in SmartPort firmware; fixed with the RAM-ring logger + live Lua
drain). UBER now completes end-to-end on IIgs (33 ops + 17 checks,
green screen); 20 hashes match DOS. Remaining IIgs defects filed
as #83 (diag-line asm pixel divergence) and #84 (interpreted
sprite ops ~1000x slow + wrong) -- both queued in this cluster;
(c) headless jlAudioInit failure -- FIXED 2026-07-04: the audio code
was wired all along; the merlin32-built "ntpplayer" binary was
simply never packed onto joey.2mg. iigs-disk now builds and adds
it; UBER logs "audioInit OK";
(d) #79 flag to the llvm816 session (libc memset/memcpy byte loops).
Only after (b) is fixed can the IIgs golden hashes be frozen and the
IIgs column return to PERF.md.
1. **#1 (CRITICAL)** draw.c:549 -- non-IIgs jlDrawPixel: bounds-check
x/y (same `(uint16_t)x >= SURFACE_WIDTH` form as the IIgs branch)
after the NULL check and return early; then call jlpDrawPixel
directly and mark. Deletes the redundant plotPixelNoMark layer on
the public path.
2. **#11** draw.c:591 -- jlDrawRect general path: compute bottom/right
edge coordinates in int32_t; skip an edge whose coordinate is
off-surface instead of letting the int16_t cast wrap. ALSO (from the
#65 verifier correction, same function): skip the interior-edge
fills entirely when h == 2, so jlDrawRect stops passing h == 0 rects
into fillRectOnSurface/surfaceMarkDirtyRect -- this restores the
"already clipped, positive dimensions" contract that Phase 2 #65
relies on before it drops the guard.
3. **#72** draw.c:417 -- jlDrawLine H/V fast paths: clip x0 (resp. y0)
into range BEFORE anchoring the clamped span (`if (x0 < 0) { span +=
x0; x0 = 0; }` after the 32-bit span, return if span <= 0, then clamp
to the surface size). Today a line from far off-surface through the
whole visible row draws nothing. Found during Phase 0 harness work;
covered by the "edge-lines" uber check (its hash changes with this
fix).
4. **#12** draw.c:656 -- jlFillCircle/jlDrawCircle huge-r handling:
32-bit full-coverage test (farthest corner dist^2 <= r^2 -> full
surface fill / early out), clamp the span loop to intersecting rows,
uint16_t loop variable. Verifier notes: seed yy = y0*y0 when jumping
into the row range (the incremental chain breaks on a jump); the
initial x walk-down from r stays O(r) once per call -- document it.
5. **#23** genericPort.c:40 -- millisElapsed: accumulate milliseconds
incrementally from uint16_t frame deltas (per verifier: do NOT keep
a total frame count and multiply -- that re-overflows at ~20h; carry
the fractional remainder, e.g. delta*20 at 50Hz, delta*50/3 with
mod-3 carry at 60Hz). Update the statics with explicit
load/add/store-style C (no `++`/`+=` on globals -- ORCA-C inc-abs
DBR trap). Document the poll-at-least-once-per-65535-frames bound
(~18 min @60Hz, ~22 min @50Hz -- NOT hours). Delete the dangling
iigs/hal.c:382-386 comment. Verify against the Phase 0 host harness.
6. **#8** codegenArena.c:236 -- IIgs: clamp totalBytes to 64KB with a
specific coreSetError string on failure; fix adventure.c to set
codegenBytes per-platform (#ifdef: small on chunky ports, 256KB only
where a flat heap exists). Document the cap at jlConfigT.codegenBytes.
7. **#46** sprite.c:346 -- gate the IIgs/DOS compiled-draw fast path on
`routineOffsets[shift][SPRITE_OP_DRAW] != SPRITE_NOT_COMPILED` (read
once via the existing byte-pointer idiom; reuse for the call).
8. **#34** codegenArena.c:294 -- shutdown/re-init dangling slots.
PREFERRED (the audit's own fix): a small sprite registry in sprite.c
-- spriteSystemShutdown, called from jlShutdown BEFORE
codegenArenaShutdown, walks live sprites and NULLs sp->slot. Zero
hot-path cost and it fixes BOTH arms: the destroy-after-reinit heap
corruption AND the draw-after-reinit stale-offset execution (an
epoch checked only at free time would miss the second arm).
Alternatives if the registry is unwanted: arena epoch stamped into
jlSpriteT and checked at every dispatch gate (hot-path cost), or a
documented "jlShutdown invalidates all sprites" contract (doc-only).
9. **#5** spriteCompile.c:202 -- on codegenArenaAlloc failure, run
codegenArenaCompact() and retry once; coreSetError on final failure
so prewarm failures are diagnosable.
10. **#31** assetLoad.c:167 -- move sprite allocations to
jlpBigAlloc/jlpBigFree UNIFORMLY. Alloc sites: cel/tileData buffers
(assetLoad.c:167, sprite.c jlSpriteCreateFromSurface buf) AND the
jlSpriteT header mallocs (sprite.c:215, sprite.c:306,
assetLoad.c:175 -- verifier requires these switch in the same
change). Free sites -- ALL FOUR for tileData plus the headers:
jlSpriteDestroy (sprite.c:325 tileData, sprite.c free(sp)), the
jlSpriteCreateFromSurface error path (sprite.c:308), and both
assetLoad error paths (assetLoad.c:172, assetLoad.c:177). A single
missed free() of a jlpBigAlloc pointer corrupts the IIgs heap
(BIG_ALLOC_HDR offset), and three of these sites are failure-path
only -- exercised by the Phase 0 torture check.
11. **#58** atarist/hal.c:1410 -- DELETE the dead dst/pd NULL checks
(callers validate; the finding's category is
unnecessary-conditional, not misordered-check), and apply the same
cleanup to the identical pattern in jlpTileSnap (deref at 1454,
dead checks at 1458/1462) per the verifier correction.
12. **#74** -- root-cause the DOS jlSpriteCompile failure (pre-existing;
UBER logs it; every DOS sprite bench measured the interpreter).
Instrument the sizing/emit/arena-alloc chain under DOSBox; the x86
emitter itself emits real code for shifts 0-1. Gates Phase 4's
compiled-path verification on DOS.
13. **#75** -- resolve the jlTilePasteMono three-way pixel divergence
(chunky generic vs Amiga vs ST overrides, caught by the Phase 0
tilePasteMono hash). Decide the contract truth (tile.h: non-zero
mono nibble -> fgColor), fix the divergent implementations, and
re-golden that hash line on the fixed ports. Blocks Phase 7 #3.
14. **#77** -- root-cause the IIgs sprite-codegen memory corruption
(MAME write-watchpoint on the stage struct / gStage static during
jlSpriteCompile + first compiled draw). Until fixed, IIgs builds
run JOEY_IIGS_SPRITE_CODEGEN=0 and the IIgs baseline measures the
interpreter. HIGHEST PRIORITY of the phase with #74: both reference
platforms' compiled sprite paths are currently dead.
15. **#78** -- fix ST jlFillCircle and jlTileFill pixel divergence vs
the chunky reference (DOS==Amiga agree); re-golden ST hash lines.
16. **#79** -- FLAG TO SCOTT (toolchain-owned): llvm-mos runtime
memset/memcpy are ~30 cyc/byte byte-loops vs ORCA's ~7 cyc/byte
MVN; reference-platform regression ~4x on every memset/memcpy path.
Library-side mitigations are #9/#19/#21; the libc fix belongs to
the llvm816 session.
Gate: 4-port builds; Phase 0 correctness checks all PASS (they cover
every edge case fixed here); hashes match Phase 0 goldens;
`make iigs-verify VERIFY_EXAMPLE=draw` checksum unchanged.
Effort: ~4-6 hours.
---
## Phase 2 -- Shared hot-path infrastructure
The cross-TU-call cluster. Everything here touches surfaceInternal.h /
port.h / surface.c / draw.c and ripples through every port; do it as
one unit with a full 4-port verify.
1. **#10 + #17** -- make gStage a plain extern (`extern jlSurfaceT
*gStage;` in surfaceInternal.h, drop static in surface.c; keep
jlStageGet() as the public accessor). Replace jlStageGet() with a
direct `== gStage` compare in: port.h IIgs macros (jlpFillRect,
jlpSurfaceClear, jlpFillCircle), draw.c:533 jlDrawPixel, palette.c,
scb.c, surface.c internal uses. Precedent: codegenArenaBase did
exactly this.
2. **#20 + #65** -- surfaceMarkDirtyRect: move the stage test and the
h==1 widen into a macro in surfaceInternal.h (ORCA/C 2.2.1 has no
inline -- macro form is a hard constraint; audit macro argument
usage for multiple-evaluation before converting call sites).
Non-stage surfaces then cost one inline compare; single-row marks
widen with no call; multi-row marks keep one call
(iigsMarkDirtyRowsInner on IIgs, C loop elsewhere). Drop the w/h
guard (#65) ONLY after Phase 1 item 2 restored the contract
(jlDrawRect h==2 was the violator); empty ranges then cannot reach
the macro.
3. **#21** -- stageDirtyClearAll: two memsets (0xFF / 0x00), matching
its sibling surfaceMarkDirtyAll.
4. **#47** -- dirty-row test unification, TWO-macro form per the
verifier correction (NOT a blanket four-site replacement): convert
DOS/ST to STAGE_DIRTY_ROW_CLEAN (min > max); keep Amiga's
`min != 0xFF` as a second documented macro (STAGE_DIRTY_ROW_TOUCHED
-- it is deliberate and cheaper under the clipped-marks invariant);
pin the IIgs present asm's compare with a comment naming the
sentinel values it assumes.
5. **#14 + #24** -- pixel read: expose the chunky nibble read as a
macro in surfaceInternal.h using SURFACE_ROW_OFFSET (verifier:
macro, not static inline, so floodFillInternal's chunky-fallback
reads can use it too). jlSamplePixel uses it inline after its bounds
check on chunky builds; jlpGenericSamplePixel body becomes the same
macro. (Do NOT expect a sprite-capture delta: the sprite.c:296-297
branch only runs on planar ports, which use their own platform
jlpSamplePixel -- review corrected the audit here.)
6. **#13** -- jlFillRect: add the 16-bit on-surface fast path before
the 32-bit clip, in the guarded form (test `w <= SURFACE_WIDTH` and
`h <= SURFACE_HEIGHT` FIRST, then `x >= 0 && y >= 0 &&
x <= (int16_t)(SURFACE_WIDTH - (int16_t)w)` etc. -- guarding before
the subtraction is what makes 16-bit safe against w up to 65535).
Verifier: keep the three span emitters calling fillRectOnSurface
directly; do NOT reroute them.
7. **#16** -- jlPaletteSet: memcpy the row + force row[0] = 0x0000,
dropping the undocumented per-entry $0RGB re-mask (pending the
decision-table sign-off; if the mask must stay, at least widen the
loop counter to uint16_t). Mirrors jlPaletteGet. Covered by the
Phase 0 palette readback assert (the surface hash cannot see
palette changes).
Gate: hashes match goldens x4 (dirty-band changes are hash-blind --
that is what the next line is for); present-output check: captureDos.sh
against a pre-phase golden PNG for DOS, iigs-verify
VERIFY_EXAMPLE=draw checksum compare for IIgs, and a documented
run-amiga.sh / run-atarist.sh visual pass of the draw example after
multiple presents (68k C dirty loops diverge from the IIgs asm; a
dirty-band under-mark shows as stale rows only at present time).
Capture bench delta against the Phase 0 baseline.
Effort: ~4-6 hours (mostly the 4-port present verification).
---
## Phase 3 -- draw.c micro-optimizations
1. **#9 + #37 + #36** -- jlDrawLine/jlDrawCircle fallbacks: compute the
dirty box from the endpoints (line) / center+radius (circle) before
the loop, clamped after, as now; delete the per-pixel and
per-iteration box accumulation (the circle's 8 compares per
iteration go with it). RISK NOTE: a too-small precomputed box
under-marks and is invisible to hashes -- the Phase 2 gate's
present-output checks re-run here.
2. **#38** -- jlFillCircle C span loop: one bounding-box dirty mark
after the loop instead of per-span marks. Over-approximates each
row's band to the circle width; pixel output identical; matches
what the IIgs asm path already does. (Over-marking is the SAFE
direction, unlike item 1.)
3. **#35** -- floodFillInternal C fallback: hoist the matchEqual
predicate out of the walk loops (two loop variants), use the Phase 2
read macro instead of jlpSamplePixel calls where the port is chunky.
4. **#61** -- plotPixelNoMark: drop the NULL re-check (all callers
validate); KEEP the per-pixel bounds check (load-bearing for
clipping).
5. **#62** -- define the shared on-surface rect predicate ONCE, here,
in its final form (review blocker): the #13-style guarded 16-bit
macro -- `w <= SURFACE_WIDTH && h <= SURFACE_HEIGHT` first, then the
subtraction compares. That form is correct for BOTH users: jlDrawRect
(raw uint16_t w/h up to 65535) and sprite.c (widthPx/heightPx up to
2040 = 255 tiles * 8 -- NOT "bounded by 320/200"). jlDrawRect's
fast-path test switches to it now; sprite.c merges in Phase 4 as a
pure call-site change with NO rewrite of the macro.
Gate: hashes match goldens x4 (the Phase 0 edge-case checks cover the
w=65535 / huge-r paths through the new predicate); present-output
checks as in Phase 2; bench diag-line/circle deltas on DOS (the only
port that always runs the fallbacks).
Effort: ~2-3 hours.
---
## Phase 4 -- Sprite pipeline + codegen
Landing order within the phase (review): land items 2-12 first as one
reviewable, hash-gated unit; land item 1 (the risky inner-loop rewrite)
LAST as its own change with its own before/after clipped-sprite hash
comparison, so a failed rewrite reverts alone. Within items 2-12, do
the deletions (#55, #27) BEFORE the shared-derive refactor (#29 + #64)
so the refactor never touches code that is about to be deleted.
1. **#2 + #18 + #45** -- rewrite spriteDrawInterpreted's inner loop
(LAND LAST):
- hoist row-invariant source math out of the column loop (#45),
- carry dst byte pointer + parity incrementally (kill the
writeDstNibble call per pixel, drop its redundant & 0x0F masks),
- aligned-phase byte loop: src byte == 0 skips two pixels,
both-nibbles-opaque stores the whole byte, single-nibble merges
stay RMW; chunk per tile column (4 contiguous bytes, then advance
srcTileRow by TILE_BYTES -- verifier: the naive *src++ sketch
glosses this); keep the per-pixel walker for the misaligned phase.
Rollback: revert just the loop; the writeDstNibble path stays in git
history.
2. **#19** -- jlSpriteSaveUnder/RestoreUnder fallbacks: hoist
SURFACE_ROW_OFFSET-based start pointers, walk with += stride adds;
kill both per-row multiplies.
3. **#44** -- switch isFullyOnSurface's body to the Phase 3 shared
predicate (pure call-site merge; the macro is already 16-bit-safe
for the 2040-px sprite domain -- do not modify it).
4. **#55** -- remove spriteEmit68k.c from the Amiga/ST build lists.
DECISION for Scott: delete the file (git history keeps it) or leave
it unbuilt.
5. **#27** -- Amiga compiled save/restore dispatchers: replace the
bodies with ST-style empty linker stubs -- FULL DELETION BREAKS THE
LINK (sprite.c calls them unconditionally; verifier correction).
Delete the shift-alias loop and the aspirational comments at
spriteCompile.c:172-178/241-245. Note for the future: when real
Amiga save/restore emitters land, the restore gate must accept
planar copyBytes = spriteBytesPerRow+4.
6. **#29 + #64** -- one shared macro/helper derives (shift, routeIdx,
routeOffset, fnAddr) once; the surviving dispatcher gates and
spriteCompiled* callees consume it.
7. **#28** -- spriteCompiledDraw (IIgs): byte-pointer idiom for
routineOffsets like the rest of the file.
8. **#59** -- SPLIT_BACKUP_CACHED: move the split cache per-sprite (the
jlSpriteT already caches banks) or delete it; measure both on IIgs.
9. **#32** -- jlSpriteBankLoad calls spriteInitFields (then overrides
ownsTileData); fix the spriteInternal.h comment.
10. **#39** -- DEFAULT_CODEGEN_BYTES per-platform (#ifdef: keep 8KB
IIgs, size 68k ports from their emitters' measured bytes/sprite;
document at jlConfigT).
11. **#56** -- dedupe emitter helpers (c2pColumn/putWord into a shared
m68k header; shiftedByteAt/spriteSourceByte into a shared codegen
header).
12. **#54 + #63** -- fix the stale spriteEmitIigs.c contract comment
and the sprite.c:343 "IIgs never takes this" comment.
Gate: hashes match goldens x4 -- the Phase 0 clipped-sprite checks are
the load-bearing coverage here (UBER's timed sprite ops are compiled
and on-surface; they cannot see interpreter regressions).
`make iigs-verify VERIFY_EXAMPLE=sprite` checksum before/after;
captureDos.sh sprite example compare on DOS. Bench: clipped-sprite ops
expect 2-3x; compiled ops modest gains from dispatch dedup.
Effort: ~1-2 days.
---
## Phase 5 -- Tile path + generic fallbacks
1. **#22** -- jlDrawText: validate bx/by once at entry (verifier: the
wrap logic does not sanitize the FIRST position -- reject
out-of-range starts), validate srcBx/srcBy from asciiMap per glyph,
call jlpTileCopyMasked directly, accumulate one union dirty rect for
the string. Covered by the Phase 0 drawText hash check (uber.c has
no other drawText coverage).
2. **#48** -- tile.c wrappers: shifts (<< 3) for pixel coords, or fold
coord derivation into the dirty-mark call site.
3. **#51** -- jlpGenericTileCopyMasked: whole-byte store when both
nibbles are opaque; keep the merge for mixed bytes. Bit-identical.
4. **#50 + #70** -- shared 4-byte row-copy macro used by TileCopy,
TilePaste, TileSnap; jlpGenericTileFill uses GENERIC_TILE_ROW0.
5. **#69** -- drop the duplicate fg/bg masks (mask once in the public
wrapper, document the precondition in the generic).
6. **#49** -- jlpGenericFillRect: hoist pxStart/pxEnd/midBytes/edge
nibble work out of the row loop; walk the row pointer with
+= stride.
7. **#30** -- assetLoad constants: use TILE_BYTES from joey/tile.h;
derive ASSET_MAX_TILES_W/H from SURFACE_WIDTH/HEIGHT and
TILE_PIXELS_PER_SIDE.
8. **#66, #67, #68, #57, #60** -- delete SURFACE_PIXELS_PER_WORD;
200 -> SURFACE_HEIGHT in the LUT decl; fix tile.c stale comment; fix
surface.h hash doc; fix or implement the assetLoad segment comment
(DECISION: add the segment directive it promises, or correct the
comment -- check IIgs root-segment pressure first).
Gate: hashes match goldens x4 (#51 especially must be bit-identical;
the drawText check covers #22); DOS bench deltas on tile ops (DOS runs
the generics); hello example on DOS + IIgs for text rendering.
Effort: ~3-4 hours.
---
## Phase 6 -- SFX mixer (DOS/ST builds only)
Verification FIRST (review: no named gate could actually verify this
phase): audioSfxMix.c is portable C -- build a host harness on the
check-blank.sh pattern that compiles the OLD and NEW mixer with host
cc, mixes a fixed seed sample set (fixed-buffer and streaming slots,
rate conversion up and down), and diffs output buffers. This proves #7
and #33 bit-exact and bounds #6 to 1 LSB concretely before any
emulator run.
1. **#6** -- interp(): the verifier-validated int16 form (explicit
int16_t locals so gcc emits one muls.w):
`int16_t d = (int16_t)(s1 - s0);`
`int16_t fH = (int16_t)(uint16_t)(frac >> 1);`
`return (int)s0 + (int)(((int32_t)d * fH) >> 15);`
Output may differ by 1 LSB of 8-bit audio (inaudible) -- accepted
deviation, document it.
2. **#7** -- posFp: uint32_t index + uint16_t frac with carry-compare;
split stepFp once per slot; update audioSfxSlotArm/ArmStream/
streamRefill. Bit-identical.
3. **#33** -- cache slot fields in locals across the inner loop
(volatile dst stores force re-reads today), re-reading only after
streamRefill; AND split the fixed-buffer vs streaming cases into two
inner loops so the loop-invariant isStream branch runs once per slot
(both halves are the audited fix; the plan review caught the split
being dropped).
Gate: host-harness diff (above); DOS + ST builds; audio example smoke
on DOSBox/Hatari; absence of the ST ~83ms refill warning under load as
a secondary signal.
Effort: ~3-4 hours including the harness.
---
## Phase 7 -- Platform override work (biggest wins, biggest effort)
Each item is independently landable. Golden hashes for jlTilePasteMono
and jlFloodFill were captured in Phase 0 against the generic paths --
the new implementations must reproduce them exactly.
1. **#3** -- IIgs jlpTilePasteMono: new iigsTilePasteMonoInner in
joeyDraw.s -- 16-entry nibble-pair LUT expands each mono byte
directly into surface rows, no intermediate jlTileT, no second paste
pass. Add JL_HAS_TILE_PASTE_MONO to platform.h IIgs block + port.h
macro. ORCA-M rules: explicit LONGA/LONGI after every REP/SEP, same
load segment as the C callers (DRAWPRIMS), delete stale example
binaries before rebuild. Gate: uber tilePasteMono hash identical to
the Phase 0 generic-path golden on IIgs; `make iigs-verify
VERIFY_EXAMPLE=spacetaxi` before/after (title screen is the
acceptance test -- logo repaint currently costs ~10 frame budgets
per 4-frame period).
2. **#4** -- ST flood plane hooks: implement jlpFloodWalkPlanes +
jlpFloodScanRowPlanes against the word-interleaved StPlanarT layout,
modeled on the Amiga versions (walk 16 px per 4-word group via
combined match masks). Pure 68000, no blitter. C first; hand-asm
only if the C version misses the target. Update the port.h comment
claiming these hooks are Amiga-only. Gate: uber floodFill hash
identical to golden on ST; Hatari flood timing (expect ~5-15x).
3. **#42** -- DOS: #define the planar no-op hooks to ((void)0) like the
IIgs block.
4. **#41** -- fillWord derivation: pick ONE owner. Either the asm
derives it from the nibble (as the port.h comment claims) or C keeps
deriving via a 16-entry uint16_t LUT instead of the * 0x1111
multiply helper. Update the comment to match reality.
5. **#53** -- iigsDrawPixelInner: remove the dead PHB/PLB framing and
fold the row offset via `adc gRowOffsetLut,x` to eliminate the
dpxlTmp round-trip. Two verifier-identified hazards (the plan review
killed the earlier AUXWRITE caveat -- it does not apply here, and
dpxlTmp is DBR-absolute, not long-absolute):
(a) deleting phb renumbers EVERY stack-relative arg offset
(8/10/12,s -> 7/9/11,s) -- miss one and the routine reads the wrong
arg while still "working"; update the comment block at
joeyDraw.s:1402-1405;
(b) do NOT relocate the scratch D-relative -- D-relative stores land
at the bank-0 alias, not the data-bank .bss.
Gate: iigs-verify VERIFY_EXAMPLE=draw checksum + before/after bench
on the fixed clock (hottest inner op).
6. **#52** -- iigsBlitStageToShr dead upload paths, verifier keep-list
(the review caught two traps here): the entry becomes zero-arg --
only ONE far pointer + flags word is currently pushed, not two;
delete the marshalling at joeyDraw.s:2765-2772 and both flag-gated
MVN blocks (2787-2859); KEEP the caller-DBR stash at 2780-2784 (the
narrow-row MVN path reloads $E1:9DFE after every MVN -- deleting the
stash corrupts presents); delete the orphaned bsFrame BSS
(4417-4420); update hal.c:98/298 and the stale jlpInit comment at
hal.c:270-273. Gate: iigs-verify against an example that produces
PARTIAL-ROW dirty bands (not just the default draw demo), checksum
before/after.
7. **#26** -- platform.h decorative IIgs flags: make the registry
authoritative AND loud (review: plain #ifdef-consume recreates the
silent-fallback rot this finding describes): keep the flags, and
pair the port.h IIgs macro block with
`#if !defined(JL_HAS_<OP>) #error` checks so a missing/removed flag
is a compile failure, not a silent generic fallback.
Gate (phase-wide): full 4-port UBER re-run against goldens;
per-item gates above.
Effort: ~2-4 days (dominated by the two new implementations).
---
## Phase 8 -- API-surface decisions + final re-baseline
1. **#40** -- input predicates: DECISION for Scott. Option A: macro
fast paths in joey/input.h reading the extern state arrays (keeps
functions for ABI; exposes internals in a public header). Option B:
accept the call cost (the honest post-Phase-0 number is far better
than the old 3382 anyway). Recommendation: B unless a real game
shows input in its frame profile.
2. **#43** -- jlRandom 16-bit-half rewrite, IIGS-ONLY behind a platform
#if (verifier: the decomposed form is a wash-to-slight-LOSS on
68000 -- keep the current 32-bit C for the GCC ports). Output is
bit-identical either way; the Phase 0 golden-sequence check covers
it on all ports.
3. **#25 (final)** -- regenerate PERF.md completely: new IIgs floor,
new ratios, document BOTH artifacts (SEI clock corruption + the
now-fixed per-iteration poll tax) and the honest full-screen
ceiling; drop the stale-baseline caveats.
Gate: full 4-port bench + hash run; PERF.md tells the truth.
Effort: ~1-2 hours plus bench runs.
---
## Decisions Scott must make (collected)
| # | Decision | Recommendation |
| --- | --- | --- |
| #16 | Drop the undocumented $0RGB re-mask in jlPaletteSet (memcpy + force color 0)? Slight behavior change for non-conforming input. | Yes -- contract already requires $0RGB |
| #22 | jlDrawText: out-of-range initial bx/by becomes an explicit reject instead of silently absorbed per-glyph. | Reject at entry |
| #8 | Arena >64KB on IIgs: clamp silently or fail with error? | Clamp + coreSetError note |
| #34 | Sprite registry in sprite.c vs arena epoch vs doc-only contract? | Registry (fixes both corruption arms, zero hot-path cost) |
| #55 | spriteEmit68k.c: delete or keep unbuilt? | Delete (git history keeps it) |
| #38 | Accept dirty-band over-approximation for fillCircle fallback spans? | Yes -- matches IIgs asm path |
| #6 | Accept 1-LSB audio deviation for the muls.w interp rewrite? | Yes -- inaudible |
| #40 | Input predicate macros in public header vs keep functions? | Keep functions (B) -- DECIDED 2026-07-05, documented at the predicate block in src/core/input.c |
| #60 | assetLoad IIgs segment: add the promised directive or fix the comment? | Check root-segment pressure, then decide |
## What this plan deliberately does NOT do
- No runtime dispatch, no C++/newer-C features, no ST blitter, no
wholesale planar-architecture changes (per the audit's ground rules
and the standing 68k-asm-rewrite plan -- deep C micro-opts to the
Amiga/ST HALs beyond the listed findings are out of scope because the
hand-asm rewrite will replace them).
- No toolchain changes (llvm-mos/ORCA are owned elsewhere; anything
needing compiler work gets flagged to Scott instead).
- Refuted findings (PERF-AUDIT.md bottom) are not actioned.
## Progress tracking
Mark each finding number done here as it lands:
- Phase 0: [x] uber-batch [x] #15 [x] #71 [x] uber-checks [x] bench-iigs.sh [x] millis-harness [x] #73 [x] #76 [x] #25a (3-port; IIgs blocked on stabilization) [x] goldens (DOS/ST/Amiga; IIgs partial)
- Phase 1 COMPLETE 2026-07-05 (all four ports 43/43 bit-identical hashes): [x] #1 [x] #11 (+h==2) [x] #72 [x] #12 [x] #74 [x] #75 [x] #77 (root-caused; fix=toolchain ask 5; codegen off) [x] #78 [x] #79-flag [x] #81 [x] #82 [x] #83 [x] #85 [x] #86 [x] #87 [x] #23 [x] #8 [x] #46 [x] #34 [x] #5 [x] #58 [x] #31 -- new: #88 filed (jlTileT snap/mono semantics wart, Phase 5) [ ] #23 [ ] #8 [ ] #46 [ ] #34 [ ] #5 [ ] #31 [ ] #58
- Phase 2 COMPLETE 2026-07-05 (bit-neutral, 43/43 held on all four ports; DOS paletteSet +92%, drawPixel +37%, samplePixel +21%; IIgs paletteSet +8%, samplePixel +6%): [x] #10 [x] #17 [x] #20 [x] #65 [x] #21 [x] #47 [x] #14 [x] #24 [x] #13 [x] #16 (final form = unrolled word copy, NOT memcpy -- the clang IIgs libc memcpy byte-loop regressed it; finding #79 caveat)
- Phase 3 COMPLETE 2026-07-05 (bit-neutral, 43/43 x4; IIgs drawCircle r=80 -> 70 ops/s, fillCircle r=40 -> 42): [x] #9 [x] #37 [x] #36 [x] #38 [x] #35 [x] #61 [x] #62 -- new: #89 filed (diag fallback int16 dx/dy latent, opportunistic)
- Phase 4 COMPLETE 2026-07-05 (43/43 x4; IIgs interpreted spriteDraw 18->48 ops/s (2.7x), save +18%, restore +15%; review harness: 726,915 differential cases, 0 mismatches): all items incl. #19 + #29-runtime stragglers; #55 file deleted; #59 cache deleted; sprite.h docs modernized
- Phase 5 COMPLETE 2026-07-05 (bit-neutral 43/43 x4; DOS tileSnap +42%, tileCopyMasked +29%, tilePaste +18%, fillRect +8-9%): all items; #22 landed as entry-sanitation preserving historical wrap semantics (strict reject would have broken the offgrid golden); jlDrawText needed a noinline union helper for the w65816 reg allocator
- Phase 6 COMPLETE 2026-07-05 (host harness 12/12: #7+#33 bit-identical in exact mode pre-#6; #6 maxAbsDelta=1 on the 3 odd-step scenarios, within the 1-LSB bound; baseline recaptured post-#6; DOS+ST builds clean; ST asm shows 1 muls.w per split loop, 0 __mulsi3): [x] #6 [x] #7 [x] #33 -- emulator audio smoke (DOSBox/Hatari) not yet run
- Phase 7 COMPLETE 2026-07-05: [x] #3 [x] #4 [x] #42 [x] #41 [x] #53 [x] #52 [x] #26 -- #3/#4 landed
+ hash-identical x4 (tilePasteMono=B0829554, floodFill=780DC48B). Perf captured
2026-07-05 via isolated UBER timed ops (same-CPU override-vs-generic before/after,
built by toggling the port.h #3 macro / platform.h #4 flags): #3 IIgs tilePasteMono
asm 1083 vs generic 323 ops/sec = 3.35x (timed hash 8A39C22B identical both paths --
asm output bit-exact); #4 ST flood plane-hooks 13.5 vs generic 75 frames/flood = 5.6x
(within the 5-15x target). Measured via the UBER micro-benchmark rather than the
plan's spacetaxi/flood-scenario gates -- more isolated, and correctness already
proven by the x4 golden match.
+ Golden coverage extended 2026-07-05 (re-analysis follow-up): the original
tilePasteMono/floodFill goldens left two paths hash-blind -- the #3 asm combos
idx 0 (bg:bg) and idx 3 (fg:fg) were never selected by the alternating 0x0F/0xF0
mono tile, and only jlFloodFill (matchEqual) exercised the #4 ST plane hooks, never
the bounded stop-mask + group-skip branch. Added checkTileMonoFloodExtra() in
uber.c (runs LAST so no earlier cumulative hash shifts): tilePasteMonoAll pastes a
0x00/0xFF/0x0F/0xF0 tile (all four combos) and floodFillBounded fills an enclosed
color-3 box across four 16px groups. Re-ran all four ports: both new checks
hash-identical x4 (tilePasteMonoAll=30C3A2C9, floodFillBounded=EF6B6F51); goldens
updated to 45/45. The two previously by-inspection-only paths are now empirically
bit-exact vs the DOS generic reference. NOTE: Amiga bench needed TIMEOUT=600 (the
last-running checks overran the default 150s, same truncation as the Phase 0 note).
+ #41 verified COMPLETE 2026-07-05: found already landed (opportunistically,
during the earlier tileFill/fillCircle/fillRect asm work) -- iigsTileFillInner,
iigsFillCircleInner, and iigsFillRectInner all take the raw nibble and derive
the fill word/bytes in asm (joeyDraw.s:420-430 etc.); the port.h macros pass
(c & 0x0F); no C-side * 0x1111 survives anywhere (grep-verified, comments
only); the port.h:100-105/136-138 comments match reality. surfaceClear's
fillWord stays C-derived via (d | d<<8) -- shifts/OR, no multiply helper, so
ownership is consistent per-op and documented at each site. No code change;
checkbox was stale.
+ #52 verified COMPLETE 2026-07-05: also found already landed (the clang-era
joeyDraw.s rewrite folded it in) -- iigsBlitStageToShr is zero-arg (hal.c:106
extern, hal.c:309 call), the scbPtr/palettePtr/uploadFlags marshalling and both
flag-gated MVN blocks are gone, the caller-DBR stash to $E1:9DFE is KEPT per the
verifier correction (joeyDraw.s:3214-3218), bsFrame BSS deleted (grep-verified),
and the jlpInit/jlpPresent comments in hal.c already tell the truth (jlpPresent
even credits #52). Gate evidence: today's IIgs UBER run exercised the narrow-row
MVN path (fillRect-32x32 present = 16-word dirty band < the 32-word threshold,
~300 iterations) and completed to the green done-screen, which requires the DBR
stash restore to work; UBER 45/45 hashes matched.
+ #53 verified COMPLETE 2026-07-05: same story -- iigsDrawPixelInner has no
PHB/PLB (frame = php+phd = +3; args correctly renumbered to x@7,s y@9,s
nib@11,s with the ABI comment updated, joeyDraw.s:1856-1858), the row offset
folds directly via `adc gRowOffsetLut,x` (line 1899), dpxlTmp is deleted
(grep-verified), and scratch was NOT relocated D-relative per the verifier
hazard note. No isolated before/after bench: #53 landed inside the wholesale
clang-era rewrite (no "before" tree), and the verifier-predicted ~1% delta is
below MAME run-to-run noise. Correctness gates: UBER jlDrawPixel + edge-pixels
hashes bit-exact vs DOS x4 (45/45), and `make iigs-verify VERIFY_EXAMPLE=draw`
PASS -- checksum=08483E, distinctNibbles=15 (RECORDED AS THE BASELINE; no prior
checksum was ever written down -- future draw-path gates compare against this).
RESIDUAL flagged, out of #53 scope: iigsDrawLineInner / iigsDrawCircleInner /
the tile inners still carry phb in their prologues -- the audit verifier
explicitly deferred that same-pattern audit to the IIGS_PERF_PLAN Phase 4
line/circle work; their arg-offset comments all assume the +4 frame, so any
future phb deletion must renumber every N,s in each routine.
+ #3 SPACETAXI TITLE-SCREEN ACCEPTANCE TEST attempted 2026-07-05 (the plan's
original gate, deferred from the Phase 7 landing): BLOCKED ON FINDING #77
(toolchain) -- but the attempt built the entire test rig and fixed four real
bugs found along the way. Landed: IIgs asset bake (examples/spacetaxi/
generated/iigs, .tbk target byte 4), make-iigs-disk.sh DATA-tree packing
(CREATEFOLDER + per-file ADDFILE when STAXI is on the disk -- the spacetaxi
PLAN.md Phase 6 packager task), STAXI now BUILDS on IIgs (the iigs.mk:74
"blocked on 2-byte cross-seg reloc" comment is stale -- the toolchain landed
it; STAXI still deliberately omitted from iigs-examples pending the #77 fix),
scripts/bench-staxi-title.sh (per-frame SHR logo-cell cadence sampler; the
intended 4-frame period vs measured), scripts/diag-staxi-screen.sh (SHR/RAM
dump + PC histogram + ring extraction). Bug fixes landed (all 45/45
golden-verified x4): (1) IIgs jlLogReset did a floppy-truncate write --
first-ever floppy WRITE on the headless path, #82 class; now ring-only, with
jlLogFlush switched to "w" (self-truncating full-ring dump, idempotent);
(2) jlTileBankLoad read tile banks 32 bytes/fread (1000 freads per font
bank!) -- now batched 8KB/fread (ASSET_TILE_READ_BATCH), same truncated-file
contract; (3) NEW API jlTileBankLoadToSurface (tile.h) streams a bank
straight onto a surface's block grid with a 256-byte static scratch --
no caller-side 32KB buffer, which no IIgs example can allocate (heap is
665 bytes; a 32KB static blows the bank-0 BSS ceiling); spacetaxi's
loadFontSheet now uses it; (4) jlpGenericSurfaceAllocPixels used calloc --
on IIgs that's the malloc-returns-1 trap (#81) shredding bank 0 through
pointer 1 on the first OFFSCREEN jlSurfaceCreate (stage uses the pinned
path, so UBER never hit it); now jlpBigAlloc + memset with matching
jlpBigFree in jlpGenericSurfaceFreePixels. THE BLOCKER: with all four
fixed, STAXI boots to "title loaded, tilebank=0" but dies layout-dependently
when jlpBigAlloc handles land inside the app's own unreserved BSS spans
(finding #77 exactly; pixels@$09:0BF4 run survived to title-load, other
layouts died in fopen). An app-side BSS-claim mitigation was built and
REVERTED: the linker's __bss_seg symbols ARE readable via a DATA32 .long
table (C IMM16 refs trip the 2-byte cINTERSEG limit), but a SUCCESSFUL
NewHandle claim over the app's own BSS span kills the app instantly
(silent return-to-Finder, reproduced twice) -- the reservation must be made
loader-side. Full recipe + evidence filed in LLVM816-ASKS.md item 5.
Re-run the gate (scripts/bench-staxi-title.sh both port.h-macro arms) once
the toolchain lands item 5.
- Phase 8 COMPLETE 2026-07-05: [x] #40 [x] #43 [x] #25b -- ALL PHASES DONE.
#40 decided per recommendation B (keep functions; documented at the predicate
block in src/core/input.c and in the decisions table above). #43 landed as an
IIgs-only #if in src/core/random.c: state stored as two uint16_t halves, the
13/17/5 xorshift triple decomposed to 16-bit shift identities (hi-before-lo
update order per step); bit-identity host-proven (UBER golden sequence 4/4 +
50M-state sweep vs the 32-bit reference, scratchpad rngHalvesCheck.c) and
emulator-gated (random-golden PASS x4); GCC ports keep the 32-bit form per the
verifier correction. Note: UBER has no timed jlRandom row, so the ~2-3x/call
win is analytic, not benched. #25b: PERF.md fully regenerated from same-day
4-port captures -- fresh table via tools/uber-perf-table, both clock artifacts
documented (poll tax fixed / SEI tick loss inherent with the three IIgs rows
tagged clock-unreliable against the ~29 ops/sec ceiling), 45/45 x4 correctness
statement, per-port sprite-row caveats (DOS compiled, IIgs interpreter pending
#77), below-floor punch list, stale stabilization/history sections dropped.
Phase-wide gate: full 4-port bench + hash run 2026-07-05, 45/45 goldens on all
four ports, random-golden PASS x4.

File diff suppressed because it is too large Load diff

1208
PERF.md

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ platforms from a single codebase:
The Apple IIgs defines the capability ceiling. Stronger platforms coast. The Apple IIgs defines the capability ceiling. Stronger platforms coast.
Hot paths are hand-written assembly per port; the public API is C. Hot paths are hand-written assembly per port; the public API is C.
See `docs/DESIGN.md` for the full 1.0 design. See `claudeDocs/DESIGN.md` for the full 1.0 design.
## Quick start ## Quick start
@ -411,7 +411,7 @@ pops the next typed character (printable ASCII 0x20..0x7E plus
`JL_CHAR_BACKSPACE`/`TAB`/`RETURN`/`ESCAPE`) or -1 when empty, with `JL_CHAR_BACKSPACE`/`TAB`/`RETURN`/`ESCAPE`) or -1 when empty, with
shift, caps lock, and the machine's keyboard layout already applied by shift, caps lock, and the machine's keyboard layout already applied by
the backend -- so punctuation like `.` and `:` arrives correctly on the backend -- so punctuation like `.` and `:` arrives correctly on
every port. Refilled by the same `jlInputPoll`; see `docs/input.md` every port. Refilled by the same `jlInputPoll`; see `claudeDocs/input.md`
for per-port sources and limitations. for per-port sources and limitations.
```c ```c
@ -556,7 +556,7 @@ 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 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 loopback self-test. `scripts/check-serial.sh` unit-tests the portable gate
layer against a loopback mock with no hardware. Full reference, per-port layer against a loopback mock with no hardware. Full reference, per-port
behavior, and porting notes: [`docs/serial.md`](docs/serial.md). behavior, and porting notes: [`claudeDocs/serial.md`](claudeDocs/serial.md).
### Files, saves & disk space (`joey/file.h`) -- opt-in ### Files, saves & disk space (`joey/file.h`) -- opt-in
@ -587,7 +587,7 @@ Directory create / delete / free-space use a per-port HAL (POSIX `mkdir` +
`remove` + `_dos_dskfre` on the X68000). `scripts/check-save.sh` `remove` + `_dos_dskfre` on the X68000). `scripts/check-save.sh`
round-trips the core against real files; `make iigs-verify-save` boots the 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 IIgs save path under MAME. Full reference, per-port behavior, and porting
notes: [`docs/save.md`](docs/save.md). notes: [`claudeDocs/save.md`](claudeDocs/save.md).
### Debug logging (`joey/debug.h`) ### Debug logging (`joey/debug.h`)

9
assets/joeylibLogo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

BIN
assets/joeylib_logo_-_320x200.png (Stored with Git LFS) Normal file

Binary file not shown.

532
docs/ApiAudio.adoc Normal file
View file

@ -0,0 +1,532 @@
== Audio
Declared in `<joey/audio.h>`.
There are three layers, and on two platforms they share physical hardware:
* *Module playback* -- `jlAudioPlayMod`, four-channel Protracker `.MOD`.
* *Chip tracker* -- `jlMusicPlay`, a portable event stream driving three tone
voices plus noise at register-write cost, with no CPU mixing. The recommended
music path.
* *Voices and effects* -- `jlAudioVoice`, `jlAudioNoise`, `jlAudioPlaySfx` and
the streaming forms.
[WARNING]
====
On the Atari ST and Amiga the tone voices *are* the hardware the module player
drives. While a `.MOD` plays, `jlAudioVoice` writes fight the player's register
writes and lose. Choose the module layer or the voice and tracker layer per
scene, never both at once.
====
A failed `jlAudioInit` is not fatal: `jlInit` still succeeds and every audio
call becomes a silent no-op.
=== Constants
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`JOEY_AUDIO_SFX_SLOTS` |5 |Sound effect slots, 0..4.
|`JOEY_AUDIO_VOICES` |3 |PSG-style tone voices, 0..2.
|`JL_SFX_RAW_SIGNED8` |0 |Raw stream format: `int8_t` PCM.
|`JL_SFX_RAW_UNSIGNED8_NOZERO` |1 |Raw stream format: `uint8_t` biased 0x80,
and 0x00 is forbidden (it is the DOC stop byte).
|===
[TIP]
====
Prefer *slot 4* for game sound effects when music is playing. The IIgs maps
tracker tone voices onto slots 0..2 and the noise voice onto slot 3. On the
other ports the slots are independent of the voice layer.
====
=== jlAudioInit
[source,c]
----
bool jlAudioInit(void);
----
*Parameters:* none.
*Returns:* `true` if the platform has a working audio engine and started it;
`false` silently otherwise, with the rest of the API still callable but silent.
=== jlAudioShutdown
[source,c]
----
void jlAudioShutdown(void);
----
Tears the engine down. Safe when audio was never initialised.
*Parameters:* none. *Returns:* nothing.
=== jlAudioFrameTick
[source,c]
----
void jlAudioFrameTick(void);
----
Hooks the engine into your game loop. Most platforms drive their engines from a
hardware interrupt and ignore this, but it is required for any port that mixes
in user context, it advances tracker playback, and on IIgs and Amiga it is what
fires the next chunk of a streaming sound effect.
Call it once per frame, unconditionally.
*Parameters:* none. *Returns:* nothing.
=== jlAudioPlayMod
[source,c]
----
void jlAudioPlayMod(const uint8_t *data, uint32_t length, bool loop);
----
Begins module playback. If a module is already playing it is replaced.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`data` |`const uint8_t *` |Platform-native module blob: `.mod` on most ports,
`.amod` on Amiga, `.ntp` on IIgs. The asset pipeline produces the right form.
|`length` |`uint32_t` |Blob size in bytes.
|`loop` |`bool` |`true` plays forever.
|===
*Returns:* nothing.
[NOTE]
====
`loop = false` needs help on the Amiga: PTPlayer has no native song-end signal,
so the module must contain an `E8FF` marker at song end. The `joeymod` tool's
`.amod` output injects it. A plain `.mod` with no marker loops anyway on Amiga.
====
=== jlAudioStopMod
[source,c]
----
void jlAudioStopMod(void);
----
Stops the current module and resets the playhead, so the next `jlAudioPlayMod`
starts from the top.
*Parameters:* none. *Returns:* nothing.
=== jlAudioIsPlayingMod
[source,c]
----
bool jlAudioIsPlayingMod(void);
----
*Parameters:* none.
*Returns:* `true` while a module is producing output. `false` after a stop,
before the first play, and on platforms where audio init failed.
=== jlAudioPlaySfx
[source,c]
----
void jlAudioPlaySfx(uint8_t slot, const uint8_t *sample,
uint32_t length, uint16_t rateHz);
----
Triggers a one-shot digital sound effect. If the slot is already playing, the
new sample replaces it.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot 0..`JOEY_AUDIO_SFX_SLOTS`-1. Prefer 4 alongside music.
|`sample` |`const uint8_t *` |Raw signed 8-bit PCM.
|`length` |`uint32_t` |Sample count in bytes.
|`rateHz` |`uint16_t` |The rate the sample was recorded at; the engine
re-pitches to its own output rate.
|===
*Returns:* nothing.
[NOTE]
====
The full -128..127 range is accepted everywhere, but the IIgs plays -128 as
-127: its DOC treats the corresponding byte as a wave-end marker and would halt
the voice. One level in 256, inaudible in practice.
====
=== jlAudioStreamFillT
[source,c]
----
typedef uint32_t (*jlAudioStreamFillT)(void *ctx, int8_t *dst, uint32_t count);
----
Stream refill callback. Write up to `count` signed 8-bit samples to `dst` and
return how many you wrote. Returning 0 signals end of stream and the slot
deactivates.
[WARNING]
====
This is called from the audio engine's refill context -- the main thread on DOS
and ST, but the *audio interrupt* on Amiga and IIgs. It must not block and must
not allocate.
====
=== jlAudioPlaySfxStream
[source,c]
----
void jlAudioPlaySfxStream(uint8_t slot, jlAudioStreamFillT fill,
void *ctx, uint16_t rateHz);
----
Plays a streaming sound effect, pulling samples from your callback as the
slot's prefetch buffer drains. Sound effects can therefore be arbitrarily long
with no caller-side allocation.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot 0..4. Replaces whatever is playing there.
|`fill` |`jlAudioStreamFillT` |Refill callback.
|`ctx` |`void *` |Opaque pointer handed back to the callback.
|`rateHz` |`uint16_t` |Rate your callback produces; the engine resamples with
linear interpolation.
|===
*Returns:* nothing.
[IMPORTANT]
====
On IIgs and Amiga the engine plays one chunk at a time and fires the next from
`jlAudioFrameTick`, so *you must be calling `jlAudioFrameTick` every frame* or
the stream stops after its first chunk. A chunk seam on those ports can carry
up to one frame of jitter.
====
=== jlAudioSfxRawFormat / jlAudioPlaySfxStreamRaw
[source,c]
----
uint8_t jlAudioSfxRawFormat(void);
void jlAudioPlaySfxStreamRaw(uint8_t slot, jlAudioStreamFillT fill,
void *ctx, uint16_t rateHz);
----
`jlAudioSfxRawFormat` returns this port's raw stream format, either
`JL_SFX_RAW_SIGNED8` or `JL_SFX_RAW_UNSIGNED8_NOZERO`.
`jlAudioPlaySfxStreamRaw` is `jlAudioPlaySfxStream` except your callback
delivers bytes *already in that format*, so the port skips its per-byte
conversion of every chunk. On the IIgs that conversion is a 65816 byte loop
over each 3.5 KB chunk, and it is enough to stall a game for a third of a
second per chunk of speech. Every other port streams signed PCM natively, so
there this is simply the plain call.
If your producer cannot make the port's format, use `jlAudioPlaySfxStream`.
*Parameters:* as `jlAudioPlaySfxStream`. *Returns:* nothing.
=== jlAudioSetPan
[source,c]
----
typedef enum {
AUDIO_PAN_LEFT = 0, AUDIO_PAN_CENTER, AUDIO_PAN_RIGHT, AUDIO_PAN_COUNT
} jlAudioPanE;
void jlAudioSetPan(uint8_t slot, jlAudioPanE pan);
----
Places a sound effect slot in the stereo field. Slots start centred, and the
setting sticks to the *slot*, surviving retriggers -- so set it once for a
sound that should come from one side.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot 0..4.
|`pan` |`jlAudioPanE` |Placement. Out-of-range values are ignored.
|===
*Returns:* nothing.
What each port can actually do:
[cols="1,4", options="header"]
|===
|Platform |Behaviour
|IIgs |All three. The DOC pans per oscillator, so a centred sound plays on two
oscillators, one per side. A stock IIgs sums to mono, so this shows up only on
a stereo card or an emulator.
|Amiga |Left and right only, by choosing the Paula channel side. Paula cannot
place one voice in both ears, so `AUDIO_PAN_CENTER` leaves PTPlayer's own
channel choice alone.
|X68000 |Ignored. Everything mixes into one ADPCM stream, so panning would move
all audio at once.
|DOS, ST |Ignored. Output is mono.
|===
=== jlAudioStopSfx
[source,c]
----
void jlAudioStopSfx(uint8_t slot);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`slot` |`uint8_t` |Slot to silence. A no-op if already idle.
|===
*Returns:* nothing.
=== jlAudioTone
[source,c]
----
void jlAudioTone(uint16_t freqHz);
----
Direct hardware tone generator: plays a square wave continuously until the next
call. Designed for AGI-era music where the engine programs a divisor and lets
the chip play, so CPU cost during playback is zero. Single voice; uses voice 0
on platforms with several oscillators. On DOS this programs PIT counter 2 and
the speaker gate.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`freqHz` |`uint16_t` |Frequency in Hz. 0 silences the generator.
|===
*Returns:* nothing. A no-op on platforms with no reachable tone generator --
fall back to `jlAudioPlaySfxStream` there if you need synthesised sound.
=== jlAudioVoice
[source,c]
----
void jlAudioVoice(uint8_t voice, uint16_t freqHz, uint8_t atten);
----
Three-voice PSG-style polyphonic tone generator.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`voice` |`uint8_t` |Voice 0, 1 or 2.
|`freqHz` |`uint16_t` |Frequency in Hz. 0 silences this voice.
|`atten` |`uint8_t` |AGI / SN76489-style 4-bit attenuation: 0 loudest,
15 silent, about 2 dB per step.
|===
*Returns:* nothing.
Mapping to hardware: DOS uses AdLib OPL2 melodic channels 0..2; the ST uses
YM2149 channels A, B and C; the Amiga uses Paula channels 0..2 looping a
two-byte square sample; the IIgs uses Ensoniq DOC oscillators playing a
resident square.
Voice state is sticky -- a voice keeps its last frequency until you change it
-- and CPU cost during sustained playback is zero, because the hardware
oscillates on its own.
[IMPORTANT]
====
The IIgs is the exception to stickiness: each call is an approximately 80 ms
retriggered one-shot, so a note held longer must be re-asserted about once a
frame. It also requires `jlAudioInit`, because the DOC path rides the tracker
engine. On DOS, ST and Amiga this is direct hardware programming and works
without `jlAudioInit`.
====
=== jlAudioNoise
[source,c]
----
void jlAudioNoise(uint8_t pitch, uint8_t atten);
----
Noise and percussion generator -- the fourth logical voice of the tracker
layer.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`pitch` |`uint8_t` |0..31 in YM2149 noise-period style: 0 is the brightest
hiss (hats), 31 the lowest rumble (toms).
|`atten` |`uint8_t` |As `jlAudioVoice`. 15 or above silences.
|===
*Returns:* nothing.
Mapping: the ST uses the YM2149 noise generator mixed onto channel C; DOS uses
the OPL2 rhythm-mode snare, leaving the three tone channels untouched; the
Amiga uses Paula channel 3 with an LFSR noise sample; the IIgs uses a resident
noise sample on slot 3 with the same retriggered one-shot model as its tone
voices.
[WARNING]
====
On the ST, noise shares channel C's volume register with tone voice 2. Author
noise hits and voice-2 notes so they do not sound simultaneously; `songbake`
warns when they overlap.
====
=== jlMusicPlay
[source,c]
----
bool jlMusicPlay(const uint8_t *data, uint32_t length, bool loop);
----
Plays a JYM1 event stream baked by `songbake` through the tone and noise layer.
One authored song plays on all five ports at register-write cost. Because there
is no CPU mixing, this is fully viable on the ST and cheap on DOS, which
`.MOD` playback is not.
Playback is pumped by `jlAudioFrameTick` and paced by `jlMillisElapsed`, so
tempo holds across 50, 60 and 70 Hz ports.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`data` |`const uint8_t *` |JYM1 stream. *Read in place* -- it must stay valid
until `jlMusicStop` or song end.
|`length` |`uint32_t` |Stream size in bytes.
|`loop` |`bool` |`true` restarts at the authored loop point, or the top if the
song has none. `false` silences the voices at song end.
|===
*Returns:* `true` on success; `false` on a malformed stream, in which case
nothing plays.
[NOTE]
====
Call `jlAudioInit` first for the song to be audible on the IIgs -- see
`jlAudioVoice`.
====
=== jlMusicStop
[source,c]
----
void jlMusicStop(void);
----
Stops tracker playback and silences tones and noise. The playhead is discarded;
use `jlMusicPause` to keep it. Safe when nothing is playing.
*Parameters:* none. *Returns:* nothing.
=== jlMusicIsPlaying
[source,c]
----
bool jlMusicIsPlaying(void);
----
*Parameters:* none.
*Returns:* `true` while a stream is loaded and active. Paused counts as active;
stopped or finished does not.
=== jlMusicPause / jlMusicResume
[source,c]
----
void jlMusicPause(void);
void jlMusicResume(void);
----
Pauses in place -- voices go silent, the playhead keeps its position -- and
picks up where it left off. The current row restarts, and wall-clock time spent
paused is not fast-forwarded through. Both are safe no-ops when there is no
active song.
*Parameters:* none. *Returns:* nothing.
=== jlMusicSetAtten
[source,c]
----
void jlMusicSetAtten(uint8_t atten);
----
Global music attenuation, added to every authored voice and noise attenuation.
Applies immediately to sounding voices and to everything played afterwards.
Step it over successive frames for fades.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`atten` |`uint8_t` |0 plays as authored, 15 is silent.
|===
*Returns:* nothing.
[NOTE]
====
`jlMusicPlay` does *not* reset this, so a fade-out carries into the next song
unless you clear it.
====
=== jlAudioTickRegister
[source,c]
----
typedef void (*jlAudioTickFnT)(void);
bool jlAudioTickRegister(jlAudioTickFnT fn, uint16_t hz);
----
Registers a periodic callback for sound schedulers needing better than
frame-rate resolution. PSG-era music drivers use this to write register changes
at exact note boundaries: frame-rate scheduling batches several notes into one
frame, so the chip only hears the last write and short notes vanish.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`fn` |`jlAudioTickFnT` |Callback, fired from interrupt context. Pass `NULL` to
uninstall.
|`hz` |`uint16_t` |Firings per second.
|===
*Returns:* `true` if installed. Ports without a suitable timer source return
`false`.
[WARNING]
====
`fn` runs in interrupt context. Keep it to memory reads and direct hardware
register writes -- no allocation, no `printf`, nothing blocking.
====
On DOS this reprograms PIT counter 0 and hooks INT 8, keeping the BIOS tick
counter advancing at the canonical 18.2 Hz through an accumulator so `time`
stays correct.
=== jlAudioCriticalEnter / jlAudioCriticalExit
[source,c]
----
void jlAudioCriticalEnter(void);
void jlAudioCriticalExit(void);
----
Critical section against the tick interrupt. If you mutate state that the
registered tick callback reads, wrap the mutation in these so the interrupt
cannot observe a half-written value. On DOS this is `cli` and `sti`; on ports
with no audio interrupt both are no-ops.
*Parameters:* none. *Returns:* nothing.

268
docs/ApiCore.adoc Normal file
View file

@ -0,0 +1,268 @@
== Core and Lifecycle
Declared in `<joey/core.h>`, included by `<joey/joey.h>`.
=== jlConfigT
[source,c]
----
typedef struct {
uint32_t codegenBytes;
uint32_t audioBytes;
} jlConfigT;
----
[cols="1,1,4", options="header"]
|===
|Field |Type |Description
|`codegenBytes`
|`uint32_t`
|Size of the runtime compiled-sprite cache. `0` selects a per-platform
default: 8 KB on IIgs, 32 KB on Amiga, ST and DOS. On the IIgs the cache is
one Memory Manager block that may not span a 64 KB bank boundary, so values
above 65536 are clamped to 65536, and even a clamped request can fail if no
free bank exists. Size it against `jlSpriteCompiledSize()`.
|`audioBytes`
|`uint32_t`
|Reserved. Not yet consulted by any audio engine.
|===
=== jlInit
[source,c]
----
bool jlInit(const jlConfigT *config);
----
Initialises the library: takes over the display, allocates the stage and the
codegen arena, and brings up input. Must be called before any other JoeyLib
function.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`config` |`const jlConfigT *` |Configuration. Must not be `NULL`.
|===
*Returns:* `true` on success. On failure returns `false` and `jlLastError()`
describes the reason.
=== jlShutdown
[source,c]
----
void jlShutdown(void);
----
Shuts the library down, restoring the display mode, releasing all library
resources and un-hooking any interrupt vectors the port installed. Call it on
every exit path; skipping it can leave the machine unusable.
*Parameters:* none. *Returns:* nothing.
=== jlLastError
[source,c]
----
const char *jlLastError(void);
----
*Parameters:* none.
*Returns:* the most recent error message, or `NULL` if there has been none. The
string is library-owned; do not free it.
=== jlPlatformName
[source,c]
----
const char *jlPlatformName(void);
----
*Parameters:* none.
*Returns:* a human-readable platform identifier, for example `"Apple IIgs"` or
`"MS-DOS"`. Library-owned.
=== jlVersionString
[source,c]
----
const char *jlVersionString(void);
----
*Parameters:* none.
*Returns:* the library version, for example `"1.0.0"`. Library-owned.
=== jlAlloc
[source,c]
----
void *jlAlloc(uint32_t bytes);
----
Allocates a large or persistent buffer from the platform's native allocator
rather than the small C heap. *Use this instead of `malloc` for anything
substantial.* On the IIgs the C heap lives in bank 0 and is tiny, with a single
`malloc` capped around 32 KB, so multi-kilobyte caches and frame buffers must
come from the Memory Manager instead.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`bytes` |`uint32_t` |Number of bytes required.
|===
*Returns:* a pointer to the block, or `NULL` on failure. The memory is *not*
zero-filled.
=== jlFree
[source,c]
----
void jlFree(void *p);
----
Releases a buffer from `jlAlloc`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`p` |`void *` |Block to release. `NULL` is accepted and ignored.
|===
*Returns:* nothing.
=== jlMemCopy
[source,c]
----
void jlMemCopy(void *dst, const void *src, uint32_t bytes);
----
Copies a block. Same contract as `memcpy`, including that the regions must not
overlap, but the IIgs implements it as `MVN` block moves at 7 cycles per byte
instead of a libc byte loop, split correctly at bank boundaries.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`void *` |Destination.
|`src` |`const void *` |Source. Must not overlap `dst`.
|`bytes` |`uint32_t` |Byte count.
|===
*Returns:* nothing.
=== jlWaitVBL
[source,c]
----
void jlWaitVBL(void);
----
Blocks until the next display vertical blank, pacing a game loop to the
display's native refresh rate. Cheap on every port, because the underlying
mechanism is always a hardware-level wait rather than a software timer.
*Parameters:* none. *Returns:* nothing.
=== jlFrameCount
[source,c]
----
uint16_t jlFrameCount(void);
----
*Parameters:* none.
*Returns:* a monotonic 16-bit frame counter.
[WARNING]
====
You must poll faster than twice `jlFrameHz()` or edges are missed, and the
counter wraps roughly every 18 minutes at 60 Hz. For elapsed-time work prefer
`jlMillisElapsed`.
====
=== jlFrameHz
[source,c]
----
uint16_t jlFrameHz(void);
----
*Parameters:* none.
*Returns:* the nominal display frame rate in Hz -- 50 on Amiga PAL, 60 on IIgs,
ST NTSC and X68000, 70 on VGA mode 13h. The real cadence may drift slightly;
this is the figure to divide by when converting frames to seconds.
=== jlMillisElapsed
[source,c]
----
uint32_t jlMillisElapsed(void);
----
Monotonic millisecond counter since `jlInit`. DOS and ST read a real hardware
timer; IIgs and Amiga derive it from the frame counter, so the value steps at
frame granularity there. Two calls separated by a long render report the true
elapsed time, not the number of vertical blanks that fired.
*Parameters:* none.
*Returns:* milliseconds since `jlInit`.
[NOTE]
====
On the frame-derived ports you must poll at least once every 65535 frames --
about 18 minutes at 60 Hz -- or wrapped frames are lost.
====
=== jlRandom
[source,c]
----
uint32_t jlRandom(void);
----
Deterministic pseudo-random generator, xorshift32. Produces *bit-identical*
output on every JoeyLib port for a given seed, which `rand()` does not.
Period 2^32^-1. Suitable for games, not for cryptography.
*Parameters:* none. *Returns:* the next 32-bit value.
=== jlRandomRange
[source,c]
----
uint16_t jlRandomRange(uint16_t bound);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`bound` |`uint16_t` |Exclusive upper bound.
|===
*Returns:* a value in `[0, bound)`. A `bound` of 0 returns 0.
=== jlRandomSeed
[source,c]
----
void jlRandomSeed(uint32_t seed);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`seed` |`uint32_t` |Any 32-bit value. A seed of 0 is remapped to 1, because
xorshift cannot escape the all-zero state.
|===
*Returns:* nothing.

104
docs/ApiDebug.adoc Normal file
View file

@ -0,0 +1,104 @@
== Debug Logging
Declared in `<joey/debug.h>`.
A crash-tracing logger that writes to a file beside the executable. Debugging a
program on an emulated 1988 machine is awkward, and this is usually the fastest
way to find out what it did before it stopped doing it. The headless run
scripts retrieve the log automatically.
[NOTE]
====
The logger needs a multi-kilobyte ring buffer, which the tightest targets
cannot spare. Space Taxi compiles it out entirely on the IIgs and X68000 for
that reason. If you are debugging a memory-tight program, expect to do the
same and to observe hardware state through the emulator instead.
====
=== jlLog
[source,c]
----
void jlLog(const char *msg);
----
Appends a message to the log.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`msg` |`const char *` |NUL-terminated message.
|===
*Returns:* nothing.
=== jlLogF
[source,c]
----
void jlLogF(const char *fmt, ...);
----
Appends a formatted message, `printf` style.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`fmt` |`const char *` |Format string.
|`...` | |Arguments matching the format string.
|===
*Returns:* nothing.
[TIP]
====
Keep format strings simple. The C libraries on these targets vary in what they
support, and on the IIgs the `printf` family is expensive in both code size and
time.
====
=== jlLogFlush
[source,c]
----
void jlLogFlush(void);
----
Flushes buffered log output to disk. Call it before anything that might crash
or exit, or the last and most interesting lines may never be written.
*Parameters:* none. *Returns:* nothing.
=== jlLogReset
[source,c]
----
void jlLogReset(void);
----
Clears the log, discarding previous contents. Call once at startup so each run
produces a fresh log rather than appending to the last one.
*Parameters:* none. *Returns:* nothing.
[source,c]
----
int main(void) {
jlConfigT config;
config.codegenBytes = 32UL * 1024;
config.audioBytes = 0;
if (!jlInit(&config)) {
return 1;
}
jlLogReset();
jlLogF("build %s %s", __DATE__, __TIME__);
// ... game ...
jlLogFlush();
jlShutdown();
return 0;
}
----

226
docs/ApiDraw.adoc Normal file
View file

@ -0,0 +1,226 @@
== Drawing Primitives
Declared in `<joey/draw.h>`.
All primitives clip to the surface rectangle. Out-of-bounds draws are silent
no-ops, never errors, and there are no unclipped variants in the public API --
you cannot corrupt memory by drawing off the edge. Every primitive marks the
rows it touched, so `jlStagePresent` knows what to copy.
Primitives work in colour indices. Per-scanline palette resolution happens only
at display time, so drawing does not care which palette a line uses.
=== jlSurfaceClear
[source,c]
----
void jlSurfaceClear(jlSurfaceT *s, uint8_t colorIndex);
----
Fills the whole surface with one colour index. Clearing to colour 0 is a
legitimate clear-to-black, not a skipped no-op.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlDrawPixel
[source,c]
----
void jlDrawPixel(jlSurfaceT *s, int16_t x, int16_t y, uint8_t colorIndex);
----
Plots one pixel. Off-surface coordinates are no-ops.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`x`, `y` |`int16_t` |Position. Valid range is 0..319 and 0..199.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlSamplePixel
[source,c]
----
uint8_t jlSamplePixel(const jlSurfaceT *s, int16_t x, int16_t y);
----
Reads one pixel.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`const jlSurfaceT *` |Source surface.
|`x`, `y` |`int16_t` |Position.
|===
*Returns:* the colour index at that position, or 0 for an off-surface read.
=== jlDrawLine
[source,c]
----
void jlDrawLine(jlSurfaceT *s, int16_t x0, int16_t y0,
int16_t x1, int16_t y1, uint8_t colorIndex);
----
Draws a Bresenham line. Both endpoints are inclusive. Off-surface pixels are
skipped individually, so a line that crosses the edge draws its visible part; a
line entirely off-surface draws nothing. Horizontal and vertical runs take fast
paths.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`x0`, `y0` |`int16_t` |Start point, inclusive.
|`x1`, `y1` |`int16_t` |End point, inclusive.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlDrawRect
[source,c]
----
void jlDrawRect(jlSurfaceT *s, int16_t x, int16_t y,
uint16_t w, uint16_t h, uint8_t colorIndex);
----
Draws a one-pixel-wide rectangle outline. A 1xN or Nx1 rectangle degenerates to
a line, and 1x1 to a single pixel. Zero dimensions are no-ops.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`x`, `y` |`int16_t` |Top-left corner.
|`w`, `h` |`uint16_t` |Size in pixels. Zero is a no-op.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlFillRect
[source,c]
----
void jlFillRect(jlSurfaceT *s, int16_t x, int16_t y,
uint16_t w, uint16_t h, uint8_t colorIndex);
----
Fills a solid rectangle. Zero dimensions are no-ops.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`x`, `y` |`int16_t` |Top-left corner.
|`w`, `h` |`uint16_t` |Size in pixels.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlDrawCircle
[source,c]
----
void jlDrawCircle(jlSurfaceT *s, int16_t cx, int16_t cy,
uint16_t r, uint8_t colorIndex);
----
Draws a circle outline using the Bresenham midpoint algorithm. A radius of 0
plots a single pixel.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`cx`, `cy` |`int16_t` |Centre.
|`r` |`uint16_t` |Radius in pixels.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlFillCircle
[source,c]
----
void jlFillCircle(jlSurfaceT *s, int16_t cx, int16_t cy,
uint16_t r, uint8_t colorIndex);
----
Fills a disk, emitting one span per scanline using midpoint symmetry. A radius
of 0 plots a single pixel.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`cx`, `cy` |`int16_t` |Centre.
|`r` |`uint16_t` |Radius in pixels.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlFloodFill
[source,c]
----
void jlFloodFill(jlSurfaceT *s, int16_t x, int16_t y, uint8_t newColor);
----
Flood-fills a 4-connected region, replacing every pixel of the original colour
reachable by north, south, east and west steps. A no-op if the start point is
off-surface or already `newColor`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`x`, `y` |`int16_t` |Seed point.
|`newColor` |`uint8_t` |Replacement colour index.
|===
*Returns:* nothing.
=== jlFloodFillBounded
[source,c]
----
void jlFloodFillBounded(jlSurfaceT *s, int16_t x, int16_t y,
uint8_t newColor, uint8_t boundaryColor);
----
Flood-fills a 4-connected region, stopping at pixels of `boundaryColor` rather
than at pixels differing from the seed colour. Every reachable pixel that is
not the boundary colour becomes `newColor`.
This is the primitive for vector-art playback, such as Sierra-style picture
drawing: outline a closed region with `jlDrawLine` in the boundary colour, then
fill it with this.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`x`, `y` |`int16_t` |Seed point.
|`newColor` |`uint8_t` |Replacement colour index.
|`boundaryColor` |`uint8_t` |Colour that stops the fill.
|===
*Returns:* nothing.

151
docs/ApiFile.adoc Normal file
View file

@ -0,0 +1,151 @@
== Files and Saves
Declared in `<joey/file.h>`.
Two directories sit beside the executable, staged there by each platform's disk
packager:
* `DATA/` -- read-only game content: levels, sprites, audio.
* `SAVES/` -- writable saves and preferences.
Both are addressed by a *bare name relative to their directory*. The prefix is
added for you, so never hard-code `"DATA/"` or `"SAVES/"` yourself.
[IMPORTANT]
====
Keep names portable across the retro filesystems: at most 8 characters plus a
short extension, letters and digits only, so they satisfy both ProDOS and DOS
8.3. Do not depend on case being preserved.
====
A failed open or write is non-fatal and simply reports failure, so save code
can degrade gracefully on a platform or volume where `SAVES/` is unavailable.
=== jlDataOpen
[source,c]
----
FILE *jlDataOpen(const char *name, const char *mode);
----
Opens a read-only data file. The `DATA/` prefix is prepended automatically.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`name` |`const char *` |Name relative to `DATA/`, for example
`"levels/title.dat"`.
|`mode` |`const char *` |Standard `fopen` mode string.
|===
*Returns:* a `FILE *`, or `NULL` if the path would overflow the internal buffer
or the file cannot be opened.
=== jlSaveOpen
[source,c]
----
FILE *jlSaveOpen(const char *name, const char *mode);
----
Opens a save file for streaming. For write and append modes the `SAVES/`
directory is created on demand. Use this for large or incremental saves; for a
whole-file blob prefer `jlSaveWrite` and `jlSaveRead`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`name` |`const char *` |Name relative to `SAVES/`, for example `"hero.sav"`.
|`mode` |`const char *` |Standard `fopen` mode string.
|===
*Returns:* a `FILE *`, or `NULL` on failure.
=== jlSaveWrite
[source,c]
----
bool jlSaveWrite(const char *name, const void *buf, uint32_t len);
----
Writes a whole save file, replacing any existing contents.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`name` |`const char *` |Name relative to `SAVES/`.
|`buf` |`const void *` |Data to write.
|`len` |`uint32_t` |Byte count.
|===
*Returns:* `true` only if all `len` bytes were written.
=== jlSaveRead
[source,c]
----
uint32_t jlSaveRead(const char *name, void *buf, uint32_t max);
----
Reads a whole save file.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`name` |`const char *` |Name relative to `SAVES/`.
|`buf` |`void *` |Destination buffer.
|`max` |`uint32_t` |Buffer capacity.
|===
*Returns:* the number of bytes read; 0 if the file is missing or empty.
=== jlSaveExists
[source,c]
----
bool jlSaveExists(const char *name);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`name` |`const char *` |Name relative to `SAVES/`.
|===
*Returns:* `true` if the file exists and can be opened for reading.
=== jlSaveDelete
[source,c]
----
bool jlSaveDelete(const char *name);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`name` |`const char *` |Name relative to `SAVES/`.
|===
*Returns:* `true` if the file was removed.
=== jlDiskFree
[source,c]
----
uint32_t jlDiskFree(void);
----
*Parameters:* none.
*Returns:* free space in bytes on the volume holding `SAVES/`. Returns 0 if it
cannot be determined -- some ports have no query for it -- and saturates at
`UINT32_MAX` on volumes larger than 4 GB.
[source,c]
----
// Check before writing a large save on a floppy-based target.
if (jlDiskFree() < neededBytes) {
showMessage("Disk full");
}
----

257
docs/ApiInput.adoc Normal file
View file

@ -0,0 +1,257 @@
== Input
Declared in `<joey/input.h>`.
Call `jlInputPoll()` exactly once per frame to refresh keyboard, mouse and
joystick state. Everything else in this section reads the state that poll
captured.
Two parallel keyboard interfaces share that one poll. The *predicates* answer
"is the fire key held?", which is the right shape for games. The *character
queue* answers "which character did the user type?", with shift, caps lock and
the machine's keyboard layout already applied, which is the right shape for
text entry. They coexist freely.
Edge predicates are one-shot: they return `true` only in the frame the
transition happened.
=== Enumerations
[source,c]
----
typedef enum {
KEY_NONE = 0,
KEY_A .. KEY_Z,
KEY_0 .. KEY_9,
KEY_SPACE, KEY_ESCAPE, KEY_RETURN, KEY_TAB, KEY_BACKSPACE,
KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT,
KEY_LSHIFT, KEY_RSHIFT, KEY_LCTRL, KEY_LALT,
KEY_F1 .. KEY_F10,
KEY_COUNT
} jlKeyE;
typedef enum {
MOUSE_BUTTON_NONE = 0, MOUSE_BUTTON_LEFT,
MOUSE_BUTTON_RIGHT, MOUSE_BUTTON_MIDDLE, MOUSE_BUTTON_COUNT
} jlMouseButtonE;
typedef enum { JOYSTICK_0 = 0, JOYSTICK_1, JOYSTICK_COUNT } jlJoystickE;
typedef enum { JOY_BUTTON_0 = 0, JOY_BUTTON_1, JOY_BUTTON_COUNT } jlJoyButtonE;
----
Constants: `JOYSTICK_AXIS_MAX` (127) and `JOYSTICK_AXIS_MIN` (-127);
`JL_CHAR_QUEUE_SIZE` (32).
Control codes delivered through the character queue: `JL_CHAR_BACKSPACE`
(0x08), `JL_CHAR_TAB` (0x09), `JL_CHAR_RETURN` (0x0D), `JL_CHAR_ESCAPE` (0x1B)
and `JL_CHAR_DELETE` (0x7F). That last one is *forward* delete, the key marked
Del -- a text editor needs both it and backspace.
=== jlInputPoll
[source,c]
----
void jlInputPoll(void);
----
Refreshes all input state and refills the typed-character queue. Call once per
frame, typically just before drawing.
*Parameters:* none. *Returns:* nothing.
[WARNING]
====
Exactly once per frame. Polling twice makes edge predicates miss transitions;
not polling means nothing ever changes.
====
=== jlInputGetChar
[source,c]
----
int jlInputGetChar(void);
----
Pops the next typed character. Shift, caps lock and the keyboard layout have
already been applied by the backend, so punctuation and shifted symbols arrive
correctly. The layout is the machine's own on IIgs, Amiga, ST and X68000; the
DOS port translates with a fixed US layout.
Arrow keys are not characters and stay on the predicate API.
*Parameters:* none.
*Returns:* a 7-bit code -- printable ASCII 0x20..0x7E, or one of the
`JL_CHAR_*` control codes -- or `-1` if the queue is empty.
[NOTE]
====
The queue holds `JL_CHAR_QUEUE_SIZE - 1` characters, so 31. When full, further
characters are dropped until you pop some. Auto-repeat may deliver repeats on
ports whose keyboard services repeat; repeat policy is yours to manage. 7-bit
ASCII only -- no Unicode, no input methods.
The return type is plain `int`, not a stdint type, deliberately: this is the
`getchar()`-style "byte or -1" contract.
====
=== jlWaitForAnyKey
[source,c]
----
void jlWaitForAnyKey(void);
----
Blocks until the user presses any key, polling internally so per-port input
machinery -- including interrupt-driven audio samplers -- keeps running during
the wait.
*Parameters:* none. *Returns:* nothing. The character queue is emptied on
return, so the dismissing keystroke is consumed rather than delivered as text.
=== jlKeyDown / jlKeyPressed / jlKeyReleased
[source,c]
----
bool jlKeyDown(jlKeyE key);
bool jlKeyPressed(jlKeyE key);
bool jlKeyReleased(jlKeyE key);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`key` |`jlKeyE` |Key to test.
|===
*Returns:* `jlKeyDown` is `true` while the key is held. `jlKeyPressed` is
`true` only in the frame the key went down; `jlKeyReleased` only in the frame
it came up.
=== jlMousePresent
[source,c]
----
bool jlMousePresent(void);
----
*Parameters:* none.
*Returns:* `true` if the platform confirmed a mouse at init. On DOS that means
the INT 33h driver reset successfully; on Amiga, ST, IIgs and X68000 the OS
mouse is always wired.
=== jlMouseX / jlMouseY
[source,c]
----
int16_t jlMouseX(void);
int16_t jlMouseY(void);
----
*Parameters:* none.
*Returns:* pointer position in surface coordinates, 0..319 and 0..199.
=== jlMouseDown / jlMousePressed / jlMouseReleased
[source,c]
----
bool jlMouseDown(jlMouseButtonE button);
bool jlMousePressed(jlMouseButtonE button);
bool jlMouseReleased(jlMouseButtonE button);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`button` |`jlMouseButtonE` |Which button.
|===
*Returns:* held state, rising edge, and falling edge respectively.
=== jlJoystickConnected
[source,c]
----
bool jlJoystickConnected(jlJoystickE js);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`js` |`jlJoystickE` |Which stick, `JOYSTICK_0` or `JOYSTICK_1`.
|===
*Returns:* `true` if the platform reports a stick on that port.
=== jlJoystickX / jlJoystickY
[source,c]
----
int8_t jlJoystickX(jlJoystickE js);
int8_t jlJoystickY(jlJoystickE js);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`js` |`jlJoystickE` |Which stick.
|===
*Returns:* axis value, -127 to +127. Digital sticks snap to the extremes;
analog sticks return the raw centred value. Test against a threshold rather
than for exact values, so the same code works with both.
=== jlJoyDown / jlJoyPressed / jlJoyReleased
[source,c]
----
bool jlJoyDown(jlJoystickE js, jlJoyButtonE button);
bool jlJoyPressed(jlJoystickE js, jlJoyButtonE button);
bool jlJoyReleased(jlJoystickE js, jlJoyButtonE button);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`js` |`jlJoystickE` |Which stick.
|`button` |`jlJoyButtonE` |Which button.
|===
*Returns:* held state, rising edge, and falling edge respectively.
=== jlJoystickReset
[source,c]
----
void jlJoystickReset(jlJoystickE js, uint8_t deadZone);
----
Re-enables joystick polling and recalibrates the resting position.
This matters on the IIgs. That port *auto-disables* joystick polling after a
short window with no stick detected, because polling costs about 3 ms a frame
there -- and it does not re-probe on its own. If a user plugs a stick in while
your program runs, you must call this to resume polling.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`js` |`jlJoystickE` |Which stick.
|`deadZone` |`uint8_t` |Raw readings within this many units of centre clamp
to 0. Pass 0 to disable the dead zone.
|===
*Returns:* nothing.
[IMPORTANT]
====
The next poll after this call captures the stick's *current* raw position as
the new centre, so the user must be holding the stick centred when you call it.
On Amiga, ST and DOS the sticks are genuinely digital and already report -1, 0
or +1, so recalibration is a no-op and `deadZone` is ignored. The call still
clears any auto-disconnect state, so it is safe and correct to make it
unconditionally.
====

120
docs/ApiPalette.adoc Normal file
View file

@ -0,0 +1,120 @@
== Palette and Scanline Control
Declared in `<joey/palette.h>`.
Colours are 12-bit `$0RGB`: bits 11..8 red, 7..4 green, 3..0 blue. Each surface
holds 16 palettes of 16 colours, plus a 200-entry table (the SCB table) naming
which palette each scanline uses. That is how a JoeyLib program shows more than
16 colours at once.
[IMPORTANT]
====
Colour 0 of every palette is black, always. `jlPaletteSet` silently masks entry
0 to `$000` whatever you pass. This is deliberate: the Amiga and ST hardware
border follows colour 0 of the active palette, and colour 0 is the transparent
colour for sprite drawing. You have 15 usable colours per palette.
====
=== jlPaletteSet
[source,c]
----
void jlPaletteSet(jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16);
----
Loads all 16 colours of one palette.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`paletteIndex` |`uint8_t` |Which palette, 0..15.
|`colors16` |`const uint16_t *` |Exactly 16 `$0RGB` values. Entry 0 is forced
to `$000` regardless of what you supply.
|===
*Returns:* nothing.
=== jlPaletteGet
[source,c]
----
void jlPaletteGet(const jlSurfaceT *s, uint8_t paletteIndex, uint16_t *out16);
----
Reads all 16 colours of one palette.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`const jlSurfaceT *` |Source surface.
|`paletteIndex` |`uint8_t` |Which palette, 0..15.
|`out16` |`uint16_t *` |Receives 16 `$0RGB` values. Caller-owned, and must have
room for all 16.
|===
*Returns:* nothing.
=== jlScbSet
[source,c]
----
void jlScbSet(jlSurfaceT *s, uint16_t line, uint8_t paletteIndex);
----
Assigns a palette to a single scanline.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`line` |`uint16_t` |Scanline, 0..199.
|`paletteIndex` |`uint8_t` |Palette to use on that line, 0..15.
|===
*Returns:* nothing.
=== jlScbSetRange
[source,c]
----
void jlScbSetRange(jlSurfaceT *s, uint16_t firstLine, uint16_t lastLine,
uint8_t paletteIndex);
----
Assigns a palette to an inclusive range of scanlines. The usual way to set up a
screen, whether that is one palette everywhere or several horizontal bands.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`firstLine` |`uint16_t` |First scanline, inclusive.
|`lastLine` |`uint16_t` |Last scanline, inclusive.
|`paletteIndex` |`uint8_t` |Palette to use, 0..15.
|===
*Returns:* nothing.
[source,c]
----
// Sky on the top third, ground below, using two palettes.
jlScbSetRange(stage, 0, 65, 0);
jlScbSetRange(stage, 66, SURFACE_HEIGHT - 1, 1);
----
=== jlScbGet
[source,c]
----
uint8_t jlScbGet(const jlSurfaceT *s, uint16_t line);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`const jlSurfaceT *` |Source surface.
|`line` |`uint16_t` |Scanline, 0..199.
|===
*Returns:* the palette index assigned to that scanline.

212
docs/ApiSerial.adoc Normal file
View file

@ -0,0 +1,212 @@
== Serial (Optional Add-On)
Declared in `<joey/serial.h>`.
[IMPORTANT]
====
This header is *not* pulled in by `<joey/joey.h>`. Include it explicitly:
[source,c]
----
#include <joey/joey.h>
#include <joey/serial.h>
----
A program that never references `jlSerial*` pays nothing -- the linker drops
the module entirely.
====
One byte-stream API spans every port while the HAL hides very different
hardware: the IIgs drives a Zilog 8530 SCC directly or a 6551 card in a slot;
DOS uses an interrupt-driven 8250/16550 UART; the Amiga uses `serial.device`;
the ST goes through the BIOS Aux channel.
The model is *non-blocking and buffered*. Received bytes accumulate in a
background ring -- a hardware interrupt on DOS, the OS on Amiga and ST, or
`jlSerialPoll` on the IIgs -- and reads never block. Call `jlSerialPoll` once
per frame: it is a no-op where the interrupt or OS already fills the ring, and
the hardware drain where it does not, so calling it every frame is always
correct.
A failed `jlSerialOpen` is non-fatal: it returns `false` and every other call
becomes a safe no-op, exactly like a failed audio init.
=== Types
[source,c]
----
typedef enum {
JL_SERIAL_DEFAULT = 0, // the platform's natural RS-232 port
JL_SERIAL_MODEM, // IIgs modem port (SCC channel A)
JL_SERIAL_PRINTER, // IIgs printer port (SCC channel B)
JL_SERIAL_SLOT // card/port selected by config->unit
} jlSerialDeviceE;
typedef enum {
JL_SERIAL_PARITY_NONE = 0, JL_SERIAL_PARITY_ODD, JL_SERIAL_PARITY_EVEN
} jlSerialParityE;
typedef enum {
JL_SERIAL_FLOW_NONE = 0, // no handshaking
JL_SERIAL_FLOW_RTSCTS, // hardware RTS/CTS
JL_SERIAL_FLOW_XONXOFF // software XON/XOFF
} jlSerialFlowE;
typedef struct {
uint32_t baud; // 300 .. 115200, clamped per port
uint8_t dataBits; // 5 .. 8
uint8_t stopBits; // 1 or 2
jlSerialParityE parity;
jlSerialFlowE flow;
uint8_t unit; // JL_SERIAL_SLOT only
} jlSerialConfigT;
----
The IIgs is the machine with three device choices; elsewhere `JL_SERIAL_MODEM`
and `JL_SERIAL_PRINTER` both mean the default port. `JL_SERIAL_DEFAULT` selects
the IIgs modem port, DOS COM1, the ST Modem port or Amiga unit 0.
`unit` disambiguates `JL_SERIAL_SLOT` and is ignored otherwise: an IIgs Super
Serial Card slot 1..7, a DOS COM number 1..4, or an Amiga `serial.device` unit.
=== jlSerialOpen
[source,c]
----
bool jlSerialOpen(jlSerialDeviceE device, const jlSerialConfigT *config);
----
Opens and configures the port. Only one port may be open at a time; a second
open without an intervening close fails.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`device` |`jlSerialDeviceE` |Which interface.
|`config` |`const jlSerialConfigT *` |Line configuration.
|===
*Returns:* `true` on success. `false` if the port cannot be opened or the
platform has no serial support -- and then every other call no-ops.
=== jlSerialClose
[source,c]
----
void jlSerialClose(void);
----
Closes the port and releases its resources. Safe when not open.
*Parameters:* none. *Returns:* nothing.
=== jlSerialPoll
[source,c]
----
void jlSerialPoll(void);
----
Pumps the receive and transmit buffers. Call once per game frame.
*Parameters:* none. *Returns:* nothing.
=== jlSerialAvailable
[source,c]
----
uint16_t jlSerialAvailable(void);
----
*Parameters:* none.
*Returns:* the number of bytes waiting in the receive buffer, readable now
without blocking.
=== jlSerialRead
[source,c]
----
uint16_t jlSerialRead(uint8_t *buf, uint16_t max);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`buf` |`uint8_t *` |Destination buffer.
|`max` |`uint16_t` |Buffer capacity.
|===
*Returns:* the number of bytes copied, 0 to `max`. Never blocks.
=== jlSerialWrite
[source,c]
----
uint16_t jlSerialWrite(const uint8_t *buf, uint16_t len);
----
Sends bytes. Transmit is a bounded poll.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`buf` |`const uint8_t *` |Data to send.
|`len` |`uint16_t` |Byte count.
|===
*Returns:* the number of bytes accepted, which may be fewer than `len` if a
flow-controlled peer or a full transmit path stalls.
=== jlSerialWriteByte / jlSerialReadByte
[source,c]
----
bool jlSerialWriteByte(uint8_t b);
int16_t jlSerialReadByte(void);
----
Single-byte convenience calls.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`b` |`uint8_t` |Byte to send.
|===
*Returns:* `jlSerialWriteByte` returns `true` if the byte was accepted.
`jlSerialReadByte` returns the byte, or `-1` when none is ready.
=== jlSerialFlush
[source,c]
----
void jlSerialFlush(void);
----
Discards any buffered received bytes.
*Parameters:* none. *Returns:* nothing.
=== jlSerialSetRts
[source,c]
----
void jlSerialSetRts(bool on);
----
Asserts or drops RTS, for a caller doing its own receive-side flow control.
`JL_SERIAL_FLOW_RTSCTS` asks the *port* to handshake; this is the other half --
the application saying "my buffer is nearly full, stop sending", and later "go
ahead". That is what a client with a small ring and a peer that can be told to
pause actually needs.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`on` |`bool` |`true` asserts RTS, meaning ready to receive.
|===
*Returns:* nothing. A port with no controllable RTS line no-ops, and a closed
port no-ops; neither is an error, so this can be driven unconditionally.

406
docs/ApiSprite.adoc Normal file
View file

@ -0,0 +1,406 @@
== Sprites
Declared in `<joey/sprite.h>`.
A sprite is a rectangle of 8x8 tiles drawn at an arbitrary pixel position with
colour-0 transparency. Sprite pixel data is `widthTiles * heightTiles * 32`
bytes of 4bpp packed data, laid out as a flat blob of tiles in row-major order.
The distinguishing feature is that *sprite blitters are compiled at run time*.
The library emits native 65816, 68000 or x86 code for each sprite, including
pre-shifted variants so smooth horizontal motion needs no runtime bit shifting.
[IMPORTANT]
====
`jlSpriteDraw` does *not* compile on demand. An uncompiled sprite draws through
an interpreter roughly *forty times slower*, and nothing reports this. Always
call `jlSpriteCompile` or `jlSpritePrewarm` during loading.
====
=== jlSpriteT and jlSpriteBackupT
[source,c]
----
typedef struct jlSpriteT jlSpriteT; // opaque
typedef struct {
jlSpriteT *sprite;
int16_t x;
int16_t y;
uint16_t width; // pixels
uint16_t height; // pixels
uint8_t *bytes; // caller-owned, capacity >= sizeBytes
uint16_t sizeBytes;
} jlSpriteBackupT;
----
You supply `bytes` and its capacity; the library fills in the rest to describe
what it actually captured. On planar ports the captured region is the
*16-pixel-aligned window* containing the sprite, not the sprite rectangle, and
the `x`, `width`, `height` and `sizeBytes` fields report that true geometry. It
is the recorded window you must not overwrite before restoring.
=== JOEY_SPRITE_BACKUP_BYTES
[source,c]
----
#define JOEY_SPRITE_BACKUP_BYTES(_wT, _hT) /* ... */
----
Worst-case backup capacity for a `widthTiles` by `heightTiles` sprite at any x
position, on every port. Use it to size your backing array.
[source,c]
----
// 2-byte aligned by construction -- see the warning below.
static uint16_t gBacking[JOEY_SPRITE_BACKUP_BYTES(4, 4) / 2];
----
[WARNING]
====
Give the backing array *at least 2-byte alignment*. An odd buffer address
routes save and restore onto slow interpreted paths on the planar ports,
because the 68000 cannot do word accesses at odd addresses. Declaring it
`uint16_t`, as above, guarantees this.
====
=== jlSpriteBankLoad
[source,c]
----
uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels,
uint16_t maxCels, uint16_t *outPalette);
----
Loads cels from a baked `.spr` file. Each cel becomes a freshly allocated
`jlSpriteT`, released with `jlSpriteDestroy`. `.spr` banks are cross-target --
the same file works on every platform.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`path` |`const char *` |File path.
|`outCels` |`jlSpriteT **` |Receives the sprites. Caller-owned array.
|`maxCels` |`uint16_t` |Capacity of `outCels`; the file's cel count is capped
to this.
|`outPalette` |`uint16_t *` |Optional, may be `NULL`. Receives the embedded
16-entry `$0RGB` palette if present; otherwise left unchanged.
|===
*Returns:* the number of cels written. Returns 0 if the file is missing, the
magic or target byte is wrong, or no cel could be allocated.
=== jlSpriteBankLoadPrecompiled
[source,c]
----
uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels,
uint16_t maxCels, uint16_t *outPalette);
----
Loads cels from a `.spc` pre-compiled bank built for *this* platform. Identical
contract to `jlSpriteBankLoad`, except each cel arrives already compiled: its
native routines are copied straight into the codegen arena and it draws at full
speed with no runtime compilation.
A cel that does not fit the arena loads uncompiled and falls back to the
interpreter -- never an error. This makes it a drop-in, faster-starting
replacement for `jlSpriteBankLoad` plus a compile pass.
*Parameters:* as `jlSpriteBankLoad`.
*Returns:* the number of cels written, or 0 if the file is missing or its
magic, target or shift count does not match this build.
=== jlSpriteBankSavePrecompiled
[source,c]
----
bool jlSpriteBankSavePrecompiled(const char *path, jlSpriteT **cels,
uint16_t count, const uint16_t *palette);
----
Writes an already-compiled bank to a `.spc` cache file -- the runtime twin of
the offline `spritebake`. Call it after loading and compiling so a later launch
can skip the compile. This lets you ship the small cross-platform `.spr` and
cache the large per-platform compiled form on first run.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`path` |`const char *` |Destination path.
|`cels` |`jlSpriteT **` |The sprites to cache. Uncompiled ones are cached
as-is and will compile again next run.
|`count` |`uint16_t` |How many sprites.
|`palette` |`const uint16_t *` |Optional, may be `NULL`. Embedded if given.
|===
*Returns:* `true` on success; `false` if the destination is not writable, which
is harmless -- the program simply keeps compiling at startup.
=== jlSpriteCreate
[source,c]
----
jlSpriteT *jlSpriteCreate(const uint8_t *tileData, uint8_t widthTiles,
uint8_t heightTiles);
----
Wraps a tile-data blob in a sprite.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`tileData` |`const uint8_t *` |`widthTiles * heightTiles * 32` bytes. *Not
copied* -- it must outlive the sprite, and you keep ownership.
|`widthTiles` |`uint8_t` |Width in tiles. Must be non-zero.
|`heightTiles` |`uint8_t` |Height in tiles. Must be non-zero.
|===
*Returns:* the sprite, or `NULL` if either dimension is 0 or the codegen arena
cannot fit a placeholder entry.
=== jlSpriteDestroy
[source,c]
----
void jlSpriteDestroy(jlSpriteT *sp);
----
Releases a sprite and any codegen entries cached for it. The tile data it was
built from is *not* freed -- that buffer is yours.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`sp` |`jlSpriteT *` |Sprite to release.
|===
*Returns:* nothing.
=== jlSpriteCompile
[source,c]
----
bool jlSpriteCompile(jlSpriteT *sp);
----
Compiles the sprite's draw routines into the codegen arena. Idempotent --
compiling an already-compiled sprite is a no-op that returns `true`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`sp` |`jlSpriteT *` |Sprite to compile.
|===
*Returns:* `true` if the sprite is compiled. `false` if the arena is full (you
may call `jlSpriteCompact` and retry) or the sprite has no source tile data.
=== jlSpriteCompiledSize
[source,c]
----
uint32_t jlSpriteCompiledSize(const jlSpriteT *sp);
----
Diagnostic. The right way to size `jlConfigT.codegenBytes` for real artwork on
the current platform, rather than guessing.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`sp` |`const jlSpriteT *` |Sprite to measure.
|===
*Returns:* bytes occupied in the codegen arena, summed over all compiled shift
variants. Returns 0 if the sprite was never compiled.
=== jlSpritePrewarm
[source,c]
----
void jlSpritePrewarm(jlSpriteT *sp);
----
Hints that a sprite will be drawn soon. A wrapper around `jlSpriteCompile` that
ignores the return value, for callers that do not need to handle failure.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`sp` |`jlSpriteT *` |Sprite to compile.
|===
*Returns:* nothing.
=== jlSpriteDraw
[source,c]
----
void jlSpriteDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y);
----
Draws the sprite at a pixel position. Source pixels of colour 0 are skipped.
Off-surface portions are clipped.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Destination surface.
|`sp` |`jlSpriteT *` |Sprite to draw.
|`x`, `y` |`int16_t` |Top-left pixel position.
|===
*Returns:* nothing.
=== jlSpriteSaveUnder
[source,c]
----
void jlSpriteSaveUnder(const jlSurfaceT *s, jlSpriteT *sp,
int16_t x, int16_t y, jlSpriteBackupT *backup);
----
Captures the destination region that a `jlSpriteDraw` at the same position
would write, so it can be restored later.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`const jlSurfaceT *` |Source surface.
|`sp` |`jlSpriteT *` |Sprite whose footprint to capture.
|`x`, `y` |`int16_t` |Position the sprite will be drawn at.
|`backup` |`jlSpriteBackupT *` |Receives the capture. You must set `bytes` to
a buffer of at least `JOEY_SPRITE_BACKUP_BYTES(w, h)` capacity for fully
in-bounds draws. For clipped draws only the visible bytes are stored.
|===
*Returns:* nothing. The captured geometry is reported in the backup's `x`,
`width`, `height` and `sizeBytes` fields.
=== jlSpriteRestoreUnder
[source,c]
----
void jlSpriteRestoreUnder(jlSurfaceT *s, const jlSpriteBackupT *backup);
----
Repaints the destination from a previous capture.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Destination surface.
|`backup` |`const jlSpriteBackupT *` |A backup from `jlSpriteSaveUnder`, not
invalidated by other writes overlapping its recorded window.
|===
*Returns:* nothing.
=== jlSpriteSaveAndDraw
[source,c]
----
void jlSpriteSaveAndDraw(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
jlSpriteBackupT *backup);
----
Captures then draws, in one call. Identical in effect to `jlSpriteSaveUnder`
followed by `jlSpriteDraw`, but validation is shared, the destination pointer
is computed once, and one dirty-rectangle mark covers both. Saves roughly a
full dispatcher chain per sprite per frame.
*Parameters:* as `jlSpriteSaveUnder`. *Returns:* nothing.
=== The Trusted Variants
[source,c]
----
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);
----
Expert inner-loop entry points. Each has the same effect as its plain
counterpart but *skips the per-call geometry validation* -- roughly eleven
range and alignment checks plus the NULL and sprite-slot gates.
Use them only in a game that pre-compiles its sprites and already clamps
positions on screen. Anything the fast path does not recognise falls back to
the fully validated call, so a conservative caller degrades safely rather than
corrupting -- but the guarantees below are still yours to keep.
[WARNING]
====
Behaviour is *undefined* -- out-of-bounds writes, crashes -- unless all of the
following hold:
* `s`, `backup`, `backup->sprite` and `backup->bytes` are non-`NULL`;
* the sprite is fully on-surface at `(x, y)`, because *no clipping happens*;
* for the restore, the backup came from a save that took the compiled path:
16-pixel-aligned x and width on planar ports, even on chunky, height equal
to the sprite's pixel height, and a buffer at least 2-byte aligned;
* the recorded window is fully on the destination surface.
Use the plain calls anywhere those invariants are not statically guaranteed.
====
The canonical pattern is a trusted save-and-draw this frame, a trusted restore
next frame.
=== jlSpriteCreateFromSurface
[source,c]
----
jlSpriteT *jlSpriteCreateFromSurface(const jlSurfaceT *src, int16_t x, int16_t y,
uint8_t widthTiles, uint8_t heightTiles);
----
Snapshots a tile-aligned region of a surface into a new sprite. The pixels are
copied into a sprite-owned buffer, so the source may be modified afterwards.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`src` |`const jlSurfaceT *` |Source surface.
|`x`, `y` |`int16_t` |Top-left in *pixels*, and both must be multiples of 8.
|`widthTiles`, `heightTiles` |`uint8_t` |Size in *tiles*, each 8x8 pixels.
|===
*Returns:* the new sprite, or `NULL` if the coordinates are not tile-aligned.
=== jlSpriteCompact
[source,c]
----
void jlSpriteCompact(void);
----
Defragments the codegen arena, consolidating free space and reclaiming holes
left by destroyed sprites. Costs time proportional to the bytes in use, so call
it between levels rather than per frame.
Sprite pointers you hold are *not* invalidated: internal indirection means draw
calls pick up new code addresses automatically.
*Parameters:* none. *Returns:* nothing.
=== jlSpriteCodegenBytesUsed / jlSpriteCodegenBytesTotal
[source,c]
----
uint32_t jlSpriteCodegenBytesUsed(void);
uint32_t jlSpriteCodegenBytesTotal(void);
----
Arena introspection, for judging whether to compact or to raise
`codegenBytes`.
*Parameters:* none.
*Returns:* bytes in use, and total arena size. Free space is the difference,
though it may be fragmented across holes until `jlSpriteCompact` runs.

229
docs/ApiSurface.adoc Normal file
View file

@ -0,0 +1,229 @@
== Surfaces and Presenting
Declared in `<joey/surface.h>` and `<joey/present.h>`.
A surface is a 320x200 16-colour image with its own 200-entry SCB table and its
own 16x16 palette set. Storage is target-native -- chunky 4bpp on IIgs and DOS,
planar on Amiga, ST and X68000 -- but every entry point speaks in colour
indices, so your code never sees the difference.
=== Constants
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`SURFACE_WIDTH` |320 |Surface width in pixels.
|`SURFACE_HEIGHT` |200 |Surface height in pixels.
|`SURFACE_BYTES_PER_ROW` |160 |Bytes per row in chunky 4bpp form.
|`SURFACE_PIXELS_SIZE` |32000 |Total pixel bytes.
|`SURFACE_PALETTE_COUNT` |16 |Palettes per surface.
|`SURFACE_COLORS_PER_PALETTE` |16 |Colours per palette.
|`SURFACE_PALETTE_ENTRIES` |256 |Total palette entries per surface.
|===
=== jlSurfaceT
[source,c]
----
typedef struct jlSurfaceT jlSurfaceT;
----
Opaque. Obtain one from `jlSurfaceCreate`, `jlStageGet` or a loader.
=== jlSurfaceCreate
[source,c]
----
jlSurfaceT *jlSurfaceCreate(void);
----
Allocates a new offscreen surface.
*Parameters:* none.
*Returns:* the surface, or `NULL` on failure (`jlLastError()` explains why).
=== jlSurfaceDestroy
[source,c]
----
void jlSurfaceDestroy(jlSurfaceT *s);
----
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Surface to release. `NULL` is a no-op, and passing the
stage is also a no-op -- the library owns it.
|===
*Returns:* nothing.
=== jlStageGet
[source,c]
----
jlSurfaceT *jlStageGet(void);
----
Returns the library-owned stage: the back buffer that `jlStagePresent` flips to
the display. Always valid between `jlInit` and `jlShutdown`.
On the IIgs the stage's pixel buffer is pinned to bank `$01` Super Hi-Res space
with shadowing inhibited, so writes run at full speed and stay isolated from
the displayed framebuffer until the next present.
*Parameters:* none. *Returns:* the stage surface.
=== jlSurfaceCopy
[source,c]
----
void jlSurfaceCopy(jlSurfaceT *dst, const jlSurfaceT *src);
----
Copies pixels, SCBs and palettes from `src` into `dst`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination. Must be valid.
|`src` |`const jlSurfaceT *` |Source. Must be valid.
|===
*Returns:* nothing.
=== jlSurfaceCopyRect
[source,c]
----
void jlSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src,
int16_t x, int16_t y, uint16_t w, uint16_t h);
----
Copies a pixel rectangle from `src` to `dst` *at the same position* -- there is
no repositioning, so the planar ports never have to shift bits. This is the
erase half of the clean-buffer pattern: keep a pristine copy of the scene, and
restore rectangles from it before redrawing sprites.
Copies pixels only. SCBs and palettes are untouched.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`src` |`const jlSurfaceT *` |Source surface.
|`x`, `y` |`int16_t` |Top-left of the rectangle, clipped to the surface.
|`w`, `h` |`uint16_t` |Size in pixels.
|===
*Returns:* nothing.
[WARNING]
====
The rectangle is clipped to the surface and then *widened outward to the
16-pixel group grid on every port*: `x` rounds down and `x + w` rounds up to
multiples of 16. Two consequences you own:
* up to 15 extra pixels each side are copied, so a snapped erase can clobber
on-screen content outside the rectangle you asked for;
* it silently invalidates any live `jlSpriteBackupT` whose recorded window it
touches.
Erase *all* windows, then draw *all* sprites. Never mix this with per-sprite
save and restore in the same region.
====
=== jlSurfaceSaveFile
[source,c]
----
bool jlSurfaceSaveFile(const jlSurfaceT *src, const char *path);
----
Writes a surface to disk in target-native form: 32000 pixel bytes, then 200 SCB
bytes, then 512 bytes of `$0RGB` palette data.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`src` |`const jlSurfaceT *` |Surface to write.
|`path` |`const char *` |Destination path.
|===
*Returns:* `true` on success; `false` if the file could not be opened or the
write failed.
[IMPORTANT]
====
The format is *not portable*. No endianness or layout conversion is performed,
so the same platform that wrote a file must be the one that reads it. Use this
for runtime caches, not for shipped assets.
====
=== jlSurfaceLoadFile
[source,c]
----
bool jlSurfaceLoadFile(jlSurfaceT *dst, const char *path);
----
Reads a surface from disk, overwriting `dst`. The surface keeps its identity --
nothing is reallocated.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Surface to overwrite.
|`path` |`const char *` |Source path.
|===
*Returns:* `true` on success; `false` if the file is missing, the wrong size,
or unreadable.
=== jlSurfaceHash
[source,c]
----
uint32_t jlSurfaceHash(const jlSurfaceT *s);
----
Computes a 32-bit content hash over the surface: the packed chunky pixel byte
stream, then the 200 SCB bytes, then the 256 palette entries.
The important property is that *the same logical pixels hash the same on every
port*, whatever the internal storage format. That makes it the tool for proving
two platforms render identically -- which is exactly how the library's own
cross-port validation works.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`const jlSurfaceT *` |Surface to hash.
|===
*Returns:* the hash value.
=== jlStagePresent
[source,c]
----
void jlStagePresent(void);
----
Flips the dirty regions of the stage to the display and clears the dirty state.
On chunky platforms this is a direct copy; on planar platforms it is a
chunky-to-planar conversion.
Drawing primitives mark per-row dirty ranges as a side effect, so you only need
to call this at the end of a frame and everything you drew appears. Rows
nothing touched since the last present are skipped.
*Parameters:* none. *Returns:* nothing.
[NOTE]
====
If you modify pixels by a route other than the library's primitives, the dirty
tracking does not know, and those rows will not be presented.
====

328
docs/ApiTile.adoc Normal file
View file

@ -0,0 +1,328 @@
== Tiles and Text
Declared in `<joey/tile.h>`.
A tile is not a separate object. It is the 8x8 region of a surface at block
coordinates `(bx, by)`, where `bx` runs 0..39 and `by` runs 0..24 -- 40x25
blocks across a 320x200 surface. The tile API moves 32-byte blocks between
surfaces or fills them.
The consequence is that everything else works on tiles too. You can paint tile
content with `jlDrawPixel` and `jlFillRect`, generate it procedurally, or edit
it at run time, because a "tileset" is just a surface you agree to treat as
one. Fonts, terrain sets and spritesheets are all the same thing.
=== Constants and Types
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`TILE_PIXELS_PER_SIDE` |8 |Tile edge in pixels.
|`TILE_BYTES_PER_ROW` |4 |Bytes per tile row (8 pixels at 4bpp).
|`TILE_BYTES` |32 |Bytes in one tile.
|`TILE_BLOCKS_PER_ROW` |40 |Tile columns on a surface.
|`TILE_BLOCKS_PER_COL` |25 |Tile rows on a surface.
|`TILE_NO_GLYPH` |0xFFFF |`jlDrawText` map entry meaning "skip this character".
|`TILE_MAP_SKIP` |0xFF |`jlTileMapPaste` entry meaning "leave this cell alone".
|===
[source,c]
----
typedef struct jlTileT {
uint8_t pixels[TILE_BYTES];
} jlTileT;
----
A stack-allocatable 32-byte tile snapshot, so save-under style work does not
need a whole scratch surface.
[IMPORTANT]
====
A `jlTileT` is *target-native*: plane-major on the planar ports, chunky on the
others. Treat it as opaque. In particular, do not feed a tile captured by
`jlTileSnap` to `jlTilePasteMono`, which wants a chunky silhouette -- that is a
format mismatch and draws the wrong shape. Use `jlTilePasteGlyph` for 1-bit
glyph data instead.
====
=== jlTileCopy
[source,c]
----
void jlTileCopy(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy,
const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy);
----
Copies one 8x8 block between surfaces, opaquely.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`dstBx`, `dstBy` |`uint8_t` |Destination block coordinates.
|`src` |`const jlSurfaceT *` |Source surface. May be the same as `dst`.
|`srcBx`, `srcBy` |`uint8_t` |Source block coordinates.
|===
*Returns:* nothing. Out-of-range block coordinates on either side are silent
no-ops.
=== jlTileCopyMasked
[source,c]
----
void jlTileCopyMasked(jlSurfaceT *dst, uint8_t dstBx, uint8_t dstBy,
const jlSurfaceT *src, uint8_t srcBx, uint8_t srcBy,
uint8_t transparentIndex);
----
As `jlTileCopy`, but source pixels equal to `transparentIndex` are skipped and
the destination keeps its original value. This is the call for fonts and for
any tileset with see-through pixels.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`dstBx`, `dstBy` |`uint8_t` |Destination block coordinates.
|`src` |`const jlSurfaceT *` |Source surface.
|`srcBx`, `srcBy` |`uint8_t` |Source block coordinates.
|`transparentIndex` |`uint8_t` |Source colour index treated as transparent.
|===
*Returns:* nothing.
=== jlTileFill
[source,c]
----
void jlTileFill(jlSurfaceT *s, uint8_t bx, uint8_t by, uint8_t colorIndex);
----
Fills one block with a solid colour. Equivalent to
`jlFillRect(s, bx*8, by*8, 8, 8, colorIndex)` but skips the clipping maths,
since block coordinates are already known to be in range.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`s` |`jlSurfaceT *` |Target surface.
|`bx`, `by` |`uint8_t` |Block coordinates.
|`colorIndex` |`uint8_t` |Colour index 0..15.
|===
*Returns:* nothing.
=== jlTileBankLoad
[source,c]
----
uint16_t jlTileBankLoad(const char *path, jlTileT *outTiles, uint16_t maxTiles,
bool *outValid, uint16_t *outPalette);
----
Loads tiles from a baked `.tbk` file. The file's per-target byte is checked
against the build platform and mismatches are refused.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`path` |`const char *` |File path.
|`outTiles` |`jlTileT *` |Receives the tiles. Caller-owned.
|`maxTiles` |`uint16_t` |Capacity of `outTiles`.
|`outValid` |`bool *` |Optional, may be `NULL`. Set `true` per loaded slot, so
loaded tiles can be told from leftovers in a fixed-size array.
|`outPalette` |`uint16_t *` |Optional, may be `NULL`. Receives the embedded
16-entry `$0RGB` palette if the file has one.
|===
*Returns:* the number of tiles written. Returns 0 if the file is missing, the
magic or target byte is wrong, or `maxTiles` is 0.
=== jlTileBankLoadToSurface
[source,c]
----
uint16_t jlTileBankLoadToSurface(const char *path, jlSurfaceT *dst,
uint16_t *outPalette);
----
Streams a baked `.tbk` straight onto a surface's block grid: tile `i` lands at
block `(i % 40, i / 40)`, the row-major layout the bake tools author against.
Use this instead of `jlTileBankLoad` for anything large. A 1000-tile font sheet
is 32000 bytes, which no IIgs program can hold in an array or in bank-0 BSS;
this needs only a small internal scratch buffer and one pass over the file.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`path` |`const char *` |File path.
|`dst` |`jlSurfaceT *` |Destination surface.
|`outPalette` |`uint16_t *` |Optional, may be `NULL`. As `jlTileBankLoad`.
|===
*Returns:* the number of tiles pasted. Blocks beyond the file's tile count keep
their previous content.
=== jlTileSnap
[source,c]
----
void jlTileSnap(const jlSurfaceT *src, uint8_t bx, uint8_t by, jlTileT *out);
----
Captures one block into a caller-supplied `jlTileT`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`src` |`const jlSurfaceT *` |Source surface.
|`bx`, `by` |`uint8_t` |Block coordinates.
|`out` |`jlTileT *` |Receives the 32-byte snapshot.
|===
*Returns:* nothing.
=== jlTilePaste
[source,c]
----
void jlTilePaste(jlSurfaceT *dst, uint8_t bx, uint8_t by, const jlTileT *in);
----
Pastes a `jlTileT` back onto a surface, always opaquely. Use
`jlTileCopyMasked` if you need transparency.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`bx`, `by` |`uint8_t` |Block coordinates.
|`in` |`const jlTileT *` |Tile to paste.
|===
*Returns:* nothing.
=== jlTilePasteMono
[source,c]
----
void jlTilePasteMono(jlSurfaceT *dst, uint8_t bx, uint8_t by,
const jlTileT *in, uint8_t fgColor, uint8_t bgColor);
----
Pastes a tile as a silhouette: any non-zero source pixel draws `fgColor`, zero
pixels draw `bgColor`. Useful when the bitmap is a fixed glyph but the colour
comes from a separate map, as in C64-style colour RAM. Opaque.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`bx`, `by` |`uint8_t` |Block coordinates.
|`in` |`const jlTileT *` |Source tile, read as a silhouette.
|`fgColor` |`uint8_t` |Colour for non-zero pixels. Masked to 4 bits.
|`bgColor` |`uint8_t` |Colour for zero pixels. Masked to 4 bits.
|===
*Returns:* nothing.
[WARNING]
====
This call wants a *chunky* silhouette, but a `jlTileT` from `jlTileSnap` is
target-native. Feeding one to this function on a planar port is a format
mismatch. For glyphs, use `jlTilePasteGlyph`, which takes unambiguous 1-bit
input.
====
=== jlTilePasteGlyph
[source,c]
----
void jlTilePasteGlyph(jlSurfaceT *dst, uint8_t bx, uint8_t by,
const uint8_t *bits, uint8_t fgColor, uint8_t bgColor);
----
Pastes an 8x8 one-bit-per-pixel glyph: `bits[row]` is that row, most
significant bit leftmost. A set bit draws `fgColor`, a clear bit `bgColor`.
Opaque.
This is the shape a character generator already has -- a font ROM, a C64
charset, anything 1bpp -- so nothing needs expanding into a `jlTileT` first.
*Prefer this over `jlTilePasteMono` for glyphs.*
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`bx`, `by` |`uint8_t` |Block coordinates.
|`bits` |`const uint8_t *` |8 bytes, one per glyph row, MSB leftmost.
|`fgColor` |`uint8_t` |Set-bit colour. Masked to 4 bits.
|`bgColor` |`uint8_t` |Clear-bit colour. Masked to 4 bits.
|===
*Returns:* nothing.
=== jlDrawText
[source,c]
----
void jlDrawText(jlSurfaceT *dst, uint8_t bx, uint8_t by,
const jlSurfaceT *fontSurface, const uint16_t *asciiMap,
const char *str);
----
Draws a NUL-terminated ASCII string using glyphs from a font surface. Glyph
colour 0 is transparent, so the background shows through. The cursor wraps at
the right edge and truncates at the bottom.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`bx`, `by` |`uint8_t` |Starting block coordinates.
|`fontSurface` |`const jlSurfaceT *` |Surface holding the glyphs.
|`asciiMap` |`const uint16_t *` |256 entries mapping ASCII code to glyph
position: low byte is source block X, high byte is source block Y. An entry of
`TILE_NO_GLYPH` skips that character, advancing the cursor without drawing.
|`str` |`const char *` |NUL-terminated string.
|===
*Returns:* nothing.
=== jlTileMapPaste
[source,c]
----
void jlTileMapPaste(jlSurfaceT *dst, uint8_t bx, uint8_t by,
uint8_t wTiles, uint8_t hTiles,
const jlTileT *tiles, const uint8_t *map);
----
Pastes a whole rectangle of tiles in one call, so validation, dispatch and the
dirty-rectangle union are paid once for the rectangle instead of once per tile.
For anything larger than a few tiles this is substantially faster than looping
over `jlTilePaste`.
[cols="1,1,4", options="header"]
|===
|Parameter |Type |Description
|`dst` |`jlSurfaceT *` |Destination surface.
|`bx`, `by` |`uint8_t` |Top-left block coordinates.
|`wTiles`, `hTiles` |`uint8_t` |Rectangle size in tiles. Zero is a no-op.
|`tiles` |`const jlTileT *` |The tileset.
|`map` |`const uint8_t *` |`wTiles * hTiles` tileset indices, row-major, so
`map[row * wTiles + col]`. `TILE_MAP_SKIP` leaves that cell untouched.
|===
*Returns:* nothing. A rectangle whose edge crosses the surface boundary is
rejected as a no-op.
[WARNING]
====
Any map index other than `TILE_MAP_SKIP` must be a valid tileset index. The
batch does not know the tileset length and does *not* range-check it -- an
out-of-range index reads past the end of your array.
====

197
docs/Appendix.adoc Normal file
View file

@ -0,0 +1,197 @@
[appendix]
== Constants Reference
=== Surface
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`SURFACE_WIDTH` |320 |Surface width in pixels.
|`SURFACE_HEIGHT` |200 |Surface height in pixels.
|`SURFACE_BYTES_PER_ROW` |160 |Bytes per row, chunky 4bpp.
|`SURFACE_PIXELS_SIZE` |32000 |Total pixel bytes.
|`SURFACE_PALETTE_COUNT` |16 |Palettes per surface.
|`SURFACE_COLORS_PER_PALETTE` |16 |Colours per palette.
|`SURFACE_PALETTE_ENTRIES` |256 |Total palette entries.
|===
=== Tiles
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`TILE_PIXELS_PER_SIDE` |8 |Tile edge in pixels.
|`TILE_BYTES_PER_ROW` |4 |Bytes per tile row.
|`TILE_BYTES` |32 |Bytes per tile.
|`TILE_BLOCKS_PER_ROW` |40 |Tile columns per surface.
|`TILE_BLOCKS_PER_COL` |25 |Tile rows per surface.
|`TILE_NO_GLYPH` |0xFFFF |`jlDrawText`: skip this character.
|`TILE_MAP_SKIP` |0xFF |`jlTileMapPaste`: leave this cell untouched.
|===
=== Input
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`JOYSTICK_AXIS_MAX` |127 |Maximum axis value.
|`JOYSTICK_AXIS_MIN` |-127 |Minimum axis value.
|`JL_CHAR_QUEUE_SIZE` |32 |Queue capacity; 31 characters can be pending.
|`JL_CHAR_BACKSPACE` |0x08 |Backspace, deletes before the caret.
|`JL_CHAR_TAB` |0x09 |Tab.
|`JL_CHAR_RETURN` |0x0D |Return.
|`JL_CHAR_ESCAPE` |0x1B |Escape.
|`JL_CHAR_DELETE` |0x7F |Forward delete, removes the character under the caret.
|===
=== Audio
[cols="3,1,4", options="header"]
|===
|Name |Value |Meaning
|`JOEY_AUDIO_SFX_SLOTS` |5 |Sound effect slots, 0..4.
|`JOEY_AUDIO_VOICES` |3 |Tone voices, 0..2.
|`JL_SFX_RAW_SIGNED8` |0 |Raw stream format: signed 8-bit PCM.
|`JL_SFX_RAW_UNSIGNED8_NOZERO` |1 |Raw stream format: unsigned biased 0x80.
|===
[appendix]
== Function Index by Header
[cols="2,5", options="header"]
|===
|Header |Functions
|`core.h`
|`jlInit`, `jlShutdown`, `jlLastError`, `jlPlatformName`, `jlVersionString`,
`jlAlloc`, `jlFree`, `jlMemCopy`, `jlWaitVBL`, `jlFrameCount`, `jlFrameHz`,
`jlMillisElapsed`, `jlRandom`, `jlRandomRange`, `jlRandomSeed`
|`surface.h`
|`jlSurfaceCreate`, `jlSurfaceDestroy`, `jlStageGet`, `jlSurfaceCopy`,
`jlSurfaceCopyRect`, `jlSurfaceSaveFile`, `jlSurfaceLoadFile`,
`jlSurfaceHash`
|`present.h`
|`jlStagePresent`
|`palette.h`
|`jlPaletteSet`, `jlPaletteGet`, `jlScbSet`, `jlScbSetRange`, `jlScbGet`
|`draw.h`
|`jlSurfaceClear`, `jlDrawPixel`, `jlSamplePixel`, `jlDrawLine`, `jlDrawRect`,
`jlFillRect`, `jlDrawCircle`, `jlFillCircle`, `jlFloodFill`,
`jlFloodFillBounded`
|`tile.h`
|`jlTileCopy`, `jlTileCopyMasked`, `jlTileFill`, `jlTileBankLoad`,
`jlTileBankLoadToSurface`, `jlTileSnap`, `jlTilePaste`, `jlTilePasteMono`,
`jlTilePasteGlyph`, `jlDrawText`, `jlTileMapPaste`
|`sprite.h`
|`jlSpriteBankLoad`, `jlSpriteBankLoadPrecompiled`,
`jlSpriteBankSavePrecompiled`, `jlSpriteCreate`, `jlSpriteDestroy`,
`jlSpriteCompile`, `jlSpriteCompiledSize`, `jlSpritePrewarm`, `jlSpriteDraw`,
`jlSpriteDrawTrusted`, `jlSpriteSaveUnder`, `jlSpriteSaveUnderTrusted`,
`jlSpriteRestoreUnder`, `jlSpriteRestoreUnderTrusted`, `jlSpriteSaveAndDraw`,
`jlSpriteSaveAndDrawTrusted`, `jlSpriteCreateFromSurface`, `jlSpriteCompact`,
`jlSpriteCodegenBytesUsed`, `jlSpriteCodegenBytesTotal`
|`input.h`
|`jlInputPoll`, `jlInputGetChar`, `jlWaitForAnyKey`, `jlKeyDown`,
`jlKeyPressed`, `jlKeyReleased`, `jlMousePresent`, `jlMouseX`, `jlMouseY`,
`jlMouseDown`, `jlMousePressed`, `jlMouseReleased`, `jlJoystickConnected`,
`jlJoystickX`, `jlJoystickY`, `jlJoyDown`, `jlJoyPressed`, `jlJoyReleased`,
`jlJoystickReset`
|`audio.h`
|`jlAudioInit`, `jlAudioShutdown`, `jlAudioPlayMod`, `jlAudioStopMod`,
`jlAudioIsPlayingMod`, `jlAudioPlaySfx`, `jlAudioPlaySfxStream`,
`jlAudioSfxRawFormat`, `jlAudioPlaySfxStreamRaw`, `jlAudioSetPan`,
`jlAudioStopSfx`, `jlAudioTone`, `jlAudioTickRegister`,
`jlAudioCriticalEnter`, `jlAudioCriticalExit`, `jlAudioVoice`, `jlAudioNoise`,
`jlMusicPlay`, `jlMusicStop`, `jlMusicIsPlaying`, `jlMusicPause`,
`jlMusicResume`, `jlMusicSetAtten`, `jlAudioFrameTick`
|`file.h`
|`jlDataOpen`, `jlSaveOpen`, `jlSaveWrite`, `jlSaveRead`, `jlSaveExists`,
`jlSaveDelete`, `jlDiskFree`
|`serial.h` (opt-in)
|`jlSerialOpen`, `jlSerialClose`, `jlSerialPoll`, `jlSerialAvailable`,
`jlSerialRead`, `jlSerialWrite`, `jlSerialWriteByte`, `jlSerialReadByte`,
`jlSerialFlush`, `jlSerialSetRts`
|`debug.h`
|`jlLog`, `jlLogF`, `jlLogFlush`, `jlLogReset`
|===
[appendix]
== Troubleshooting
[cols="2,5", options="header"]
|===
|Symptom |Likely cause
|Build produces stale or missing binaries, `Error 127 (ignored)`
|`toolchains/env.sh` was not sourced in this shell.
|Sprites draw but the game crawls
|The sprites were never compiled. Call `jlSpritePrewarm` during loading. Check
`jlSpriteCompiledSize` returns non-zero.
|Sprites compile on some machines and not others
|`codegenBytes` is too small for the 68000 or x86 emitters, which need four to
eight times the IIgs figure. Measure with `jlSpriteCompiledSize`.
|Works everywhere except the IIgs, failing at startup
|A `malloc` too large for the bank-0 C heap. Use `jlAlloc`.
|Crashes on IIgs deep in a call chain
|A large function-local array overflowing the soft stack. Make it `static`.
|Nothing appears on screen
|`jlStagePresent` was never called, or drawing went to a surface that is not
the stage.
|Part of the screen never updates
|Pixels were written by something other than the library primitives, so the
dirty tracking did not see them.
|Runs at different speeds on different machines
|Per-frame steps are hard-coded. Scale by `jlFrameHz()` or use
`jlMillisElapsed()`.
|Edge predicates never fire
|`jlInputPoll` is being called more than once per frame, or not at all.
|Typed text misses characters
|The character queue holds 31 entries and drops the rest. Pop it every frame.
|Music plays but `jlAudioVoice` does nothing
|On ST and Amiga the module player owns those channels. Use one layer per
scene.
|A note sounds for only a fraction of a second on the IIgs
|`jlAudioVoice` is a roughly 80 ms one-shot there. Re-assert it about once a
frame.
|A streaming sound effect stops after its first chunk
|`jlAudioFrameTick` is not being called every frame. IIgs and Amiga fire the
next chunk from it.
|Sprite trails or corruption around moving objects
|Save-under and clean-buffer erasing are being mixed in one region, or a
`jlSurfaceCopyRect` invalidated a live backup by snapping outward to the
16-pixel grid.
|Save and restore are unexpectedly slow on Amiga or ST
|The backup buffer is at an odd address. Declare it `uint16_t`.
|A tile bank refuses to load
|`.tbk` files are per-target. Bake one for each platform.
|Your program is missing from the IIgs disk image
|The 800 KB floppy is full. Build with `JOEY_DISK_EXAMPLES` naming it, and
check the packager's `added:` line.
|===

336
docs/Assets.adoc Normal file
View file

@ -0,0 +1,336 @@
== Creating Assets
JoeyLib does not load PNGs, WAVs or MODs at run time. Every asset is converted
on the build host into a compact binary the target can use directly, which is
what keeps loading fast and the runtime small. This chapter covers each asset
type: what you author, which tool bakes it, and which API call loads it.
=== The Pipeline at a Glance
[cols="2,2,2,3", options="header"]
|===
|You author |Tool |Baked file |Loaded by
|Indexed PNG tile sheet
|`assetbake.py --type tile`
|`.tbk`
|`jlTileBankLoad`, `jlTileBankLoadToSurface`
|Indexed PNG sprite sheet
|`assetbake.py --type sprite`
|`.spr`
|`jlSpriteBankLoad`
|A `.spr`
|`spritebake`
|`.spc`
|`jlSpriteBankLoadPrecompiled`
|Text note notation
|`songbake.py`
|`.jym` or a C header
|`jlMusicPlay`
|Protracker `.MOD`
|`joeymod`
|`.mod`, `.amod`, `.ntp`
|`jlAudioPlayMod`
|Raw signed 8-bit PCM
|(none -- copied)
|`.sfx`
|`jlAudioPlaySfx`
|===
Baked assets are staged into `DATA/` beside the executable, and the program
opens them with bare names.
=== Graphics: Tiles and Sprites
==== Authoring the PNG
Both tile and sprite sheets start as an *indexed* PNG with strict rules:
* Mode `P` (indexed / palette), *not* RGB or RGBA.
* At most *16 colours*.
* Width and height both *multiples of 8*.
* Palette entry 0 is the transparent / background colour. Remember the library
forces it to black on screen.
Any decent pixel editor can produce this. In GIMP: *Image > Mode > Indexed*,
"Generate optimum palette" with maximum 16 colours, and no dithering --
dithering invents colours and will push you over the limit.
Lay the artwork out on a grid. A tile sheet is read as 8x8 cells row by row; a
sprite sheet is read as cells of whatever size you declare with `--cell`, also
row by row.
==== Baking Tiles
[source,console]
----
$ python3 tools/assetbake/assetbake.py --type tile --target atarist \
font.png build/atarist/bin/DATA/font.tbk
----
Tile banks are *per-target*, because the 8x8 block layout is the platform's
native pixel format -- plane-major on Amiga, row-major-with-planes-per-row on
ST, chunky 4bpp on DOS and IIgs. You must bake one `.tbk` per platform, and the
loader refuses a file baked for a different target rather than drawing garbage.
`--target` is required for tiles, and accepts `amiga`, `atarist`, `dos` or
`iigs`.
==== Baking Sprites
[source,console]
----
$ python3 tools/assetbake/assetbake.py --type sprite --cell 2x3 \
player.png build/atarist/bin/DATA/player.spr
----
`--cell` gives the cel size *in tiles, not pixels*, so `2x3` means 16x24
pixels. The sheet is cut into cels row by row.
Sprite `.spr` banks are *cross-target*: the same file works on every platform,
because the sprite walker reads chunky 4bpp and converts at draw time. Bake
once, ship everywhere.
Both formats can embed the 16-entry `$0RGB` palette, and both loaders will hand
it back through their `outPalette` parameter so artwork carries its own colours
rather than depending on whatever the stage happens to have set.
==== Pre-Compiling Sprites
A `.spr` still needs compiling into native blitter code at run time. For a
large bank that is a visible startup pause. `spritebake` does it offline:
[source,console]
----
$ build/tools/spritebake-atarist player.spr player.spc
----
The result is a *per-platform* `.spc` holding pre-emitted native routines.
`jlSpriteBankLoadPrecompiled` copies them straight into the codegen arena and
skips compilation entirely. A cel too big for the arena silently loads
uncompiled rather than failing, so this is a safe drop-in replacement.
The `spritebake` binaries are built per target by `make/tools.mk`, because each
one links the emitter for its own CPU while running on the build host.
There is a third option if you would rather ship only the small cross-platform
`.spr`: compile once on first run and cache the result with
`jlSpriteBankSavePrecompiled`, then load the cache on later runs. That trades a
one-time startup cost for a much smaller distribution.
=== Music: the Portable Chip Tracker
This is the recommended music path. One authored song plays on all five ports
at register-write cost, with no CPU mixing -- which matters enormously on the
ST, where `.MOD` playback is only marginally viable.
Songs are plain text, one row per line:
[source]
----
; Four bars of Am - F - C - G.
tickms 110
loop
; voice0 voice1 voice2 noise
A-2:2 A-4:5 --- N16:5
--- C-5:5 E-5:3 off
A-3:3 E-4:5 off N2:7
--- A-4:5 C-5:3 off
----
The columns are three tone voices plus an optional fourth noise column.
[cols="1,4", options="header"]
|===
|Cell |Meaning
|`C-4:3`
|Note on: note name, octave, then `:` and an attenuation 0..14 where 0 is
loudest. Sharps use `#`, as in `C#4:3`. Range `C-0` to `B-7`. The `:atten`
part may be omitted and defaults to 0.
|`@440:3`
|Note on at an exact frequency in Hz, for chip-accurate transcription where
equal-tempered note names would round the pitch.
|`---`
|Hold: this voice is unchanged for this row.
|`off`
|Silence this voice.
|`N4:3`
|Noise on (fourth column only): pitch 0..31, where 0 is the brightest hiss
and 31 the lowest rumble, plus attenuation.
|===
Directives: `tickms N` sets milliseconds per row and is required before the
first row (later occurrences change tempo mid-song); `loop` marks the next row
as the loop-back target; `repeat N` ... `endrepeat` repeats a block, and may be
nested.
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
----
The C header form is usually what you want for a small song: it compiles into
the program and needs no file I/O at all.
[WARNING]
====
On the Atari ST the noise generator shares channel C's volume register with
tone voice 2. Author noise hits and voice-2 notes so they do not sound at the
same time; `songbake` warns when they overlap.
====
=== Music: Protracker Modules
For richer music, author a four-channel `.MOD` in any tracker (ProTracker,
MilkyTracker, OpenMPT). Then convert per target:
[source,console]
----
$ tools/joeymod/joeymod song.mod song.mod # DOS and ST: validated copy
$ tools/joeymod/joeymod song.mod song.amod # Amiga
$ tools/joeymod/joeymod song.mod song.ntp # Apple IIgs
----
The output extension selects the conversion:
* *`.mod`* -- a passthrough copy with the signature validated. DOS and ST
detect song end inside libxmp-lite, so the file is used as-is.
* *`.amod`* -- the same, plus an `E8FF` stop marker injected at song end. The
Amiga replayer, PTPlayer, has no native song-end signal, so without this
marker `jlAudioPlayMod(..., loop=false)` loops anyway. Other replayers ignore
`E8FF`, so an `.amod` is harmless if it reaches the wrong port.
* *`.ntp`* -- NinjaTrackerPlus format for the IIgs, produced by the staged
converter under `toolchains/iigs/ntp/`.
Keep modules modest. A four-channel mix is real CPU work on DOS and the ST, and
the sample data has to fit in memory alongside your game.
=== Sound Effects
Sound effects are *raw signed 8-bit PCM* with no header at all -- just sample
bytes. Convert from a WAV with any audio editor, or on the command line:
[source,console]
----
$ sox input.wav -r 11025 -c 1 -b 8 -e signed-integer output.sfx
----
Then play it, telling the library what rate it was recorded at:
[source,console]
----
jlAudioPlaySfx(4, sfxBytes, sfxLen, 11025);
----
Choose a rate that suits the machines: 8 to 11 kHz is plenty for game effects
and keeps the data small, which matters when the whole thing must fit on a
floppy.
Two things to know. Prefer *slot 4* if music is playing, because the IIgs maps
tracker tone voices onto slots 0 to 2 and the noise voice onto slot 3. And the
IIgs plays a sample value of -128 as -127, because its DOC reads the
corresponding byte as a wave-end marker -- one level out of 256, inaudible in
practice, but it explains a tiny waveform difference if you ever measure one.
For long sounds such as speech, use `jlAudioPlaySfxStream` with a fill callback
instead of holding the whole sample in memory.
=== Screens and Backgrounds
A whole 320x200 screen can be saved and reloaded in target-native form:
[source,c]
----
jlSurfaceSaveFile(surface, "title.sur");
jlSurfaceLoadFile(surface, "title.sur");
----
The file is 32000 bytes of pixels, 200 SCB bytes and 512 bytes of palette. It
is *not* portable between platforms -- no format conversion happens, so the
machine that wrote it must be the machine that reads it. It is best used as a
runtime cache (a generated title screen, say), not as a shipped asset. For
shipped artwork, bake a tile bank and compose the screen with
`jlTileBankLoadToSurface`.
=== Staging Assets into the Build
Baked files must end up in `DATA/` next to the executable. Each port's makefile
does this with an explicit rule. The simple copy case, from `make/atarist.mk`:
[source,makefile]
----
DATA_DIR := $(BINDIR)/DATA
DATA_FILES := $(DATA_DIR)/test.mod $(DATA_DIR)/test.sfx
$(DATA_DIR)/test.mod: $(REPO_DIR)/assets/test.mod
@mkdir -p $(DATA_DIR)
cp $< $@
----
And a bake rule looks like:
[source,makefile]
----
$(DATA_DIR)/font.tbk: $(MY_ASSETS)/font.png
@mkdir -p $(DATA_DIR)
python3 $(ASSETBAKE_BIN) --type tile --target atarist $< $@
----
Add the baked files to the port's `all` target so they are built alongside the
binaries. Remember that tile banks and `.spc` files need one rule *per
platform*, while `.spr`, `.jym` and `.sfx` files are shared.
=== Loading Assets at Run Time
[source,c]
----
// Tiles into a caller-owned array.
jlTileT tiles[256];
bool valid[256];
uint16_t palette[SURFACE_COLORS_PER_PALETTE];
uint16_t n = jlTileBankLoad("font.tbk", tiles, 256, valid, palette);
// Or stream a big bank straight onto a surface, with no caller buffer.
uint16_t m = jlTileBankLoadToSurface("font.tbk", fontSurface, palette);
// Sprites, pre-compiled.
jlSpriteT *cels[32];
uint16_t count = jlSpriteBankLoadPrecompiled("player.spc", cels, 32, palette);
// Apply the palette the artwork shipped with.
jlPaletteSet(stage, 0, palette);
----
Prefer `jlTileBankLoadToSurface` for anything large. A 1000-tile font sheet is
32000 bytes, which no IIgs program can hold in a local array or in bank-0 BSS,
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:
[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
----
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.

190
docs/BuildIntegration.adoc Normal file
View file

@ -0,0 +1,190 @@
== Adding Your Program to the Build
JoeyLib does not install as a system library. Programs are built from inside
the repository by the same makefiles that build the library, because each
target needs its own cross compiler, link script, packaging step and disk
image. Adding a program means adding it to each port's makefile.
=== The Shape of a Program Directory
Put your sources under `examples/`, one directory per program:
----
examples/mygame/
mygame.c
mygame.h
assets/
tiles.png
sprites.png
music.song
----
Keep the authoring assets next to the code and let the makefiles bake them into
`DATA/`, as described in <<_creating_assets>>.
=== Adding It to One Port
Each port's makefile declares programs explicitly. The pattern in
`make/atarist.mk`, for a single-source program:
[source,makefile]
----
MYGAME_SRC := $(EXAMPLES)/mygame/mygame.c
MYGAME_BIN := $(BINDIR)/MYGAME.PRG
----
Add the binary to the port's `all` target so it gets built:
[source,makefile]
----
all atarist: $(LIB) ... $(MYGAME_BIN) $(DATA_FILES)
----
And give it a build rule, copying the shape of an existing example in that
file. Do not invent your own compiler invocation: the flags, include paths and
link order in those rules encode real constraints, several of them the result
of long debugging.
For a multi-file program, list the sources:
[source,makefile]
----
MYGAME_SRCS := $(EXAMPLES)/mygame/mygame.c \
$(EXAMPLES)/mygame/world.c \
$(EXAMPLES)/mygame/render.c
----
Repeat for each port you want to support: `make/iigs.mk`, `make/amiga.mk`,
`make/atarist.mk`, `make/dos.mk` and `make/x68000.mk`.
[NOTE]
====
Yes, that is five makefile edits for one program. The ports genuinely differ:
the IIgs needs OMF segmentation and a disk image, the Amiga a startup-sequence
entry, DOS a DPMI stub, and the X68000 a Human68k floppy layout. There is no
single rule that covers all five.
====
=== Naming
File naming conventions differ per platform and the makefiles reflect that:
[cols="2,2,4", options="header"]
|===
|Platform |Convention |Example
|Apple IIgs |Uppercase, 8 characters |`MYGAME`
|Atari ST |Uppercase 8.3, `.PRG` |`MYGAME.PRG`
|MS-DOS |Uppercase 8.3, `.EXE` |`MYGAME.EXE`
|Amiga |Mixed case, no extension |`MyGame`
|Sharp X68000 |Uppercase 8.3, `.X` |`MYGAME.X`
|===
Keep the base name to 8 characters and letters and digits only, so the same
name works everywhere.
=== Disk Images and Packaging
Some ports need a packaging step beyond compiling.
*Apple IIgs.* Programs are placed on an 800 KB ProDOS disk image:
[source,console]
----
$ make iigs-disk
$ JOEY_DISK_EXAMPLES="MYGAME" make iigs-disk # just yours
----
The floppy cannot hold every example at once, so the packager drops some. If
your program is missing from the image, build it with `JOEY_DISK_EXAMPLES`
naming it explicitly, and check the `added:` line the packager prints. A
program plus large assets may not fit 800 KB at all, in which case use the
bigger game-disk image on a CFFA2 card:
[source,console]
----
$ JOEY_GAME_DISK_SIZE=2048KB scripts/make-iigs-game-disk.sh \
build/iigs/bin/mygame.2mg MYGAME
----
*Sharp X68000.* Programs ship on a Human68k floppy. Be aware that a stale image
can hide hundreds of kilobytes in subdirectories that a directory listing does
not show; rebuild from empty rather than deleting files if space seems to
vanish.
*Amiga.* The runner writes an `s/startup-sequence` that invokes your binary.
=== Verifying It Actually Runs
Build success is weak evidence on retro targets. Run it:
[source,console]
----
$ scripts/run-iigs.sh MYGAME
$ scripts/run-amiga.sh MyGame
$ scripts/run-atarist.sh MYGAME
$ scripts/run-dos.sh MYGAME
$ scripts/run-x68000.sh MYGAME
----
There are also headless runners that capture a screenshot and the program's log
without opening a window, which is what you want when iterating:
[source,console]
----
$ scripts/run-atarist-headless.sh MYGAME 90
$ scripts/run-amiga-headless.sh MyGame 90
----
=== The Gates
The repository carries automated checks. They exist because several classes of
bug on these machines are invisible until measured, and they are worth running
against your own program too.
[cols="2,4", options="header"]
|===
|Command |What it proves
|`make verify-audio`
|Every port's audio gate: the IIgs DOC and X68000 chips are read directly,
the ST's YM2149 register writes are traced, and the Amiga and DOS output is
recorded and graded. Exits 3, not 0, if a gate had to be skipped.
|`make iigs-verify`
|The IIgs actually reached the program and drew something.
|`scripts/bench-<port>.sh`
|Per-operation throughput for that port.
|`tools/diff-uber-hashes`
|Compares a port's rendered output against the IIgs golden reference,
operation by operation, by surface hash.
|===
That last one is the strongest tool in the repository. `jlSurfaceHash` produces
the same value for the same logical pixels on every platform regardless of
internal storage format, so a hash mismatch means one port is genuinely drawing
different pixels from the others.
=== Logging from the Target
Debugging a program running on an emulated 1988 machine is awkward. JoeyLib
provides a logger that writes to a file next to the executable:
[source,c]
----
jlLogReset();
jlLogF("level %d loaded, %u cels", levelNum, celCount);
jlLogFlush();
----
The headless runners retrieve that file automatically.
[NOTE]
====
The logger needs a multi-kilobyte ring buffer, which some targets cannot
afford. Space Taxi compiles it out entirely on the IIgs and X68000 for that
reason. If you are debugging a memory-tight program, expect to do the same, and
fall back to observing hardware state through an emulator instead.
====

204
docs/GettingStarted.adoc Normal file
View file

@ -0,0 +1,204 @@
== Getting Started
=== Setting Up the Toolchains
JoeyLib cross-compiles for five machines, and every cross toolchain lives under
`toolchains/` in the repository. They are fetched and built once:
[source,console]
----
$ ./toolchains/install.sh
----
Then, *in every shell session before you build anything*:
[source,console]
----
$ source toolchains/env.sh
----
[WARNING]
====
Forgetting `source toolchains/env.sh` is the single most common build problem.
The makefiles reference the cross compilers only through the variables that
script sets, so without it a build can appear to succeed while producing stale
or missing binaries, and errors are reported as `Error 127 (ignored)`. If a
build behaves strangely, source the script and try again.
====
Check what the build can see:
[source,console]
----
$ make status
Toolchain availability:
iigs: yes
amiga: yes
atarist: yes
dos: yes
----
=== Building
[source,console]
----
$ make # every target whose toolchain is present
$ make iigs # Apple IIgs only
$ make amiga # Commodore Amiga only
$ make atarist # Atari ST only
$ make dos # MS-DOS only
$ make clean # remove all build outputs
----
The X68000 port has its own makefile:
[source,console]
----
$ make -f make/x68000.mk
----
Built programs land under `build/<platform>/bin/`. The IIgs additionally
produces a bootable disk image:
[source,console]
----
$ make iigs-disk # build/iigs/bin/joey.2mg
----
[TIP]
====
Never judge a retro build by its exit status alone. Check that the file you
expect actually appeared and has a recent timestamp. `make iigs` builds the
library but *no examples* -- `make iigs-disk` is what builds and packages them.
====
=== Hello, JoeyLib
The smallest complete program. This is `examples/hello/hello.c`:
[source,c]
----
#include <stdio.h>
#include <joey/joey.h>
int main(void) {
jlConfigT config;
config.codegenBytes = 8 * 1024;
config.audioBytes = 64UL * 1024;
if (!jlInit(&config)) {
fprintf(stderr, "jlInit failed: %s\n", jlLastError());
return 1;
}
printf("JoeyLib %s\n", jlVersionString());
printf("Platform: %s\n", jlPlatformName());
printf("Hello from JoeyLib.\n");
jlShutdown();
return 0;
}
----
Three things to notice, because they apply to every JoeyLib program:
. *One header.* `#include <joey/joey.h>` pulls in everything except the
optional serial add-on.
. *`jlInit` can fail*, and when it does `jlLastError()` tells you why. Always
check it.
. *`jlShutdown` is not optional.* It restores the display mode, releases the
Memory Manager blocks or DOS memory the library took, and on several ports
un-hooks interrupt vectors. Skipping it can leave the machine in an unusable
state.
=== Something on the Screen
`hello.c` prints text but never draws. Here is the smallest program that puts
graphics on screen and waits for a keypress:
[source,c]
----
#include <joey/joey.h>
int main(void) {
jlConfigT config;
jlSurfaceT *stage;
uint16_t colors[SURFACE_COLORS_PER_PALETTE];
uint8_t i;
config.codegenBytes = 8 * 1024;
config.audioBytes = 0;
if (!jlInit(&config)) {
return 1;
}
stage = jlStageGet(); // the library-owned back buffer
// Build palette 0. Entry 0 is forced black whatever we put here.
for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) {
colors[i] = 0x000;
}
colors[1] = 0xFFF; // white
colors[2] = 0xF00; // red
colors[3] = 0x00F; // blue
jlPaletteSet(stage, 0, colors);
// Every scanline uses palette 0 (this is also the default).
jlScbSetRange(stage, 0, SURFACE_HEIGHT - 1, 0);
jlSurfaceClear(stage, 0);
jlFillRect(stage, 40, 30, 240, 140, 3);
jlDrawRect(stage, 40, 30, 240, 140, 1);
jlDrawLine(stage, 0, 0, SURFACE_WIDTH - 1, SURFACE_HEIGHT - 1, 2);
jlStagePresent(); // nothing is visible until this runs
jlWaitForAnyKey();
jlShutdown();
return 0;
}
----
The critical line is `jlStagePresent()`. All drawing goes to an off-screen
back buffer called the *stage*; nothing reaches the display until you present.
=== Running What You Built
Helper scripts boot each target in an emulator:
[source,console]
----
$ scripts/run-iigs.sh DRAW
$ scripts/run-amiga.sh Draw
$ scripts/run-atarist.sh DRAW
$ scripts/run-dos.sh DRAW
$ scripts/run-x68000.sh DRAW
----
Note the capitalisation differences: the IIgs, ST and DOS use uppercase names
(ProDOS, GEMDOS and DOS 8.3 conventions), while the Amiga keeps mixed case.
=== The Example Programs
The `examples/` directory is the best available reference, and every example is
built for every platform:
[cols="1,4", options="header"]
|===
|Example |What it shows
|`hello` |Minimal init, version query, shutdown.
|`draw` |Every drawing primitive, laid out in four quadrants.
|`pattern` |Palette and per-scanline palette (SCB) work.
|`keys` |Typed-character input and the key predicates.
|`joy` |Joystick axes, buttons and recalibration.
|`sprite` |Sprite loading, compiling, save-under and restore.
|`audio` |Tracker music, `.MOD` playback and digital sound effects.
|`save` |Save files and the disk-space query.
|`serial` |The optional RS-232 add-on.
|`adventure` |A small complete game.
|`agi` |A Sierra AGI interpreter, showing the library under real load.
|`spacetaxi` |A full port of Space Taxi: the largest example.
|`uber` |The validation and benchmark harness.
|===

61
docs/Manual.adoc Normal file
View file

@ -0,0 +1,61 @@
= JoeyLib Manual
Scott Duensing <scott.duensing@gmail.com>
:revnumber: 1.0
:revdate: 2026
:doctype: book
:title-logo-image: image:../assets/joeylibLogo.svg[JoeyLib, pdfwidth=4.5in, align=center]
:toc: left
:toclevels: 3
:sectnums:
:sectnumlevels: 3
:source-highlighter: rouge
:icons: font
:experimental:
:xrefstyle: short
// One book: the guide chapters first, then the API reference, then the
// appendices. Every chapter is its own .adoc in docs/ so a chapter can be
// edited without touching the rest. Build with scripts/build-docs.sh, or
// `make docs`, which writes build/docs/JoeyLib-Manual.html and .pdf.
= Guide
include::Overview.adoc[]
include::GettingStarted.adoc[]
include::ProgramLayout.adoc[]
include::Portability.adoc[]
include::Assets.adoc[]
include::BuildIntegration.adoc[]
= API Reference
include::ApiCore.adoc[]
include::ApiSurface.adoc[]
include::ApiPalette.adoc[]
include::ApiDraw.adoc[]
include::ApiTile.adoc[]
include::ApiSprite.adoc[]
include::ApiInput.adoc[]
include::ApiAudio.adoc[]
include::ApiFile.adoc[]
include::ApiSerial.adoc[]
include::ApiDebug.adoc[]
= Appendices
include::Appendix.adoc[]

118
docs/Overview.adoc Normal file
View file

@ -0,0 +1,118 @@
== Introduction
=== What JoeyLib Is
JoeyLib is a single-codebase C game library for five 16-bit and 32-bit retro
machines. You write one program against one API, and the same source compiles
and runs on all of them:
[cols="2,2,2,4", options="header"]
|===
|Platform |CPU |RAM |Notes
|Apple IIgs
|65C816 at 2.8 MHz
|1 MB typical
|The reference platform. Accelerators run faster with no code changes.
|Commodore Amiga
|68000 (A500 class)
|512 KB chip, 512 KB fast recommended
|OCS chipset. Native planar bitplanes.
|Atari ST
|68000 (STF or STE)
|512 KB
|Word-interleaved planar. Shares 68000 assembly with the Amiga port.
|MS-DOS
|386 or later
|2 MB
|VGA required. DPMI supplied by an embedded CWSDPMI.
|Sharp X68000
|68000 at 10 MHz
|stock
|Draws into the text plane, 320x200 centred in a 512x512 screen.
|===
The Apple IIgs is deliberately the slowest target and the design reference. If
something performs acceptably there, it performs acceptably everywhere.
=== The Display Contract
Every JoeyLib program gets exactly the same screen on every platform:
* *320 x 200 pixels*, always. There is no other resolution.
* *16 colours on screen at once*, chosen per scanline from one of 16 palettes.
* *Colour 0 of every palette is black*, always, and is forced to black by the
library even if you try to set it to something else. This is not negotiable:
the Amiga and ST hardware border follows colour 0 of the active palette, and
colour 0 is the transparent colour for sprite drawing. You have 15 usable
colours per palette.
* Colours are *12-bit `$0RGB`*: bits 11..8 red, 7..4 green, 3..0 blue.
* Origin `(0, 0)` is *top-left*. X increases right, Y increases down.
Coordinates are signed `int16_t`, so off-screen positions are representable.
Every drawing primitive clips to the surface rectangle. An out-of-bounds draw
is a silent no-op, never an error. There are no "fast unclipped" variants in
the public API, so you cannot corrupt memory by drawing off the edge.
=== What the Library Hides
The five machines store pixels in genuinely incompatible ways. JoeyLib keeps
that out of your program:
[cols="2,3", options="header"]
|===
|Platform |In-memory pixel storage
|Apple IIgs, MS-DOS
|Chunky 4bpp packed: two pixels per byte, high nibble is the left pixel.
|Commodore Amiga
|Native planar: four separate bitplanes.
|Atari ST
|Native planar, word-interleaved: four plane words per 16 pixels, per row.
|Sharp X68000
|Text-plane planes, four planes 128 KB apart.
|===
Every public entry point speaks in *colour indices, 0 to 15*. Your code never
sees the storage format. The one place the difference leaks through is
documented and flagged in this manual -- see <<_writing_portable_code>>.
=== What You Get
* Surfaces, palettes and per-scanline palette selection
* Drawing primitives: pixels, lines, rectangles, circles, flood fill
* 8x8 tiles, tile banks, tile maps and bitmap text
* Sprites with colour-0 transparency and *runtime-compiled blitters* -- the
library emits native 65816, 68000 or x86 code for each sprite at run time
* Keyboard (both game-style predicates and a typed-character queue), mouse
and joystick
* Four-channel Protracker `.MOD` music, a portable chip-tracker music format,
digital one-shot and streaming sound effects, and a PSG-style tone and
noise layer
* Read-only game data files and writable save files
* An optional RS-232 serial add-on
* A deterministic, seedable random generator that produces *bit-identical*
output on every port for a given seed
=== How to Read This Manual
If you are new to JoeyLib, read <<_getting_started>>, then
<<_anatomy_of_a_joeylib_program>>, then <<_writing_portable_code>>. Those three
chapters are the whole programming model.
<<_creating_assets>> covers getting graphics, music and sound into a form the
library can load.
<<_adding_your_program_to_the_build>> explains how to get your own program
compiling for all five targets.
The API Reference documents every public function: its exact signature, each
parameter, what it returns, and any per-platform behaviour you need to know
about.

278
docs/Portability.adoc Normal file
View file

@ -0,0 +1,278 @@
== Writing Portable Code
A JoeyLib program that follows the rules in this chapter compiles and behaves
the same on all five machines. Most of the rules exist because one particular
target cannot do something the others can, and the Apple IIgs is usually that
target.
=== The Rules, in Short
. Use `jlAlloc` for anything large, never `malloc`.
. Never assume a frame rate. Ask `jlFrameHz()` or use `jlMillisElapsed()`.
. Never assume colour 0 is anything but black.
. Compile your sprites before you draw them.
. Keep large objects out of function-local variables on the IIgs.
. Use `jlRandom`, not `rand`, when identical behaviour across ports matters.
. Keep file names short, uppercase-safe and 8.3-compatible.
. Do not mix save-under with clean-buffer erasing in the same screen region.
. Pick one of the audio layers per scene, not two.
The rest of this chapter explains each one.
=== Memory
This is the rule that catches everyone. *The C heap on the IIgs is tiny.* It
lives in bank 0, and a single `malloc` is capped at roughly 32 KB -- often far
less in practice, because the clang libc heap on that port can be a few hundred
bytes. A program that `malloc`s a 32000-byte buffer works on four targets and
fails on the reference one.
Use the library allocator, which is backed by each platform's native allocator
(the Memory Manager on the IIgs):
[source,c]
----
uint8_t *buffer = (uint8_t *)jlAlloc(32000);
if (buffer == NULL) {
// handle it -- this genuinely can fail on a stock machine
}
// ...
jlFree(buffer);
----
`jlAlloc` does *not* zero the memory it returns.
For bulk copies, prefer `jlMemCopy` over `memcpy`. It has the same contract
(no overlap) but the IIgs implements it as `MVN` block moves at 7 cycles per
byte instead of a libc byte loop, split correctly at bank boundaries.
==== Large Local Variables
On the IIgs, a large function-local variable in a deep call chain can silently
overflow the soft stack and crash -- roughly 2 KB is enough to do it, and the
trigger is call depth rather than anything the compiler warns about. Make big
buffers `static`, or allocate them with `jlAlloc`:
[source,c]
----
// DANGEROUS on the IIgs, especially deep in a call chain:
void renderScene(void) {
uint8_t scratch[2048];
// ...
}
// Safe everywhere:
void renderScene(void) {
static uint8_t scratch[2048];
// ...
}
----
=== Timing
Covered in <<_frame_rate_independence>>, and worth repeating because it is the
second most common portability bug: the targets run at 50, 60 and 70 Hz. Scale
by `jlFrameHz()` or work from `jlMillisElapsed()`.
`jlFrameCount()` is a 16-bit counter, so you must poll it faster than twice the
frame rate or you will miss edges, and it wraps roughly every 18 minutes at
60 Hz.
=== Colour and the Palette
Colour 0 is black in every palette on every platform, and `jlPaletteSet`
silently rewrites entry 0 to `$000` no matter what you pass. Do not design
artwork that needs colour 0 to be something else -- you have 15 free colours per
palette, and 16 palettes to switch between per scanline.
Colour 0 is also the transparent colour for sprites. A sprite pixel of colour 0
is never drawn. If you need an opaque rectangle, use the tile operations, which
have both opaque and masked forms.
=== Sprites
Two rules.
*Compile before drawing.* `jlSpriteDraw` never compiles on demand. An
uncompiled sprite runs through the interpreter at roughly 1/40th the speed, and
nothing warns you.
*Align your backup buffers.* Give any `jlSpriteBackupT` backing array 2-byte
alignment, by declaring it `uint16_t` or with an alignment attribute. An odd
buffer address routes save and restore onto slow interpreted paths on the
planar ports, because the 68000 cannot perform word accesses at odd addresses.
[source,c]
----
// Right: 2-byte aligned by construction.
static uint16_t gBacking[JOEY_SPRITE_BACKUP_BYTES(4, 4) / 2];
// Risky: may land on an odd address.
static uint8_t gBacking[JOEY_SPRITE_BACKUP_BYTES(4, 4)];
----
Note also that on the planar ports the region a save-under actually captures is
the *16-pixel-aligned window* containing the sprite, not the sprite rectangle.
`jlSpriteSaveUnder` reports the true geometry in the backup structure's
`x`, `width`, `height` and `sizeBytes` fields. It is that recorded window you
must not overwrite before restoring.
=== Randomness
`rand()` differs wildly between C libraries, so a procedurally generated level
would not match across ports. `jlRandom` is xorshift32 and produces
*bit-identical* output on every platform for a given seed:
[source,c]
----
jlRandomSeed(12345);
uint16_t roll = jlRandomRange(6); // 0..5, same everywhere
----
A seed of 0 is remapped to 1, because xorshift cannot escape the all-zero
state. This generator is fine for games and useless for cryptography.
=== Files
Two directories sit beside the executable, and the library prefixes them for
you:
* `DATA/` -- read-only game content, opened with `jlDataOpen`
* `SAVES/` -- writable saves and preferences, via the `jlSave*` calls
Pass bare names: `jlDataOpen("levels/title.dat", "rb")`, never
`"DATA/levels/title.dat"`.
Keep names portable across the retro filesystems this library targets: *at most
8 characters plus a short extension, letters and digits only*, so they satisfy
both ProDOS and DOS 8.3. Do not rely on case being preserved.
A failed save is not fatal -- the calls report failure and your program should
degrade gracefully, because on some volumes `SAVES/` simply is not writable.
=== Audio
JoeyLib gives you three sound layers, and on two platforms they share physical
hardware:
[cols="2,5", options="header"]
|===
|Layer |What it is
|Module playback
|`jlAudioPlayMod` -- four-channel Protracker `.MOD`, mixed in software on
DOS and ST, hardware-replayed on Amiga and IIgs.
|Chip tracker
|`jlMusicPlay` -- a portable event stream driving three tone voices plus
noise, at register-write cost with no CPU mixing.
|Voices and effects
|`jlAudioVoice`, `jlAudioNoise`, `jlAudioPlaySfx` and the streaming forms.
|===
[WARNING]
====
On the Atari ST and Amiga, the tone voices *are* the same hardware the module
player drives. While a `.MOD` is playing, `jlAudioVoice` writes fight the
player's register writes and lose. Choose the module layer or the voice and
tracker layer *per scene*, not both at once.
====
The chip tracker is the portable choice for music. It costs almost nothing on
every port -- unlike `.MOD` playback, which needs real CPU mixing and is
marginal on the ST.
On the IIgs, `jlAudioVoice` and `jlAudioNoise` require `jlAudioInit` (they ride
the tracker engine's DOC path) and behave as retriggered one-shots of about
80 ms, so a note held longer must be re-asserted roughly once a frame. On DOS,
ST and Amiga those two calls are direct hardware programming and work without
`jlAudioInit`.
A failed `jlAudioInit` is *not* fatal. `jlInit` still succeeds and every audio
call becomes a silent no-op, so a program need not treat missing sound as an
error.
=== Per-Platform Behaviour Summary
The API is uniform; these are the places where what you get differs.
[cols="2,2,2,2,2,2", options="header"]
|===
|Feature |IIgs |Amiga |Atari ST |MS-DOS |X68000
|Frame rate
|60 Hz
|50 Hz (PAL)
|60 Hz (NTSC)
|70 Hz
|60 Hz
|Pixel storage
|chunky 4bpp
|planar
|planar, interleaved
|chunky 4bpp
|text-plane planar
|Stereo pan
|all three
|left and right
|ignored (mono)
|ignored (mono)
|ignored
|`jlAudioTone`
|DOC oscillator
|Paula
|YM2149
|PIT counter 2
|-
|Joystick
|analog, auto-disables
|digital
|digital
|digital
|digital
|Keyboard layout
|machine's own
|machine's own
|machine's own
|fixed US
|machine's own
|Serial ports
|modem, printer, slot card
|`serial.device`
|Modem port
|COM1..COM4
|-
|===
The joystick row deserves a note: the IIgs port *auto-disables* joystick
polling after a short window with no stick detected, because polling costs
about 3 ms per frame there. It does not re-probe on its own. If a user plugs a
stick in while the program is running, you must call `jlJoystickReset` to
resume polling.
=== A Portability Checklist
Before you call a program portable, confirm:
* [ ] No `malloc` of anything over a few hundred bytes -- use `jlAlloc`.
* [ ] No large function-local arrays; they are `static` or heap.
* [ ] Movement and animation scale with `jlFrameHz()` or `jlMillisElapsed()`.
* [ ] `jlInputPoll()` is called exactly once per frame.
* [ ] `jlAudioFrameTick()` is called every frame.
* [ ] Every sprite is compiled during loading.
* [ ] Sprite backup buffers are 2-byte aligned and sized with the macro.
* [ ] Save file names are 8.3-safe.
* [ ] Only one audio layer is active per scene.
* [ ] `jlShutdown()` runs on every exit path.
* [ ] The program has actually been run on all five targets, not just built.
That last item is the one that matters. All five ports have emulator run
scripts under `scripts/`, and building successfully proves considerably less
than running.

359
docs/ProgramLayout.adoc Normal file
View file

@ -0,0 +1,359 @@
== Anatomy of a JoeyLib Program
Every JoeyLib program has the same four-part shape: configure and initialise,
set up the screen and load assets, run a frame loop, shut down. This chapter
walks through each part and ends with a complete annotated skeleton you can
copy.
=== Part 1: Configure and Initialise
[source,c]
----
jlConfigT config;
config.codegenBytes = 32UL * 1024; // sprite blitter cache
config.audioBytes = 0; // reserved, not yet consulted
if (!jlInit(&config)) {
// jlLastError() explains why. There is no display yet, so on a
// real machine the only place to report this is stderr or a log.
return 1;
}
----
`codegenBytes` sizes the *codegen arena*: the scratch memory the library emits
native sprite blitter code into. Getting it wrong is the most common cause of
sprites drawing slowly, so it is worth understanding.
* `0` selects a per-platform default: 8 KB on the IIgs, 32 KB on
Amiga, ST and DOS.
* The 68000 and x86 emitters produce code roughly four to eight times less
dense than the 65816 one, so the same sprite needs far more arena space on
those ports. A 32x32 sprite can need 8 to 16 KB there.
* Measure, do not guess: after compiling a sprite, `jlSpriteCompiledSize()`
reports its exact cost on the current platform.
* On the IIgs the arena is one Memory Manager block that may not cross a 64 KB
bank boundary, so requests above 65536 are clamped to 65536 -- and even a
clamped request can fail if no free bank exists.
If the arena is too small, sprites still draw: they fall back to an
interpreter that is roughly *forty times slower*. Nothing reports this as an
error, so a program that never checks can be silently crawling.
=== Part 2: The Stage, the Palette and Assets
[source,c]
----
jlSurfaceT *stage = jlStageGet();
----
The *stage* is the library-owned back buffer. You never create or destroy it;
it is valid from `jlInit` to `jlShutdown`. All drawing goes here, and
`jlStagePresent()` copies it to the real display.
Set up colour before drawing:
[source,c]
----
uint16_t colors[SURFACE_COLORS_PER_PALETTE];
// ... fill in 16 entries of $0RGB ...
jlPaletteSet(stage, 0, colors);
jlScbSetRange(stage, 0, SURFACE_HEIGHT - 1, 0);
----
Each surface carries *16 palettes of 16 colours* plus a 200-entry table (one
per scanline) selecting which palette that line uses. That table is the *SCB*,
or scanline control byte table. Using one palette everywhere is the simple
case; assigning different palettes to different bands is how a JoeyLib program
shows more than 16 colours at once.
Load assets after init, because sprite loading needs the codegen arena:
[source,c]
----
jlSpriteT *cels[16];
uint16_t palette[SURFACE_COLORS_PER_PALETTE];
uint16_t count = jlSpriteBankLoad("player.spr", cels, 16, palette);
uint16_t i;
for (i = 0; i < count; i++) {
jlSpritePrewarm(cels[i]); // compile NOW, not mid-frame
}
----
[IMPORTANT]
====
`jlSpriteDraw` does *not* compile a sprite on demand. An uncompiled sprite
draws through the interpreter every single time. Call `jlSpriteCompile` or
`jlSpritePrewarm` during loading -- otherwise the first draw of each sprite
either stalls a frame (if you compile lazily yourself) or never gets fast at
all.
====
=== Part 3: The Frame Loop
The library does not schedule anything. You own the loop. The canonical shape:
[source,c]
----
while (running) {
jlWaitVBL(); // pace to the display's refresh
jlInputPoll(); // refresh keyboard, mouse, joystick
jlAudioFrameTick(); // pump the audio engine
if (jlKeyPressed(KEY_ESCAPE)) {
running = false;
}
updateGame(); // your logic
renderFrame(stage); // your drawing
jlStagePresent(); // make it visible
}
----
Four library calls, in that order, and everything else is yours.
`jlWaitVBL()` blocks until the next vertical blank. It is cheap on every port
because the underlying mechanism is always a hardware wait, never a software
timer.
`jlInputPoll()` must be called exactly once per frame. The edge predicates
(`jlKeyPressed`, `jlKeyReleased`, and the mouse and joystick equivalents) are
defined relative to the *previous poll*, so calling it twice per frame makes
edges disappear, and not calling it means nothing ever changes.
`jlAudioFrameTick()` is a no-op on ports whose audio runs from a hardware
interrupt, but it is required on ports that mix in user context, and it is what
advances tracker music and keeps streaming sound effects fed. Call it
unconditionally, once per frame.
`jlStagePresent()` flips the dirty parts of the stage to the display. The
drawing primitives record which rows they touched, so presenting only copies
what actually changed. You do not need to tell it what you drew.
[NOTE]
====
If you write pixels by some route other than the library's primitives, the
dirty tracking does not know about it, and `jlStagePresent` will skip those
rows. That is the one case where you have to mark the region yourself.
====
==== Frame-Rate Independence
The five targets do not share a refresh rate: 50 Hz (Amiga PAL), 60 Hz (IIgs,
ST NTSC, X68000) and 70 Hz (VGA mode 13h). A loop that moves a sprite "two
pixels per frame" runs 40 percent faster on DOS than on a PAL Amiga.
Two ways to handle it. Ask the frame rate and scale your per-frame step:
[source,c]
----
uint16_t hz = jlFrameHz(); // 50, 60 or 70
----
Or drive from the millisecond clock, which is the more robust choice for
anything that must feel the same everywhere:
[source,c]
----
uint32_t now = jlMillisElapsed();
uint32_t step = now - previous;
previous = now;
----
`jlMillisElapsed()` reports true elapsed wall-clock time, so a frame that took
a long time to render is accounted for correctly rather than counted as one
tick.
=== Part 4: Shut Down
[source,c]
----
jlAudioShutdown(); // if you called jlAudioInit
jlShutdown();
return 0;
----
`jlShutdown` restores the display mode, frees the library's allocations, and
un-hooks interrupt vectors. Sprites and surfaces you created are released with
it, but the buffers *you* allocated (including tile data you handed to
`jlSpriteCreate`, which is never copied) remain yours to free.
=== The Complete Skeleton
This compiles and runs on all five targets as-is.
[source,c]
----
// A minimal but complete JoeyLib program.
#include <joey/joey.h>
#define PLAYER_SPEED 2
static jlSurfaceT *gStage;
static bool gRunning = true;
static int16_t gPlayerX = 160;
static int16_t gPlayerY = 100;
static void buildPalette(void);
static void handleInput(void);
static void renderFrame(void);
static void buildPalette(void) {
uint16_t colors[SURFACE_COLORS_PER_PALETTE];
uint8_t i;
for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) {
colors[i] = 0x000;
}
colors[1] = 0xFFF; // white
colors[2] = 0x0C0; // green
colors[3] = 0x00C; // blue
jlPaletteSet(gStage, 0, colors);
jlScbSetRange(gStage, 0, SURFACE_HEIGHT - 1, 0);
}
static void handleInput(void) {
if (jlKeyPressed(KEY_ESCAPE)) {
gRunning = false;
}
if (jlKeyDown(KEY_LEFT)) {
gPlayerX -= PLAYER_SPEED;
}
if (jlKeyDown(KEY_RIGHT)) {
gPlayerX += PLAYER_SPEED;
}
if (jlKeyDown(KEY_UP)) {
gPlayerY -= PLAYER_SPEED;
}
if (jlKeyDown(KEY_DOWN)) {
gPlayerY += PLAYER_SPEED;
}
// A joystick, if one is attached, drives the same movement.
if (jlJoystickConnected(JOYSTICK_0)) {
if (jlJoystickX(JOYSTICK_0) < -64) {
gPlayerX -= PLAYER_SPEED;
}
if (jlJoystickX(JOYSTICK_0) > 64) {
gPlayerX += PLAYER_SPEED;
}
}
// Keep the player on screen: the primitives would clip safely, but
// clamping keeps the object whole.
if (gPlayerX < 0) {
gPlayerX = 0;
}
if (gPlayerX > SURFACE_WIDTH - 16) {
gPlayerX = SURFACE_WIDTH - 16;
}
if (gPlayerY < 0) {
gPlayerY = 0;
}
if (gPlayerY > SURFACE_HEIGHT - 16) {
gPlayerY = SURFACE_HEIGHT - 16;
}
}
static void renderFrame(void) {
jlSurfaceClear(gStage, 0);
jlDrawRect(gStage, 0, 0, SURFACE_WIDTH, SURFACE_HEIGHT, 1);
jlFillRect(gStage, gPlayerX, gPlayerY, 16, 16, 2);
}
int main(void) {
jlConfigT config;
config.codegenBytes = 32UL * 1024;
config.audioBytes = 0;
if (!jlInit(&config)) {
return 1;
}
gStage = jlStageGet();
if (gStage == NULL) {
jlShutdown();
return 1;
}
buildPalette();
jlRandomSeed(12345); // deterministic on every port
while (gRunning) {
jlWaitVBL();
jlInputPoll();
jlAudioFrameTick();
handleInput();
renderFrame();
jlStagePresent();
}
jlShutdown();
return 0;
}
----
=== Two Drawing Strategies
Once you have moving objects over a background, you need to erase them. There
are two patterns, and *mixing them in the same region corrupts the screen*.
==== Save-Under and Restore
Before drawing a sprite, capture what is underneath; next frame, put it back.
[source,c]
----
uint16_t backing[JOEY_SPRITE_BACKUP_BYTES(2, 2) / 2];
jlSpriteBackupT backup;
backup.bytes = (uint8_t *)backing;
backup.sizeBytes = sizeof(backing);
// each frame:
jlSpriteRestoreUnder(stage, &backup); // erase last frame's sprite
jlSpriteSaveAndDraw(stage, sprite, x, y, &backup);
----
Size the buffer with the `JOEY_SPRITE_BACKUP_BYTES` macro, and declare it as
`uint16_t` (or otherwise 2-byte aligned) -- an odd address pushes save and
restore onto slow interpreted paths on the planar ports, because the 68000
cannot do word accesses at odd addresses.
==== The Clean-Buffer Pattern
Keep a pristine copy of the background, and restore rectangles from it.
[source,c]
----
jlSurfaceT *clean = jlSurfaceCreate();
// ... draw the static scene into clean, once ...
// each frame:
jlSurfaceCopyRect(stage, clean, x, y, w, h); // erase EVERY window first
// ... then draw ALL sprites ...
----
This scales better with many sprites, and it is the right choice when sprites
overlap. Erase every window first, then draw every sprite -- never interleave
erase and draw.
[WARNING]
====
`jlSurfaceCopyRect` widens the copied region outward to the 16-pixel group grid
on *every* port, so it can touch up to 15 pixels either side of the rectangle
you asked for. That means it can overwrite content just outside your rectangle,
and it silently invalidates any live `jlSpriteBackupT` whose recorded window it
touches. This is exactly why the two patterns must not be mixed in one region.
====

View file

@ -141,7 +141,7 @@ static unsigned char gBackupBytes[JOEY_SPRITE_BACKUP_BYTES(2, 2)] __attribute__(
static jlTileT gTileScratch; static jlTileT gTileScratch;
// Phase 0 workload rows (NATIVE-PERF-PLAN.md item 3): patterned tiles // Phase 0 workload rows (claudeDocs/NATIVE-PERF-PLAN.md item 3): patterned tiles
// seeded via draw+snap (tile pixels are platform-private; only a snap // seeded via draw+snap (tile pixels are platform-private; only a snap
// round-trips portably) and dedicated gameFrame sprite backups so the // round-trips portably) and dedicated gameFrame sprite backups so the
// correctness-check gBackup state stays untouched. // correctness-check gBackup state stays untouched.
@ -338,7 +338,7 @@ static void op_surfaceMarkDirty(void) { /* jlDrawPixel already marks; use fill i
jlFillRect(gStage, 0, 0, 32, 32, 0); } jlFillRect(gStage, 0, 0, 32, 32, 0); }
// ----- Phase 0 workload ops (NATIVE-PERF-PLAN.md item 3) ----- // ----- Phase 0 workload ops (claudeDocs/NATIVE-PERF-PLAN.md item 3) -----
// Worst-case unaligned x on every port at once: odd -> IIgs/DOS // Worst-case unaligned x on every port at once: odd -> IIgs/DOS
// compiled shift-1 (widened RMW row); x % 8 == 7 -> ST shift index 2 // compiled shift-1 (widened RMW row); x % 8 == 7 -> ST shift index 2
@ -1387,7 +1387,7 @@ static void DRAWSHOWCASE_ATTR drawShowcase(void) {
// Each check draws a deterministic scene and logs the surface hash so // Each check draws a deterministic scene and logs the surface hash so
// tools/diff-uber-hashes can compare ports against each other and against // tools/diff-uber-hashes can compare ports against each other and against
// the frozen golden logs. Several checks intentionally capture the CURRENT // the frozen golden logs. Several checks intentionally capture the CURRENT
// behavior of known bugs (PERF-AUDIT.md #1, #11, #12, #72): their hash // behavior of known bugs (claudeDocs/PERF-AUDIT.md #1, #11, #12, #72): their hash
// lines are EXPECTED to change when the Phase 1 fixes land -- re-golden // lines are EXPECTED to change when the Phase 1 fixes land -- re-golden
// exactly those lines then, nothing else. // exactly those lines then, nothing else.
// //
@ -1729,7 +1729,7 @@ static void __attribute__((noinline)) runAllTests(void) {
// Surface mark dirty (via jlFillRect's mark step). // Surface mark dirty (via jlFillRect's mark step).
timeOp("surfaceMarkDirtyRect (via jlFillRect 32x32)", op_surfaceMarkDirty); timeOp("surfaceMarkDirtyRect (via jlFillRect 32x32)", op_surfaceMarkDirty);
// ----- Phase 0 workload rows (NATIVE-PERF-PLAN.md item 3). Appended // ----- Phase 0 workload rows (claudeDocs/NATIVE-PERF-PLAN.md item 3). Appended
// last on purpose: timed hashes are cumulative and the correctness // last on purpose: timed hashes are cumulative and the correctness
// checks clear the stage first, so the frozen lines above stay // checks clear the stage first, so the frozen lines above stay
// bit-identical (Phase-7 golden-extension precedent). // bit-identical (Phase-7 golden-extension precedent).

View file

@ -122,7 +122,7 @@ void jlInputPoll(void);
// the keyboard layout are already applied by the backend, so // the keyboard layout are already applied by the backend, so
// punctuation and shifted symbols arrive correctly on every port. // punctuation and shifted symbols arrive correctly on every port.
// (The layout is the machine's own on IIgs/Amiga/ST/X68000; the DOS // (The layout is the machine's own on IIgs/Amiga/ST/X68000; the DOS
// port translates with a fixed US layout -- see docs/input.md.) // port translates with a fixed US layout -- see claudeDocs/input.md.)
// //
// The queue is refilled by jlInputPoll() -- no extra bring-up is // The queue is refilled by jlInputPoll() -- no extra bring-up is
// needed. It holds JL_CHAR_QUEUE_SIZE - 1 characters; when full, // needed. It holds JL_CHAR_QUEUE_SIZE - 1 characters; when full,

View file

@ -3,7 +3,7 @@
// Colors are 12-bit $0RGB (bits 11..8 red, 7..4 green, 3..0 blue). // Colors are 12-bit $0RGB (bits 11..8 red, 7..4 green, 3..0 blue).
// The library forces color 0 of every palette to black; jlPaletteSet // The library forces color 0 of every palette to black; jlPaletteSet
// silently masks that entry to $000 regardless of what the caller // silently masks that entry to $000 regardless of what the caller
// passed. See docs/DESIGN.md sections 5 and 10. // passed. See claudeDocs/DESIGN.md sections 5 and 10.
#ifndef JOEYLIB_PALETTE_H #ifndef JOEYLIB_PALETTE_H
#define JOEYLIB_PALETTE_H #define JOEYLIB_PALETTE_H

View file

@ -84,7 +84,7 @@
// under MAME with the wait-state patch: TVRAM costs 1.92 cycles/access // under MAME with the wait-state patch: TVRAM costs 1.92 cycles/access
// against GVRAM's 1.03, but needs only 0.25 word-writes per 16-colour pixel // against GVRAM's 1.03, but needs only 0.25 word-writes per 16-colour pixel
// against GVRAM's 1.0 -- so the text plane still wins ~2x. See // against GVRAM's 1.0 -- so the text plane still wins ~2x. See
// docs/x68000_mame_crtc_timing.md. Do not switch to JOEYLIB_NATIVE_PLANAR // claudeDocs/x68000_mame_crtc_timing.md. Do not switch to JOEYLIB_NATIVE_PLANAR
// until that is settled and measured. // until that is settled and measured.
#define JOEYLIB_CPU_68000 #define JOEYLIB_CPU_68000
#define JOEYLIB_ENDIAN_BIG #define JOEYLIB_ENDIAN_BIG

View file

@ -5,7 +5,7 @@
// planar platforms (Amiga, Atari ST) this is a chunky-to-planar // planar platforms (Amiga, Atari ST) this is a chunky-to-planar
// conversion. Drawing primitives mark per-row dirty ranges on the // conversion. Drawing primitives mark per-row dirty ranges on the
// stage as a side effect, so jlStagePresent only touches rows that // stage as a side effect, so jlStagePresent only touches rows that
// actually changed since the last present. See docs/DESIGN.md. // actually changed since the last present. See claudeDocs/DESIGN.md.
#ifndef JOEYLIB_PRESENT_H #ifndef JOEYLIB_PRESENT_H
#define JOEYLIB_PRESENT_H #define JOEYLIB_PRESENT_H

View file

@ -18,7 +18,7 @@
// variants into the codegen arena and the draw/save/restore calls // variants into the codegen arena and the draw/save/restore calls
// dispatch to the compiled routines, falling back to the interpreter // dispatch to the compiled routines, falling back to the interpreter
// for clipped draws or uncompiled variants. On the IIgs the compiled // for clipped draws or uncompiled variants. On the IIgs the compiled
// path is temporarily toolchain-gated off (PERF-AUDIT.md #77). // path is temporarily toolchain-gated off (claudeDocs/PERF-AUDIT.md #77).
// jlSpritePrewarm compiles ahead of the first draw so the emit cost // jlSpritePrewarm compiles ahead of the first draw so the emit cost
// does not land mid-frame. // does not land mid-frame.

View file

@ -8,7 +8,7 @@
// regardless, so game code does not see the storage format. // regardless, so game code does not see the storage format.
// //
// Each surface carries its own 200-entry SCB (scanline control byte) // Each surface carries its own 200-entry SCB (scanline control byte)
// table and a 16-by-16 $0RGB palette set. See docs/DESIGN.md section 6. // table and a 16-by-16 $0RGB palette set. See claudeDocs/DESIGN.md section 6.
#ifndef JOEYLIB_SURFACE_H #ifndef JOEYLIB_SURFACE_H
#define JOEYLIB_SURFACE_H #define JOEYLIB_SURFACE_H

View file

@ -2,7 +2,7 @@
# #
# BRING-UP STATE. This builds libjoey.a and the serial example against the # BRING-UP STATE. This builds libjoey.a and the serial example against the
# generic backend -- no native primitives, no audio, no sprite codegen yet. # generic backend -- no native primitives, no audio, no sprite codegen yet.
# See docs/x68000_port.md for what is and is not real. # See claudeDocs/x68000_port.md for what is and is not real.
# #
# The toolchain is elf2x68k (BSD-3, gcc 13.4.0 / binutils / newlib), staged into # The toolchain is elf2x68k (BSD-3, gcc 13.4.0 / binutils / newlib), staged into
# toolchains/x68000 by the tarball. m68k-xelf-gcc emits ELF; the driver's # toolchains/x68000 by the tarball. m68k-xelf-gcc emits ELF; the driver's

View file

@ -7,7 +7,7 @@
# kills DOSBox. tools/uber-perf-table reads build/dos/bin/JOEYLOG.TXT. # kills DOSBox. tools/uber-perf-table reads build/dos/bin/JOEYLOG.TXT.
# #
# Uses the same dosbox-386sx16.conf as run-dos.sh so the cycle budget # Uses the same dosbox-386sx16.conf as run-dos.sh so the cycle budget
# matches the PERF.md reference machine. # matches the claudeDocs/PERF.md reference machine.
# #
# scripts/bench-dos.sh # default 90s timeout # scripts/bench-dos.sh # default 90s timeout
# TIMEOUT=120 scripts/bench-dos.sh # override # TIMEOUT=120 scripts/bench-dos.sh # override

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# bench-iigs.sh - Headless UBER perf capture for the IIgs (the reference # bench-iigs.sh - Headless UBER perf capture for the IIgs (the reference
# port). Until this script existed, PERF.md's IIgs column was carried over # port). Until this script existed, claudeDocs/PERF.md's IIgs column was carried over
# from old captures because nothing could get joeylog.txt off the emulated # from old captures because nothing could get joeylog.txt off the emulated
# disk; every "hashes identical x4" gate needs this. # disk; every "hashes identical x4" gate needs this.
# #

57
scripts/build-docs.sh Executable file
View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# build-docs.sh - render the JoeyLib manual to HTML and PDF.
#
# Sources are docs/Manual.adoc plus the chapter files it includes, all in
# docs/. Output goes to build/docs/, which is gitignored along with the rest
# of build/ -- the .adoc sources are the tracked deliverable, the rendered
# books are regenerated on demand.
#
# The HTML is built with -a data-uri so pictures are embedded and the page is
# a single self-contained file that can be copied anywhere without an images
# folder beside it.
#
# Usage: scripts/build-docs.sh [html|pdf]
# with no argument, both.
# Requires: asciidoctor and asciidoctor-pdf
# gem install asciidoctor asciidoctor-pdf rouge
set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
src=$repo/docs/Manual.adoc
out=$repo/build/docs
what=${1:-both}
case "$what" in
html|pdf|both) ;;
*) echo "usage: $0 [html|pdf]" >&2; exit 2 ;;
esac
[ -f "$src" ] || { echo "build-docs: missing $src" >&2; exit 2; }
need=(asciidoctor)
[ "$what" = "html" ] || need+=(asciidoctor-pdf)
for tool in "${need[@]}"; do
command -v "$tool" >/dev/null || {
echo "build-docs: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
exit 2
}
done
mkdir -p "$out"
# -a allow-uri-read lets the SVG cover image be read from assets/.
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"
echo "build-docs: $out/JoeyLib-Manual.html"
fi
if [ "$what" = "pdf" ] || [ "$what" = "both" ]; then
asciidoctor-pdf "${common[@]}" \
"$src" -o "$out/JoeyLib-Manual.pdf"
echo "build-docs: $out/JoeyLib-Manual.pdf"
fi
ls -la "$out"

View file

@ -6,7 +6,7 @@
# #
# EXPECTED TO FAIL until the Phase 1 #23 fix lands (the clock runs # EXPECTED TO FAIL until the Phase 1 #23 fix lands (the clock runs
# backward at the 65536-frame wrap); after that it must PASS and becomes # backward at the 65536-frame wrap); after that it must PASS and becomes
# the regression gate. See PERF-AUDIT-PLAN.md Phase 0/Phase 1. # the regression gate. See claudeDocs/PERF-AUDIT-PLAN.md Phase 0/Phase 1.
set -euo pipefail set -euo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)

53
scripts/pulseRecordLib.sh Normal file
View file

@ -0,0 +1,53 @@
# pulseRecordLib.sh - shared PulseAudio capture helpers. SOURCE, do not run.
#
# Used by verify-amiga-audio.sh and verify-dos-audio.sh, the two gates that
# grade recorded sound because Paula and the Sound Blaster cannot be tapped
# register-by-register under fs-uae and DOSBox.
#
# The recording scheme in both: create a null sink for THIS run, point only
# the emulator at it with PULSE_SINK, and record that sink's monitor with
# parec. The host's default sink is never touched, so the gates are safe to
# run on a desktop that is playing something else, and two gates can never
# collide because the sink name carries the pid.
# pulseEnsureServer -- locate the audio server, or fail with a real reason.
#
# A sanitised environment (cron, CI, `env -i`, a systemd unit without the
# user session) has no XDG_RUNTIME_DIR, and pactl then reports "no server"
# even though one is running -- measured: `env -i make verify-audio` skipped
# both recording gates for exactly this. The socket lives at a predictable
# path, so derive it before giving up.
pulseEnsureServer() {
if pactl info >/dev/null 2>&1; then
return 0
fi
if [ -z "${XDG_RUNTIME_DIR:-}" ] && [ -S "/run/user/$(id -u)/pulse/native" ]; then
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
if pactl info >/dev/null 2>&1; then
return 0
fi
fi
if [ -z "${PULSE_SERVER:-}" ] && [ -S "/run/user/$(id -u)/pulse/native" ]; then
export PULSE_SERVER="unix:/run/user/$(id -u)/pulse/native"
if pactl info >/dev/null 2>&1; then
return 0
fi
fi
return 1
}
# pulseCreateSink <name> -- echo the module id of a new null sink.
pulseCreateSink() {
pactl load-module module-null-sink \
sink_name="$1" \
sink_properties=device.description="$1" 2>/dev/null
}
# pulseUnloadSink <moduleId> -- always call this; a leaked null sink on the
# user's audio server is the one lasting side effect these gates can have.
pulseUnloadSink() {
[ -n "${1:-}" ] && pactl unload-module "$1" 2>/dev/null
return 0
}

View file

@ -49,14 +49,18 @@ prog=${1:-Audio}
binDir=$repo/build/amiga/bin binDir=$repo/build/amiga/bin
kickstart=$repo/toolchains/emulators/support/kickstart.rom kickstart=$repo/toolchains/emulators/support/kickstart.rom
grader=$repo/tools/gradeAudioPhases.py grader=$repo/tools/gradeAudioPhases.py
pulseLib=$repo/scripts/pulseRecordLib.sh
label=verify-amiga-audio label=verify-amiga-audio
[ -f "$kickstart" ] || { echo "$label: missing $kickstart" >&2; exit 2; } [ -f "$kickstart" ] || { echo "$label: missing $kickstart" >&2; exit 2; }
[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; } [ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
[ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; }
# shellcheck source=scripts/pulseRecordLib.sh
. "$pulseLib"
for tool in fs-uae Xvfb xdotool parec pactl python3; do for tool in fs-uae Xvfb xdotool parec pactl python3; do
command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; } command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
done done
pactl info >/dev/null 2>&1 || { echo "$label: no PulseAudio/PipeWire server to record from" >&2; exit 2; } pulseEnsureServer || { echo "$label: no PulseAudio/PipeWire server to record from (tried XDG_RUNTIME_DIR and /run/user/$(id -u)/pulse/native)" >&2; exit 2; }
if [ -d "$binDir/$prog" ]; then if [ -d "$binDir/$prog" ]; then
progDir=$binDir/$prog progDir=$binDir/$prog
@ -80,7 +84,7 @@ cleanup() {
[ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null [ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null
# Always unload the sink: leaving null sinks behind on the user's # Always unload the sink: leaving null sinks behind on the user's
# audio server is the one side effect this gate could have. # audio server is the one side effect this gate could have.
[ -n "$sinkModule" ] && pactl unload-module "$sinkModule" 2>/dev/null pulseUnloadSink "$sinkModule"
if [ -n "${AMIGA_AUDIO_KEEP:-}" ]; then if [ -n "${AMIGA_AUDIO_KEEP:-}" ]; then
echo "$label: kept $work" >&2 echo "$label: kept $work" >&2
else else
@ -101,9 +105,7 @@ fi
printf 'stack 32768\n:%s\n' "$prog" > "$work/hd/s/startup-sequence" printf 'stack 32768\n:%s\n' "$prog" > "$work/hd/s/startup-sequence"
sinkName="joeyAudioGate$$" sinkName="joeyAudioGate$$"
sinkModule=$(pactl load-module module-null-sink \ sinkModule=$(pulseCreateSink "$sinkName") || sinkModule=""
sink_name="$sinkName" \
sink_properties=device.description="$sinkName" 2>/dev/null) || sinkModule=""
[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; } [ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
Xvfb "$display" -screen 0 800x600x24 -nolisten tcp >/dev/null 2>&1 & Xvfb "$display" -screen 0 800x600x24 -nolisten tcp >/dev/null 2>&1 &

110
scripts/verify-audio-all.sh Executable file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
# verify-audio-all.sh - Run every port's audio gate and summarise.
#
# Driven by `make verify-audio`. Five gates, two kinds:
#
# iigs DOC wavetable + sound IRQ vector (register tap)
# x68000 YM2151 noise + PPI port C + ADPCM rate (register tap)
# atarist YM2149 write trace, with the pc (register tap)
# amiga recorded sound, four loudness phases (Paula has no tap)
# dos recorded sound, four loudness phases (SB has no tap)
#
# SEQUENTIAL, deliberately. The two recording gates drive the emulator on
# wall-clock sleeps and grade fixed time windows of the capture, so a
# second emulator competing for CPU can slide a phase boundary and fail a
# gate that is actually fine. Running five emulators at once also makes
# the timing-sensitive register gates noisier. The whole set is ~2.5
# minutes; parallelism is not worth the flakiness.
#
# Exit codes, distinguishing "it works" from "it did not run":
# 0 every selected gate PASSED
# 1 at least one gate FAILED (a real defect)
# 3 no failures, but at least one gate SKIPPED for a missing
# prerequisite -- an unbuilt port or absent emulator. Non-zero on
# purpose: a run that quietly tested nothing must not look green.
#
# Each gate already reports its own evidence line; this only adds the
# roll-up. Gate stdout/stderr is echoed as it happens, so a failure shows
# its diagnosis here and not just a verdict.
#
# Usage: scripts/verify-audio-all.sh [port ...]
# with no arguments, all five. Otherwise any of:
# iigs x68000 atarist amiga dos
# Env: AUDIO_ALL_LOG also append the full transcript to this file
# Requires: whatever each selected gate requires; missing ones SKIP.
set -uo pipefail
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
label=verify-audio-all
ALL_PORTS="iigs x68000 atarist amiga dos"
ports=${*:-$ALL_PORTS}
for p in $ports; do
case " $ALL_PORTS " in
*" $p "*) ;;
*) echo "$label: unknown port '$p' (choose from: $ALL_PORTS)" >&2; exit 2 ;;
esac
done
gateFor() { # $1 = port -> script + args
case $1 in
iigs) echo "scripts/verify-iigs-audio.sh staxi" ;;
x68000) echo "scripts/verify-x68000-audio.sh" ;;
atarist) echo "scripts/verify-atarist-audio.sh" ;;
amiga) echo "scripts/verify-amiga-audio.sh" ;;
dos) echo "scripts/verify-dos-audio.sh" ;;
esac
}
passed=""
failed=""
skipped=""
declare -A elapsed
for port in $ports; do
gate=$(gateFor "$port")
echo
echo "=== $label: $port ($gate) ==="
start=$SECONDS
if [ -n "${AUDIO_ALL_LOG:-}" ]; then
( cd "$repo" && $gate ) 2>&1 | tee -a "$AUDIO_ALL_LOG"
rc=${PIPESTATUS[0]}
else
( cd "$repo" && $gate )
rc=$?
fi
elapsed[$port]=$((SECONDS - start))
case $rc in
0) passed="$passed $port" ;;
2) skipped="$skipped $port" ;;
*) failed="$failed $port" ;;
esac
done
count() { set -- $1; echo $#; }
echo
echo "================ $label summary ================"
for port in $ports; do
case " $passed " in *" $port "*) verdict=PASS ;; *)
case " $skipped " in *" $port "*) verdict="SKIP (prerequisite missing)" ;; *)
verdict=FAIL ;;
esac ;;
esac
printf " %-8s %-28s %3ds\n" "$port" "$verdict" "${elapsed[$port]}"
done
echo " ----"
printf " %d passed, %d failed, %d skipped\n" \
"$(count "$passed")" "$(count "$failed")" "$(count "$skipped")"
if [ -n "$failed" ]; then
echo "$label: FAIL -$failed" >&2
exit 1
fi
if [ -n "$skipped" ]; then
echo "$label: INCOMPLETE - skipped$skipped (build those ports, then re-run)" >&2
exit 3
fi
echo "$label: PASS (all $(count "$passed") audio gates)"
exit 0

View file

@ -47,14 +47,18 @@ prog=${1:-AUDIO}
binDir=$repo/build/dos/bin binDir=$repo/build/dos/bin
conf=$repo/scripts/dosbox-386sx16.conf conf=$repo/scripts/dosbox-386sx16.conf
grader=$repo/tools/gradeAudioPhases.py grader=$repo/tools/gradeAudioPhases.py
pulseLib=$repo/scripts/pulseRecordLib.sh
label=verify-dos-audio label=verify-dos-audio
[ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; } [ -f "$grader" ] || { echo "$label: missing $grader" >&2; exit 2; }
[ -f "$pulseLib" ] || { echo "$label: missing $pulseLib" >&2; exit 2; }
# shellcheck source=scripts/pulseRecordLib.sh
. "$pulseLib"
[ -f "$conf" ] || { echo "$label: missing $conf" >&2; exit 2; } [ -f "$conf" ] || { echo "$label: missing $conf" >&2; exit 2; }
for tool in dosbox Xvfb xdotool parec pactl python3; do for tool in dosbox Xvfb xdotool parec pactl python3; do
command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; } command -v "$tool" >/dev/null || { echo "$label: $tool not installed" >&2; exit 2; }
done done
pactl info >/dev/null 2>&1 || { echo "$label: no PulseAudio/PipeWire server to record from" >&2; exit 2; } pulseEnsureServer || { echo "$label: no PulseAudio/PipeWire server to record from (tried XDG_RUNTIME_DIR and /run/user/$(id -u)/pulse/native)" >&2; exit 2; }
# Space Taxi ships as its own folder; everything else is a bare EXE. # Space Taxi ships as its own folder; everything else is a bare EXE.
if [ -f "$binDir/$prog/$prog.EXE" ]; then if [ -f "$binDir/$prog/$prog.EXE" ]; then
@ -79,7 +83,7 @@ cleanup() {
[ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null [ -n "$xvfbPid" ] && kill "$xvfbPid" 2>/dev/null
# Always unload the sink: a leaked null sink on the user's audio # Always unload the sink: a leaked null sink on the user's audio
# server is the one side effect this gate could have. # server is the one side effect this gate could have.
[ -n "$sinkModule" ] && pactl unload-module "$sinkModule" 2>/dev/null pulseUnloadSink "$sinkModule"
if [ -n "${DOS_AUDIO_KEEP:-}" ]; then if [ -n "${DOS_AUDIO_KEEP:-}" ]; then
echo "$label: kept $work" >&2 echo "$label: kept $work" >&2
else else
@ -90,9 +94,7 @@ cleanup() {
trap cleanup EXIT trap cleanup EXIT
sinkName="joeyDosGate$$" sinkName="joeyDosGate$$"
sinkModule=$(pactl load-module module-null-sink \ sinkModule=$(pulseCreateSink "$sinkName") || sinkModule=""
sink_name="$sinkName" \
sink_properties=device.description="$sinkName" 2>/dev/null) || sinkModule=""
[ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; } [ -n "$sinkModule" ] || { echo "$label: could not create a null sink to record from" >&2; exit 2; }
Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 & Xvfb "$display" -screen 0 1024x768x24 -nolisten tcp >/dev/null 2>&1 &

View file

@ -1210,7 +1210,7 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
// the rect to the surface, so x/y/w/h are on-surface. The shadow planes // the rect to the surface, so x/y/w/h are on-surface. The shadow planes
// are off-screen, so this is invisible until jlStagePresent. // are off-screen, so this is invisible until jlStagePresent.
// //
// Layout reminder (see docs/amiga_planar.md): each plane word covers 16 // Layout reminder (see claudeDocs/amiga_planar.md): each plane word covers 16
// horizontal pixels; bit 15 = leftmost pixel of that word. The blitter // horizontal pixels; bit 15 = leftmost pixel of that word. The blitter
// fills whole words and cuts the partial-pixel left/right edges with its // fills whole words and cuts the partial-pixel left/right edges with its
// hardware first/last-word masks (fwm/lwm), so a single DEST(+C) blit per // hardware first/last-word masks (fwm/lwm), so a single DEST(+C) blit per

View file

@ -29,8 +29,8 @@
* math -- see spriteCompiledDraw + the prologue/epilogue in spriteEmitIigs.c. * math -- see spriteCompiledDraw + the prologue/epilogue in spriteEmitIigs.c.
* Codegen is ON by default; set JOEY_IIGS_SPRITE_CODEGEN to 0 to force the * Codegen is ON by default; set JOEY_IIGS_SPRITE_CODEGEN to 0 to force the
* interpreter. */ * interpreter. */
/* PERF-AUDIT.md #77 -- ROOT-CAUSED 2026-07-04, FIXED toolchain-side /* claudeDocs/PERF-AUDIT.md #77 -- ROOT-CAUSED 2026-07-04, FIXED toolchain-side
* 2026-07-06 (LLVM816-ASKS.md item 5, JOEYLIB-REPLY.md): the GS/OS * 2026-07-06 (claudeDocs/LLVM816-ASKS.md item 5, JOEYLIB-REPLY.md): the GS/OS
* loader used to Memory-Manager-claim only each segment's IMAGE bytes, * loader used to Memory-Manager-claim only each segment's IMAGE bytes,
* so the BSS+heap span above the image was MM-free space and the codegen * so the BSS+heap span above the image was MM-free space and the codegen
* arena's NewHandle could land INSIDE the app's own entry-bank BSS * arena's NewHandle could land INSIDE the app's own entry-bank BSS

View file

@ -269,7 +269,7 @@ uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t maxCel
jlpBigFree(buf); jlpBigFree(buf);
break; break;
} }
// Shared init path (spriteInternal.h, PERF-AUDIT.md #32): sets // Shared init path (spriteInternal.h, claudeDocs/PERF-AUDIT.md #32): sets
// the 0xFF/0 compiled-state sentinels and registers the sprite // the 0xFF/0 compiled-state sentinels and registers the sprite
// for shutdown. ownsTileData true: the cel owns its buf copy. // for shutdown. ownsTileData true: the cel owns its buf copy.
spriteInitFields(sp, buf, widthTiles, heightTiles, true); spriteInitFields(sp, buf, widthTiles, heightTiles, true);

View file

@ -6,7 +6,7 @@
// so far on disk. // so far on disk.
// //
// On the IIgs the per-line-flush design is a triple failure // On the IIgs the per-line-flush design is a triple failure
// (PERF-AUDIT.md #80/#82): the libc fflush does not commit through the // (claudeDocs/PERF-AUDIT.md #80/#82): the libc fflush does not commit through the
// GS/OS FST (nothing persists until fclose), each real FST write costs // GS/OS FST (nothing persists until fclose), each real FST write costs
// seconds of emulated floppy time, and a write can wedge indefinitely // seconds of emulated floppy time, and a write can wedge indefinitely
// inside SmartPort firmware retries -- one such wedge stalled the UBER // inside SmartPort firmware retries -- one such wedge stalled the UBER

View file

@ -18,7 +18,7 @@
// Default codegen arena size when jlConfigT.codegenBytes == 0. // Default codegen arena size when jlConfigT.codegenBytes == 0.
// Per-platform because emitter code density differs ~4-8x across CPUs // Per-platform because emitter code density differs ~4-8x across CPUs
// (PERF-AUDIT.md #39): // (claudeDocs/PERF-AUDIT.md #39):
// //
// IIgs (65816): ~3-4 KB per all-opaque 32x32 sprite, so 8 KB fits // IIgs (65816): ~3-4 KB per all-opaque 32x32 sprite, so 8 KB fits
// the typical working set and keeps the attrNoCross Memory Manager // the typical working set and keeps the attrNoCross Memory Manager
@ -162,7 +162,7 @@ void jlShutdown(void) {
// Reset compiled-sprite state (slot pointers, routine offsets, patch // Reset compiled-sprite state (slot pointers, routine offsets, patch
// caches) in every registered sprite BEFORE the arena teardown frees // caches) in every registered sprite BEFORE the arena teardown frees
// the slot structs -- see spriteSystemShutdown in spriteInternal.h // the slot structs -- see spriteSystemShutdown in spriteInternal.h
// (PERF-AUDIT.md #34). // (claudeDocs/PERF-AUDIT.md #34).
spriteSystemShutdown(); spriteSystemShutdown();
codegenArenaShutdown(); codegenArenaShutdown();
stageFree(); stageFree();

View file

@ -87,7 +87,7 @@
#endif #endif
// ----- Sprite registry (PERF-AUDIT.md #34) ----- // ----- Sprite registry (claudeDocs/PERF-AUDIT.md #34) -----
// //
// jlShutdown frees the codegen arena (codegenArenaShutdown) while // jlShutdown frees the codegen arena (codegenArenaShutdown) while
// user-owned sprites may still hold ArenaSlotT pointers. If such a // user-owned sprites may still hold ArenaSlotT pointers. If such a
@ -524,7 +524,7 @@ jlSpriteT *jlSpriteCreate(const uint8_t *tileData, uint8_t widthTiles, uint8_t h
} }
/* jlpBigAlloc, NOT the C heap: the IIgs C heap is a few hundred /* jlpBigAlloc, NOT the C heap: the IIgs C heap is a few hundred
* bytes of unprotected bank-0 leftover; at exhaustion its malloc * bytes of unprotected bank-0 leftover; at exhaustion its malloc
* returns garbage instead of NULL (PERF-AUDIT.md #81). Other ports * returns garbage instead of NULL (claudeDocs/PERF-AUDIT.md #81). Other ports
* map jlpBigAlloc to malloc. Applies to every sprite-struct and * map jlpBigAlloc to malloc. Applies to every sprite-struct and
* owned-tileData allocation in this file (finding #31). */ * owned-tileData allocation in this file (finding #31). */
sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT)); sp = (jlSpriteT *)jlpBigAlloc((uint32_t)sizeof(jlSpriteT));
@ -1330,7 +1330,7 @@ void jlSpriteSaveUnderTrusted(const jlSurfaceT *s, jlSpriteT *sp, int16_t x, int
// init path for every sprite: jlSpriteCreate, jlSpriteCreateFromSurface, // init path for every sprite: jlSpriteCreate, jlSpriteCreateFromSurface,
// and jlSpriteBankLoad (assetLoad.c) all route through here so the // and jlSpriteBankLoad (assetLoad.c) all route through here so the
// 0xFF/0 sentinel rules in spriteResetCompiledState have one source // 0xFF/0 sentinel rules in spriteResetCompiledState have one source
// of truth (PERF-AUDIT.md #32). // of truth (claudeDocs/PERF-AUDIT.md #32).
void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData) { void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData) {
sp->tileData = tileData; sp->tileData = tileData;
sp->widthTiles = widthTiles; sp->widthTiles = widthTiles;

View file

@ -206,10 +206,10 @@ struct jlSpriteT {
// init path for every sprite -- jlSpriteCreate and // init path for every sprite -- jlSpriteCreate and
// jlSpriteCreateFromSurface in sprite.c plus jlSpriteBankLoad in // jlSpriteCreateFromSurface in sprite.c plus jlSpriteBankLoad in
// assetLoad.c -- so the sentinel rules live in one place // assetLoad.c -- so the sentinel rules live in one place
// (PERF-AUDIT.md #32). Defined in sprite.c. // (claudeDocs/PERF-AUDIT.md #32). Defined in sprite.c.
void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData); void spriteInitFields(jlSpriteT *sp, const uint8_t *tileData, uint8_t widthTiles, uint8_t heightTiles, bool ownsTileData);
// Sprite registry (sprite.c, PERF-AUDIT.md #34). spriteInitFields // Sprite registry (sprite.c, claudeDocs/PERF-AUDIT.md #34). spriteInitFields
// registers every sprite it initializes; jlSpriteCompile re-registers // registers every sprite it initializes; jlSpriteCompile re-registers
// on success (idempotent) as a belt-and-braces guard, since compile // on success (idempotent) as a belt-and-braces guard, since compile
// is the moment the sprite first holds an arena slot. // is the moment the sprite first holds an arena slot.

View file

@ -141,7 +141,7 @@ jlSurfaceT *jlSurfaceCreate(void) {
// jlpBigAlloc, NOT the C heap: on IIgs the C heap is a few hundred // jlpBigAlloc, NOT the C heap: on IIgs the C heap is a few hundred
// bytes of bank-0 leftover that is NEITHER big enough for this // bytes of bank-0 leftover that is NEITHER big enough for this
// struct NOR reserved from the Memory Manager (Phase 1 IIgs // struct NOR reserved from the Memory Manager (Phase 1 IIgs
// stabilization; PERF-AUDIT.md #77/#81). Other ports map // stabilization; claudeDocs/PERF-AUDIT.md #77/#81). Other ports map
// jlpBigAlloc to malloc, so behavior is unchanged there. // jlpBigAlloc to malloc, so behavior is unchanged there.
s = (jlSurfaceT *)jlpBigAlloc((uint32_t)sizeof(jlSurfaceT)); s = (jlSurfaceT *)jlpBigAlloc((uint32_t)sizeof(jlSurfaceT));
if (s == NULL) { if (s == NULL) {

View file

@ -389,7 +389,7 @@ void jlpWaitVBL(void) {
// old implementation edge-polled $3DA bit 3 and only advanced when a // old implementation edge-polled $3DA bit 3 and only advanced when a
// call happened to land DURING the ~1ms retrace pulse, so any caller // call happened to land DURING the ~1ms retrace pulse, so any caller
// doing more than ~1ms of work between polls silently lost frames // doing more than ~1ms of work between polls silently lost frames
// (PERF-AUDIT.md finding #73 -- UBER's batched timing loop stalled on // (claudeDocs/PERF-AUDIT.md finding #73 -- UBER's batched timing loop stalled on
// it outright). This form is poll-rate-independent and monotonic; // it outright). This form is poll-rate-independent and monotonic;
// jlpWaitVBL above still synchronizes to the real retrace. // jlpWaitVBL above still synchronizes to the real retrace.
uint16_t jlpFrameCount(void) { uint16_t jlpFrameCount(void) {

View file

@ -480,7 +480,7 @@ void jlpShutdown(void) {
// post-main exit path is broken (a php/rtl thunk at segment offset // post-main exit path is broken (a php/rtl thunk at segment offset
// $0072 mis-balances the stack and the machine COPs to the monitor // $0072 mis-balances the stack and the machine COPs to the monitor
// at $00:0002 -- reproduced identically for KEYS and STAXI, see // at $00:0002 -- reproduced identically for KEYS and STAXI, see
// LLVM816-ASKS.md #6). jlShutdown is the last thing every example // claudeDocs/LLVM816-ASKS.md #6). jlShutdown is the last thing every example
// calls, so ending the process here is equivalent and safe; on // calls, so ending the process here is equivalent and safe; on
// IIgs jlShutdown therefore does not return. If QuitGS itself // IIgs jlShutdown therefore does not return. If QuitGS itself
// errors, fall through to the old (crashing) path rather than // errors, fall through to the old (crashing) path rather than
@ -504,7 +504,7 @@ void jlpShutdown(void) {
// Memory Manager reservation for the pinned framebuffer. Without it the // Memory Manager reservation for the pinned framebuffer. Without it the
// MM considers $01:2000-$9CFF free and can place ANY handle inside the // MM considers $01:2000-$9CFF free and can place ANY handle inside the
// display -- jlpBigAlloc'd sprite/surface structs landed there and were // display -- jlpBigAlloc'd sprite/surface structs landed there and were
// painted over by surface clears (PERF-AUDIT.md #85, the root of the // painted over by surface clears (claudeDocs/PERF-AUDIT.md #85, the root of the
// #84 sprite pathology). attrAddr claims the exact address; the range // #84 sprite pathology). attrAddr claims the exact address; the range
// is in special memory (bank $01) by definition. The claim MUST happen // is in special memory (bank $01) by definition. The claim MUST happen
// in jlpInit, BEFORE the first jlpBigAlloc: stageAlloc allocates the // in jlpInit, BEFORE the first jlpBigAlloc: stageAlloc allocates the
@ -517,7 +517,7 @@ static void *gStagePixelsHandle = NULL;
// pointer-scratch symbols -- the [pix],y pattern reads/writes bank 0 at the // pointer-scratch symbols -- the [pix],y pattern reads/writes bank 0 at the
// symbol's OFFSET) plus the link-time C heap window. If an MM handle // symbol's OFFSET) plus the link-time C heap window. If an MM handle
// (codegen arena, jlpBigAlloc scratch, a GS/OS file buffer) lands there, // (codegen arena, jlpBigAlloc scratch, a GS/OS file buffer) lands there,
// every draw primitive plots through stomped pointers (PERF-AUDIT.md #88, // every draw primitive plots through stomped pointers (claudeDocs/PERF-AUDIT.md #88,
// the codegen-on showcase hang). // the codegen-on showcase hang).
// The span is COMPUTED from the link symbols, not hard-coded: the old // The span is COMPUTED from the link symbols, not hard-coded: the old
// $B900-$BEFF constant was the DEMO layout's top slice, and the bigger // $B900-$BEFF constant was the DEMO layout's top slice, and the bigger
@ -631,7 +631,7 @@ void jlpSurfaceCopyRect(jlSurfaceT *dst, const jlSurfaceT *src, uint16_t x, uint
// Block until the next VBL boundary by waiting for the heartbeat tick // Block until the next VBL boundary by waiting for the heartbeat tick
// (incremented by the VBL interrupt handler) to advance. The // (incremented by the VBL interrupt handler) to advance. The
// old form polled $C019's VBL status bit, which never reads high under // old form polled $C019's VBL status bit, which never reads high under
// MAME's apple2gs (PERF-AUDIT.md finding #76): the wait-for-scan loop // MAME's apple2gs (claudeDocs/PERF-AUDIT.md finding #76): the wait-for-scan loop
// spun forever, hanging every program that paces with jlWaitVBL. UBER // spun forever, hanging every program that paces with jlWaitVBL. UBER
// is the only example that calls it, so no verification gate ever // is the only example that calls it, so no verification gate ever
// caught this. Tick granularity IS the VBL boundary, so semantics are // caught this. Tick granularity IS the VBL boundary, so semantics are

View file

@ -3576,7 +3576,7 @@ dlnPlotOdd:
; of A, so $FFF0 clears the target's low nibble and preserves the whole ; of A, so $FFF0 clears the target's low nibble and preserves the whole
; adjacent byte at Y+1; OR sets nib into the low nibble. (The old $F0FF ; adjacent byte at Y+1; OR sets nib into the low nibble. (The old $F0FF
; had the bytes swapped: it never cleared the target nibble and erased ; had the bytes swapped: it never cleared the target nibble and erased
; the neighbor's low nibble -- PERF-AUDIT.md #87.) ; the neighbor's low nibble -- claudeDocs/PERF-AUDIT.md #87.)
lda [pix],y lda [pix],y
and #0xFFF0 and #0xFFF0
ora dlnNibLo ora dlnNibLo
@ -3825,7 +3825,7 @@ dcLoopBody:
; target's high nibble for even x, $FFF0 clears the target's low ; target's high nibble for even x, $FFF0 clears the target's low
; nibble for odd x -- both preserve the whole adjacent byte), then ; nibble for odd x -- both preserve the whole adjacent byte), then
; writes both bytes back. (The old odd mask $F0FF had its bytes ; writes both bytes back. (The old odd mask $F0FF had its bytes
; swapped and corrupted the neighbor -- PERF-AUDIT.md #87.) ; swapped and corrupted the neighbor -- claudeDocs/PERF-AUDIT.md #87.)
; Octants 1-4: y-row pair (cx +/- x, cy +/- y). ; Octants 1-4: y-row pair (cx +/- x, cy +/- y).
; Plot 1: (cx+x, cy+y) ; Plot 1: (cx+x, cy+y)

View file

@ -14,7 +14,7 @@
// VERIFIED UNDER EMULATION (MAME 0.264 + the patches in patches/): this path // VERIFIED UNDER EMULATION (MAME 0.264 + the patches in patches/): this path
// renders, and UBER's captured hashes are byte-identical to the Apple IIgs // renders, and UBER's captured hashes are byte-identical to the Apple IIgs
// golden reference. Input, serial and audio are separately proven -- see // golden reference. Input, serial and audio are separately proven -- see
// docs/x68000_port.md for the evidence for each. // claudeDocs/x68000_port.md for the evidence for each.
#include <x68k/dos.h> #include <x68k/dos.h>
#include <x68k/iocs.h> #include <x68k/iocs.h>

View file

@ -152,7 +152,7 @@ void jlpSurfaceClear(jlSurfaceT *s, uint8_t doubled) {
// the rect to the surface, so x/y/w/h are on-surface. The shadow planes // the rect to the surface, so x/y/w/h are on-surface. The shadow planes
// are off-screen, so this is invisible until jlStagePresent. // are off-screen, so this is invisible until jlStagePresent.
// //
// Layout reminder (see docs/amiga_planar.md): each plane word covers 16 // Layout reminder (see claudeDocs/amiga_planar.md): each plane word covers 16
// horizontal pixels; bit 15 = leftmost pixel of that word. The blitter // horizontal pixels; bit 15 = leftmost pixel of that word. The blitter
// fills whole words and cuts the partial-pixel left/right edges with its // fills whole words and cuts the partial-pixel left/right edges with its
// hardware first/last-word masks (fwm/lwm), so a single DEST(+C) blit per // hardware first/last-word masks (fwm/lwm), so a single DEST(+C) blit per

View file

@ -1,6 +1,6 @@
// Sharp X68000 per-surface planar storage. // Sharp X68000 per-surface planar storage.
// //
// Step 1 of the storage-model conversion (see docs/x68000_port.md). Nothing // Step 1 of the storage-model conversion (see claudeDocs/x68000_port.md). Nothing
// includes this yet -- the port still ships the chunky-plus-expand path, which // includes this yet -- the port still ships the chunky-plus-expand path, which
// is hash-verified against the Apple IIgs golden reference. This header lands // is hash-verified against the Apple IIgs golden reference. This header lands
// first so the conversion can proceed in verifiable increments instead of one // first so the conversion can proceed in verifiable increments instead of one
@ -29,7 +29,7 @@
// 4 planes x 200 rows x 40 bytes = 32,000 bytes per frame, against 128,000 // 4 planes x 200 rows x 40 bytes = 32,000 bytes per frame, against 128,000
// bytes of GVRAM word writes on the chunky path. Bus traffic is what the // bytes of GVRAM word writes on the chunky path. Bus traffic is what the
// measured wait states punish (TVRAM 1.92 cycles/access, GVRAM 1.03 -- see // measured wait states punish (TVRAM 1.92 cycles/access, GVRAM 1.03 -- see
// docs/x68000_mame_crtc_timing.md), so a 4x traffic cut beats TVRAM's higher // claudeDocs/x68000_mame_crtc_timing.md), so a 4x traffic cut beats TVRAM's higher
// per-access cost by roughly 2x overall. // per-access cost by roughly 2x overall.
// //
// Unlike the Amiga there is no Chip RAM constraint: planes are plain malloc, // Unlike the Amiga there is no Chip RAM constraint: planes are plain malloc,

View file

@ -1,3 +1,18 @@
# UBER reference log for the amiga port, captured 2026-07-07.
#
# ONLY THE hash= FIELDS ARE AUTHORITATIVE. tools/diff-uber-hashes reads those
# and ignores every other line, including these comments.
#
# The "N iters / N frames = N ops/sec" figures are a dated snapshot of the one
# run that captured this file. They are NOT a performance baseline and have
# drifted by more than 2x on some ports, so do not compare ports with them.
# For real throughput run scripts/bench-<port>.sh and render the comparison
# with tools/uber-perf-table, which reads a fresh build/<port> joeylog.txt and
# never reads this file.
#
# Regenerating: only do it deliberately, and only when a pixel change is
# intended -- these hashes are what every port is held to. The IIgs is the
# reference (DESIGN.md calls it the perf floor).
amiga: shadow plane 0 in CHIP (blitter, addr=$00015280) amiga: shadow plane 0 in CHIP (blitter, addr=$00015280)
amiga: shadow plane 1 in CHIP (blitter, addr=$000171C0) amiga: shadow plane 1 in CHIP (blitter, addr=$000171C0)
amiga: shadow plane 2 in CHIP (blitter, addr=$00019100) amiga: shadow plane 2 in CHIP (blitter, addr=$00019100)

View file

@ -1,3 +1,18 @@
# UBER reference log for the atarist port, captured 2026-07-07.
#
# ONLY THE hash= FIELDS ARE AUTHORITATIVE. tools/diff-uber-hashes reads those
# and ignores every other line, including these comments.
#
# The "N iters / N frames = N ops/sec" figures are a dated snapshot of the one
# run that captured this file. They are NOT a performance baseline and have
# drifted by more than 2x on some ports, so do not compare ports with them.
# For real throughput run scripts/bench-<port>.sh and render the comparison
# with tools/uber-perf-table, which reads a fresh build/<port> joeylog.txt and
# never reads this file.
#
# Regenerating: only do it deliberately, and only when a pixel change is
# intended -- these hashes are what every port is held to. The IIgs is the
# reference (DESIGN.md calls it the perf floor).
UBER: jlSpriteCompile: 1 call in <= 1 frame UBER: jlSpriteCompile: 1 call in <= 1 frame
UBER: audioInit OK UBER: audioInit OK

View file

@ -1,3 +1,18 @@
# UBER reference log for the dos port, captured 2026-07-07.
#
# ONLY THE hash= FIELDS ARE AUTHORITATIVE. tools/diff-uber-hashes reads those
# and ignores every other line, including these comments.
#
# The "N iters / N frames = N ops/sec" figures are a dated snapshot of the one
# run that captured this file. They are NOT a performance baseline and have
# drifted by more than 2x on some ports, so do not compare ports with them.
# For real throughput run scripts/bench-<port>.sh and render the comparison
# with tools/uber-perf-table, which reads a fresh build/<port> joeylog.txt and
# never reads this file.
#
# Regenerating: only do it deliberately, and only when a pixel change is
# intended -- these hashes are what every port is held to. The IIgs is the
# reference (DESIGN.md calls it the perf floor).
UBER: jlSpriteCompile: 1 call in <= 1 frame UBER: jlSpriteCompile: 1 call in <= 1 frame
UBER: audioInit OK UBER: audioInit OK

View file

@ -1,3 +1,18 @@
# UBER reference log for the iigs port, captured 2026-07-07.
#
# ONLY THE hash= FIELDS ARE AUTHORITATIVE. tools/diff-uber-hashes reads those
# and ignores every other line, including these comments.
#
# The "N iters / N frames = N ops/sec" figures are a dated snapshot of the one
# run that captured this file. They are NOT a performance baseline and have
# drifted by more than 2x on some ports, so do not compare ports with them.
# For real throughput run scripts/bench-<port>.sh and render the comparison
# with tools/uber-perf-table, which reads a fresh build/<port> joeylog.txt and
# never reads this file.
#
# Regenerating: only do it deliberately, and only when a pixel change is
# intended -- these hashes are what every port is held to. The IIgs is the
# reference (DESIGN.md calls it the perf floor).
UBER: jlSpriteCompile: 1 call in <= 1 frame UBER: jlSpriteCompile: 1 call in <= 1 frame
UBER: audioInit OK UBER: audioInit OK
UBER: showcase cells: 0=pixels 1=lineH 2=lineV 3=diag 4=rect 5=fillRect 6=circle 7=fillCircle 8=tiles 9=sprite 10=flood 11=scene UBER: showcase cells: 0=pixels 1=lineH 2=lineV 3=diag 4=rect 5=fillRect 6=circle 7=fillCircle 8=tiles 9=sprite 10=flood 11=scene

View file

@ -8,7 +8,7 @@
// implementations below instead of the generic stubs. // implementations below instead of the generic stubs.
// //
// TODAY this harness FAILS: jlpGenericMillisElapsed is a pure function of // TODAY this harness FAILS: jlpGenericMillisElapsed is a pure function of
// the wrapping uint16_t frame count (PERF-AUDIT.md finding #23), so the // the wrapping uint16_t frame count (claudeDocs/PERF-AUDIT.md finding #23), so the
// clock jumps backward every 65536 frames (~18 min at 60Hz). The Phase 1 // clock jumps backward every 65536 frames (~18 min at 60Hz). The Phase 1
// fix makes it accumulate deltas; this harness must then PASS and becomes // fix makes it accumulate deltas; this harness must then PASS and becomes
// the regression gate for it. // the regression gate for it.

173
tools/traceLogo.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""traceLogo.py -- trace a flat-colour PNG logo into a layered SVG.
The JoeyLib logo ships as a 320x200 PNG, which is fine on screen and poor
everywhere else: the manual's cover wants it large, and a bitmap that small
either pixelates or blurs when scaled up. This produces a resolution-free SVG
of the same artwork so it can be set at any size.
How it works. The art is flat cartoon colour with antialiased edges, so:
1. k-means the opaque pixels down to `--colors` clusters, which recovers the
handful of real colours and throws away the antialiasing gradient,
2. build a one-bit mask per colour,
3. run each mask through potrace (the pure-Python `potracer`), which fits
Bezier curves to the region outlines,
4. emit one SVG <path> per colour.
Layer order matters: the paths are written largest-area first so big shapes sit
behind small ones, EXCEPT the darkest colour, which is written last. In cartoon
art the darkest colour is the ink outline, and it has to be on top or the fills
eat it.
Transparent pixels are left out of every mask, so the SVG keeps the PNG's
transparent background.
Needs `potracer`, `pillow` and `numpy`. They are not build dependencies -- the
checked-in SVG is the deliverable and this script only needs running if the
source art changes:
python3 -m venv /tmp/tracevenv
/tmp/tracevenv/bin/pip install potracer pillow numpy
/tmp/tracevenv/bin/python tools/traceLogo.py assets/logo.png assets/logo.svg
"""
import argparse
import sys
import numpy as np
import potrace
from PIL import Image
def kmeans(samples, k, iterations=24, seed=1):
"""Tiny k-means over RGB samples. Returns (centres, labels)."""
rng = np.random.default_rng(seed)
# Seed the centres on well-separated samples (k-means++ style, simplified).
centres = [samples[rng.integers(len(samples))].astype(np.float64)]
for _ in range(k - 1):
d = np.min([np.sum((samples - c) ** 2, axis=1) for c in centres], axis=0)
total = d.sum()
if total <= 0:
centres.append(samples[rng.integers(len(samples))].astype(np.float64))
continue
centres.append(samples[np.searchsorted(np.cumsum(d / total),
rng.random())].astype(np.float64))
centres = np.array(centres)
labels = np.zeros(len(samples), dtype=np.int32)
for _ in range(iterations):
dist = ((samples[:, None, :] - centres[None, :, :]) ** 2).sum(axis=2)
newLabels = np.argmin(dist, axis=1)
if np.array_equal(newLabels, labels):
break
labels = newLabels
for i in range(k):
hit = samples[labels == i]
if len(hit):
centres[i] = hit.mean(axis=0)
return centres, labels
def pathData(path):
"""potrace path -> SVG 'd'. potracer already emits image-space
coordinates (y increasing downward), so no axis flip is applied -- adding
one renders the art upside down."""
out = []
for curve in path:
start = curve.start_point
out.append(f"M{start.x:.2f},{start.y:.2f}")
for seg in curve:
if seg.is_corner:
out.append(f"L{seg.c.x:.2f},{seg.c.y:.2f}")
out.append(f"L{seg.end_point.x:.2f},{seg.end_point.y:.2f}")
else:
out.append(
f"C{seg.c1.x:.2f},{seg.c1.y:.2f}"
f" {seg.c2.x:.2f},{seg.c2.y:.2f}"
f" {seg.end_point.x:.2f},{seg.end_point.y:.2f}")
out.append("Z")
return "".join(out)
def main(argv):
ap = argparse.ArgumentParser(description="Trace a flat-colour PNG into SVG.")
ap.add_argument("input")
ap.add_argument("output")
ap.add_argument("--colors", type=int, default=5,
help="number of flat colours to recover (default 5)")
ap.add_argument("--alpha", type=int, default=128,
help="alpha above which a pixel counts as opaque (default 128)")
ap.add_argument("--turdsize", type=int, default=2,
help="drop traced specks smaller than this many pixels")
args = ap.parse_args(argv[1:])
im = Image.open(args.input).convert("RGBA")
width, height = im.size
data = np.array(im)
rgb = data[:, :, :3].astype(np.float64)
opaque = data[:, :, 3] > args.alpha
if not opaque.any():
print(f"traceLogo: {args.input} is fully transparent", file=sys.stderr)
return 1
centres, labels = kmeans(rgb[opaque], args.colors)
# Rebuild a full-size label image; -1 means transparent.
labelImage = np.full((height, width), -1, dtype=np.int32)
labelImage[opaque] = labels
layers = []
for i in range(args.colors):
mask = labelImage == i
area = int(mask.sum())
if area == 0:
continue
# Two polarity traps, both measured against this logo:
# * pass the BOOLEAN mask, not 0/1 bytes -- potracer compares against
# blacklevel=0.5, so a 0/1 uint8 array reads as entirely
# foreground and traces the whole canvas as one rectangle;
# * pass the INVERSE -- potracer treats False as the ink it traces,
# so handing it the mask directly traces the background and every
# layer comes out as a full-canvas block.
bitmap = potrace.Bitmap(~mask)
traced = bitmap.trace(turdsize=args.turdsize)
r, g, b = (int(round(v)) for v in centres[i])
layers.append({
"area": area,
"color": f"#{r:02X}{g:02X}{b:02X}",
"luma": 0.299 * r + 0.587 * g + 0.114 * b,
"d": pathData(traced),
})
if not layers:
print("traceLogo: nothing to trace", file=sys.stderr)
return 1
# Largest first so big shapes sit behind, then force the ink (darkest)
# layer to the very top.
layers.sort(key=lambda l: -l["area"])
ink = min(layers, key=lambda l: l["luma"])
layers.remove(ink)
layers.append(ink)
with open(args.output, "w") as f:
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
f.write(f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {width} {height}" width="{width}" '
f'height="{height}">\n')
f.write(f' <title>{args.input}</title>\n')
for layer in layers:
f.write(f' <path fill="{layer["color"]}" '
f'fill-rule="evenodd" d="{layer["d"]}"/>\n')
f.write('</svg>\n')
print(f"traceLogo: {args.output} -- {len(layers)} colour layers, "
f"{width}x{height} viewBox")
for layer in sorted(layers, key=lambda l: -l["area"]):
print(f" {layer['color']} {layer['area']:6d} px")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))